Files
Nexus/scripts/generate_nexus_secrets.py
T

243 lines
8.0 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Generate per-host Nexus secrets (install / Docker).
Fresh install generates unique NEXUS_* keys only (MySQL is self-hosted).
Migration (--from-env) may still import legacy MYSQL_* from old .env.prod.
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(*, include_mysql: bool = False) -> dict[str, str]:
out: dict[str, str] = {
"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(),
}
if include_mysql:
out["MYSQL_ROOT_PASSWORD"] = secrets.token_urlsafe(24)
out["MYSQL_PASSWORD"] = secrets.token_urlsafe(24)
return out
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(include_mysql=False)
for key in (
"NEXUS_SECRET_KEY",
"NEXUS_API_KEY",
"NEXUS_ENCRYPTION_KEY",
"MYSQL_ROOT_PASSWORD",
"MYSQL_PASSWORD",
):
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 (
"NEXUS_SECRET_KEY",
"NEXUS_API_KEY",
"NEXUS_ENCRYPTION_KEY",
"MYSQL_ROOT_PASSWORD",
"MYSQL_PASSWORD",
):
if secrets_map.get(key):
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(include_mysql=False)
wizard = bool(args.wizard_pending)
else:
secrets_map = generate_nexus_secrets(include_mysql=False)
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()