Files
Nexus/deploy/nexus-1panel.sh
T
Nexus Deploy dd66d99f2d fix(deploy): nx 上帝菜单巡检修补 1panel-network 与升级竞态
purge 阶段 compose 叠加 1panel overlay;菜单启动/重建与回滚后校验网络;install-auto 已安装改走 update。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:27:01 +08:00

1279 lines
42 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Nexus 6.0 — 1Panel + Docker 一键安装 / 升级(参数已全部内置,无需 export)
#
# 新机 1Panel 终端一条命令:
# curl -fsSL "http://66.154.115.8:3000/admin/Nexus/raw/branch/main/deploy/nexus-1panel.sh" | bash -s install
#
# 已 clone 到 /opt/nexus:
# cd /opt/nexus && bash deploy/nexus-1panel.sh install --skip-clone
#
# 其他:
# bash deploy/nexus-1panel.sh install --profile 4c16g
# bash deploy/nexus-1panel.sh upgrade
# bash deploy/nexus-1panel.sh check
# bash deploy/nexus-1panel.sh ports
#
# 公共仓库匿名 clone,无需 Gitea 令牌。
# 仅私人仓库可选: deploy/nexus-1panel.secrets.sh 或 /root/.nexus-deploy.env
# 或 clone 时 URL 带令牌,脚本会从 git remote 自动解析
set -euo pipefail
# ═══════════════════════════════════════════════════════════════════
# 内置路径/域名(无敏感信息,可提交 Git)
# ═══════════════════════════════════════════════════════════════════
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
NEXUS_GITEA_HOST="${NEXUS_GITEA_HOST:-66.154.115.8:3000}"
NEXUS_GITEA_REPO="${NEXUS_GITEA_REPO:-admin/Nexus.git}"
NEXUS_DOMAIN="${NEXUS_DOMAIN:-api.synaglobal.vip}"
NEXUS_PROFILE="${NEXUS_PROFILE:-2c8g}"
NEXUS_FROM_ENV="${NEXUS_FROM_ENV:-/www/wwwroot/api.synaglobal.vip/.env}"
NEXUS_BACKUP_DIR="${NEXUS_BACKUP_DIR:-/var/backups/nexus}"
NEXUS_DEPLOY_ENV="${NEXUS_DEPLOY_ENV:-/root/.nexus-deploy.env}"
NEXUS_PUBLISH_PORT="${NEXUS_PUBLISH_PORT:-8600}"
GIT_BRANCH="${NEXUS_GIT_BRANCH:-main}"
COMPOSE_FILE="docker/docker-compose.prod.yml"
COMPOSE_1PANEL="docker/docker-compose.1panel.yml"
ENV_PROD="docker/.env.prod"
HOST_PROFILE_FILE="docker/.host-profile"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
SKIP_CLONE=false
FRESH_INSTALL=false
REUSE_SECRETS=false
FROM_ENV="${NEXUS_FROM_ENV}"
NO_BACKUP=false
REQUIRE_BACKUP=false
PRUNE_IMAGES=false
NO_CACHE=false
CHECK_ONLY=false
DRY_RUN=false
CUSTOM_CPUS=""
CUSTOM_MEM_GB=""
usage() {
cat <<'EOF'
Nexus 1Panel 一键脚本
bash deploy/nexus-1panel.sh install [选项] 克隆/配置/启动
bash deploy/nexus-1panel.sh upgrade [选项] 拉代码+备份+重建
bash deploy/nexus-1panel.sh check 检查远程是否有更新
bash deploy/nexus-1panel.sh ports 检查 Gitea/8600/80/443 与防火墙
bash deploy/nexus-1panel.sh init-token <令牌> 覆盖内置 Gitea 令牌(一般不需要)
资源档位(三选一):
--profile 2c8g|4c16g|1c4g 预设(默认 2c8g = 2核8G
--cpus N --mem-gb N 自动匹配最近预设
安装选项:
--skip-clone 已在 /opt/nexus,仅 pull
--fresh 全新安装(本机自动生成唯一密钥 + /app/install.html
--from-env PATH 从旧 .env 迁机导入密钥
--reuse-secrets 使用 secrets 文件中的 NEXUS 密钥(勿用于新机)
--domain NAME 覆盖域名
升级选项:
--no-backup --require-backup --prune --no-cache --dry-run --check(仅查 Git,不重建)
Git: 公共仓库匿名 clone(无需令牌);私人仓库见 init-token
EOF
exit 0
}
script_dir() {
dirname "$(readlink -f "${BASH_SOURCE[0]}")"
}
secrets_file_path() {
echo "$(script_dir)/nexus-1panel.secrets.sh"
}
load_token_from_git_remote() {
local root="${1:-$NEXUS_ROOT}"
[[ -n "${NEXUS_GITEA_TOKEN:-}" ]] && return 0
[[ -d "${root}/.git" ]] || return 0
local url
url="$(git -C "$root" remote get-url origin 2>/dev/null || true)"
if [[ "$url" =~ http://[^:]+:([^@]+)@ ]]; then
NEXUS_GITEA_TOKEN="${BASH_REMATCH[1]}"
info "已从 git remote 解析 Gitea 令牌"
fi
}
load_secrets() {
local secrets_file
secrets_file="$(secrets_file_path)"
if [[ -f "$secrets_file" ]]; then
# shellcheck disable=SC1090
set -a
source "$secrets_file"
set +a
info "已加载 $secrets_file"
fi
if [[ -f "$NEXUS_DEPLOY_ENV" ]]; then
# shellcheck disable=SC1090
set -a
source "$NEXUS_DEPLOY_ENV"
set +a
info "已加载 $NEXUS_DEPLOY_ENV"
fi
load_token_from_git_remote "$NEXUS_ROOT"
if [[ "$FRESH_INSTALL" == true ]]; then
unset NEXUS_SECRET_KEY NEXUS_API_KEY NEXUS_ENCRYPTION_KEY
info "全新安装:忽略 secrets 中的 NEXUS 密钥,将为本机自动生成"
fi
}
resolve_profile() {
if [[ -n "$CUSTOM_CPUS" && -n "$CUSTOM_MEM_GB" ]]; then
local c m
c="$CUSTOM_CPUS"
m="$CUSTOM_MEM_GB"
if [[ "$c" -le 1 && "$m" -le 4 ]]; then
NEXUS_PROFILE="1c4g"
elif [[ "$c" -le 2 && "$m" -le 8 ]]; then
NEXUS_PROFILE="2c8g"
else
NEXUS_PROFILE="4c16g"
fi
info "规格 ${c}C${m}G → 档位 ${NEXUS_PROFILE}"
return 0
fi
case "$NEXUS_PROFILE" in
1c4g|2c8g|4c16g) ;;
*)
error "未知档位: $NEXUS_PROFILE(可用: 1c4g, 2c8g, 4c16g"
exit 1
;;
esac
}
profile_env_file() {
local root="$1"
echo "$root/docker/profiles/${NEXUS_PROFILE}.env"
}
save_host_profile() {
local root="$1"
echo "$NEXUS_PROFILE" >"$root/$HOST_PROFILE_FILE"
}
load_saved_profile() {
local root="$1"
if [[ -f "$root/$HOST_PROFILE_FILE" ]]; then
NEXUS_PROFILE="$(tr -d '\r\n' <"$root/$HOST_PROFILE_FILE")"
info "使用已保存档位: $NEXUS_PROFILE"
fi
}
apply_pool_to_env_prod() {
local root="$1" prof="$2"
local env_file="$root/$ENV_PROD"
[[ -f "$env_file" ]] || return 0
local pool overflow
pool="$(grep -E '^NEXUS_DB_POOL_SIZE=' "$prof" | cut -d= -f2-)"
overflow="$(grep -E '^NEXUS_DB_MAX_OVERFLOW=' "$prof" | cut -d= -f2-)"
[[ -n "$pool" ]] && sed -i "s|^NEXUS_DB_POOL_SIZE=.*|NEXUS_DB_POOL_SIZE=${pool}|" "$env_file"
[[ -n "$overflow" ]] && sed -i "s|^NEXUS_DB_MAX_OVERFLOW=.*|NEXUS_DB_MAX_OVERFLOW=${overflow}|" "$env_file"
}
has_1panel_network() {
docker network inspect 1panel-network >/dev/null 2>&1
}
upsert_env_var() {
local file="$1" key="$2" val="$3"
if grep -qE "^${key}=" "$file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
else
echo "${key}=${val}" >>"$file"
fi
}
migrate_redis_url_host() {
local old_url="$1" new_host="$2"
python3 - "$old_url" "$new_host" <<'PY'
import sys
from urllib.parse import quote, unquote, urlparse
old_url, new_host = sys.argv[1], sys.argv[2]
u = urlparse(old_url)
if u.scheme != "redis":
sys.exit(0)
password = unquote(u.password or "")
if not password and u.username:
password = unquote(u.username)
if password and new_host:
print(f"redis://:{quote(password, safe='')}@{new_host}:6379/0")
PY
}
# Prefer wizard-written redis://:pass@host from nexus-state or .env.prod; avoid clobbering on nx update.
resolve_nexus_redis_url() {
local redis_host="$1"
local env_file="$2"
local vol_url="" existing="" migrated="" host_lc="${redis_host,,}"
local state_vol
state_vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)"
if [[ -n "$state_vol" ]]; then
vol_url="$(docker run --rm -v "${state_vol}:/data" alpine sh -c \
"grep '^NEXUS_REDIS_URL=' /data/.env 2>/dev/null | cut -d= -f2- | tr -d '\"'" 2>/dev/null || true)"
if [[ -n "$vol_url" ]]; then
if [[ "${vol_url,,}" == *"${host_lc}"* ]]; then
echo "$vol_url"
return 0
fi
if [[ "$vol_url" == *"@"* ]]; then
migrated="$(migrate_redis_url_host "$vol_url" "$redis_host" 2>/dev/null || true)"
if [[ -n "$migrated" ]]; then
echo "$migrated"
return 0
fi
fi
fi
fi
if [[ -f "$env_file" ]]; then
existing="$(grep '^NEXUS_REDIS_URL=' "$env_file" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
if [[ -n "$existing" && "$existing" == *"@"* ]]; then
if [[ "${existing,,}" == *"${host_lc}"* ]]; then
echo "$existing"
return 0
fi
migrated="$(migrate_redis_url_host "$existing" "$redis_host" 2>/dev/null || true)"
if [[ -n "$migrated" ]]; then
echo "$migrated"
return 0
fi
fi
fi
echo "redis://${redis_host}:6379/0"
}
nexus_container_name() {
docker ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
}
nexus_publish_port() {
local root="$1"
local env_file="$root/$ENV_PROD"
local port
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$env_file" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
echo "${port:-8600}"
}
nexus_container_has_install_lock() {
local cname="$1"
docker exec "$cname" test -f /app/.install_locked 2>/dev/null \
|| docker exec "$cname" test -f /var/lib/nexus/.install_locked 2>/dev/null
}
restore_install_lock_in_container() {
local cname="$1"
[[ -n "$cname" ]] || return 0
docker exec "$cname" sh -c '
if [ ! -f /app/.install_locked ] && [ -f /var/lib/nexus/.install_locked ]; then
cp /var/lib/nexus/.install_locked /app/.install_locked
chmod 600 /app/.install_locked
fi
' 2>/dev/null || true
}
nexus_on_1panel_network() {
local cname="$1"
[[ -n "$cname" ]] || return 1
docker inspect "$cname" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null \
| grep -qw '1panel-network'
}
verify_nexus_1panel_network() {
local root="$1"
if ! has_1panel_network; then
return 0
fi
local cname
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
restore_install_lock_in_container "$cname"
if nexus_on_1panel_network "$cname"; then
info "已验证 Nexus 接入 1panel-network"
return 0
fi
error "Nexus 容器 ${cname} 未接入 1panel-network,无法解析 1Panel MySQL/Redis 主机名"
warn "正在叠加 docker-compose.1panel.yml 重新创建容器..."
compose_cmd "$root" up -d --no-build --remove-orphans
sleep 2
cname="$(nexus_container_name)"
restore_install_lock_in_container "$cname"
if nexus_on_1panel_network "$cname"; then
info "已修复:Nexus 已接入 1panel-network"
return 0
fi
error "修复失败:Nexus 仍未接入 1panel-network"
return 1
}
set_install_complete() {
local root="$1"
local env_file="$root/$ENV_PROD"
local cname
[[ -f "$env_file" ]] || return 0
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
restore_install_lock_in_container "$cname"
if nexus_container_has_install_lock "$cname"; then
upsert_env_var "$env_file" "NEXUS_INSTALL_WIZARD_PENDING" "0"
info "安装已完成:NEXUS_INSTALL_WIZARD_PENDING=0"
fi
}
sync_env_prod_to_volume() {
local root="$1"
local env_file="$root/$ENV_PROD"
local state_vol cname
state_vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)"
[[ -n "$state_vol" && -f "$env_file" ]] || return 0
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
restore_install_lock_in_container "$cname"
nexus_container_has_install_lock "$cname" || return 0
python3 - "$env_file" "$state_vol" <<'PY'
import re
import subprocess
import sys
from pathlib import Path
env_file = Path(sys.argv[1])
vol = sys.argv[2]
keys = (
"NEXUS_SECRET_KEY",
"NEXUS_API_KEY",
"NEXUS_ENCRYPTION_KEY",
"NEXUS_REDIS_URL",
"NEXUS_DATABASE_URL",
)
prod = {}
for line in env_file.read_text(encoding="utf-8").splitlines():
m = re.match(r"^([A-Z0-9_]+)=(.*)$", line.strip())
if m and m.group(1) in keys and m.group(2).strip():
prod[m.group(1)] = m.group(2).strip().strip('"')
if not prod:
sys.exit(0)
cur = subprocess.run(
["docker", "run", "--rm", "-v", f"{vol}:/data", "alpine", "cat", "/data/.env"],
capture_output=True,
text=True,
)
text = cur.stdout if cur.returncode == 0 else ""
if not text.strip():
sys.exit(0)
lines = text.splitlines()
out = []
seen = set()
for line in lines:
key = line.split("=", 1)[0].strip() if "=" in line else ""
if key in prod:
out.append(f'{key}={prod[key]}')
seen.add(key)
else:
out.append(line)
for key, val in prod.items():
if key not in seen:
out.append(f'{key}={val}')
new_text = "\n".join(out).rstrip() + "\n"
subprocess.run(
["docker", "run", "--rm", "-i", "-v", f"{vol}:/data", "alpine", "sh", "-c", "cat > /data/.env && chmod 600 /data/.env"],
input=new_text,
text=True,
check=True,
)
print("synced")
PY
info "已将 docker/.env.prod 密钥/URL 同步至 nexus-state 卷"
}
tag_rollback_image() {
local root="$1" old_id
old_id="$(docker ps --filter "name=nexus-prod-nexus" --format '{{.Image}}' 2>/dev/null | head -1 || true)"
if [[ -z "$old_id" ]]; then
old_id="$(compose_cmd "$root" images -q nexus 2>/dev/null | head -1 || true)"
fi
if [[ -n "$old_id" ]]; then
docker tag "$old_id" "nexus-prod-nexus:rollback" 2>/dev/null || true
info "已标记回滚镜像 nexus-prod-nexus:rollback"
fi
}
gate_new_image_import() {
local root="$1" img
img="$(compose_cmd "$root" images -q nexus 2>/dev/null | head -1 || true)"
if [[ -z "$img" ]]; then
error "镜像门控失败:未找到 nexus 镜像"
return 1
fi
if ! docker run --rm --entrypoint python3 "$img" -c "import server.main" >/dev/null 2>&1; then
error "镜像门控失败:import server.main"
return 1
fi
info "镜像门控通过 (import server.main)"
return 0
}
rollback_compose_upgrade() {
local root="$1"
if docker image inspect nexus-prod-nexus:rollback >/dev/null 2>&1; then
warn "健康检查失败,回滚至上一镜像..."
docker tag nexus-prod-nexus:rollback nexus-prod-nexus:latest 2>/dev/null || true
compose_cmd "$root" up -d --no-build --remove-orphans
verify_nexus_1panel_network "$root" || true
return 0
fi
warn "无回滚镜像 nexus-prod-nexus:rollback,跳过自动回滚"
return 1
}
write_1panel_hosts_volume() {
local mysql_host="${1:-}" redis_host="${2:-}"
[[ -n "$mysql_host" || -n "$redis_host" ]] || return 0
local vol json
vol="$(docker volume ls -q --filter name=nexus-web-data 2>/dev/null | head -1 || true)"
if [[ -z "$vol" ]]; then
warn "未找到 nexus-web-data 卷,跳过写入 1panel-hosts.json"
return 0
fi
json="{"
if [[ -n "$mysql_host" ]]; then
json+="\"db_host\":\"${mysql_host}\""
[[ -n "$redis_host" ]] && json+=","
fi
if [[ -n "$redis_host" ]]; then
json+="\"redis_host\":\"${redis_host}\""
fi
json+="}"
if printf '%s' "$json" | docker run --rm -i -v "${vol}:/data" alpine:3.19 \
sh -c 'cat > /data/1panel-hosts.json && chmod 644 /data/1panel-hosts.json'; then
info "已写入卷 ${vol} → web/data/1panel-hosts.json"
else
warn "写入 1panel-hosts.json 失败(向导仍可读 .env.prod 环境变量)"
fi
}
sync_1panel_service_hosts() {
local root="$1"
local env_file="$root/$ENV_PROD"
local detect="$root/deploy/detect-1panel-services.sh"
[[ -f "$env_file" ]] || return 0
if ! has_1panel_network; then
warn "未找到 1panel-network — 请先在 1Panel 应用商店安装 MySQL/Redis"
return 0
fi
if [[ ! -x "$detect" ]]; then
chmod +x "$detect" 2>/dev/null || true
fi
if [[ ! -x "$detect" ]]; then
warn "未找到 detect-1panel-services.sh,跳过 1Panel 容器名探测"
return 0
fi
local line mysql_host="" redis_host="" detect_out
if ! detect_out="$("$detect" 2>&1)"; then
warn "1Panel 容器探测: ${detect_out:- MySQL/Redis 容器运行}"
return 0
fi
while IFS= read -r line; do
case "$line" in
NEXUS_1PANEL_DB_HOST=*)
mysql_host="${line#NEXUS_1PANEL_DB_HOST=}"
upsert_env_var "$env_file" NEXUS_1PANEL_DB_HOST "$mysql_host"
info "1Panel MySQL 容器: $mysql_host(安装向导将预填为数据库主机)"
;;
NEXUS_1PANEL_REDIS_HOST=*)
redis_host="${line#NEXUS_1PANEL_REDIS_HOST=}"
upsert_env_var "$env_file" NEXUS_1PANEL_REDIS_HOST "$redis_host"
local redis_url
redis_url="$(resolve_nexus_redis_url "$redis_host" "$env_file")"
upsert_env_var "$env_file" "NEXUS_REDIS_URL" "$redis_url"
if [[ "$redis_url" == *"@"* ]]; then
info "1Panel Redis 容器: $redis_hostNEXUS_REDIS_URL 已保留向导密码)"
else
info "1Panel Redis 容器: $redis_hostNEXUS_REDIS_URL 占位,向导步骤 3 填密码后写入卷 .env)"
fi
;;
esac
done <<<"$detect_out"
write_1panel_hosts_volume "$mysql_host" "$redis_host"
}
compose_cmd() {
local root="$1"
shift
local prof
prof="$(profile_env_file "$root")"
if [[ ! -f "$prof" ]]; then
error "缺少档位文件: $prof"
exit 1
fi
cd "$root"
local -a compose_args=(-f "$COMPOSE_FILE")
if has_1panel_network && [[ -f "$COMPOSE_1PANEL" ]]; then
compose_args+=(-f "$COMPOSE_1PANEL")
fi
docker compose "${compose_args[@]}" --env-file "$ENV_PROD" --env-file "$prof" "$@"
}
rand_secret() {
openssl rand -hex 24 2>/dev/null || head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
rand_pass() {
openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
read_env_val() {
local file="$1" key="$2"
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
parse_gitea_endpoint() {
local hp="${NEXUS_GITEA_HOST}"
GITEA_HOST="${hp%%:*}"
GITEA_PORT="${hp##*:}"
if [[ "$GITEA_HOST" == "$GITEA_PORT" || -z "$GITEA_PORT" ]]; then
GITEA_PORT=3000
fi
}
nexus_publish_port() {
local root="${1:-}"
local p="${NEXUS_PUBLISH_PORT:-8600}"
if [[ -n "$root" && -f "$root/$ENV_PROD" ]]; then
local v
v="$(read_env_val "$root/$ENV_PROD" NEXUS_PUBLISH_PORT)"
[[ -n "$v" ]] && p="$v"
fi
echo "$p"
}
tcp_reachable() {
local host="$1" port="$2" t="${3:-3}"
if command -v nc >/dev/null 2>&1; then
nc -z -w "$t" "$host" "$port" 2>/dev/null
return $?
fi
if command -v timeout >/dev/null 2>&1; then
timeout "$t" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null \
&& { exec 3<&- 3>&-; return 0; }
else
bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null \
&& { exec 3<&- 3>&-; return 0; }
fi
return 1
}
local_port_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnH 2>/dev/null | grep -qE ":${port}[[:space:]]"
return $?
fi
if command -v netstat >/dev/null 2>&1; then
netstat -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"
return $?
fi
curl -sf --max-time 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1
}
firewall_may_block_port() {
local port="$1"
if command -v ufw >/dev/null 2>&1; then
local st
st="$(ufw status 2>/dev/null | head -1 || true)"
if [[ "$st" == *"active"* ]]; then
ufw status 2>/dev/null | grep -qE "${port}/tcp[[:space:]]+ALLOW" && return 1
return 0
fi
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>/dev/null | grep -qi running; then
firewall-cmd --list-ports 2>/dev/null | grep -q "${port}/tcp" && return 1
firewall-cmd --list-services 2>/dev/null | grep -qE '^(http|https)$' \
&& [[ "$port" == "80" || "$port" == "443" ]] && return 1
return 0
fi
return 1
}
remind_firewall() {
local port="$1" label="$2"
if firewall_may_block_port "$port"; then
warn "防火墙可能未放行 ${port}/tcp${label}"
warn " 本机: ufw allow ${port}/tcp 或 1Panel → 主机 → 防火墙"
warn " 云厂商控制台: 安全组入站放行 ${port}/tcp"
fi
}
check_ports_preflight() {
local root="${1:-$NEXUS_ROOT}"
local nport issues=0
parse_gitea_endpoint
nport="$(nexus_publish_port "$root")"
step "检查端口与防火墙..."
if tcp_reachable "$GITEA_HOST" "$GITEA_PORT"; then
info "Gitea ${GITEA_HOST}:${GITEA_PORT} — 可达(git 拉取)"
else
warn "Gitea ${GITEA_HOST}:${GITEA_PORT} — 无法连接,clone/pull 会失败"
warn " 请确认 Gitea 在线,且本机出站与该端口未被防火墙/安全组拦截"
issues=$((issues + 1))
fi
if local_port_listening "$nport"; then
info "127.0.0.1:${nport} — 已有监听(可能为旧 Nexus 实例)"
else
info "127.0.0.1:${nport} — 尚未监听(安装后将由 Compose 绑定)"
fi
for spec in "80:HTTP" "443:HTTPS"; do
local p="${spec%%:*}" label="${spec##*:}"
if local_port_listening "$p"; then
info "本机 :${p} — 已监听(${label} / 1Panel"
else
warn "本机 :${p} — 未监听,外网 ${label} 暂不可用"
warn " 安装完成后: 1Panel → 网站 → 反代 http://127.0.0.1:${nport} 并申请 SSL"
issues=$((issues + 1))
fi
remind_firewall "$p" "${label} 公网"
done
remind_firewall 22 "SSH(远程维护)"
if [[ "$issues" -gt 0 ]]; then
warn "共 ${issues} 项端口提醒(可继续安装,但请先处理 Gitea 不可达问题)"
else
info "端口预检通过"
fi
echo ""
}
check_ports_post() {
local root="$1"
local nport health_ext
nport="$(nexus_publish_port "$root")"
step "检查服务端口..."
if local_port_listening "$nport"; then
info "Nexus 127.0.0.1:${nport} — 已监听"
else
warn "127.0.0.1:${nport} — 未监听,请执行: cd $root && docker compose ... ps"
fi
if local_port_listening 443; then
health_ext="$(curl -sk --max-time 8 -o /dev/null -w '%{http_code}' "https://${NEXUS_DOMAIN}/health" 2>/dev/null || echo "000")"
if [[ "$health_ext" == "200" ]]; then
info "https://${NEXUS_DOMAIN}/health — 外网可达 (HTTP 200)"
else
warn "https://${NEXUS_DOMAIN}/health — 返回 HTTP ${health_ext}"
warn " 若外网打不开: 1Panel 反代是否指向 127.0.0.1:${nport},安全组是否放行 443"
fi
else
warn "本机 443 未监听 — 请在 1Panel 创建站点并启用 HTTPS,否则浏览器无法访问 ${NEXUS_DOMAIN}"
remind_firewall 443 "HTTPS"
fi
if ! local_port_listening 80; then
info "本机 80 未监听(仅 HTTPS 时正常)"
fi
echo ""
}
cmd_ports() {
require_root
check_ports_preflight "$NEXUS_ROOT"
if [[ -f "$NEXUS_ROOT/$ENV_PROD" ]]; then
check_ports_post "$NEXUS_ROOT"
fi
}
require_root() {
if [[ "$(id -u)" -ne 0 ]]; then
error "请切换到 root 执行(su -),不要用 sudo 包一层"
exit 1
fi
}
require_token() {
if [[ -n "${NEXUS_GITEA_TOKEN:-}" ]]; then
return 0
fi
info "未配置 Gitea 令牌,按公共仓库匿名 clone/pull"
}
git_remote_url() {
if [[ -n "${NEXUS_GITEA_TOKEN:-}" ]]; then
echo "http://admin:${NEXUS_GITEA_TOKEN}@${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
else
echo "http://${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
fi
}
ensure_packages() {
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git curl openssl ca-certificates >/dev/null 2>&1 || true
fi
}
ensure_docker() {
if docker compose version >/dev/null 2>&1; then
info "Docker Compose: $(docker compose version --short 2>/dev/null || docker compose version)"
return 0
fi
error "未检测到 docker compose。请在 1Panel → 容器 安装 Docker。"
exit 1
}
clone_or_pull() {
local url
url="$(git_remote_url)"
if [[ "$SKIP_CLONE" == true ]]; then
if [[ ! -d "$NEXUS_ROOT/.git" ]]; then
error "--skip-clone 但 $NEXUS_ROOT 不是 git 仓库"
exit 1
fi
info "在 $NEXUS_ROOT 执行 git pull..."
git -C "$NEXUS_ROOT" remote set-url origin "$url" 2>/dev/null || true
git -C "$NEXUS_ROOT" fetch origin "$GIT_BRANCH" --prune
git -C "$NEXUS_ROOT" reset --hard "origin/${GIT_BRANCH}"
return 0
fi
mkdir -p "$(dirname "$NEXUS_ROOT")"
if [[ -d "$NEXUS_ROOT/.git" ]]; then
info "仓库已存在,同步 origin/${GIT_BRANCH}..."
git -C "$NEXUS_ROOT" remote set-url origin "$url" 2>/dev/null || true
git -C "$NEXUS_ROOT" fetch origin "$GIT_BRANCH" --prune
git -C "$NEXUS_ROOT" reset --hard "origin/${GIT_BRANCH}"
return 0
fi
if [[ -d "$NEXUS_ROOT" ]] && [[ -n "$(ls -A "$NEXUS_ROOT" 2>/dev/null)" ]]; then
error "目录非空且不是 git 仓库: $NEXUS_ROOT"
exit 1
fi
rm -rf "$NEXUS_ROOT"
info "克隆 Nexus → $NEXUS_ROOT ..."
git clone "$url" "$NEXUS_ROOT"
git -C "$NEXUS_ROOT" checkout "$GIT_BRANCH" 2>/dev/null || true
}
write_temp_secrets_env() {
local f
f="$(mktemp /tmp/nexus-secrets-import.XXXXXX.env)"
{
printf 'NEXUS_SECRET_KEY=%s\n' "$NEXUS_SECRET_KEY"
printf 'NEXUS_API_KEY=%s\n' "$NEXUS_API_KEY"
printf 'NEXUS_ENCRYPTION_KEY=%s\n' "$NEXUS_ENCRYPTION_KEY"
} >"$f"
chmod 600 "$f"
echo "$f"
}
write_env_prod() {
local root="$1"
local env_file="$root/$ENV_PROD"
local example="$root/docker/.env.prod.example"
local gen="$root/scripts/generate_nexus_secrets.py"
local prof backup tmp
if [[ ! -f "$example" ]]; then
error "缺少 $example"
exit 1
fi
if [[ ! -f "$gen" ]]; then
error "缺少 $gen"
exit 1
fi
if [[ -f "$env_file" ]]; then
warn "$env_file 已存在,仅更新域名与连接池"
sed -i "s|^NEXUS_CORS_ORIGINS=.*|NEXUS_CORS_ORIGINS=https://${NEXUS_DOMAIN}|" "$env_file"
sed -i "s|^NEXUS_API_BASE_URL=.*|NEXUS_API_BASE_URL=https://${NEXUS_DOMAIN}|" "$env_file"
apply_pool_to_env_prod "$root" "$(profile_env_file "$root")"
chmod 600 "$env_file"
return 0
fi
prof="$(profile_env_file "$root")"
backup="/root/.nexus-install-secrets-$(date +%Y%m%d%H%M%S).env"
if [[ -n "$FROM_ENV" && -f "$FROM_ENV" ]]; then
info "迁机:从 $FROM_ENV 导入密钥..."
python3 "$gen" write-prod \
--template "$example" \
--output "$env_file" \
--domain "$NEXUS_DOMAIN" \
--profile-env "$prof" \
--from-env "$FROM_ENV" \
--backup "$backup"
elif [[ "$FRESH_INSTALL" == true ]]; then
info "全新安装:为本机生成唯一 NEXUS 密钥(MySQL/Redis 请自行安装)..."
python3 "$gen" write-prod \
--template "$example" \
--output "$env_file" \
--domain "$NEXUS_DOMAIN" \
--profile-env "$prof" \
--fresh \
--wizard-pending \
--backup "$backup"
info "密钥备份: $backup(仅本机,勿复制到其他服务器)"
info "完成后访问 https://${NEXUS_DOMAIN}/app/install.html"
elif [[ "$REUSE_SECRETS" == true && -n "${NEXUS_SECRET_KEY:-}" && -n "${NEXUS_API_KEY:-}" && -n "${NEXUS_ENCRYPTION_KEY:-}" ]]; then
warn "使用 secrets 中的 NEXUS 密钥(仅迁机/复用,新机勿用 --reuse-secrets"
tmp="$(write_temp_secrets_env)"
python3 "$gen" write-prod \
--template "$example" \
--output "$env_file" \
--domain "$NEXUS_DOMAIN" \
--profile-env "$prof" \
--from-env "$tmp" \
--backup "$backup"
rm -f "$tmp"
else
info "未迁机导入:为本机生成唯一密钥..."
python3 "$gen" write-prod \
--template "$example" \
--output "$env_file" \
--domain "$NEXUS_DOMAIN" \
--profile-env "$prof" \
--fresh \
--wizard-pending \
--backup "$backup"
info "密钥备份: $backup"
if [[ "$FRESH_INSTALL" != true ]]; then
info "完成后访问 https://${NEXUS_DOMAIN}/app/install.html"
fi
fi
chmod 600 "$env_file"
info "已写入 $env_file"
}
purge_legacy_bundled_services() {
local root="$1"
local s
for s in \
"$root/deploy/uninstall-mysql-compose.sh" \
"$root/deploy/uninstall-redis-compose.sh"; do
if [[ -x "$s" ]]; then
step "清理遗留 Compose 内置服务: $(basename "$s")"
NEXUS_ROOT="$root" bash "$s" || true
fi
done
}
compose_up() {
local root="$1"
save_host_profile "$root"
purge_legacy_bundled_services "$root"
sync_1panel_service_hosts "$root"
if has_1panel_network; then
info "已接入 1panel-networkMySQL/Redis 使用 1Panel 容器名互联)"
fi
info "档位: ${NEXUS_PROFILE}(仅 Nexus 容器;MySQL/Redis 请自行安装)"
info "构建并启动(首次约 1020 分钟)..."
compose_cmd "$root" up -d --build --remove-orphans
verify_nexus_1panel_network "$root" || return 1
}
verify_install_wizard() {
local root="$1"
local port="${2:-$(nexus_publish_port "$root")}"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
if [[ "$code" == "200" ]]; then
info " /app/install.html → HTTP 200"
return 0
fi
warn " /app/install.html → HTTP $code(尝试同步静态文件…)"
if [[ -x "$root/deploy/sync-install-wizard-to-container.sh" ]]; then
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$root" bash "$root/deploy/sync-install-wizard-to-container.sh" || true
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
if [[ "$code" == "200" ]]; then
info " /app/install.html → HTTP 200(已同步)"
return 0
fi
fi
warn " 请重建镜像: cd $root && docker compose -f docker/docker-compose.prod.yml --env-file docker/.env.prod build --no-cache nexus"
return 1
}
verify_health() {
local root="$1" title="${2:-安装}"
local i health spa port
port="$(nexus_publish_port "$root")"
info "等待服务就绪 (端口 ${port})..."
for i in $(seq 1 60); do
health=$(curl -sf "http://127.0.0.1:${port}/health" 2>/dev/null || true)
if [[ "$health" == "ok" ]]; then
spa=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/" 2>/dev/null || echo "000")
verify_install_wizard "$root" "$port" || true
echo ""
info "═══ ${title}完成 ═══"
info " 档位: ${NEXUS_PROFILE}"
info " /health → ok"
info " /app/ → HTTP $spa"
info " 目录: $root"
if [[ -d "$root/.git" ]]; then
info " 版本: $(git -C "$root" log -1 --oneline)"
fi
echo ""
info "1Panel → 网站 → 反代 http://127.0.0.1:${port} 域名: ${NEXUS_DOMAIN}"
info "配置示例: $root/deploy/1panel/openresty-nexus.conf.example"
if [[ "$FRESH_INSTALL" == true ]] || ! grep -qE '^NEXUS_SECRET_KEY=.+$' "$root/$ENV_PROD" 2>/dev/null; then
info "浏览器: https://${NEXUS_DOMAIN}/app/install.html"
else
info "浏览器: https://${NEXUS_DOMAIN}/app/"
fi
echo ""
NEXUS_ROOT="$root" bash "$root/deploy/install-nx-cli.sh" 2>/dev/null || true
info "运维菜单: nx 或 nexus-update"
check_ports_post "$root"
return 0
fi
sleep 5
done
error "健康检查超时"
check_ports_post "$root" || true
compose_cmd "$root" logs --tail=80 nexus || true
return 1
}
ensure_repo() {
if [[ ! -d "$NEXUS_ROOT/.git" ]]; then
error "未找到 $NEXUS_ROOT,请先: bash $0 install"
exit 1
fi
if [[ ! -f "$NEXUS_ROOT/$ENV_PROD" ]]; then
error "缺少 $NEXUS_ROOT/$ENV_PROD"
exit 1
fi
}
git_fetch() {
local url
url="$(git_remote_url)"
git -C "$NEXUS_ROOT" remote set-url origin "$url" 2>/dev/null || true
git -C "$NEXUS_ROOT" fetch origin "$GIT_BRANCH" --prune
}
commits_behind() {
git -C "$NEXUS_ROOT" rev-list "HEAD..origin/${GIT_BRANCH}" --count 2>/dev/null || echo "0"
}
show_pending() {
local behind
behind="$(commits_behind)"
info "当前: $(git -C "$NEXUS_ROOT" log -1 --oneline)"
info "远程: $(git -C "$NEXUS_ROOT" log -1 --oneline "origin/${GIT_BRANCH}")"
if [[ "$behind" == "0" ]]; then
info "已是最新。"
return 1
fi
warn "落后 ${behind} 个提交:"
git -C "$NEXUS_ROOT" log --oneline "HEAD..origin/${GIT_BRANCH}"
return 0
}
backup_mysql() {
local root="$1" ts file db_url cname
if [[ "$NO_BACKUP" == true ]]; then
warn "已跳过备份 (--no-backup)"
return 0
fi
if ! command -v mysqldump >/dev/null 2>&1; then
warn "未安装 mysqldump,跳过 MySQL 备份"
return 0
fi
cname="$(docker ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
db_url=""
if [[ -n "$cname" ]]; then
db_url="$(docker exec "$cname" grep -E '^NEXUS_DATABASE_URL=' /app/.env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)"
fi
if [[ -z "$db_url" ]]; then
warn "未找到 NEXUS_DATABASE_URL(外置 MySQL 需先完成安装向导),跳过备份"
return 0
fi
mkdir -p "$NEXUS_BACKUP_DIR"
ts="$(date +%Y%m%d-%H%M%S)"
file="${NEXUS_BACKUP_DIR}/nexus-before-upgrade-${ts}.sql"
step "备份外置 MySQL → $file"
if ! python3 - "$db_url" "$file" <<'PY'
import sys
import urllib.parse
from subprocess import run
url = sys.argv[1].replace("mysql+aiomysql://", "mysql://", 1)
out = sys.argv[2]
p = urllib.parse.urlparse(url)
user = urllib.parse.unquote(p.username or "")
password = urllib.parse.unquote(p.password or "")
host = p.hostname or "127.0.0.1"
if host == "host.docker.internal":
host = "127.0.0.1"
port = str(p.port or 3306)
db = (p.path or "/nexus").lstrip("/").split("?")[0]
env = {**__import__("os").environ, "MYSQL_PWD": password}
cmd = [
"mysqldump", "-h", host, "-P", port, "-u", user,
"--single-transaction", "--routines", "--triggers", "--set-gtid-purged=OFF", db,
]
r = run(cmd, env=env, capture_output=True, text=True)
if r.returncode != 0:
sys.stderr.write(r.stderr or r.stdout or "mysqldump failed\n")
sys.exit(r.returncode)
open(out, "w", encoding="utf-8").write(r.stdout)
PY
then
if [[ "$REQUIRE_BACKUP" == true ]]; then
error "mysqldump 失败,已中止(--require-backup"
rm -f "$file"
exit 1
fi
warn "mysqldump 失败,继续升级(加 --require-backup 可在备份失败时中止)"
rm -f "$file"
return 0
fi
gzip -f "$file"
info "备份: ${file}.gz"
}
git_upgrade() {
local before after
before="$(git -C "$NEXUS_ROOT" rev-parse HEAD)"
step "同步 origin/${GIT_BRANCH}"
git -C "$NEXUS_ROOT" reset --hard "origin/${GIT_BRANCH}"
after="$(git -C "$NEXUS_ROOT" rev-parse HEAD)"
info "代码: ${before:0:7}${after:0:7}"
}
cmd_init_token() {
require_root
local tok="${1:-}"
if [[ -z "$tok" ]]; then
error "用法: $0 init-token <Gitea令牌>"
exit 1
fi
printf 'NEXUS_GITEA_TOKEN=%s\n' "$tok" >"$NEXUS_DEPLOY_ENV"
chmod 600 "$NEXUS_DEPLOY_ENV"
info "已写入 $NEXUS_DEPLOY_ENV"
}
cmd_install() {
echo ""
echo "=========================================="
echo " Nexus 1Panel 一键安装"
echo " 域名: ${NEXUS_DOMAIN} 档位: ${NEXUS_PROFILE}"
echo "=========================================="
echo ""
require_root
require_token
resolve_profile
ensure_packages
ensure_docker
check_ports_preflight "$NEXUS_ROOT"
clone_or_pull
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
write_env_prod "$NEXUS_ROOT"
compose_up "$NEXUS_ROOT"
verify_health "$NEXUS_ROOT" "安装" || exit 1
}
cmd_upgrade() {
echo ""
echo "=========================================="
echo " Nexus 1Panel 一键升级"
echo "=========================================="
echo ""
require_root
require_token
ensure_docker
ensure_repo
load_saved_profile "$NEXUS_ROOT"
resolve_profile
check_ports_preflight "$NEXUS_ROOT"
git_fetch
show_pending || true
if [[ "$CHECK_ONLY" == true ]]; then
exit 0
fi
if [[ "$DRY_RUN" == true ]]; then
info "dry-run 结束。"
exit 0
fi
local behind
behind="$(commits_behind)"
if [[ "$behind" != "0" ]]; then
backup_mysql "$NEXUS_ROOT"
git_upgrade
else
info "Git 已最新,仍重建 Docker 镜像与容器(避免旧 entrypoint 缓存)"
if [[ "$NO_BACKUP" != true ]]; then
backup_mysql "$NEXUS_ROOT"
fi
fi
apply_pool_to_env_prod "$NEXUS_ROOT" "$(profile_env_file "$NEXUS_ROOT")"
save_host_profile "$NEXUS_ROOT"
purge_legacy_bundled_services "$NEXUS_ROOT"
sync_1panel_service_hosts "$NEXUS_ROOT"
sync_env_prod_to_volume "$NEXUS_ROOT"
tag_rollback_image "$NEXUS_ROOT"
if [[ "$NO_CACHE" == true ]]; then
step "无缓存重建 Nexus 镜像..."
compose_cmd "$NEXUS_ROOT" build --no-cache nexus
else
step "重建 Nexus 镜像..."
compose_cmd "$NEXUS_ROOT" build nexus
fi
if ! gate_new_image_import "$NEXUS_ROOT"; then
exit 1
fi
step "重建容器(--remove-orphans 清理旧 mysql/redis..."
compose_cmd "$NEXUS_ROOT" up -d --no-build --remove-orphans
if ! verify_nexus_1panel_network "$NEXUS_ROOT"; then
rollback_compose_upgrade "$NEXUS_ROOT" || true
exit 1
fi
local port wiz_code i health
port="$(nexus_publish_port "$NEXUS_ROOT")"
for i in $(seq 1 30); do
health="$(curl -sf "http://127.0.0.1:${port}/health" 2>/dev/null || true)"
[[ "$health" == "ok" ]] && break
sleep 2
done
wiz_code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")"
if [[ "$wiz_code" != "200" && -x "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh" ]]; then
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh"
fi
set_install_complete "$NEXUS_ROOT"
if [[ "$PRUNE_IMAGES" == true ]]; then
docker image prune -f >/dev/null 2>&1 || true
fi
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
if ! verify_health "$NEXUS_ROOT" "升级"; then
rollback_compose_upgrade "$NEXUS_ROOT" || true
exit 1
fi
}
cmd_check() {
require_root
require_token
ensure_repo
git_fetch
show_pending || true
}
parse_common_install() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--skip-clone) SKIP_CLONE=true; shift ;;
--fresh) FRESH_INSTALL=true; shift ;;
--reuse-secrets) REUSE_SECRETS=true; shift ;;
--profile) NEXUS_PROFILE="$2"; shift 2 ;;
--cpus) CUSTOM_CPUS="$2"; shift 2 ;;
--mem-gb|--memory-gb) CUSTOM_MEM_GB="$2"; shift 2 ;;
--domain) NEXUS_DOMAIN="$2"; shift 2 ;;
--from-env) FROM_ENV="$2"; shift 2 ;;
--root) NEXUS_ROOT="$2"; shift 2 ;;
--no-backup) NO_BACKUP=true; shift ;;
--prune|--prune-images) PRUNE_IMAGES=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
*)
error "未知参数: $1"
usage
;;
esac
done
}
parse_common_upgrade() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--profile) NEXUS_PROFILE="$2"; shift 2 ;;
--cpus) CUSTOM_CPUS="$2"; shift 2 ;;
--mem-gb|--memory-gb) CUSTOM_MEM_GB="$2"; shift 2 ;;
--root) NEXUS_ROOT="$2"; shift 2 ;;
--branch) GIT_BRANCH="$2"; shift 2 ;;
--no-backup) NO_BACKUP=true; shift ;;
--require-backup) REQUIRE_BACKUP=true; shift ;;
--prune|--prune-images) PRUNE_IMAGES=true; shift ;;
--no-cache|--rebuild) NO_CACHE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--check) CHECK_ONLY=true; shift ;;
*)
error "未知参数: $1"
usage
;;
esac
done
}
main() {
local cmd="${1:-install}"
shift || true
load_secrets
case "$cmd" in
install)
parse_common_install "$@"
cmd_install
;;
upgrade)
parse_common_upgrade "$@"
cmd_upgrade
;;
check)
CHECK_ONLY=true
parse_common_upgrade "$@"
cmd_check
;;
init-token)
cmd_init_token "${1:-}"
;;
ports)
cmd_ports
;;
-h|--help|help)
usage
;;
*)
error "未知子命令: $cmd"
usage
;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi