#!/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 archive_install_wizard_in_container "$cname" 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 if ! verify_nexus_1panel_network "$root"; then error "回滚后仍未接入 1panel-network" return 1 fi 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_host(NEXUS_REDIS_URL 已保留向导密码)" else info "1Panel Redis 容器: $redis_host(NEXUS_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 } reset_nexus_state_volume_for_fresh() { local vol vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)" [[ -n "$vol" ]] || return 0 warn "全新安装:清除 nexus-state 卷内旧 .env / .install_locked" docker run --rm -v "${vol}:/data" alpine:3.19 \ sh -c 'rm -f /data/.env /data/.install_locked' 2>/dev/null || true } restore_install_wizard_html_in_container() { local cname="$1" [[ -n "$cname" ]] || return 0 docker exec "$cname" sh -c ' rm -f /app/.install_locked if [ -f /app/web/app/install.html.bak ] && [ ! -f /app/web/app/install.html ]; then mv /app/web/app/install.html.bak /app/web/app/install.html fi ' 2>/dev/null || true } compose_up() { local root="$1" cname save_host_profile "$root" purge_legacy_bundled_services "$root" sync_1panel_service_hosts "$root" if [[ "$FRESH_INSTALL" == true ]]; then reset_nexus_state_volume_for_fresh fi if has_1panel_network; then info "已接入 1panel-network(MySQL/Redis 使用 1Panel 容器名互联)" fi info "档位: ${NEXUS_PROFILE}(仅 Nexus 容器;MySQL/Redis 请自行安装)" info "构建并启动(首次约 10~20 分钟)..." compose_cmd "$root" up -d --build --remove-orphans verify_nexus_1panel_network "$root" || return 1 if [[ "$FRESH_INSTALL" == true ]]; then cname="$(nexus_container_name)" restore_install_wizard_html_in_container "$cname" fi } archive_install_wizard_in_container() { local cname="$1" [[ -n "$cname" ]] || return 0 docker exec "$cname" sh -c ' if [ -f /app/web/app/install.html.bak ]; then rm -f /app/web/app/install.html elif [ -f /app/web/app/install.html ]; then mv /app/web/app/install.html /app/web/app/install.html.bak fi ' 2>/dev/null || true } verify_install_wizard() { local root="$1" local port="${2:-$(nexus_publish_port "$root")}" local code cname cname="$(nexus_container_name)" if [[ -n "$cname" ]] && nexus_container_has_install_lock "$cname"; then archive_install_wizard_in_container "$cname" 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" == "404" ]]; then info " /app/install.html 已归档 → HTTP 404" return 0 fi warn " 已锁定但 /app/install.html → HTTP $code" return 1 fi 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 dump_sh if [[ "$NO_BACKUP" == true ]]; then warn "已跳过备份 (--no-backup)" return 0 fi dump_sh="${root}/deploy/mysql_dump_to_file.sh" if [[ ! -x "$dump_sh" ]]; then warn "缺少 $dump_sh,跳过 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 ! NEXUS_ROOT="$root" bash "$dump_sh" --output "$file" --root "$root"; 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 " 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 cname="$(nexus_container_name)" if [[ -n "$cname" ]] && nexus_container_has_install_lock "$cname"; then archive_install_wizard_in_container "$cname" info "安装已锁定,跳过 install.html 热同步" else 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 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