release: nexus btpanel session fix and app-v2

This commit is contained in:
Codex Release Bot
2026-07-08 22:31:31 +08:00
commit 1933f0af6e
2457 changed files with 350105 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# Nexus Docker
## 前置
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)Windows/macOS)或 Docker Engine + Compose v2
## 快速启动(正常模式)
```bash
# 1. 生成密钥(写入 docker/.env,已 gitignore
python docker/generate_env.py
# 2. 构建并启动
docker compose up -d --build
# 3. 健康检查
curl -s http://localhost:8600/health # 期望: ok
# 4. 打开 SPA
# http://localhost:8600/app/
```
默认管理员需在安装向导或已有 DB 中配置;若 DB 为空且未跑过 install,可删卷后走安装模式。
## 安装向导模式
不创建 `docker/.env`(或清空 `NEXUS_SECRET_KEY`)时,应用以 **安装模式** 启动:
- http://localhost:8600/app/install.html
完成向导后 `.env` 会写入容器;`nexus-state` 卷会在重启时恢复配置。
## 常用命令
```bash
docker compose logs -f nexus
docker compose down # 停止,保留数据卷
docker compose down -v # 停止并删除数据卷(清空 DB
docker compose build --no-cache nexus
```
## 服务
| 环境 | Compose 服务 | 说明 |
|------|-------------|------|
| 本地开发 `docker-compose.yml` | nexus + mysql + redis | 一键本地联调 |
| 生产 `docker-compose.prod.yml` | **仅 nexus** | MySQL/Redis 宿主机或 1Panel 自建 |
生产 1Panel`docker/.env.prod` 可含 `NEXUS_REDIS_URL`(供 `nx update` 探测同步),**不经 Compose 注入容器**;运行时 Redis/MySQL URL 以安装向导写入的 `/app/.env``nexus-state` 卷)为准。MySQL `NEXUS_DATABASE_URL` 仅由向导步骤 3 写入。
## 生产注意
- 修改 `NEXUS_*` 为强密码;MySQL 使用自建库强密码。
- 建议前置 Nginx/TLS,仅暴露 443。
- 多实例部署需共享 MySQL、Redis;后台任务由 Redis primary lock 保证单实例执行。
### 1Panel 生产(Compose + OpenResty
```bash
cp docker/.env.prod.example docker/.env.prod # 从宝塔 .env 迁入密钥
docker compose -f docker/docker-compose.prod.yml --env-file docker/.env.prod up -d --build
```
- 镜像:`Dockerfile.prod`(含 `npm ci` + `vite build` + **`web/app/install.html` 安装向导**
- 仅绑定 `127.0.0.1:8600`OpenResty 反代见 `deploy/1panel/openresty-nexus.conf.example`
- **2C8G 调优**`docker/profiles/2c8g.env``NEXUS_MEM_LIMIT` + `NEXUS_DB_POOL_SIZE=30`);外置 MySQL 可参考 `docker/mysql-prod-2c8g.cnf` 手工调优
- 完整迁移步骤:`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`
+12
View File
@@ -0,0 +1,12 @@
# 1Panel overlay — join Nexus to 1panel-network (App Store MySQL/Redis use this network).
# Applied automatically when `docker network inspect 1panel-network` succeeds (deploy/nexus-1panel.sh).
services:
nexus:
networks:
- 1panel-network
networks:
1panel-network:
external: true
name: 1panel-network
+61
View File
@@ -0,0 +1,61 @@
# Nexus 6.0 — production Compose (1Panel / bare Docker host)
# MySQL / Redis 不在 Compose 内 — 请在宿主机或 1Panel 自行安装
#
# Usage:
# bash deploy/nexus-1panel.sh install --profile 2c8g
# docker compose -f docker/docker-compose.prod.yml \
# --env-file docker/.env.prod --env-file docker/profiles/2c8g.env up -d --build
#
# OpenResty on host proxies https://api.synaglobal.vip → 127.0.0.1:8600
name: nexus-prod
services:
nexus:
build:
context: ..
dockerfile: Dockerfile.prod
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:${NEXUS_PUBLISH_PORT:-8600}:8600"
environment:
NEXUS_PERSIST_DIR: /var/lib/nexus
NEXUS_DOCKER_WRITE_ENV: "1"
NEXUS_HOST: "0.0.0.0"
NEXUS_PORT: "8600"
NEXUS_DEPLOY_PATH: /app
NEXUS_HOST_ROOT: ${NEXUS_HOST_ROOT:-/opt/nexus}
# Redis URL 仅来自 /app/.env(安装向导 + entrypoint export_app_env),勿在此注入以免覆盖密码
NEXUS_1PANEL_DB_HOST: ${NEXUS_1PANEL_DB_HOST:-}
NEXUS_1PANEL_REDIS_HOST: ${NEXUS_1PANEL_REDIS_HOST:-}
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:?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}
volumes:
- nexus-state:/var/lib/nexus
- nexus-web-data:/app/web/data
mem_limit: ${NEXUS_MEM_LIMIT:-2g}
cpus: ${NEXUS_CPUS:-1.5}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- nexus-internal
networks:
nexus-internal:
driver: bridge
volumes:
nexus-state:
nexus-web-data:
+175
View File
@@ -0,0 +1,175 @@
#!/bin/sh
# Nexus Docker entrypoint — wait for deps, sync .env with persistent volume, exec uvicorn.
set -eu
PERSIST_DIR="${NEXUS_PERSIST_DIR:-/var/lib/nexus}"
PERSIST_ENV="${PERSIST_DIR}/.env"
wait_tcp() {
host="$1"
port="$2"
echo "entrypoint: waiting for ${host}:${port}..."
until python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
s.connect(('${host}', ${port}))
s.close()
" 2>/dev/null; do
sleep 2
done
}
wait_tcp_optional() {
host="$1"
port="$2"
tries="${3:-15}"
label="${4:-service}"
echo "entrypoint: waiting for ${label} ${host}:${port} (max ${tries} tries)..."
n=0
while [ "$n" -lt "$tries" ]; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
s.connect(('${host}', ${port}))
s.close()
" 2>/dev/null; then
echo "entrypoint: ${label} ready at ${host}:${port}"
return 0
fi
n=$((n + 1))
sleep 2
done
echo "entrypoint: warn — ${label} not reachable at ${host}:${port}; install on host or set URL in .env"
return 0
}
restore_persisted_install_lock() {
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
return 0
fi
PERSIST_LOCK="${PERSIST_DIR}/.install_locked"
if [ -f "${PERSIST_LOCK}" ] && [ ! -f /app/.install_locked ]; then
cp "${PERSIST_LOCK}" /app/.install_locked
chmod 600 /app/.install_locked
echo "entrypoint: restored .install_locked from ${PERSIST_LOCK}"
fi
}
archive_install_wizard_if_locked() {
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
rm -f /app/.install_locked "${PERSIST_DIR}/.install_locked" 2>/dev/null || true
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
echo "entrypoint: restored install.html from .bak (wizard pending)"
fi
return 0
fi
if [ ! -f /app/.install_locked ] && [ ! -f "${PERSIST_DIR}/.install_locked" ]; then
return 0
fi
if [ -f /app/web/app/install.html.bak ]; then
if [ -f /app/web/app/install.html ]; then
rm -f /app/web/app/install.html
echo "entrypoint: removed install.html (wizard archived as install.html.bak)"
fi
return 0
fi
if [ -f /app/web/app/install.html ]; then
mv /app/web/app/install.html /app/web/app/install.html.bak
echo "entrypoint: archived install.html → install.html.bak"
fi
}
restore_persisted_env() {
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
return 0
fi
if [ ! -f "${PERSIST_ENV}" ]; then
return 0
fi
if ! grep -q '^NEXUS_DATABASE_URL=' "${PERSIST_ENV}" 2>/dev/null; then
echo "entrypoint: warn — ${PERSIST_ENV} missing NEXUS_DATABASE_URL; skip restore (complete install wizard)"
return 0
fi
cp "${PERSIST_ENV}" /app/.env
chmod 600 /app/.env
echo "entrypoint: restored .env from ${PERSIST_ENV}"
}
# Compose may inject placeholder NEXUS_REDIS_URL (no password). Wizard .env wins.
export_app_env() {
if [ ! -f /app/.env ]; then
return 0
fi
set -a
# shellcheck disable=SC1091
. /app/.env
set +a
echo "entrypoint: exported NEXUS_* from /app/.env (overrides Compose placeholders)"
}
write_env_from_environment() {
if [ -f /app/.env ]; then
return 0
fi
if [ "${NEXUS_DOCKER_WRITE_ENV:-}" != "1" ]; then
return 0
fi
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
if [ -z "${NEXUS_DATABASE_URL:-}" ]; then
echo "entrypoint: skip writing /app/.env — NEXUS_DATABASE_URL not set (use /app/install.html)"
return 0
fi
mkdir -p "${PERSIST_DIR}"
{
echo "# Generated by docker/entrypoint.sh — do not commit"
env | grep '^NEXUS_' | sort
} > /app/.env
chmod 600 /app/.env
cp /app/.env "${PERSIST_ENV}"
echo "entrypoint: wrote .env from container environment"
}
persist_state_on_exit() {
if [ -f /app/.env ]; then
mkdir -p "${PERSIST_DIR}"
cp /app/.env "${PERSIST_ENV}"
chmod 600 "${PERSIST_ENV}" 2>/dev/null || true
fi
if [ -f /app/.install_locked ]; then
mkdir -p "${PERSIST_DIR}"
cp /app/.install_locked "${PERSIST_DIR}/.install_locked"
chmod 600 "${PERSIST_DIR}/.install_locked" 2>/dev/null || true
fi
}
trap persist_state_on_exit EXIT INT TERM
# MySQL / Redis 均由用户自行安装在宿主机;安装向导阶段不阻塞启动
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_MYSQL_WAIT:-0}" != "1" ]; then
MYSQL_HOST="${NEXUS_1PANEL_DB_HOST:-${MYSQL_HOST:-host.docker.internal}}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
wait_tcp_optional "${MYSQL_HOST}" "${MYSQL_PORT}" 30 "MySQL"
fi
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_REDIS_WAIT:-0}" != "1" ]; then
REDIS_HOST="${NEXUS_1PANEL_REDIS_HOST:-${REDIS_HOST:-host.docker.internal}}"
REDIS_PORT="${REDIS_PORT:-6379}"
wait_tcp_optional "${REDIS_HOST}" "${REDIS_PORT}" 15 "Redis"
fi
restore_persisted_install_lock
archive_install_wizard_if_locked
restore_persisted_env
write_env_from_environment
export_app_env
exec "$@"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Generate docker/.env with random secrets for local Compose."""
from __future__ import annotations
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 main() -> None:
if OUT.exists():
raise SystemExit(f"Refusing to overwrite existing {OUT} — delete it first.")
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")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
# MySQL 8.4 — Nexus production tuning for 1 vCPU / 4 GiB host (minimal)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
max_connections = 80
innodb_read_io_threads = 2
innodb_write_io_threads = 2
table_open_cache = 1000
tmp_table_size = 32M
max_heap_table_size = 32M
[client]
default-character-set = utf8mb4
+25
View File
@@ -0,0 +1,25 @@
# MySQL 8.4 — Nexus production tuning for 2 vCPU / 8 GiB host
# Mounted by docker/docker-compose.prod.yml (see docs/design/plans/2026-06-04-1panel-docker-production.md)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
# ~1 GiB for InnoDB (leave RAM for Nexus, Redis, 1Panel, OS)
innodb_buffer_pool_size = 1024M
innodb_log_file_size = 256M
# Nexus async pool default 30+20; leave headroom for admin/migrations
max_connections = 120
# 2 vCPU: avoid aggressive IO threads
innodb_read_io_threads = 4
innodb_write_io_threads = 4
# Sensible defaults on small VMs
table_open_cache = 2000
tmp_table_size = 64M
max_heap_table_size = 64M
[client]
default-character-set = utf8mb4
+20
View File
@@ -0,0 +1,20 @@
# MySQL 8.4 — Nexus production tuning for 4 vCPU / 16 GiB host
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 4096M
innodb_log_file_size = 512M
max_connections = 200
innodb_read_io_threads = 8
innodb_write_io_threads = 8
table_open_cache = 4000
tmp_table_size = 128M
max_heap_table_size = 128M
[client]
default-character-set = utf8mb4
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Write project-root .env from docker/.env for host uvicorn (127.0.0.1 MySQL/Redis)."""
from __future__ import annotations
import sys
from pathlib import Path
from urllib.parse import quote_plus
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
DOCKER_ENV = ROOT / "docker" / ".env"
OUT = ROOT / ".env"
def _parse(path: Path) -> dict[str, str]:
data: dict[str, str] = {}
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()
return data
def main() -> None:
if not DOCKER_ENV.exists():
raise SystemExit(f"Missing {DOCKER_ENV} — run: python3 docker/generate_env.py")
d = _parse(DOCKER_ENV)
mysql_pass = d.get("MYSQL_PASSWORD", "")
if not mysql_pass:
raise SystemExit("MYSQL_PASSWORD missing in docker/.env")
for key in ("NEXUS_SECRET_KEY", "NEXUS_API_KEY", "NEXUS_ENCRYPTION_KEY"):
if not d.get(key):
raise SystemExit(f"{key} missing in docker/.env — run generate_env.py")
db_user = "nexus"
db_pass_q = quote_plus(mysql_pass)
deploy = str(ROOT)
cors = d.get("NEXUS_CORS_ORIGINS", "http://localhost:8600,http://127.0.0.1:8600")
api_base = d.get("NEXUS_API_BASE_URL", "http://localhost:8600")
lines = [
"# Nexus .env — host uvicorn dev (from docker/sync_root_env.py)",
"",
"NEXUS_HOST=0.0.0.0",
"NEXUS_PORT=8600",
f"NEXUS_DEPLOY_PATH={deploy}",
f"NEXUS_CORS_ORIGINS={cors}",
f"NEXUS_API_BASE_URL={api_base}",
"",
f"NEXUS_DATABASE_URL=mysql+aiomysql://{db_user}:{db_pass_q}@127.0.0.1:3306/nexus",
"NEXUS_DB_POOL_SIZE=20",
"NEXUS_DB_MAX_OVERFLOW=10",
"",
f"NEXUS_SECRET_KEY={d['NEXUS_SECRET_KEY']}",
f"NEXUS_API_KEY={d['NEXUS_API_KEY']}",
f"NEXUS_ENCRYPTION_KEY={d['NEXUS_ENCRYPTION_KEY']}",
"",
"NEXUS_REDIS_URL=redis://127.0.0.1:6379/0",
"NEXUS_SSH_STRICT_HOST_CHECKING=false",
"",
"# MySQL MCP (optional): python3 scripts/sync_mysql_mcp_env.py",
"MYSQL_HOST=127.0.0.1",
"MYSQL_PORT=3306",
f"MYSQL_USER={db_user}",
f"MYSQL_PASSWORD={mysql_pass}",
"MYSQL_DATABASE=nexus",
"MYSQL_READONLY=false",
"MYSQL_DATABASE_ALLOWLIST=nexus",
]
write_utf8_lf(OUT, "\n".join(lines) + "\n")
OUT.chmod(0o600)
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()