fix(deploy): SSH secrets 加载、Docker 运行时检测与外网探测脚本修复

使 deploy-production.sh 可读本机 secrets 并在非 docker 组用户下用 sudo 识别 1Panel 栈;修复 security_probe_external.sh 的 curl shift 误用与 WebSocket 自动 venv。
This commit is contained in:
Nexus Agent
2026-06-07 17:34:27 +08:00
parent 72d82d737b
commit d0be2fdcf1
9 changed files with 262 additions and 27 deletions
+21 -3
View File
@@ -17,6 +17,12 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT}"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
NEXUS_SSH_HOST="${NEXUS_SSH:-nexus}"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
if [[ -n "${NEXUS_SSH_KEY:-}" ]]; then
@@ -36,22 +42,34 @@ fi
REMOTE_DETECT=$(ssh_cmd 'bash -s' <<'REMOTE'
set -euo pipefail
docker_cmd() {
if docker "$@" 2>/dev/null; then return 0; fi
sudo docker "$@" 2>/dev/null
}
compose_cmd() {
if docker compose "$@" 2>/dev/null; then return 0; fi
sudo docker compose "$@" 2>/dev/null
}
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
[[ -d "$d/server" ]] || continue
echo "ROOT=$d"
if [[ -f "$d/docker/.env.prod" ]] && docker compose version >/dev/null 2>&1; then
if [[ -f "$d/docker/.env.prod" ]] && compose_cmd version >/dev/null 2>&1; then
prof="$d/docker/.host-profile"
[[ -f "$prof" ]] || prof="$d/docker/profiles/2c8g.env"
if [[ -f "$prof" ]]; then
args=(-f "$d/docker/docker-compose.prod.yml")
docker network inspect 1panel-network >/dev/null 2>&1 && \
docker_cmd network inspect 1panel-network >/dev/null 2>&1 && \
[[ -f "$d/docker/docker-compose.1panel.yml" ]] && \
args+=(-f "$d/docker/docker-compose.1panel.yml")
q=$(cd "$d" && docker compose "${args[@]}" \
q=$(cd "$d" && compose_cmd "${args[@]}" \
--env-file "$d/docker/.env.prod" --env-file "$prof" ps -q nexus 2>/dev/null || true)
[[ -n "$q" ]] && echo RUNTIME=docker && exit 0
fi
fi
if [[ -f "$d/deploy/nexus-1panel.sh" ]] && command -v nx >/dev/null 2>&1; then
q=$(docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -E '^nexus-prod-nexus-' || true)
[[ -n "$q" ]] && echo RUNTIME=docker && exit 0
fi
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
echo RUNTIME=supervisor
exit 0
+40
View File
@@ -226,3 +226,43 @@
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 42lines"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T12:32:11+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-deploy-ssh-secrets.md 29lines"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T12:35:38+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-deploy-ssh-secrets.md 29lines"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T12:35:53+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-probe-curl-parse-fix.md 35lines"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T17:25:18+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
+4
View File
@@ -15,3 +15,7 @@ NEXUS_DOMAIN='api.synaglobal.vip'
NEXUS_ROOT='/opt/nexus'
NEXUS_PROFILE='2c8g'
NEXUS_FROM_ENV='/www/wwwroot/api.synaglobal.vip/.env'
# 本机生产 SSHdeploy-production.sh 读取;私钥路径勿提交真实 .pem)
# NEXUS_SSH='azureuser@20.24.218.235'
# NEXUS_SSH_KEY='/path/to/nz-admin.pem'
+12 -2
View File
@@ -14,12 +14,18 @@
| `web/agent/agent.py` | 心跳 401 退出循环(对齐 BL-06 中心端) |
| `server/api/install.py` | 路由级 lock Depends;归档先于写 lock;归档失败 fail-closed |
| `.cursor/rules/nexus-security.mdc` | Agent 认证矩阵:仅 per-server key |
| `scripts/security_probe_external.sh` | WS 无 token HTTP 403 → PASS |
| `scripts/security_probe_external.sh` | curl `shift` 修复;`resolve_ws_python`WS 403 → PASS |
| `docs/changelog/2026-06-07-deploy-ssh-secrets.md` | 本机 SSH secrets 文档 |
| `docs/changelog/2026-06-07-probe-curl-parse-fix.md` | 探测脚本 curl/WS 修复 |
| `docs/reports/2026-06-07-bl07-deploy-verification.md` | 生产部署验证报告 |
| `tests/test_install_locked_404.py` | create-admin / test-credentials 锁定 404router Depends 断言 |
| `tests/test_security_unit.py` | `_reject_if_locked` 期望 404 |
| `deploy/install-nx-cli.sh` | CRLF → LF(门控 pre-flight |
| `deploy/update.sh` | CRLF → LF |
| `deploy/gate_log.jsonl` | 门控运行记录(自动生成) |
| `deploy/deploy-production.sh` | source `nexus-1panel.secrets.sh` 加载 SSH |
| `deploy/nexus-1panel.secrets.sh.example` | SSH 变量占位 |
| `docs/project/linux-dev-paths.md` | 本机 SSH/部署 SSOT |
## Step 3 规则扫描
@@ -30,6 +36,8 @@
| H3 | 归档失败仍 lock 成功 | FIXED 归档先于 lockOSError → RuntimeError → 500 |
| H4 | 安全 SSOT 与 BL-06 漂移 | FIXED `nexus-security.mdc` |
| H5 | 外网 WS 403 误判反代 | FIXED 探测脚本 PASS + 报告澄清 |
| H6 | 探测 curl `shift 3` 误报 FAIL | FIXED `probe()` shift 2/3 + 独立 code 文件 |
| H7 | WS 探测 SKIP(无 websockets | FIXED `resolve_ws_python()` 自动 venv |
## Closure
@@ -39,7 +47,9 @@
| H2 | FIXED | `install.py` `APIRouter(..., dependencies=[Depends(...)])` |
| H3 | FIXED | `_finalize_install_lock``_archive_install_wizard_html` |
| H4 | FIXED | `nexus-security.mdc` 认证矩阵 |
| H5 | FIXED | `security_probe_external.sh` L167-168 |
| H5 | FIXED | `security_probe_external.sh` WS 403 PASS |
| H6 | FIXED | `probe()` `shift 2` + `code_file` |
| H7 | FIXED | `resolve_ws_python()` |
## 验证
@@ -0,0 +1,29 @@
# Changelog — 生产 SSH 凭据纳入 secrets + deploy 自动加载
**日期**2026-06-07
## 摘要
本机生产 SSH 私钥路径写入 `deploy/nexus-1panel.secrets.sh`gitignore);`deploy-production.sh` 启动时自动 source,与 `git-push.sh` 同一凭据文件。
## 动机
本机 `Host nexus` 未指向生产 VPS;部署脚本需 `NEXUS_SSH` / `NEXUS_SSH_KEY`,避免每次手动 export。
## 涉及文件
- `deploy/deploy-production.sh`
- `deploy/nexus-1panel.secrets.sh.example`
- `docs/project/linux-dev-paths.md`
- `deploy/nexus-1panel.secrets.sh`(本地 only,不入 git
## 迁移 / 重启
无。
## 验证
```bash
source deploy/nexus-1panel.secrets.sh
ssh -i "$NEXUS_SSH_KEY" "$NEXUS_SSH" "echo SSH_OK"
```
@@ -0,0 +1,35 @@
# Changelog — 外网探测脚本 curl / WebSocket 修复
**日期**2026-06-07
## 摘要
修复 `security_probe_external.sh``probe()``shift 3` 的误用,消除 `code=000000ok200` 类误报 `[FAIL]`WebSocket 段自动解析项目 `venv` 或引导临时 venv 安装 `websockets`
## 动机
`probe GET /path` 仅 2 个参数时,bash `shift 3` 不移位,`GET``/health` 被当作 curl 额外 URLstdout 混入 `000` 错误与响应体,判定逻辑全部失真。
## 涉及文件
- `scripts/security_probe_external.sh`
## 变更
- GET`shift 2`;非 GET`shift 2` + `shift 1`(消费 body),剩余 `$@` 为额外 curl 参数
- HTTP 状态码写入独立临时文件,避免与 body 混淆
- 统一 `-X "$method"`
- `script-callback` 探测 body 满足 Pydantic 最小长度,确保测到 401 而非 422
- 新增 `resolve_ws_python()``NEXUS_PROBE_PYTHON``venv/` → 系统 python → `/tmp/nexus-probe-venv` + `websockets==14.1`
## 迁移 / 重启
无。
## 验证
```bash
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
# GET /health → [PASS]install POST → [PASS] 404;无 code=000000... 误报
# WebSocket → [PASS] HTTP 403(不再 [SKIP]
```
+8 -4
View File
@@ -20,12 +20,16 @@
| 用途 | Linux |
|------|--------|
| SSH 配置 | `~/.ssh/config``Host nexus` |
| 私钥 | `~/.ssh/id_rsa_nexus` |
| 生产目录 | `/www/wwwroot/api.synaglobal.vip/` |
| SSH 目标 | `azureuser@20.24.218.235`(生产 VPS |
| 私钥 | 本机路径写在 `deploy/nexus-1panel.secrets.sh``NEXUS_SSH_KEY`**不入 git** |
| 一键部署 | `bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh`(自动 source secrets |
| 生产目录 | `/opt/nexus`Docker)或 `/www/wwwroot/api.synaglobal.vip/`Supervisor |
```bash
ssh nexus "cd /www/wwwroot/api.synaglobal.vip && git pull && supervisorctl restart nexus"
# secrets 已配置时无需 export;否则:
# export NEXUS_SSH=azureuser@20.24.218.235
# export NEXUS_SSH_KEY='/path/to/nz-admin.pem'
bash deploy/deploy-production.sh
```
## Cursor MCP
@@ -0,0 +1,53 @@
# BL-07 / code-review 跟进 — 生产部署验证(2026-06-07
## Git
| 项 | 值 |
|----|-----|
| Commit | `72d82d7``fix(security): BL-07 WS 403 澄清与 code-review 跟进加固` |
| 上一版 | `e5e2582` |
| 远程 | Gitea `main` 已同步 |
## 部署
| 项 | 状态 |
|----|------|
| 触发 | 菜单 **1**`deploy-production.sh` 运行时检测为 `unknown`azureuser 无 docker 组) |
| 实际执行 | `ssh azureuser@20.24.218.235``sudo bash /opt/nexus/deploy/nexus-1panel.sh upgrade` |
| MySQL 备份 | `/var/backups/nexus/nexus-before-upgrade-20260607-043622.sql.gz` |
| 容器 | `nexus-prod-nexus-1` 重建 **healthy** |
| `/health` | `ok` |
| `/app/` | HTTP 200 |
| install.html | 已归档 → HTTP 404 |
## 外网探测(部署后)
`NEXUS_PROBE_BASE=https://api.synaglobal.vip`
| 探测项 | HTTP | 判定 |
|--------|------|------|
| `GET /health` | 200 | ✅ |
| `GET /health/detail` | 401 | ✅ BL-02 |
| `GET /openapi.json` `/docs` `/redoc` | 404 | ✅ BL-04 |
| `GET /api/install/*` | 404 | ✅ BL-05 |
| `POST /api/install/init-db` | **404** | ✅ 路由级 lock(此前为 422 |
| `POST /api/install/create-admin` 等 | 404 | ✅ |
| `POST /api/settings/bing-wallpapers/sync` | 401 | ✅ BL-03 |
| Agent 无 key / 假 key | 422 / 401 | ✅ BL-06 |
| `wss://.../ws/alerts` 无 token | **403** | ✅ BL-07 |
| `wss://.../ws/sync` 无 token | **403** | ✅ BL-07 |
**备注**2026-06-07 已修复 `security_probe_external.sh``shift 3` 误用;重跑应全 `[PASS]`WS 需本机 `websockets` 包)。
## 本地门控(推送前)
```bash
bash deploy/pre_deploy_check.sh # 7/7 PASS
bash scripts/local_verify.sh # 全绿
```
## 后续
- `deploy/deploy-production.sh` 已补 `sudo docker` 回退检测,下次菜单 **1** 应可自动识别 Docker。
- 边端 Agent 需从 `/agent/agent.py` 拉取新版本(401 停循环);子机不自动升级。
- 可选:修 probe 脚本解析、commit deploy-ssh 相关文档(不含 secrets)。
+60 -18
View File
@@ -3,8 +3,10 @@
# Usage: NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
REPORT_DIR="${NEXUS_PROBE_REPORT_DIR:-/tmp/nexus-external-probe}"
PROBE_VENV="${NEXUS_PROBE_VENV_DIR:-/tmp/nexus-probe-venv}"
mkdir -p "$REPORT_DIR"
OUT="$REPORT_DIR/probe-$(date -u +%Y%m%dT%H%M%SZ).txt"
@@ -15,24 +17,65 @@ fail() { log " [FAIL] $*"; }
info() { log " [INFO] $*"; }
warn() { log " [WARN] $*"; }
# Resolve a Python with `websockets` for BL-07 probes (project venv → system → bootstrap venv).
resolve_ws_python() {
local py cand
if [[ -n "${NEXUS_PROBE_PYTHON:-}" && -x "${NEXUS_PROBE_PYTHON}" ]]; then
if "${NEXUS_PROBE_PYTHON}" -c 'import websockets' 2>/dev/null; then
echo "${NEXUS_PROBE_PYTHON}"
return 0
fi
fi
for cand in "${ROOT}/venv/bin/python" "${ROOT}/.venv/bin/python"; do
[[ -x "$cand" ]] || continue
if "$cand" -c 'import websockets' 2>/dev/null; then
echo "$cand"
return 0
fi
done
if command -v python3 >/dev/null 2>&1; then
if python3 -c 'import websockets' 2>/dev/null; then
echo python3
return 0
fi
fi
if ! command -v python3 >/dev/null 2>&1; then
return 1
fi
if [[ ! -x "${PROBE_VENV}/bin/python" ]]; then
python3 -m venv "${PROBE_VENV}" 2>>"$OUT" || return 1
fi
if ! "${PROBE_VENV}/bin/python" -c 'import websockets' 2>/dev/null; then
"${PROBE_VENV}/bin/pip" install -q "websockets==14.1" 2>>"$OUT" || return 1
fi
echo "${PROBE_VENV}/bin/python"
}
# probe METHOD PATH [JSON_BODY] [extra curl args...]
# Prints HTTP status code to stdout only.
probe() {
local method="$1" path="$2" body="${3:-}"
shift 3 || true
# shift 3 is wrong for GET (only 2 args): bash leaves $@ untouched when n > $#.
shift 2
if [[ "$method" != "GET" ]]; then
shift 1
fi
local url="${BASE}${path}"
local tmp
local tmp code_file
tmp="$(mktemp)"
local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20)
if [[ "$method" == "GET" ]]; then
:
else
curl_args+=(-X "$method" -H "Content-Type: application/json")
code_file="$(mktemp)"
local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20 -X "$method")
if [[ "$method" != "GET" ]]; then
curl_args+=(-H "Content-Type: application/json")
[[ -n "$body" ]] && curl_args+=(-d "$body")
fi
# shellcheck disable=SC2206
local code
code=$(curl "${curl_args[@]}" "$@" "$url" 2>>"$OUT" || echo "000")
if ! curl "${curl_args[@]}" "$@" "$url" >"$code_file" 2>>"$OUT"; then
code="000"
else
code="$(tr -d '\r\n' <"$code_file")"
fi
rm -f "$code_file"
local snippet
snippet="$(head -c 400 "$tmp" | tr '\n' ' ')"
plog ""
@@ -116,7 +159,7 @@ c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat bad key rejected ($c)" || fail "code=$c"
c=$(probe POST /api/agent/script-callback \
'{"job_id":"probe","server_id":1,"secret":"x","exit_code":0}' \
'{"job_id":"probe0001","server_id":1,"secret":"invalid-probe-secret","exit_code":0}' \
-H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "404" || "$c" == "429" ]] && pass "POST script-callback bad key ($c)" || fail "code=$c"
@@ -136,16 +179,13 @@ c=$(probe GET /agent/install.sh)
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"
# ── WebSocket ──
if command -v python3 >/dev/null 2>&1; then
WS_PY="$(resolve_ws_python || true)"
if [[ -n "${WS_PY}" ]]; then
log ""
log "── WebSocket probes"
python3 - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
log "── WebSocket probes (python=${WS_PY})"
"${WS_PY}" - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
import asyncio, sys
try:
import websockets
except ImportError:
print(" [SKIP] websockets not installed")
sys.exit(0)
import websockets
base = sys.argv[1].rstrip("/")
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
@@ -177,6 +217,8 @@ async def main():
asyncio.run(main())
PY
else
warn "WebSocket probes skipped: no python with websockets (set NEXUS_PROBE_PYTHON or install venv)"
fi
log ""