feat(install): per-host auto-generated secrets for Docker installs
Each fresh 1Panel/Docker install generates unique MYSQL and NEXUS keys via scripts/generate_nexus_secrets.py; install wizard reuses compose env.
This commit is contained in:
@@ -40,6 +40,8 @@ bash /tmp/quick-install.sh --profile 2c8g
|
||||
|
||||
安装完成后在浏览器打开安装向导,完成 `.env` / MySQL / 管理员初始化。
|
||||
|
||||
**每台新服务器的密钥自动不同**:安装脚本生成 `MYSQL_*` 与 `NEXUS_SECRET_KEY` / `NEXUS_API_KEY` / `NEXUS_ENCRYPTION_KEY` 写入 `docker/.env.prod`,备份在 `/root/.nexus-install-secrets-*.env`。勿把生产密钥放进 `nexus-1panel.secrets.sh`(仅保留 `NEXUS_GITEA_TOKEN`)。
|
||||
|
||||
---
|
||||
|
||||
## 已 clone 到本机(跳过再次拉仓)
|
||||
|
||||
@@ -42,3 +42,20 @@ nx god # 上帝菜单(重启 Python / 升级 / 日志)
|
||||
## 资源档位
|
||||
|
||||
`--profile 1c4g` · `2c8g`(默认)· `4c16g`
|
||||
|
||||
## 密钥(每台新服务器唯一)
|
||||
|
||||
全新安装(`install-nexus-fresh.sh` / `--fresh`)会调用 `scripts/generate_nexus_secrets.py` 自动生成:
|
||||
|
||||
- `MYSQL_ROOT_PASSWORD` / `MYSQL_PASSWORD`
|
||||
- `NEXUS_SECRET_KEY` / `NEXUS_API_KEY` / `NEXUS_ENCRYPTION_KEY`(与安装向导算法一致)
|
||||
|
||||
写入 `docker/.env.prod`,备份到 `/root/.nexus-install-secrets-*.env`(chmod 600)。
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 新机(推荐) | `curl \| bash` 或 `install-nexus-fresh.sh` |
|
||||
| 迁机保留旧密钥 | `nexus-1panel.sh install --from-env /path/to/old/.env` |
|
||||
| 显式复用 secrets 文件 | `nexus-1panel.sh install --reuse-secrets`(**勿用于新机**) |
|
||||
|
||||
`deploy/nexus-1panel.secrets.sh` 只应放 `NEXUS_GITEA_TOKEN`,不要放生产 `SECRET_KEY`。
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
#
|
||||
# bash deploy/install-nexus-fresh.sh --profile 4c16g
|
||||
#
|
||||
# 凭据(任选其一,无需 export):
|
||||
# - /root/.nexus-deploy.env
|
||||
# - /root/.nexus-secrets/nexus.env
|
||||
# - 仓库内 deploy/nexus-1panel.secrets.sh(私人仓库可 git add -f 后 push)
|
||||
# 凭据(仅 Gitea git 用,NEXUS 密钥由安装脚本按机自动生成):
|
||||
# - /root/.nexus-deploy.env → NEXUS_GITEA_TOKEN
|
||||
# - deploy/nexus-1panel.secrets.sh 勿放生产 SECRET_KEY(新机勿复用)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
@@ -115,8 +114,10 @@ load_credentials() {
|
||||
set -a
|
||||
source "${NEXUS_ROOT}/deploy/nexus-1panel.secrets.sh"
|
||||
set +a
|
||||
info "已加载 ${NEXUS_ROOT}/deploy/nexus-1panel.secrets.sh"
|
||||
info "已加载 ${NEXUS_ROOT}/deploy/nexus-1panel.secrets.sh(仅 git 令牌)"
|
||||
fi
|
||||
unset NEXUS_SECRET_KEY NEXUS_API_KEY NEXUS_ENCRYPTION_KEY
|
||||
info "全新安装:NEXUS 密钥将在 docker/.env.prod 中按本机自动生成"
|
||||
}
|
||||
|
||||
install_root_commands() {
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
NEXUS_GITEA_TOKEN='你的Gitea_Access_Token'
|
||||
|
||||
NEXUS_SECRET_KEY='生产NEXUS_SECRET_KEY不可改'
|
||||
NEXUS_API_KEY='生产NEXUS_API_KEY不可改'
|
||||
NEXUS_ENCRYPTION_KEY=''
|
||||
# 新机安装勿填下面三项(install-nexus-fresh / --fresh 会自动按机生成)
|
||||
# 仅迁机时: bash deploy/nexus-1panel.sh install --reuse-secrets
|
||||
# NEXUS_SECRET_KEY=''
|
||||
# NEXUS_API_KEY=''
|
||||
# NEXUS_ENCRYPTION_KEY=''
|
||||
|
||||
NEXUS_DOMAIN='api.synaglobal.vip'
|
||||
NEXUS_ROOT='/opt/nexus'
|
||||
|
||||
+74
-42
@@ -50,6 +50,7 @@ step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
|
||||
SKIP_CLONE=false
|
||||
FRESH_INSTALL=false
|
||||
REUSE_SECRETS=false
|
||||
FROM_ENV="${NEXUS_FROM_ENV}"
|
||||
NO_BACKUP=false
|
||||
PRUNE_IMAGES=false
|
||||
@@ -74,8 +75,9 @@ Nexus 1Panel 一键脚本
|
||||
|
||||
安装选项:
|
||||
--skip-clone 已在 /opt/nexus,仅 pull
|
||||
--fresh 全新安装(/app/install.html)
|
||||
--from-env PATH 从旧 .env 导入密钥
|
||||
--fresh 全新安装(本机自动生成唯一密钥 + /app/install.html)
|
||||
--from-env PATH 从旧 .env 迁机导入密钥
|
||||
--reuse-secrets 使用 secrets 文件中的 NEXUS 密钥(勿用于新机)
|
||||
--domain NAME 覆盖域名
|
||||
|
||||
升级选项:
|
||||
@@ -128,6 +130,10 @@ load_secrets() {
|
||||
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() {
|
||||
@@ -443,15 +449,35 @@ clone_or_pull() {
|
||||
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 'MYSQL_ROOT_PASSWORD=%s\n' "${MYSQL_ROOT_PASSWORD:-$(rand_pass)}"
|
||||
printf 'MYSQL_PASSWORD=%s\n' "${MYSQL_PASSWORD:-$(rand_pass)}"
|
||||
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 已存在,仅更新域名与连接池"
|
||||
@@ -462,53 +488,58 @@ write_env_prod() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
local mysql_root mysql_pass secret api enc
|
||||
mysql_root="$(rand_pass)"
|
||||
mysql_pass="$(rand_pass)"
|
||||
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 导入密钥..."
|
||||
secret="$(read_env_val "$FROM_ENV" NEXUS_SECRET_KEY)"
|
||||
api="$(read_env_val "$FROM_ENV" NEXUS_API_KEY)"
|
||||
enc="$(read_env_val "$FROM_ENV" NEXUS_ENCRYPTION_KEY)"
|
||||
local old_root old_pass dburl
|
||||
old_root="$(read_env_val "$FROM_ENV" MYSQL_ROOT_PASSWORD)"
|
||||
old_pass="$(read_env_val "$FROM_ENV" MYSQL_PASSWORD)"
|
||||
[[ -n "$old_root" ]] && mysql_root="$old_root"
|
||||
[[ -n "$old_pass" ]] && mysql_pass="$old_pass"
|
||||
if [[ -z "$old_pass" ]]; then
|
||||
dburl="$(read_env_val "$FROM_ENV" NEXUS_DATABASE_URL)"
|
||||
if [[ "$dburl" =~ [Nn]exus:([^@]+)@ ]]; then
|
||||
mysql_pass="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
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
|
||||
secret=""
|
||||
api=""
|
||||
enc=""
|
||||
info "全新安装:完成后访问 https://${NEXUS_DOMAIN}/app/install.html"
|
||||
elif [[ -n "${NEXUS_SECRET_KEY:-}" ]]; then
|
||||
secret="$NEXUS_SECRET_KEY"
|
||||
api="${NEXUS_API_KEY:-}"
|
||||
enc="${NEXUS_ENCRYPTION_KEY:-}"
|
||||
info "使用 secrets 文件中的生产 NEXUS 密钥(Docker MySQL 密码为新生成)"
|
||||
info "全新安装:为本机生成唯一 MYSQL / NEXUS 密钥..."
|
||||
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
|
||||
secret="$(rand_secret)"
|
||||
api="$(rand_secret)"
|
||||
enc=""
|
||||
warn "已生成随机 NEXUS 密钥(仅适合测试)"
|
||||
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
|
||||
|
||||
cp "$example" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
sed -i "s|^MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=${mysql_root}|" "$env_file"
|
||||
sed -i "s|^MYSQL_PASSWORD=.*|MYSQL_PASSWORD=${mysql_pass}|" "$env_file"
|
||||
sed -i "s|^NEXUS_SECRET_KEY=.*|NEXUS_SECRET_KEY=${secret}|" "$env_file"
|
||||
sed -i "s|^NEXUS_API_KEY=.*|NEXUS_API_KEY=${api}|" "$env_file"
|
||||
sed -i "s|^NEXUS_ENCRYPTION_KEY=.*|NEXUS_ENCRYPTION_KEY=${enc}|" "$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")"
|
||||
info "已写入 $env_file"
|
||||
}
|
||||
|
||||
@@ -715,6 +746,7 @@ parse_common_install() {
|
||||
-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 ;;
|
||||
|
||||
@@ -97,6 +97,7 @@ main() {
|
||||
for cf in /root/.nexus-deploy.env /root/.nexus-secrets/nexus.env; do
|
||||
[[ -f "$cf" ]] && set -a && source "$cf" && set +a
|
||||
done
|
||||
unset NEXUS_SECRET_KEY NEXUS_API_KEY NEXUS_ENCRYPTION_KEY
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# Production Compose env — copy to docker/.env.prod (never commit)
|
||||
# Migrate NEXUS_* secrets from Baota /www/wwwroot/.../.env UNCHANGED.
|
||||
# Fresh install: bash deploy/install-nexus-fresh.sh (auto-generates unique secrets per host)
|
||||
# Migration: bash deploy/nexus-1panel.sh install --from-env /path/to/old/.env
|
||||
|
||||
MYSQL_ROOT_PASSWORD=change_me_root_strong
|
||||
MYSQL_PASSWORD=change_me_nexus_strong
|
||||
|
||||
# Required — copy from production .env (do NOT regenerate)
|
||||
# Auto-generated on fresh install (unique per server). Do NOT copy between hosts.
|
||||
NEXUS_SECRET_KEY=
|
||||
NEXUS_API_KEY=
|
||||
NEXUS_ENCRYPTION_KEY=
|
||||
|
||||
# 1 = entrypoint must not write /app/.env until install wizard step 3
|
||||
NEXUS_INSTALL_WIZARD_PENDING=0
|
||||
|
||||
# Public URL
|
||||
NEXUS_CORS_ORIGINS=https://api.synaglobal.vip
|
||||
NEXUS_API_BASE_URL=https://api.synaglobal.vip
|
||||
|
||||
@@ -64,5 +64,7 @@ docker compose -f docker/docker-compose.prod.yml --env-file docker/.env.prod up
|
||||
- 仅绑定 `127.0.0.1:8600`;OpenResty 反代见 `deploy/1panel/openresty-nexus.conf.example`
|
||||
- **2C8G 调优**:`docker/mysql-prod-2c8g.cnf`(buffer pool 1G、`max_connections=120`)+ 容器 `mem_limit` + `NEXUS_DB_POOL_SIZE=30`
|
||||
- 完整迁移步骤:`docs/design/plans/2026-06-04-1panel-docker-production.md`
|
||||
- 一键安装:`bash deploy/install-1panel-docker.sh`
|
||||
- 一键升级:`bash deploy/upgrade-1panel-docker.sh`(`--check` 仅查更新)
|
||||
|
||||
详见 `docs/design/specs/2026-06-03-docker-design.md`。
|
||||
|
||||
@@ -84,7 +84,8 @@ services:
|
||||
NEXUS_CORS_ORIGINS: ${NEXUS_CORS_ORIGINS:?set NEXUS_CORS_ORIGINS}
|
||||
NEXUS_SECRET_KEY: ${NEXUS_SECRET_KEY:?set NEXUS_SECRET_KEY}
|
||||
NEXUS_API_KEY: ${NEXUS_API_KEY:?set NEXUS_API_KEY}
|
||||
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
|
||||
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:?set NEXUS_ENCRYPTION_KEY}
|
||||
NEXUS_INSTALL_WIZARD_PENDING: ${NEXUS_INSTALL_WIZARD_PENDING:-0}
|
||||
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
|
||||
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
|
||||
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
|
||||
|
||||
@@ -38,6 +38,10 @@ write_env_from_environment() {
|
||||
if [ -z "${NEXUS_SECRET_KEY:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-}" = "1" ]; then
|
||||
echo "entrypoint: install wizard pending — skip writing /app/.env"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "${PERSIST_DIR}"
|
||||
{
|
||||
echo "# Generated by docker/entrypoint.sh — do not commit"
|
||||
|
||||
+18
-30
@@ -3,47 +3,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.generate_nexus_secrets import ( # noqa: E402
|
||||
generate_nexus_secrets,
|
||||
write_env_prod,
|
||||
)
|
||||
|
||||
OUT = ROOT / "docker" / ".env"
|
||||
EXAMPLE = ROOT / "docker" / ".env.example"
|
||||
|
||||
|
||||
def _token(n: int = 32) -> str:
|
||||
return secrets.token_urlsafe(n)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if OUT.exists():
|
||||
raise SystemExit(f"Refusing to overwrite existing {OUT} — delete it first.")
|
||||
|
||||
mysql_root = _token(24)
|
||||
mysql_pass = _token(24)
|
||||
secret = _token(32)
|
||||
api_key = _token(24)
|
||||
# Fernet-compatible (same as install wizard)
|
||||
enc_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
text = EXAMPLE.read_text(encoding="utf-8")
|
||||
lines: list[str] = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("MYSQL_ROOT_PASSWORD="):
|
||||
lines.append(f"MYSQL_ROOT_PASSWORD={mysql_root}")
|
||||
elif line.startswith("MYSQL_PASSWORD="):
|
||||
lines.append(f"MYSQL_PASSWORD={mysql_pass}")
|
||||
elif line.startswith("NEXUS_SECRET_KEY="):
|
||||
lines.append(f"NEXUS_SECRET_KEY={secret}")
|
||||
elif line.startswith("NEXUS_API_KEY="):
|
||||
lines.append(f"NEXUS_API_KEY={api_key}")
|
||||
elif line.startswith("NEXUS_ENCRYPTION_KEY="):
|
||||
lines.append(f"NEXUS_ENCRYPTION_KEY={enc_key}")
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
OUT.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
secrets_map = generate_nexus_secrets()
|
||||
write_env_prod(
|
||||
EXAMPLE,
|
||||
OUT,
|
||||
domain="localhost",
|
||||
secrets_map=secrets_map,
|
||||
profile_env=None,
|
||||
wizard_pending=False,
|
||||
)
|
||||
print(f"Wrote {OUT}")
|
||||
print("Next: docker compose up -d --build")
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 2026-06-06 — 安装脚本按机自动生成唯一密钥
|
||||
|
||||
## 摘要
|
||||
|
||||
Docker / 1Panel 全新安装路径统一通过 `scripts/generate_nexus_secrets.py` 生成每台服务器独立的 `MYSQL_*` 与 `NEXUS_SECRET_KEY` / `NEXUS_API_KEY` / `NEXUS_ENCRYPTION_KEY`,写入 `docker/.env.prod` 并备份到 `/root/.nexus-install-secrets-*.env`。安装向导 `init-db` 复用容器环境内预生成密钥,避免与 Compose 不一致。
|
||||
|
||||
## 动机
|
||||
|
||||
新机安装不应从 `nexus-1panel.secrets.sh` 复制生产密钥;此前 `--fresh` 留空密钥导致 Compose 校验失败或各机密钥相同。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/generate_nexus_secrets.py`(新建)
|
||||
- `deploy/nexus-1panel.sh` — `write_env_prod` / `--reuse-secrets` / `--fresh` 忽略 secrets 中 NEXUS 项
|
||||
- `deploy/install-nexus-fresh.sh` / `deploy/quick-install.sh`
|
||||
- `docker/generate_env.py` — 复用同一生成器
|
||||
- `docker/entrypoint.sh` — `NEXUS_INSTALL_WIZARD_PENDING=1` 时不写 `/app/.env`
|
||||
- `docker/docker-compose.prod.yml` — 传递 `NEXUS_INSTALL_WIZARD_PENDING`
|
||||
- `server/api/install.py` — `_install_keys_from_environment()`
|
||||
- `deploy/README-1panel.md` · `deploy/nexus-1panel.secrets.sh.example`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
已部署实例无需变更。新机安装后需完成 `/app/install.html` 向导。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
python3 scripts/generate_nexus_secrets.py write-prod \
|
||||
--template docker/.env.prod.example --output /tmp/t.env.prod \
|
||||
--domain test.example.com --profile-env docker/profiles/2c8g.env --fresh --wizard-pending
|
||||
grep NEXUS_SECRET_KEY /tmp/t.env.prod
|
||||
ruff check server/api/install.py scripts/generate_nexus_secrets.py
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate per-host Nexus secrets (install / Docker).
|
||||
|
||||
Each fresh install must get unique MYSQL_* and NEXUS_* keys. Matches install wizard:
|
||||
SECRET_KEY = token_hex(32)
|
||||
API_KEY = token_hex(16)
|
||||
ENCRYPTION_KEY = urlsafe_b64encode(token_bytes(32))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from server.utils.text_io import read_utf8_lf, write_utf8_lf # noqa: E402
|
||||
|
||||
|
||||
def generate_nexus_secrets() -> dict[str, str]:
|
||||
return {
|
||||
"MYSQL_ROOT_PASSWORD": secrets.token_urlsafe(24),
|
||||
"MYSQL_PASSWORD": secrets.token_urlsafe(24),
|
||||
"NEXUS_SECRET_KEY": secrets.token_hex(32),
|
||||
"NEXUS_API_KEY": secrets.token_hex(16),
|
||||
"NEXUS_ENCRYPTION_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode(),
|
||||
}
|
||||
|
||||
|
||||
def parse_env_file(path: Path) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return data
|
||||
for line in read_utf8_lf(path).splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
data[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return data
|
||||
|
||||
|
||||
def import_secrets_from_env(path: Path) -> dict[str, str]:
|
||||
"""Import NEXUS + MySQL passwords from an existing .env (migration)."""
|
||||
raw = parse_env_file(path)
|
||||
out = generate_nexus_secrets()
|
||||
for key in (
|
||||
"MYSQL_ROOT_PASSWORD",
|
||||
"MYSQL_PASSWORD",
|
||||
"NEXUS_SECRET_KEY",
|
||||
"NEXUS_API_KEY",
|
||||
"NEXUS_ENCRYPTION_KEY",
|
||||
):
|
||||
val = raw.get(key, "").strip()
|
||||
if val:
|
||||
out[key] = val
|
||||
if not out.get("NEXUS_ENCRYPTION_KEY"):
|
||||
dburl = raw.get("NEXUS_DATABASE_URL", "")
|
||||
m = re.search(r"mysql\+aiomysql://[^:]+:([^@]+)@", dburl)
|
||||
if m and not out.get("MYSQL_PASSWORD"):
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
out["MYSQL_PASSWORD"] = unquote_plus(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def apply_profile_pools(lines: list[str], profile_env: Path) -> list[str]:
|
||||
if not profile_env.is_file():
|
||||
return lines
|
||||
prof = parse_env_file(profile_env)
|
||||
pool = prof.get("NEXUS_DB_POOL_SIZE", "")
|
||||
overflow = prof.get("NEXUS_DB_MAX_OVERFLOW", "")
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if pool and line.startswith("NEXUS_DB_POOL_SIZE="):
|
||||
out.append(f"NEXUS_DB_POOL_SIZE={pool}")
|
||||
elif overflow and line.startswith("NEXUS_DB_MAX_OVERFLOW="):
|
||||
out.append(f"NEXUS_DB_MAX_OVERFLOW={overflow}")
|
||||
else:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def write_env_prod(
|
||||
template: Path,
|
||||
output: Path,
|
||||
*,
|
||||
domain: str,
|
||||
secrets_map: dict[str, str],
|
||||
profile_env: Path | None,
|
||||
wizard_pending: bool,
|
||||
) -> None:
|
||||
if not template.is_file():
|
||||
raise SystemExit(f"Missing template: {template}")
|
||||
|
||||
lines: list[str] = []
|
||||
for line in read_utf8_lf(template).splitlines():
|
||||
key = line.split("=", 1)[0].strip() if "=" in line and not line.strip().startswith("#") else ""
|
||||
if key in secrets_map:
|
||||
lines.append(f"{key}={secrets_map[key]}")
|
||||
elif key == "NEXUS_CORS_ORIGINS":
|
||||
if domain == "localhost":
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(f"NEXUS_CORS_ORIGINS=https://{domain}")
|
||||
elif key == "NEXUS_API_BASE_URL":
|
||||
if domain == "localhost":
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(f"NEXUS_API_BASE_URL=https://{domain}")
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
if wizard_pending:
|
||||
found = False
|
||||
new_lines: list[str] = []
|
||||
for line in lines:
|
||||
if line.startswith("NEXUS_INSTALL_WIZARD_PENDING="):
|
||||
new_lines.append("NEXUS_INSTALL_WIZARD_PENDING=1")
|
||||
found = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not found:
|
||||
new_lines.append("")
|
||||
new_lines.append("# Set 0 after /app/install.html completes (init-db writes /app/.env)")
|
||||
new_lines.append("NEXUS_INSTALL_WIZARD_PENDING=1")
|
||||
lines = new_lines
|
||||
|
||||
if profile_env:
|
||||
lines = apply_profile_pools(lines, profile_env)
|
||||
|
||||
header = [
|
||||
f"# Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
|
||||
"# Unique secrets for this host — do not copy to other servers",
|
||||
"",
|
||||
]
|
||||
text = "\n".join(header + lines) + "\n"
|
||||
write_utf8_lf(output, text)
|
||||
output.chmod(0o600)
|
||||
|
||||
|
||||
def write_secrets_backup(path: Path, secrets_map: dict[str, str], *, domain: str) -> None:
|
||||
lines = [
|
||||
f"# Nexus install secrets backup — {datetime.now(timezone.utc).isoformat()}",
|
||||
f"# Domain: {domain}",
|
||||
"# Store only on this server (chmod 600). Do NOT commit to Git.",
|
||||
"",
|
||||
]
|
||||
for key in (
|
||||
"MYSQL_ROOT_PASSWORD",
|
||||
"MYSQL_PASSWORD",
|
||||
"NEXUS_SECRET_KEY",
|
||||
"NEXUS_API_KEY",
|
||||
"NEXUS_ENCRYPTION_KEY",
|
||||
):
|
||||
if key in secrets_map:
|
||||
lines.append(f"{key}={secrets_map[key]}")
|
||||
write_utf8_lf(path, "\n".join(lines) + "\n")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def cmd_shell(secrets_map: dict[str, str]) -> None:
|
||||
for key, val in secrets_map.items():
|
||||
print(f"export {key}={shlex.quote(val)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate unique Nexus install secrets")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_shell = sub.add_parser("shell", help="Print export statements for bash eval")
|
||||
p_shell.add_argument("--from-env", type=Path, help="Import keys from existing .env")
|
||||
|
||||
p_write = sub.add_parser("write-prod", help="Write docker/.env.prod from template")
|
||||
p_write.add_argument("--template", type=Path, required=True)
|
||||
p_write.add_argument("--output", type=Path, required=True)
|
||||
p_write.add_argument("--domain", required=True)
|
||||
p_write.add_argument("--profile-env", type=Path, default=None)
|
||||
p_write.add_argument("--from-env", type=Path, default=None, help="Migration: import NEXUS keys")
|
||||
p_write.add_argument(
|
||||
"--fresh",
|
||||
action="store_true",
|
||||
help="New host: always generate new secrets (ignore process env)",
|
||||
)
|
||||
p_write.add_argument("--backup", type=Path, default=None, help="Write chmod 600 backup file")
|
||||
p_write.add_argument(
|
||||
"--wizard-pending",
|
||||
action="store_true",
|
||||
help="Set NEXUS_INSTALL_WIZARD_PENDING=1 in .env.prod",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "shell":
|
||||
if args.from_env:
|
||||
secrets_map = import_secrets_from_env(args.from_env)
|
||||
else:
|
||||
secrets_map = generate_nexus_secrets()
|
||||
cmd_shell(secrets_map)
|
||||
return
|
||||
|
||||
if args.cmd == "write-prod":
|
||||
if args.from_env and args.from_env.is_file():
|
||||
secrets_map = import_secrets_from_env(args.from_env)
|
||||
wizard = False
|
||||
elif args.fresh:
|
||||
secrets_map = generate_nexus_secrets()
|
||||
wizard = bool(args.wizard_pending)
|
||||
else:
|
||||
secrets_map = generate_nexus_secrets()
|
||||
wizard = bool(args.wizard_pending)
|
||||
|
||||
write_env_prod(
|
||||
args.template,
|
||||
args.output,
|
||||
domain=args.domain,
|
||||
secrets_map=secrets_map,
|
||||
profile_env=args.profile_env,
|
||||
wizard_pending=wizard,
|
||||
)
|
||||
if args.backup:
|
||||
write_secrets_backup(args.backup, secrets_map, domain=args.domain)
|
||||
print(f"Wrote {args.output}")
|
||||
if args.backup:
|
||||
print(f"Backup {args.backup}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+23
-6
@@ -98,6 +98,18 @@ def _build_redis_url(req: InitDbRequest) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _install_keys_from_environment() -> tuple[str, str, str] | None:
|
||||
"""Reuse keys pre-generated by Docker install (docker/.env.prod → compose env)."""
|
||||
import os
|
||||
|
||||
sk = os.environ.get("NEXUS_SECRET_KEY", "").strip()
|
||||
ak = os.environ.get("NEXUS_API_KEY", "").strip()
|
||||
ek = os.environ.get("NEXUS_ENCRYPTION_KEY", "").strip()
|
||||
if sk and ak and ek:
|
||||
return sk, ak, ek
|
||||
return None
|
||||
|
||||
|
||||
def _escape_env_value(value: str) -> str:
|
||||
"""Escape a value for safe inclusion in a .env file.
|
||||
|
||||
@@ -512,12 +524,17 @@ async def init_db(req: InitDbRequest):
|
||||
except Exception:
|
||||
pool_size, max_overflow = 160, 120
|
||||
|
||||
# Generate keys
|
||||
secret_key = secrets.token_hex(32)
|
||||
api_key = secrets.token_hex(16)
|
||||
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
|
||||
import base64
|
||||
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||||
# Keys: reuse Docker install pre-seed, else generate (must match install wizard)
|
||||
preseed = _install_keys_from_environment()
|
||||
if preseed:
|
||||
secret_key, api_key, encryption_key = preseed
|
||||
logger.info("Using pre-generated NEXUS keys from install environment")
|
||||
else:
|
||||
secret_key = secrets.token_hex(32)
|
||||
api_key = secrets.token_hex(16)
|
||||
import base64
|
||||
|
||||
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
# Insert settings
|
||||
settings_kv = {
|
||||
|
||||
Reference in New Issue
Block a user