Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca0de7d04d | |||
| 764703d401 | |||
| b8785d1d51 | |||
| 5b372fb8a2 | |||
| f9934bd4f1 | |||
| 66a4cba39b | |||
| 540a7fce05 | |||
| 2e911dcca7 | |||
| a5d5518054 | |||
| e3fe161ae5 | |||
| d0be2fdcf1 | |||
| 72d82d737b | |||
| eab35a35be | |||
| e5e2582743 | |||
| 722917bfc9 | |||
| ac17f91106 | |||
| 9ee5c8a708 | |||
| fb9dbcb11a | |||
| 72e46cef32 | |||
| e5d482264d |
@@ -11,7 +11,7 @@ alwaysApply: false
|
||||
| 调用方 | 机制 |
|
||||
|--------|------|
|
||||
| 管理端 API | JWT Bearer + 可选 TOTP |
|
||||
| Agent | `X-API-Key`(全局或 per-server `agent_api_key`) |
|
||||
| Agent | `X-API-Key`(仅 per-server `agent_api_key`;经 `POST /api/servers/{id}/agent-key` 生成,BL-06 起无 global 回退) |
|
||||
| WebSocket | JWT query param |
|
||||
| 安装向导 | 无 JWT(仅安装模式可用) |
|
||||
|
||||
|
||||
+50
-8
@@ -1,6 +1,7 @@
|
||||
# 1Panel + Docker 一键部署
|
||||
|
||||
> 完整知识文档:[docs/project/nexus-1panel-operations-knowledge.md](../docs/project/nexus-1panel-operations-knowledge.md)
|
||||
> 完整知识文档:[docs/project/nexus-1panel-operations-knowledge.md](../docs/project/nexus-1panel-operations-knowledge.md)
|
||||
> 巡检归档(2026-06-06):[docs/project/nexus-god-menu-audit-summary.md](../docs/project/nexus-god-menu-audit-summary.md)
|
||||
|
||||
## 脚本速查(公共仓库 · 无需令牌)
|
||||
|
||||
@@ -13,6 +14,10 @@
|
||||
| `nexus-1panel.sh` | 底层 install / upgrade | `bash deploy/nexus-1panel.sh upgrade` |
|
||||
| `fix-1panel-mysql-grant.sh` | 修复 1045 nexus@Docker | `bash deploy/fix-1panel-mysql-grant.sh` |
|
||||
| `test-1panel-redis.sh` | Redis 认证诊断 | `REDIS_PASS='...' bash deploy/test-1panel-redis.sh` |
|
||||
| `verify-1panel-install-wizard.sh` | 一键验收(网络/cron/镜像) | `bash deploy/verify-1panel-install-wizard.sh` |
|
||||
| `install_ops_cron.sh` | 宿主机 cron(巡检+备份) | `sudo bash deploy/install_ops_cron.sh` |
|
||||
| `mysql_dump_to_file.sh` | MySQL 备份(Docker exec) | 见 `db_backup.sh` / `nx` 菜单 `[9]` |
|
||||
| `upgrade.sh` | 自动识别 Docker/Supervisor | `bash deploy/upgrade.sh` |
|
||||
| **`nx`** | **统一运维菜单** | `nx` |
|
||||
|
||||
档位:`--profile 1c4g` · `2c8g`(默认)· `4c16g`
|
||||
@@ -87,13 +92,47 @@ nexus-fresh # 等同 install-nexus-fresh.sh
|
||||
nexus-install # 等同 quick-install.sh
|
||||
```
|
||||
|
||||
| 菜单 | 功能 |
|
||||
|------|------|
|
||||
| 菜单 / 子命令 | 功能 |
|
||||
|---------------|------|
|
||||
| `[1]` | 安装向导(档位 / 迁机 / skip-clone) |
|
||||
| `[2]` | 全新安装 → `/app/install.html` |
|
||||
| `[4]` | **一键更新** |
|
||||
| `[5]` | 重启 Nexus |
|
||||
| `[3]` | 状态 + `/health` + install.html 归档检查 |
|
||||
| `[4]` / `nx update` | **一键更新**(备份 + 重建镜像 + 升级后验收) |
|
||||
| `[5]` | 重启 Nexus 容器 |
|
||||
| `[6]` | 日志 |
|
||||
| `[7]` | 启动 / 停止 Compose 栈 |
|
||||
| `[8]` | 无缓存重建镜像 |
|
||||
| `[9]` | 立即备份 MySQL |
|
||||
| `[e]` / `nx cron` | 安装 health_monitor + db_backup crontab |
|
||||
| `[v]` / `nx verify` | 运行 `verify-1panel-install-wizard.sh` |
|
||||
|
||||
---
|
||||
|
||||
## 三层守护(1Panel Docker)
|
||||
|
||||
| 层 | 机制 | 说明 |
|
||||
|----|------|------|
|
||||
| Layer 1 | Compose `restart` + 容器 `healthcheck` | 容器崩溃自动拉起 |
|
||||
| Layer 2 | Python `self_monitor`(30s) | Redis/MySQL/WebSocket 自检 |
|
||||
| Layer 3 | 宿主机 `health_monitor.sh`(cron) | `/health` 连续失败 → `docker restart` + 可选 Telegram |
|
||||
|
||||
Layer 3 需**在宿主机**安装(容器内无法写 crontab):
|
||||
|
||||
```bash
|
||||
sudo nx cron
|
||||
# 或
|
||||
sudo bash /opt/nexus/deploy/install_ops_cron.sh
|
||||
```
|
||||
|
||||
MySQL 定时备份:每日 03:00 → `/var/backups/nexus/`(`docker exec` 进 1Panel MySQL 容器 dump,**无需**宿主机 `mysqldump`)。
|
||||
|
||||
---
|
||||
|
||||
## 安装完成后
|
||||
|
||||
- 向导锁定后 `install.html` → **`install.html.bak`**,外网 `/app/install.html` 返回 **404**(正常)
|
||||
- 登录:`https://你的域名/app/`
|
||||
- 验收:`sudo nx verify`
|
||||
|
||||
---
|
||||
|
||||
@@ -190,13 +229,16 @@ MySQL 密码在 1Panel 自建库时设定,向导步骤 3 填写。
|
||||
|
||||
## 安装向导验收(服务器执行)
|
||||
|
||||
在 **1Panel 终端**(root)一键检查网络、容器名预填、本地 `/health` 与 `/api/install/env-check`:
|
||||
在 **1Panel 终端**(root)一键检查网络、容器名预填、cron、镜像版本、本地 `/health`:
|
||||
|
||||
```bash
|
||||
cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
cd /opt/nexus && sudo nx verify
|
||||
```
|
||||
|
||||
通过后再浏览器打开 `https://你的域名/app/install.html`。常见修复:
|
||||
**未安装**:通过后再打开 `https://你的域名/app/install.html`。
|
||||
**已安装**:`install.html` 应 HTTP 404;验收检查 crontab、`mysql_dump_to_file.sh`、容器内 round6/7 代码特性。
|
||||
|
||||
常见修复:
|
||||
|
||||
```bash
|
||||
cd /opt/nexus && bash deploy/detect-1panel-services.sh
|
||||
|
||||
@@ -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
|
||||
@@ -100,15 +118,24 @@ else
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600")"
|
||||
PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"'" 2>/dev/null || true)"
|
||||
PORT="${PORT:-8600}"
|
||||
HEALTH=$(ssh_cmd "curl -s http://127.0.0.1:${PORT}/health" 2>/dev/null || true)
|
||||
SPA=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/app/" 2>/dev/null || echo "000")
|
||||
echo " /health → ${HEALTH}"
|
||||
echo " /app/ → ${SPA}"
|
||||
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
|
||||
echo "✓ Deploy successful"
|
||||
ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null || \
|
||||
echo " 提示: 远程未安装巡检 cron,可在服务器执行 sudo nx cron"
|
||||
if ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null; then
|
||||
echo "✓ Ops cron (health_monitor) already installed"
|
||||
else
|
||||
echo "▶ Installing ops cron (health_monitor + db_backup)…"
|
||||
if ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/install_ops_cron.sh" 2>/dev/null; then
|
||||
echo "✓ Ops cron installed"
|
||||
else
|
||||
echo " 提示: 远程 cron 安装失败,可在服务器执行: sudo nx cron"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "✗ Verification failed — check logs on server" >&2
|
||||
exit 1
|
||||
|
||||
@@ -128,3 +128,22 @@ suggest_ops_cron() {
|
||||
echo " 建议: sudo bash ${root}/deploy/install_ops_cron.sh"
|
||||
echo " 或: sudo nx cron"
|
||||
}
|
||||
|
||||
prompt_ops_cron_if_missing() {
|
||||
local root="$1"
|
||||
if nexus_has_ops_cron; then
|
||||
echo "[INFO] Crontab 已含 health_monitor / db_backup"
|
||||
return 0
|
||||
fi
|
||||
suggest_ops_cron "$root"
|
||||
if [[ ! -t 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
local ans
|
||||
read -r -p "现在安装巡检/备份 cron? [Y/n]: " ans
|
||||
if [[ "$ans" == "n" || "$ans" == "N" ]]; then
|
||||
echo "[WARN] 已跳过 cron 安装"
|
||||
return 0
|
||||
fi
|
||||
bash "${root}/deploy/install_ops_cron.sh"
|
||||
}
|
||||
|
||||
@@ -166,3 +166,273 @@
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-install-locked-404.md 34lines"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-agent-per-server-key-enforced.md 47lines"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-agent-per-server-key-enforced.md 47lines"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-bl06-agent-per-server-key.md"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 29lines"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-bl06-agent-per-server-key.md"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 42lines"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 42lines"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"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"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-probe-curl-parse-fix.md 39lines"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T18:05:19+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:29:17+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:29:31+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:29:59+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:30:41+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:30:49+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md 29lines"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-detect-chat-id.md"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-health-monitor-flock-fail-count-fix.md 21lines"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T20:53:00+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
@@ -33,6 +33,35 @@ read_env_var() {
|
||||
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true
|
||||
}
|
||||
|
||||
load_telegram_from_db() {
|
||||
local cname="$1"
|
||||
[[ -n "$cname" ]] || return 1
|
||||
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
|
||||
local out token chat
|
||||
out="$(docker exec "$cname" python3 -c "
|
||||
import asyncio
|
||||
from server.config import settings as s
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
|
||||
async def main():
|
||||
async with AsyncSessionLocal() as db:
|
||||
await s.load_settings_from_db(db)
|
||||
t = (s.TELEGRAM_BOT_TOKEN or '').strip()
|
||||
c = (s.TELEGRAM_CHAT_ID or '').strip()
|
||||
if t and c:
|
||||
print(t)
|
||||
print(c)
|
||||
|
||||
asyncio.run(main())
|
||||
" 2>/dev/null)" || true
|
||||
[[ -n "$out" ]] || return 1
|
||||
token="$(printf '%s\n' "$out" | sed -n '1p')"
|
||||
chat="$(printf '%s\n' "$out" | sed -n '2p')"
|
||||
[[ -n "$token" && -n "$chat" ]] || return 1
|
||||
BOT_TOKEN="$token"
|
||||
CHAT_ID="$chat"
|
||||
}
|
||||
|
||||
load_telegram_config() {
|
||||
local cname="$1" dotenv
|
||||
BOT_TOKEN=""
|
||||
@@ -54,6 +83,8 @@ load_telegram_config() {
|
||||
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
|
||||
esac
|
||||
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
|
||||
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
|
||||
load_telegram_from_db "$cname" || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -75,6 +106,7 @@ bump_fail_count() {
|
||||
fail=$((fail + 1))
|
||||
echo "$fail" > "$FAIL_FILE"
|
||||
) 9>"${FAIL_FILE}.lock"
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
else
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
fail=$((fail + 1))
|
||||
|
||||
@@ -64,8 +64,8 @@ done
|
||||
touch "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true
|
||||
chmod 644 "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true
|
||||
|
||||
HEALTH_LINE="* * * * * NEXUS_DEPLOY_DIR=${NEXUS_ROOT} ${DEPLOY_DIR}/health_monitor.sh >> ${HEALTH_LOG} 2>&1 # ${HEALTH_MARKER}"
|
||||
BACKUP_LINE="0 3 * * * NEXUS_ROOT=${NEXUS_ROOT} ${DEPLOY_DIR}/db_backup.sh >> ${BACKUP_LOG} 2>&1 # ${BACKUP_MARKER}"
|
||||
HEALTH_LINE="* * * * * NEXUS_DEPLOY_DIR=${NEXUS_ROOT} bash ${DEPLOY_DIR}/health_monitor.sh >> ${HEALTH_LOG} 2>&1 # ${HEALTH_MARKER}"
|
||||
BACKUP_LINE="0 3 * * * NEXUS_ROOT=${NEXUS_ROOT} bash ${DEPLOY_DIR}/db_backup.sh >> ${BACKUP_LOG} 2>&1 # ${BACKUP_MARKER}"
|
||||
|
||||
existing="$(crontab -l 2>/dev/null || true)"
|
||||
filtered="$(printf '%s\n' "$existing" | grep -v "$HEALTH_MARKER" | grep -v "$BACKUP_MARKER" | grep -v 'deploy/health_monitor.sh' | grep -v 'deploy/db_backup.sh' || true)"
|
||||
|
||||
@@ -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'
|
||||
|
||||
# 本机生产 SSH(deploy-production.sh 读取;私钥路径勿提交真实 .pem)
|
||||
# NEXUS_SSH='azureuser@20.24.218.235'
|
||||
# NEXUS_SSH_KEY='/path/to/nz-admin.pem'
|
||||
|
||||
+15
-1
@@ -363,6 +363,8 @@ keys = (
|
||||
"NEXUS_ENCRYPTION_KEY",
|
||||
"NEXUS_REDIS_URL",
|
||||
"NEXUS_DATABASE_URL",
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN",
|
||||
"NEXUS_TELEGRAM_CHAT_ID",
|
||||
)
|
||||
prod = {}
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
@@ -1211,7 +1213,19 @@ cmd_upgrade() {
|
||||
if [[ -f "$NEXUS_ROOT/deploy/detect_deploy_runtime.sh" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$NEXUS_ROOT/deploy/detect_deploy_runtime.sh"
|
||||
suggest_ops_cron "$NEXUS_ROOT"
|
||||
if [[ -t 0 ]]; then
|
||||
prompt_ops_cron_if_missing "$NEXUS_ROOT"
|
||||
else
|
||||
suggest_ops_cron "$NEXUS_ROOT"
|
||||
fi
|
||||
fi
|
||||
local vcname
|
||||
vcname="$(nexus_container_name)"
|
||||
if [[ -n "$vcname" ]] && nexus_container_has_install_lock "$vcname" \
|
||||
&& [[ -x "$NEXUS_ROOT/deploy/verify-1panel-install-wizard.sh" ]]; then
|
||||
step "升级后验收..."
|
||||
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/verify-1panel-install-wizard.sh" || \
|
||||
warn "验收脚本报告 FAIL — 见上方输出"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ menu_main() {
|
||||
[8] 重建镜像并启动(--build nexus)
|
||||
[9] 备份 MySQL
|
||||
[e] 安装巡检/备份 cron(health_monitor + db_backup)
|
||||
[v] 1Panel 安装/运维验收脚本
|
||||
|
||||
── 其它 ──
|
||||
[a] 检查 Git 是否有更新
|
||||
@@ -348,6 +349,11 @@ EOF
|
||||
ops_install_cron
|
||||
pause_enter
|
||||
;;
|
||||
v|V)
|
||||
require_root
|
||||
bash "${NX_DIR}/verify-1panel-install-wizard.sh"
|
||||
pause_enter
|
||||
;;
|
||||
a|A)
|
||||
require_root
|
||||
cmd_check
|
||||
@@ -413,6 +419,10 @@ nx_main() {
|
||||
require_root
|
||||
ops_install_cron
|
||||
;;
|
||||
verify)
|
||||
require_root
|
||||
bash "${NX_DIR}/verify-1panel-install-wizard.sh"
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
用法: nx [子命令]
|
||||
@@ -423,6 +433,7 @@ nx_main() {
|
||||
install-auto 非交互安装(默认 2c8g)
|
||||
health 健康检查
|
||||
cron 安装 health_monitor + db_backup crontab
|
||||
verify 运行 1Panel 验收脚本
|
||||
|
||||
god / ops 已合并进主菜单(兼容别名)
|
||||
|
||||
|
||||
@@ -118,4 +118,4 @@ echo ""
|
||||
info "当前 Nexus 栈:"
|
||||
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' | grep -i nexus || docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||
echo ""
|
||||
info "下一步:自建 MySQL 数据库与用户,安装向导步骤 3 使用 host.docker.internal:3306"
|
||||
info "下一步:1Panel 应用商店安装 MySQL → nx update 探测容器名 → 向导步骤 3 使用 1Panel-mysql-xxxx"
|
||||
|
||||
@@ -109,8 +109,6 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "下一步:在宿主机或 1Panel 安装 Redis,确认监听 6379:"
|
||||
echo " apt install -y redis-server && systemctl enable --now redis-server"
|
||||
echo " redis-cli ping # 期望 PONG"
|
||||
echo ""
|
||||
info "Docker 内 Nexus 连接宿主机 Redis:安装向导 / .env 使用 host.docker.internal:6379"
|
||||
info "下一步:1Panel 应用商店安装 Redis → nx update 写入 NEXUS_1PANEL_REDIS_HOST"
|
||||
echo " 向导 Redis URL:redis://:密码@1Panel-redis-xxxx:6379/0(无 root 用户)"
|
||||
echo " 诊断:bash deploy/test-1panel-redis.sh"
|
||||
|
||||
@@ -299,9 +299,9 @@ import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print((d.get('docker_defaults') or {}).get('db_host',''))
|
||||
" 2>/dev/null || true)"
|
||||
if [[ -n "$db_host" && "$db_host" != host.docker.internal ]]; then
|
||||
if [[ -n "$db_host" && "$db_host" != "host.docker.internal" ]]; then
|
||||
pass "步骤 3 预填 db_host=$db_host"
|
||||
elif [[ "$db_host" == host.docker.internal ]]; then
|
||||
elif [[ "$db_host" == "host.docker.internal" ]]; then
|
||||
fail "步骤 3 仍预填 host.docker.internal"
|
||||
info "修复: nx update --no-cache(需 b25d079+)"
|
||||
fi
|
||||
@@ -315,6 +315,44 @@ print((d.get('docker_defaults') or {}).get('db_host',''))
|
||||
fi
|
||||
}
|
||||
|
||||
check_container_image_features() {
|
||||
[[ -n "${NEXUS_CONTAINER:-}" ]] || return 0
|
||||
local host_sha py_ok html_file html_ok host_root_ok
|
||||
host_sha="$(git -C "$NEXUS_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
pass "宿主机 Git: $host_sha"
|
||||
|
||||
if docker exec "$NEXUS_CONTAINER" grep -q '_host_deploy_root' /app/server/api/install.py 2>/dev/null; then
|
||||
pass "容器 install.py 含 host_deploy_root(round6+)"
|
||||
py_ok=1
|
||||
else
|
||||
warn "容器 install.py 较旧 — 需 nx update 重建镜像"
|
||||
fi
|
||||
|
||||
html_file="/app/web/app/install.html"
|
||||
if docker exec "$NEXUS_CONTAINER" test -f /app/web/app/install.html.bak 2>/dev/null; then
|
||||
html_file="/app/web/app/install.html.bak"
|
||||
fi
|
||||
if docker exec "$NEXUS_CONTAINER" grep -q 'install_ops_cron' "$html_file" 2>/dev/null; then
|
||||
pass "容器 install.html 含 Layer 3 cron 指引(round7+)"
|
||||
html_ok=1
|
||||
else
|
||||
warn "容器 install.html 较旧 — 需 nx update 重建镜像"
|
||||
fi
|
||||
|
||||
host_root_ok="$(docker exec "$NEXUS_CONTAINER" printenv NEXUS_HOST_ROOT 2>/dev/null || true)"
|
||||
if [[ -n "$host_root_ok" ]]; then
|
||||
pass "NEXUS_HOST_ROOT=$host_root_ok"
|
||||
else
|
||||
warn "容器未注入 NEXUS_HOST_ROOT — nx update 重建后可修复"
|
||||
fi
|
||||
|
||||
if [[ -n "${py_ok:-}" && -n "${html_ok:-}" ]]; then
|
||||
info "镜像与宿主机代码特性对齐(重建后验收)"
|
||||
else
|
||||
info "修复: cd $NEXUS_ROOT && sudo nx update"
|
||||
fi
|
||||
}
|
||||
|
||||
check_openresty_hint() {
|
||||
local domain code
|
||||
domain="$(grep -E '^NEXUS_API_BASE_URL=' "$ENV_PROD" 2>/dev/null | sed 's|.*https\?://||;s|/.*||' || echo api.synaglobal.vip)"
|
||||
@@ -376,6 +414,7 @@ main() {
|
||||
check_install_api || true
|
||||
check_ops_cron || true
|
||||
check_mysql_backup || true
|
||||
check_container_image_features || true
|
||||
check_openresty_hint || true
|
||||
print_next_steps
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| [project/alert-push-policy.md](project/alert-push-policy.md) | 告警策略 |
|
||||
| [project/deploy.md](project/deploy.md) | 部署补充(宝塔/裸机) |
|
||||
| [project/nexus-1panel-operations-knowledge.md](project/nexus-1panel-operations-knowledge.md) | **1Panel + Docker 运维知识 SSOT** |
|
||||
| [project/nexus-god-menu-audit-summary.md](project/nexus-god-menu-audit-summary.md) | **nx 上帝菜单巡检归档**(2026-06-06 round1–10) |
|
||||
| [project/production-verification-checklist.md](project/production-verification-checklist.md) | L5 清单 |
|
||||
|
||||
根目录 [AGENTS.md](../AGENTS.md) · [CLAUDE.md](../CLAUDE.md) · [.cursorrules](../.cursorrules) 为薄入口。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 审计 — 2026-06-07 BL-06 Agent per-server key 强制
|
||||
|
||||
## 范围
|
||||
|
||||
移除 Agent 认证 global `API_KEY` legacy 回退;涉及 3 个后端文件 + 1 个新测试文件。
|
||||
|
||||
| 文件 | 行数 | Read 范围 |
|
||||
|------|------|-----------|
|
||||
| `server/api/agent.py` | 379 | L40-93(认证函数) |
|
||||
| `server/api/servers.py` | 2056 | L619-636, L1625-1640(安装链路) |
|
||||
| `server/application/services/script_service.py` | 735 | L44-50(回调 key) |
|
||||
| `tests/test_agent_per_server_key.py` | 88 | 全文 |
|
||||
| `tests/load_test.py` | 202 | 顶部 docstring |
|
||||
| `tests/quick_load.py` | 87 | 顶部 docstring |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | Agent 认证 fail-closed | FIXED 移除 global 回退;无 per-server key → False |
|
||||
| H2 | 安装链路静默降级 | FIXED 无 key 明确 400/批量错误项 |
|
||||
| H3 | 长任务回调 key 泄露面 | FIXED 仅用 per-server key,无 global 回退 |
|
||||
| H4 | compare_digest 时序安全 | SAFE 保留 `secrets.compare_digest` |
|
||||
| H5 | DB 查服异常 | SAFE 保持 fail-closed return False |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | FIXED | `agent.py:60-82` 仅 `agent_api_key` 匹配 |
|
||||
| H2 | FIXED | `servers.py:627-633`, `servers.py:1635-1637` |
|
||||
| H3 | FIXED | `script_service.py:44-50` ValueError |
|
||||
| H4 | SAFE | `agent.py:82` compare_digest |
|
||||
| H5 | SAFE | `agent.py:68-76` except → False |
|
||||
|
||||
## 入口表(agent.py 受影响路由)
|
||||
|
||||
| 方法 | 路径 | 鉴权 |
|
||||
|------|------|------|
|
||||
| POST | `/api/agent/heartbeat` | X-API-Key + per-server `agent_api_key` |
|
||||
| POST | `/api/agent/script-callback` | 同上 + job secret |
|
||||
|
||||
## 巡检补修(同日)
|
||||
|
||||
| 项 | 原问题 | 修复 |
|
||||
|----|--------|------|
|
||||
| H6 | `receive_heartbeat` 认证后再次 `get_server`(重复查库) | `_verify_server_api_key` 返回 `Optional[Server]`,handler 复用 |
|
||||
| H7 | 验 key 通过后 `unknown_server` discarded 分支为死代码 | 删除;不存在/无 key/错 key 统一 401 |
|
||||
| H8 | `script-callback` docstring 仍写「per-server or global」 | 改为仅 per-server |
|
||||
| H9 | 无回归测试约束 `get_server` 调用次数 | `test_verify_server_key_single_get_server_per_auth_check` + `assert_called_once_with` |
|
||||
|
||||
## 生产只读探测(2026-06-07)
|
||||
|
||||
| 探测 | 结果 |
|
||||
|------|------|
|
||||
| `GET /health` | 200 |
|
||||
| `POST /api/agent/heartbeat` 假 key | 401 |
|
||||
| `security_probe_external.sh` heartbeat/WS | PASS |
|
||||
|
||||
注:BL-06 完整语义(global key 不可冒充)需部署本地改动后生产才生效;当前生产假 key 已 401。
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] ruff 零错误(改动文件)
|
||||
- [x] import server.main
|
||||
- [x] pytest `test_agent_per_server_key.py` 9 passed(含 `assert_called_once`)
|
||||
- [x] changelog ≥10 行
|
||||
- [x] 无静默吞错;无 global key 降级
|
||||
- [x] 心跳路径单次 `get_server`(巡检补修)
|
||||
@@ -0,0 +1,67 @@
|
||||
# 审计 — 2026-06-07 ce-code-review 跟进 + BL-07 探测
|
||||
|
||||
## 范围
|
||||
|
||||
本批次 `git diff HEAD~1` 含 **BL-06**(已提交)与 **ce-code-review 跟进**(工作区)。BL-06 细读见 `2026-06-07-bl06-agent-per-server-key.md`。
|
||||
|
||||
| 文件 | 变更要点 |
|
||||
|------|----------|
|
||||
| `server/api/servers.py` | BL-06:安装链路无 global key 回退 |
|
||||
| `server/application/services/script_service.py` | BL-06:回调仅 per-server key |
|
||||
| `tests/test_agent_per_server_key.py` | BL-06:认证单元测试 |
|
||||
| `tests/load_test.py` | BL-06:文档注释 |
|
||||
| `tests/quick_load.py` | BL-06:文档注释 |
|
||||
| `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` | 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 锁定 404;router 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 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 边端 agent 401 不重试 | FIXED `agent.py` 401 → return |
|
||||
| H2 | 锁定 install 统一 404 | FIXED `install.py` router `Depends(_raise_not_found_if_locked)` |
|
||||
| H3 | 归档失败仍 lock 成功 | FIXED 归档先于 lock;OSError → 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
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | FIXED | `web/agent/agent.py` `elif resp.status_code == 401: return` |
|
||||
| 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` WS 403 PASS |
|
||||
| H6 | FIXED | `probe()` `shift 2` + `code_file` |
|
||||
| H7 | FIXED | `resolve_ws_python()` |
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `bash scripts/local_verify.sh` | 全绿(smoke 47 + test_api 26/26 + ruff) |
|
||||
| `pytest tests/test_install_locked_404.py` | 11 passed |
|
||||
| `deploy/pre_deploy_check.sh` | 待本审计入库后重跑 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 无静默吞错(归档失败 error + 500)
|
||||
- [x] changelog `2026-06-07-code-review-followups.md`
|
||||
- [x] 本地 L2b `local_verify` 证据
|
||||
- [x] 涉及文件列入本审计范围表
|
||||
@@ -0,0 +1,44 @@
|
||||
# 审计 — Layer 3 宿主机巡检 Telegram 打通
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/infrastructure/env_file.py` | `.env` upsert/read(UTF-8 LF) |
|
||||
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
|
||||
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
|
||||
| `deploy/health_monitor.sh` | `load_telegram_from_db` + `bump_fail_count` flock 修复 |
|
||||
| `deploy/deploy-production.sh` | 部署成功自动 `install_ops_cron.sh` |
|
||||
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
|
||||
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
|
||||
| `frontend/src/types/api.ts` | `OpsPatrol*` 类型 |
|
||||
| `tests/test_ops_patrol_sync.py` | 7 用例 |
|
||||
| `docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md` | changelog |
|
||||
| `docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md` | 技术说明 |
|
||||
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | Layer-3 与设置页 Telegram 脱节 | FIXED — 保存时 sync + DB 回退 |
|
||||
| H2 | `.env` 写入破坏不可变键 | SAFE — 仅 upsert `NEXUS_TELEGRAM_*` |
|
||||
| H3 | 宿主机 cron 未安装 | FIXED — deploy 自动 install_ops_cron |
|
||||
| H4 | env 同步失败静默 | SAFE — 记录 warning;DB 回退保底 |
|
||||
| H5 | 敏感 Token 泄露至日志 | SAFE — 无 log token;审计仅写布尔标志 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H5 | FIXED / SAFE | 代码 + pytest 7/7 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest `test_ops_patrol_sync.py` + `test_settings_telegram.py` 13 passed
|
||||
- [x] changelog / audit / plan
|
||||
- [x] 无 SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL 变更
|
||||
|
||||
## 验证
|
||||
|
||||
`.venv/bin/pytest tests/test_ops_patrol_sync.py tests/test_settings_telegram.py -q`
|
||||
@@ -0,0 +1,35 @@
|
||||
# 审计 — 设置页 IP 白名单订阅恢复
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/SettingsPage.vue` | 订阅 URL、解析预览、节点展示、正确 save payload |
|
||||
| `frontend/src/types/api.ts` | `SubscriptionParseResponse`、`IpAllowlistSaveRequest` |
|
||||
| `deploy/install_ops_cron.sh` | 同日前序 commit(cron bash 调用) |
|
||||
| `deploy/gate_log.jsonl` | 门控记录 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 后端 API 未删 | PASS — `parse-subscription` / `IpAllowlistSaveRequest` 仍在 |
|
||||
| H2 | SSRF 仍由后端拦截 | PASS — 未改 `settings.py` |
|
||||
| H3 | 错误 payload 修复 | FIXED — 不再 POST `{ ips }` |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | PASS | `server/api/settings.py` |
|
||||
| H2 | PASS | 解析仍走服务端 |
|
||||
| H3 | FIXED | `saveAllowlist` payload |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] vite build 通过
|
||||
- [x] changelog / audit
|
||||
|
||||
## 验证
|
||||
|
||||
设置页 → 登录 IP 白名单 → 订阅 URL + 解析预览 + 保存
|
||||
@@ -0,0 +1,43 @@
|
||||
# 审计 — 设置页 Telegram Chat ID 检测恢复
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/SettingsPage.vue` | 恢复「检测 Chat ID」按钮与选择对话框 |
|
||||
| `frontend/src/types/api.ts` | `TelegramChatsResponse` 类型 |
|
||||
| `deploy/install_ops_cron.sh` | crontab 改用 `bash` 调用(避免 +x 丢失) |
|
||||
| `deploy/deploy-production.sh` | `PORT` 缺省 8600(同日前序 commit) |
|
||||
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
|
||||
|
||||
## 结论
|
||||
|
||||
- 后端 `GET /settings/telegram/chats` 未删,仅 Vue 设置页遗漏 UI
|
||||
- 无 API 契约变更;检测前若 Token 草稿存在则先 PUT 保存
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | Chat ID 检测 API 仍存在 | PASS — `GET /settings/telegram/chats` 未改 |
|
||||
| H2 | Vue 设置页功能对等 | FIXED — 恢复检测按钮与对话框 |
|
||||
| H3 | Token 未保存时检测 | FIXED — 检测前自动 PUT token 草稿 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | PASS | `server/api/settings.py` `telegram_get_chats` |
|
||||
| H2 | FIXED | `SettingsPage.vue` `detectTelegramChats` |
|
||||
| H3 | FIXED | 检测前 `http.put telegram_bot_token` |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 前端 vite build 通过
|
||||
- [x] changelog / audit 已写
|
||||
- [x] 无 API 破坏性变更
|
||||
|
||||
## 验证
|
||||
|
||||
- `cd frontend && npx vite build` 通过
|
||||
- 设置页:Bot 收消息后检测 → 列表 → 选中写入 Chat ID
|
||||
@@ -0,0 +1,37 @@
|
||||
# 审计 — 设置页 Telegram 全流程修复
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/SettingsPage.vue` | persist/save/test/detect、8 NOTIFY 开关、reveal 免密 |
|
||||
| `frontend/src/types/api.ts` | `NOTIFY_TOGGLE_ITEMS` |
|
||||
| `server/api/settings.py` | Webhook 清除、错误详情、reveal 免密 |
|
||||
| `tests/test_settings_telegram.py` | 6 用例 |
|
||||
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 测试/检测前持久化 Token+Chat ID | FIXED — `persistTelegramConfig` |
|
||||
| H2 | getUpdates Webhook 冲突 | FIXED — `deleteWebhook` before poll |
|
||||
| H3 | Telegram API 错误可见 | FIXED — `ok:false` / sendMessage description |
|
||||
| H4 | 8 项 NOTIFY 开关 UI | FIXED — Vue v-switch + PUT |
|
||||
| H5 | reveal 免密(用户确认) | FIXED — JWT + 审计保留 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H5 | FIXED | 代码 + pytest 6/6 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest `test_settings_telegram.py` 6 passed
|
||||
- [x] changelog / audit
|
||||
- [x] 无不可变 key 变更
|
||||
|
||||
## 验证
|
||||
|
||||
`.venv/bin/pytest tests/test_settings_telegram.py -q`
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-06-06 — API 巡检 B1(servers + webssh)修复
|
||||
|
||||
## 摘要
|
||||
|
||||
完成 servers/webssh 模块 8 维安全审计;修复健康检查 `server_ids` 无上限与 pending 重试/删除审计缺口。
|
||||
|
||||
## 动机
|
||||
|
||||
按计划 B1→B6 API 巡检,servers 大部 CRUD 此前未 BUG 通读;需消除可观测的资源耗尽与审计不一致。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/schemas.py` — `ServerCheck.server_ids` max_length=50
|
||||
- `server/api/servers.py` — pending retry 失败审计、delete pending detail
|
||||
- `tests/test_security_unit.py` — ServerCheck 上限回归
|
||||
- `docs/reports/2026-06-06-api-audit-b1-servers-webssh.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无;重启 API 即可。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py tests/test_credential_poller.py -q
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# 2026-06-06 — API 巡检 B2(sync + files)修复
|
||||
|
||||
## 摘要
|
||||
|
||||
审计 sync_v2 / files / 路径校验模块;为 `SyncFiles` 与 `SyncVerify` 的 `server_ids` 增加 2000 上限,防止超大列表资源耗尽。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/schemas.py`
|
||||
- `tests/test_security_unit.py`
|
||||
- `docs/reports/2026-06-06-api-audit-b2-sync-files.md`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py -q
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2026-06-06 — API 全量巡检 B1–B6 closure
|
||||
|
||||
## 摘要
|
||||
|
||||
按 8 维清单完成 12 个 API 文件 ~122 端点巡检;修复 3 处 `server_ids` 无上限与 pending 审计缺口;0 P0/P1。
|
||||
|
||||
## 修复文件
|
||||
|
||||
- `server/api/schemas.py` — ServerCheck(50)、SyncFiles/SyncVerify/ScriptExecute(2000)
|
||||
- `server/api/servers.py` — pending retry 失败审计、delete detail
|
||||
- `tests/test_security_unit.py` — 回归用例
|
||||
|
||||
## 报告
|
||||
|
||||
`docs/reports/2026-06-06-api-audit-*.md` + [closure](docs/reports/2026-06-06-api-audit-closure.md)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py tests/test_auth_refresh_reuse.py tests/test_credential_poller.py -q
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog — BL-07 WebSocket 外网 403 澄清
|
||||
|
||||
**日期**:2026-06-06
|
||||
|
||||
## 摘要
|
||||
|
||||
澄清外网无 token WebSocket 探测见 HTTP 403 为应用层拒绝握手(非反代故障);登录后 `/ws/alerts`、`/ws/sync` 生产验证连接成功。BL-07 标为接受,更新探测脚本与报告。
|
||||
|
||||
## 动机
|
||||
|
||||
外网攻击面巡检误判「反代 403」;经直连 8600 对比与登录后 WS 验证,确认无需改 Nginx。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docs/reports/2026-06-06-external-hardening-deploy-verification.md`
|
||||
- `docs/reports/2026-06-06-external-attack-hardening-backlog.md`
|
||||
- `docs/reports/2026-06-06-external-attack-surface-audit.md` §7
|
||||
- `scripts/security_probe_external.sh`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
# WS 无 token → [PASS] rejected (HTTP 403)
|
||||
# 登录后 wss://.../ws/alerts?token=... → 连接成功(人工/API)
|
||||
```
|
||||
|
||||
## 同日决定
|
||||
|
||||
- **BL-08** 全站 IP 白名单:运维确认**不需要**,backlog 已关闭。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Changelog — 外网攻击面安全巡检(E1–E6)
|
||||
|
||||
**日期**:2026-06-06
|
||||
|
||||
## 摘要
|
||||
|
||||
对生产 `api.synaglobal.vip` 执行无凭证外网攻击面巡检:新增黑盒脚本、三份报告与加固 backlog;**无应用代码加固**(audit_only)。
|
||||
|
||||
## 动机
|
||||
|
||||
在「已登录管理员可信」前提下,审计无 JWT / 弱认证暴露面,与 B1–B6 鉴权后 API 巡检互补。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 脚本 | `scripts/security_probe_external.sh` |
|
||||
| 报告 | `docs/reports/2026-06-06-external-attack-probe-production.md` |
|
||||
| 报告 | `docs/reports/2026-06-06-external-attack-surface-audit.md` |
|
||||
| Backlog | `docs/reports/2026-06-06-external-attack-hardening-backlog.md` |
|
||||
|
||||
## 主要发现(未修复)
|
||||
|
||||
- **P2**:`GET /health/detail` 未授权可读组件状态(`PUBLIC_PREFIXES` `/health` 前缀过宽)
|
||||
- **P2**:`POST /api/settings/bing-wallpapers/sync` 未授权可触发外联下载(同上)
|
||||
- **H**:OpenAPI/Swagger/ReDoc 公网可访问
|
||||
- **0 P0/P1**
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。仅文档与探测脚本。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
```
|
||||
|
||||
证据见 `docs/reports/2026-06-06-external-attack-probe-production.md`。
|
||||
@@ -0,0 +1,41 @@
|
||||
# Changelog — 外网攻击面加固(BL-01~BL-04)
|
||||
|
||||
**日期**:2026-06-06
|
||||
|
||||
## 摘要
|
||||
|
||||
修复外网巡检发现的 P2/H 问题:收紧 `PUBLIC_PREFIXES` 前缀误匹配、强制 `/health/detail` 与壁纸 sync 鉴权、生产默认关闭 OpenAPI/Swagger/ReDoc。
|
||||
|
||||
## 动机
|
||||
|
||||
生产探测 EXT-01/02/03:`/health/detail` 与 `bing-wallpapers/sync` 无 JWT 可访问;全量 API schema 公网暴露。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/api/auth_jwt.py` | 精确路径 + 方法感知 `_is_public_path`;`require_current_admin` |
|
||||
| `server/api/health.py` | `health_detail` 使用 `require_current_admin` |
|
||||
| `server/api/settings.py` | `bing_wallpapers_sync` 使用 `require_current_admin` |
|
||||
| `server/config.py` | `EXPOSE_API_DOCS`(默认 `false`) |
|
||||
| `server/main.py` | `docs_url/redoc_url/openapi_url` 按配置关闭 |
|
||||
| `tests/test_external_attack_surface_hardening.py` | 回归测试 |
|
||||
|
||||
## 配置
|
||||
|
||||
```env
|
||||
# 仅本地开发需要时开启
|
||||
NEXUS_EXPOSE_API_DOCS=true
|
||||
```
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重启 API 进程。无 DB 迁移。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_external_attack_surface_hardening.py -q
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
# 部署后期望:health/detail 401;bing sync 401;openapi 404
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# 2026-06-06 — 前端 14 页 API 契约审计(含凭据轮询)
|
||||
|
||||
## 摘要
|
||||
|
||||
对 14 页及 composables 与后端 API 做静态契约对照;确认凭据轮询 `add-by-ip` / `pending` 四端点与预设 `username` 前后端一致;补齐共享类型与 SSOT 文档。
|
||||
|
||||
## 动机
|
||||
|
||||
凭据轮询功能已合入(`4ba45ab`),需在 2026-06-04 深审 closure 基础上增量验证 API 契约,避免部署后前后端字段漂移。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docs/reports/2026-06-06-frontend-14p-contract-audit.md` — 审计报告(新)
|
||||
- `docs/project/nexus-functional-development-guide.md` — §12.2、§12.7
|
||||
- `docs/project/nexus-functional-development-guide-appendix.md` — 附录 J
|
||||
- `frontend/src/types/api.ts` — `PendingServerItem`、`AddByIpResponse`、`PollErrorItem`
|
||||
- `frontend/src/pages/ServersPage.vue` — 改用共享类型
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无 DB 变更;文档与类型仅影响前端构建。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_credential_poller.py -q # 7 passed
|
||||
cd frontend && npx vite build
|
||||
```
|
||||
|
||||
## 生产验证(2026-06-06 续)
|
||||
|
||||
- `https://api.synaglobal.vip/health` → ok
|
||||
- OpenAPI 含四条 pending / add-by-ip 端点
|
||||
- 生产 `ServersPage` / `CredentialsPage` 分块已含 `add-by-ip`、`username`(与本地 build 哈希一致)
|
||||
- 详见 [docs/reports/2026-06-06-credential-polling-production-verification.md](../reports/2026-06-06-credential-polling-production-verification.md)
|
||||
|
||||
## 结论
|
||||
|
||||
14 页 API 契约 **PASS**;0 P0/P1 新 FINDING。生产 API + SPA **已部署**;本地 `local_verify` 因宿主机 MySQL 2013 未跑通。
|
||||
@@ -0,0 +1,29 @@
|
||||
# nx 上帝菜单巡检第十轮 — 归档总结文档
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | round4–9 分散在多份 changelog,需单一归档入口供接续 Agent / 运维查阅 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **nexus-god-menu-audit-summary.md**(新):round1–10 一览、nx 能力矩阵、三层守护、日常速查、验收标准、changelog 索引。
|
||||
2. **docs/README.md**:活跃 SSOT 表增加归档链接。
|
||||
3. **deploy/README-1panel.md**:指向归档总结。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docs/project/nexus-god-menu-audit-summary.md`
|
||||
- `docs/README.md`
|
||||
- `deploy/README-1panel.md`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
test -f docs/project/nexus-god-menu-audit-summary.md
|
||||
grep god-menu-audit-summary docs/README.md deploy/README-1panel.md
|
||||
```
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,32 @@
|
||||
# nx 上帝菜单巡检第七轮 — 安装向导守护 UI + 升级 cron 提示
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 安装完成页 Docker 清单仍写旧 cron 格式;guardian 结果无分级样式;`nx update` 后未交互提示安装 Layer 3 cron |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **install.html**:guardian 结果按 ✓/⚠/ℹ/✗ 着色;Docker 清单重排 Layer 1–5,新增「宿主机 cron」步骤(`nx cron` / `install_ops_cron.sh`);`hostDeployRoot` 动态显示宿主机路径。
|
||||
2. **install.py**:`init-db` 与 state 返回 `host_deploy_root`(`NEXUS_HOST_ROOT` 或 `/opt/nexus`)。
|
||||
3. **detect_deploy_runtime.sh**:`prompt_ops_cron_if_missing` — 交互终端缺 cron 时提示并可选一键安装。
|
||||
4. **nexus-1panel.sh upgrade**:TTY 下升级完成后调用上述提示(非 TTY 仅打印建议)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web/app/install.html`
|
||||
- `server/api/install.py`
|
||||
- `deploy/detect_deploy_runtime.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 安装向导 UI 需 `nx update` 重建镜像后生效;host 脚本 `git pull` 即可。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_security_unit.py::test_configure_guardian_docker_mode_skips_supervisor -q
|
||||
bash -n deploy/detect_deploy_runtime.sh deploy/nexus-1panel.sh
|
||||
# 交互: sudo nx update(cron 已装时应显示 [INFO] 已含…)
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# nx 上帝菜单巡检第八轮 — 镜像版本验收 + 生产 nx update
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | round6/7 改动在宿主机 git 已更新但容器镜像未重建;验收脚本 `host.docker.internal` 比较未加引号;缺升级后自动验收 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **verify-1panel-install-wizard.sh**:修复 `host.docker.internal` bash 引号 bug;新增 `check_container_image_features`(install.py / install.html / NEXUS_HOST_ROOT)。
|
||||
2. **nexus-1panel.sh upgrade**:已锁定环境升级成功后自动跑验收脚本。
|
||||
3. **nx**:菜单 `[v]` / 子命令 `nx verify` 调用验收脚本。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/verify-1panel-install-wizard.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `deploy/nx`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需 `sudo nx update` 重建镜像以使 round6/7 容器内代码生效。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/verify-1panel-install-wizard.sh
|
||||
sudo nx verify
|
||||
sudo nx update --no-backup # 重建后 check_container_image_features 应全 PASS
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# nx 上帝菜单巡检第九轮 — 1Panel 运维文档同步
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | round4–8 新增备份/cron/验收/镜像检查等内容未写入 SSOT;卸载脚本仍提示 host.docker.internal |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **deploy/README-1panel.md**:补全 nx 菜单 `[e][v][9]`、三层守护、install.html 归档、新脚本速查。
|
||||
2. **nexus-1panel-operations-knowledge.md**:升级流程、§5.2 守护与备份、§7.6–7.7、验收清单、提交索引 round4–8。
|
||||
3. **uninstall-mysql/redis-compose.sh**:收尾提示改为 1Panel 容器名路径。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/README-1panel.md`
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md`
|
||||
- `deploy/uninstall-mysql-compose.sh`
|
||||
- `deploy/uninstall-redis-compose.sh`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/uninstall-mysql-compose.sh deploy/uninstall-redis-compose.sh
|
||||
grep -E 'nx cron|mysql_dump|install.html.bak' deploy/README-1panel.md
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# BL-06:强制 Agent per-server key(移除 global 回退)
|
||||
|
||||
**日期**:2026-06-07
|
||||
**动机**:生产 0 台子机窗口,关闭 global `API_KEY` 对 Agent 心跳/回调的 legacy 回退,消除「伪造未迁移服务器心跳」残余面;收口 `risk-acceptance-single-operator.md` 接受项 2。
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- Agent 认证(`/api/agent/heartbeat`、`/api/agent/script-callback`)仅接受 per-server `agent_api_key`
|
||||
- 巡检修复:`_verify_server_api_key` 返回 `Server` 对象,心跳路径合并为单次 `get_server`(原重复查询)
|
||||
- 安装链路(单台/批量 `install-agent`)无 per-server key 时明确报错,不再静默使用 global key
|
||||
- 长任务回调注册(`script_service._agent_api_key_for_server`)同样要求 per-server key
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `server/api/agent.py` | `_verify_api_key`、`_verify_server_api_key` 移除 global 回退 |
|
||||
| `server/api/servers.py` | `install_agent_remote`、`batch_install_agent` 无 key 报错 |
|
||||
| `server/application/services/script_service.py` | `_agent_api_key_for_server` 无 key 抛 `ValueError` |
|
||||
| `tests/test_agent_per_server_key.py` | 新增回归测试 |
|
||||
| `tests/load_test.py`、`tests/quick_load.py` | 注释说明 heartbeat 需 per-server key |
|
||||
| `docs/project/risk-acceptance-single-operator.md` | 接受项 2 标记已收口 |
|
||||
| `docs/reports/2026-06-06-external-attack-hardening-backlog.md` | BL-06 已实施 |
|
||||
| `docs/reports/2026-06-06-external-attack-surface-audit.md` | §7 状态更新 |
|
||||
|
||||
## 运维语义
|
||||
|
||||
- 新建服务器(单建/CSV/批量导入)仍自动生成 `nxs-...` per-server key
|
||||
- `uninstall-agent` 将 `agent_api_key` 置 `None`;重装前须 `POST /api/servers/{id}/agent-key`
|
||||
- global `API_KEY` 仍为必需环境变量(启动校验等),但**不再**用于 Agent 认证
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移
|
||||
- 需重启 API 容器使代码生效
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_agent_per_server_key.py tests/test_external_attack_surface_hardening.py tests/test_install_locked_404.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
|
||||
回归:`test_verify_server_key_single_get_server_per_auth_check` 断言认证路径 `get_server` 仅调用一次。
|
||||
|
||||
生产探测:`POST /api/agent/heartbeat` 带 global `API_KEY` + 任意 `server_id` → 期望 **401**。
|
||||
|
||||
## 回滚
|
||||
|
||||
恢复 `agent.py` global 回退分支及 `servers.py` / `script_service.py` 中 `or settings.API_KEY` 即可。
|
||||
@@ -0,0 +1,42 @@
|
||||
# Changelog — ce-code-review 跟进修复
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
落实 `/ce-code-review` walk-through 与 auto-resolve:边端 agent 401 停循环、install 锁定路由级 404、归档失败 fail-closed、安全规范与 BL-06 对齐,并补 install 锁定测试。
|
||||
|
||||
## 动机
|
||||
|
||||
BL-06 中心端改 401 后边端仍无限重试;锁定后 POST install 畸形 body 仍可能 422;`install.html` 归档失败时 lock 仍成功;`nexus-security.mdc` 与实现漂移。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web/agent/agent.py`
|
||||
- `server/api/install.py`
|
||||
- `.cursor/rules/nexus-security.mdc`
|
||||
- `scripts/security_probe_external.sh`(BL-07:WS 403 → PASS)
|
||||
- `tests/test_install_locked_404.py`
|
||||
- `tests/test_security_unit.py`
|
||||
- `deploy/install-nx-cli.sh`、`deploy/update.sh`(CRLF→LF,门控 pre-flight)
|
||||
- `docs/audit/2026-06-07-code-review-followups.md`
|
||||
- `docs/changelog/2026-06-06-bl07-websocket-closure.md`(同日 BL-07 说明)
|
||||
- `docs/reports/2026-06-06-external-hardening-deploy-verification.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 边端 agent 需重新部署/同步 `web/agent/agent.py` 后方生效
|
||||
- 中心 API 重启后 install 路由级 Depends 生效
|
||||
- 无 DB 迁移
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash scripts/local_verify.sh
|
||||
# smoke 47 passed · test_api 26/26 · ruff F 0
|
||||
|
||||
pytest tests/test_install_locked_404.py -q
|
||||
# 11 passed
|
||||
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
@@ -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,21 @@
|
||||
# Changelog — health_monitor flock 失败计数修复
|
||||
|
||||
**日期**: 2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `bump_fail_count` 在使用 `flock` 时子 shell 未回传计数、导致 `FAIL` 恒为 0、Layer 3 永不触发重启/Telegram 的 bug。
|
||||
|
||||
## 动机
|
||||
|
||||
生产模拟巡检时发现连续 3 次 /health 失败后 `last_restart` 仍为空;`bash -x` 显示 `FAIL=0` 而 `/tmp/nexus_health_fail_count` 已为 3。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/health_monitor.sh`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 阻断 127.0.0.1:8600 后连续执行 3 次 health_monitor.sh,第 3 次应触发 Telegram + docker restart
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog — Install 锁定后全端点 404(BL-05)
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
已安装且 install 锁定时,`/api/install/*` 全部返回 **404 Not found**,不再返回 403 及「安装向导已锁定」等可指纹识别文案。
|
||||
|
||||
## 动机
|
||||
|
||||
外网攻击面巡检 BL-05:减少已部署实例的安装向导指纹泄露。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/api/install.py` | `_raise_not_found_if_locked()`;`_reject_if_locked` / `lock_install` 统一 404 |
|
||||
| `tests/test_install_locked_404.py` | 锁定端点回归测试 |
|
||||
| `scripts/security_probe_external.sh` | 锁定态 install 探测仅接受 404 |
|
||||
| `docs/reports/2026-06-06-external-attack-surface-audit.md` | §4.2 更新;§7 待定项 BL-06~08 |
|
||||
| `docs/reports/2026-06-06-external-attack-hardening-backlog.md` | BL-05 已实施 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重启 API。无 DB 迁移。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_install_locked_404.py -q
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
```
|
||||
|
||||
部署后期望:所有 `/api/install/*` 探测 → **404**。
|
||||
@@ -0,0 +1,40 @@
|
||||
# Changelog — Layer 3 宿主机巡检完美实现
|
||||
|
||||
**日期**: 2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
打通设置页 Telegram 配置与 Layer-3 宿主机巡检(`health_monitor.sh` + cron):保存时自动同步 `.env`、脚本支持从 MySQL 回退读取、设置页展示巡检状态与手动同步、部署时自动安装 cron。
|
||||
|
||||
## 动机
|
||||
|
||||
用户在设置页配置 Telegram 后,应用内告警与测试发送可用,但宿主机 cron 巡检仅读 `docker/.env.prod` / 容器 `/app/.env`,导致 Layer-3 重启告警 Telegram 可能静默失败。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/infrastructure/env_file.py` | 新增 `.env` upsert/read 工具 |
|
||||
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
|
||||
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
|
||||
| `deploy/health_monitor.sh` | `load_telegram_from_db` MySQL 回退 |
|
||||
| `deploy/deploy-production.sh` | 部署成功时自动 `install_ops_cron.sh` |
|
||||
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
|
||||
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
|
||||
| `frontend/src/types/api.ts` | OpsPatrol 类型 |
|
||||
| `tests/test_ops_patrol_sync.py` | 单元测试 |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移
|
||||
- 后端需重启/重新部署容器;前端需 vite build
|
||||
- 生产需确保 cron 已安装(部署脚本现自动尝试)
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
pytest tests/test_ops_patrol_sync.py -q
|
||||
bash scripts/local_verify.sh
|
||||
# 设置页保存 Telegram → 刷新 Layer 3 状态 → 「同步巡检 Telegram」
|
||||
# 生产: crontab -l | grep nexus-health-monitor; tail /var/log/nexus_health.log
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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 额外 URL,stdout 混入 `000` 错误与响应体,判定逻辑全部失真。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/security_probe_external.sh`
|
||||
|
||||
## 变更(续 2026-06-07 试跑)
|
||||
|
||||
- `deploy-production.sh`:`NEXUS_PUBLISH_PORT` 缺失时 `PORT` 默认 **8600**(修复空端口打到 OpenResty 404)
|
||||
|
||||
## 变更
|
||||
|
||||
- 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])
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Changelog — 设置页恢复 IP 白名单订阅功能
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
Vue SPA 设置页补回代理订阅 URL、解析预览、订阅节点展示,并修正 `POST /settings/ip-allowlist` 请求体(`manual_ips` + `subscription_url`)。
|
||||
|
||||
## 动机
|
||||
|
||||
迁移后 IP 白名单仅保留合并 textarea + 错误 payload `{ ips }`,订阅 URL 无法配置,与后端 `IpAllowlistSaveRequest` 及 `parse-subscription` 脱节。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/SettingsPage.vue`
|
||||
- `frontend/src/types/api.ts`
|
||||
|
||||
## 行为
|
||||
|
||||
- 订阅 URL 输入 +「解析预览」→ `POST /settings/ip-allowlist/parse-subscription`
|
||||
- 保存 → `{ manual_ips, subscription_url, enabled }`,触发后台 2h 刷新
|
||||
- 展示订阅节点 chip 与 `last_refresh`
|
||||
- toggle 改用 query `?enabled=`(对齐 FastAPI 参数)
|
||||
|
||||
## 验证
|
||||
|
||||
`cd frontend && npx vite build`;设置页配置订阅 URL → 解析预览 → 保存 → 刷新后见订阅节点。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog — 设置页恢复 Telegram Chat ID 自动检测
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
Vue SPA 设置页补回「检测 Chat ID」按钮,对接已有 `GET /api/settings/telegram/chats`(getUpdates)。
|
||||
|
||||
## 动机
|
||||
|
||||
迁移至 `frontend/src/pages/SettingsPage.vue` 时仅保留 Token / Chat ID 手工输入与测试发送,遗漏旧版 settings 的 getUpdates 检测能力。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/SettingsPage.vue`
|
||||
- `frontend/src/types/api.ts`
|
||||
|
||||
## 行为
|
||||
|
||||
- 点击「检测 Chat ID」:若 Token 草稿未保存则先写入 DB,再拉取最近对话列表供选择
|
||||
- 选中后自动写入并保存 `telegram_chat_id`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重新构建前端并部署(Docker 镜像内 vite build)。
|
||||
|
||||
## 验证
|
||||
|
||||
设置页 → Telegram → 向 Bot 发消息后点「检测 Chat ID」→ 弹出对话列表 → 选择后 Chat ID 填入并保存。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Changelog — 设置页 Telegram 全流程修复 + 告警开关恢复
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
修复设置页 Telegram 推送相关功能:检测/测试前自动持久化配置、独立保存按钮、getUpdates Webhook 冲突处理、测试 API 返回 Telegram 具体错误;恢复 Vue 迁移遗漏的 8 项告警通知开关;API Key / Bot Token 查看免二次密码(JWT + 审计仍保留)。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈「检测 Chat ID / 发送测试 / 多项功能无法正常使用」:测试按钮不保存草稿配置、告警分项开关在 SPA 中缺失、getUpdates 在 Bot 曾设 Webhook 时恒为空、错误提示过于笼统。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/SettingsPage.vue` — `persistTelegramConfig`、保存按钮、8 项 NOTIFY 开关
|
||||
- `frontend/src/types/api.ts` — `NOTIFY_TOGGLE_ITEMS`
|
||||
- `server/api/settings.py` — Webhook 清除、Telegram 错误详情、reveal 免密
|
||||
- `tests/test_settings_telegram.py` — 新增
|
||||
- `docs/audit/2026-06-07-settings-telegram-fix.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移
|
||||
- 需重新构建前端并重启 API(或全量部署)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_settings_telegram.py -q
|
||||
bash scripts/local_verify.sh
|
||||
bash deploy/pre_deploy_check.sh
|
||||
# 设置页:填 Token → 检测 Chat ID → 测试发送 → 切换告警开关
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
Layer-3 cron(`health_monitor.sh`)仍读 `NEXUS_TELEGRAM_*`(`.env.prod`),与 UI 保存的 MySQL 配置独立;应用内告警走 MySQL 热更新即可。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 技术说明 — Layer 3 宿主机巡检 Telegram 打通
|
||||
|
||||
**日期**: 2026-06-07
|
||||
|
||||
## 目标
|
||||
|
||||
设置页保存 `telegram_bot_token` / `telegram_chat_id` 后,Layer-3 `health_monitor.sh` 能可靠发送 Telegram(/health 连续失败 → 重启通知)。
|
||||
|
||||
## 三层配置读取顺序(health_monitor.sh)
|
||||
|
||||
1. 宿主机 `${NEXUS_ROOT}/.env`、`docker/.env.prod`
|
||||
2. 容器 `/app/.env`(`docker exec grep`)
|
||||
3. **新增** 容器内 Python 从 MySQL `settings` 表加载(与运行时 `load_settings_from_db` 一致)
|
||||
|
||||
## 应用内同步(保存 Telegram 时)
|
||||
|
||||
`ops_patrol_sync.sync_telegram_to_env` 写入:
|
||||
|
||||
- `/app/.env` + `NEXUS_PERSIST_DIR/.env`(nexus-state 卷)
|
||||
- 若 `NEXUS_HOST_ROOT` 可写:`{NEXUS_HOST_ROOT}/docker/.env.prod`
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/settings/ops-patrol/status` | 巡检 Telegram / env 同步状态 |
|
||||
| POST | `/api/settings/ops-patrol/sync-telegram` | 手动同步 DB → .env |
|
||||
|
||||
## 部署
|
||||
|
||||
`deploy-production.sh` 健康检查通过后,若远程无 `nexus-health-monitor` cron,自动执行 `install_ops_cron.sh`。
|
||||
|
||||
## 回滚
|
||||
|
||||
- revert 本 commit;cron 条目可保留(行为与旧版兼容,仅多 DB 回退)
|
||||
- 删除 `.env` 中 `NEXUS_TELEGRAM_*` 不影响应用内 DB 配置
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 版本 | 2026-06-06 |
|
||||
| 版本 | 2026-06-06(round9 同步 god-menu 巡检 round4–8) |
|
||||
| 读者 | 运维、接续开发、AI Agent |
|
||||
| 定位 | **1Panel 生产部署 SSOT**(安装向导、网络、MySQL/Redis、日常 nx update) |
|
||||
| 关联 | [deploy/README-1panel.md](../../deploy/README-1panel.md) · [nexus-functional-development-guide.md](nexus-functional-development-guide.md) · [2026-06-04-1panel-docker-production.md](../design/plans/2026-06-04-1panel-docker-production.md) |
|
||||
| 关联 | [deploy/README-1panel.md](../../deploy/README-1panel.md) · [nexus-god-menu-audit-summary.md](nexus-god-menu-audit-summary.md) · [nexus-functional-development-guide.md](nexus-functional-development-guide.md) · [2026-06-04-1panel-docker-production.md](../design/plans/2026-06-04-1panel-docker-production.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,6 +89,10 @@ install --fresh
|
||||
步骤 4 create-admin
|
||||
→ 写 admins,自动 lock(.install_locked)
|
||||
|
||||
步骤 5 lock / entrypoint
|
||||
→ install.html 重命名为 install.html.bak(外网 /app/install.html → 404)
|
||||
→ 安装锁双写:/app/.install_locked + /var/lib/nexus/.install_locked
|
||||
|
||||
entrypoint EXIT trap
|
||||
→ /app/.env 复制到 nexus-state
|
||||
```
|
||||
@@ -224,19 +228,22 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
|
||||
| `nx update` | `deploy/update.sh` → `nexus-1panel.sh upgrade` |
|
||||
| `nexus-update` | 同上 |
|
||||
|
||||
**upgrade 顺序**(2026-06-06+):
|
||||
**upgrade 顺序**(2026-06-06 god-menu round4–8+):
|
||||
|
||||
```
|
||||
git fetch → MySQL 备份(失败默认 WARN)→ git reset --hard
|
||||
→ sync_1panel_service_hosts → sync_env_prod_to_volume
|
||||
git fetch → MySQL 备份(mysql_dump_to_file.sh:docker exec 1Panel MySQL)→ git reset --hard
|
||||
→ sync_1panel_service_hosts → sync_env_prod_to_volume → NEXUS_HOST_ROOT 写入 .env.prod
|
||||
→ tag 当前镜像为 nexus-prod-nexus:rollback
|
||||
→ compose build → 镜像门控 (import server.main)
|
||||
→ compose up -d --no-build
|
||||
→(仅 install.html 非 200 时)sync-install-wizard-to-container.sh
|
||||
→ set_install_complete → verify_health(端口 NEXUS_PUBLISH_PORT)
|
||||
→ compose up -d --no-build → verify_nexus_1panel_network
|
||||
→ 已锁定:跳过 install.html 热同步;未锁定且非 200 才 sync-install-wizard
|
||||
→ set_install_complete → verify_health
|
||||
→ TTY:prompt_ops_cron_if_missing;已锁定:verify-1panel-install-wizard.sh
|
||||
→ 健康失败:自动 tag rollback + up -d 回滚
|
||||
```
|
||||
|
||||
`deploy/upgrade.sh`、`deploy-production.sh` 等会自动识别 **Docker vs Supervisor**,Docker 路径委托 `nexus-1panel.sh upgrade`。
|
||||
|
||||
| 参数 | 含义 |
|
||||
|------|------|
|
||||
| `--check` | 仅查 Git |
|
||||
@@ -257,6 +264,25 @@ git fetch → MySQL 备份(失败默认 WARN)→ git reset --hard
|
||||
|
||||
Redis 容器改名时,`resolve_nexus_redis_url` 会尝试将旧 URL 中的密码迁移到新 host。
|
||||
|
||||
### 5.2 三层守护与宿主机 cron
|
||||
|
||||
| 层 | Docker 1Panel | 裸机 Supervisor |
|
||||
|----|---------------|-----------------|
|
||||
| L1 | Compose `restart` + healthcheck | Supervisor `autorestart` |
|
||||
| L2 | `self_monitor`(30s) | 同左 |
|
||||
| L3 | **宿主机** `health_monitor.sh`(cron) | 同左 |
|
||||
|
||||
安装向导步骤 5(Docker)会提示在宿主机执行 `sudo nx cron`;`nx update` 结束时 TTY 下可交互安装。
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `install_ops_cron.sh` | 幂等写入 health_monitor(每分钟)+ db_backup(03:00) |
|
||||
| `health_monitor.sh` | Layer 3:3 次 `/health` 失败 → `docker restart` + Telegram |
|
||||
| `db_backup.sh` | 调用 `mysql_dump_to_file.sh`,保留 30 天 |
|
||||
| `mysql_dump_to_file.sh` | `docker exec` MySQL 容器 → 宿主机 mysqldump → 临时 mysql:8 客户端 |
|
||||
|
||||
备份目录默认 `/var/backups/nexus/`。菜单 `nx` → `[9]` 手动备份;`[e]` / `nx cron` 安装定时任务。
|
||||
|
||||
---
|
||||
|
||||
## 6. 脚本速查
|
||||
@@ -264,13 +290,16 @@ Redis 容器改名时,`resolve_nexus_redis_url` 会尝试将旧 URL 中的密
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| [deploy/README-1panel.md](../../deploy/README-1panel.md) | 速查表 |
|
||||
| `verify-1panel-install-wizard.sh` | 服务器一键验收 |
|
||||
| `verify-1panel-install-wizard.sh` | 服务器一键验收(含 cron、镜像特性) |
|
||||
| `detect-1panel-services.sh` | 输出 `NEXUS_1PANEL_*_HOST` |
|
||||
| `detect_deploy_runtime.sh` | Docker / Supervisor 运行时检测 |
|
||||
| `fix-1panel-mysql-grant.sh` | 修复 MySQL 1045(`nexus@'%'`) |
|
||||
| `test-1panel-redis.sh` | **Redis 多格式认证诊断** |
|
||||
| `sync-install-wizard-to-container.sh` | 热同步 `install.html` + `install.py` |
|
||||
| `sync-install-wizard-to-container.sh` | 热同步(**已归档时早退**) |
|
||||
| `uninstall-mysql-compose.sh` | 清理旧内置 MySQL 栈 |
|
||||
|
||||
`nx` 子命令:`update` · `verify` · `cron` · `health` · `install-fresh`
|
||||
|
||||
---
|
||||
|
||||
## 7. 故障排查决策树
|
||||
@@ -351,19 +380,27 @@ nx update --no-cache
|
||||
- `curl -s http://127.0.0.1:8600/health` → `ok`
|
||||
- `curl -sk https://域名/health` → 应 `ok`
|
||||
|
||||
### 7.6 install.html 非 200
|
||||
### 7.6 install.html 非 200 / 已归档 404
|
||||
|
||||
| 状态 | 期望 | 处理 |
|
||||
|------|------|------|
|
||||
| 安装中 | HTTP 200 | `sync-install-wizard-to-container.sh` 或 `nx update` |
|
||||
| **已锁定** | HTTP **404** | 正常;容器内仅 `install.html.bak` |
|
||||
|
||||
```bash
|
||||
bash deploy/sync-install-wizard-to-container.sh
|
||||
# 或 nx update --no-cache
|
||||
sudo nx verify # 已安装时应 PASS「install.html 已归档 → HTTP 404」
|
||||
```
|
||||
|
||||
### 7.7 升级前 MySQL 备份被跳过
|
||||
|
||||
旧版因宿主机无 `mysqldump` 且 URL host 为容器名而跳过。现用 `mysql_dump_to_file.sh`(`docker exec` 1Panel MySQL)。手动:`sudo bash /opt/nexus/deploy/db_backup.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单
|
||||
|
||||
```bash
|
||||
cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
cd /opt/nexus && sudo nx verify
|
||||
```
|
||||
|
||||
关键 `[PASS]`:
|
||||
@@ -371,8 +408,9 @@ cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
- Git ≥ `b25d079`(1Panel sync)
|
||||
- `NEXUS_1PANEL_DB_HOST` / `NEXUS_1PANEL_REDIS_HOST`
|
||||
- Nexus 在 `1panel-network`
|
||||
- `/app/.env` 不存在(安装模式)或连接检测全绿
|
||||
- `env-check` 的 `docker_defaults.db_host` 为 `1Panel-mysql-*`
|
||||
- **已安装**:`install.html` → 404;Crontab 含 health_monitor + db_backup
|
||||
- **已安装**:容器 `install.py` 含 `host_deploy_root`;`NEXUS_HOST_ROOT=/opt/nexus`
|
||||
- **安装中**:`/app/.env` 不存在;`env-check` 的 `docker_defaults.db_host` 为 `1Panel-mysql-*`
|
||||
|
||||
浏览器:步骤 3 **先检测** → 初始化 → 步骤 4 创建 admin → `/app/` 登录。
|
||||
|
||||
@@ -387,6 +425,10 @@ cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
| `da061d3` | 步骤 3 `test-credentials` 门禁 |
|
||||
| `807f4c2` | Redis URL `redis://:pass@` 修复(曾误加 root,已废弃) |
|
||||
| `3b2856f+` | 自动解析 `:pass@` / `default`;步骤 4 connection-check 可自愈 `.env` |
|
||||
| `4e1347b`–`ba69bc3` | Docker MySQL 备份、`install_ops_cron.sh`、`nx cron` |
|
||||
| `89865ec` | `upgrade.sh` / `deploy-production.sh` Docker·Supervisor 分流 |
|
||||
| `c9e0879`–`e5d4822` | 守护配置 `host_deploy_root`、向导 Layer 3 cron UI |
|
||||
| `72e46ce` | 升级后自动验收、`nx verify`、镜像特性检查 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Nexus 6.0 — 功能开发指南 · 附录(详细参考)
|
||||
|
||||
> **主文档**: [nexus-functional-development-guide.md](nexus-functional-development-guide.md)
|
||||
> **版本**: 2026-06-04
|
||||
> **版本**: 2026-06-06
|
||||
> **用途**: API 请求体、前端调用链、E2E/L4 对照、组件映射 — 接续开发时查表用
|
||||
|
||||
---
|
||||
@@ -413,6 +413,52 @@ NEXUS_E2E_PASSWORD=*** # 必填;测试账号勿开 TOTP
|
||||
|
||||
---
|
||||
|
||||
## 附录 J — 凭据轮询与 pending servers
|
||||
|
||||
定义: `server/application/services/credential_poller.py` · 路由: `server/api/servers.py` · 表: `pending_servers`
|
||||
|
||||
### J.1 API
|
||||
|
||||
| 方法 | 路径 | 请求体 | 成功响应 | 失败/待定 |
|
||||
|------|------|--------|----------|-----------|
|
||||
| POST | `/api/servers/add-by-ip` | `AddByIpRequest`: `domain`(必填), `port?`(默认 22), `name?` | `{ success: true, server, matched_preset, matched_username }` | `{ success: false, pending_id, errors[], message }` — 写入 pending |
|
||||
| GET | `/api/servers/pending` | — | `{ items: PendingServer[], total }` | — |
|
||||
| POST | `/api/servers/pending/{id}/retry` | `{}` | 同 add-by-ip | 同 add-by-ip;域名已存在 → **409** 并删除 pending |
|
||||
| DELETE | `/api/servers/pending/{id}` | — | **204** | 404 |
|
||||
|
||||
**PollErrorItem**(`errors[]`): `preset_type`(`password` \| `ssh_key`)、`preset_name`、`username`、`error`。
|
||||
|
||||
**PendingServer**(列表项): `id`, `name`, `domain`, `port`, `attempts`, `last_error`, `last_attempt_at`, `created_at` 及可选 `agent_port`, `target_path`, `category`, `platform_id`, `node_id`。
|
||||
|
||||
### J.2 轮询顺序
|
||||
|
||||
1. 解密全部 `password_presets`(按 id 升序),再全部 `ssh_key_presets`
|
||||
2. 每条使用预设 `username`(缺省 `root`)调用 `try_ssh_login`
|
||||
3. 首条成功 → `create_server` + 审计 `create_server`;全部失败 → `pending_servers` upsert + 审计 `add_by_ip_failed`
|
||||
|
||||
### J.3 前端(ServersPage)
|
||||
|
||||
| 用户操作 | API |
|
||||
|----------|-----|
|
||||
| 打开「快速添加」对话框 | — |
|
||||
| 提交 IP | `POST /servers/add-by-ip` |
|
||||
| 失败弹窗展示 `errors[]` | — |
|
||||
| 页面加载 / 刷新失败列表 | `GET /servers/pending` |
|
||||
| 行内重试 | `POST /servers/pending/{id}/retry` |
|
||||
| 行内删除 | `DELETE /servers/pending/{id}` |
|
||||
|
||||
类型定义: `frontend/src/types/api.ts` — `PendingServerItem`, `AddByIpResponse`, `PollErrorItem`。
|
||||
|
||||
### J.4 预设 username(CredentialsPage)
|
||||
|
||||
| 端点 | 新增字段 |
|
||||
|------|----------|
|
||||
| `POST/PUT /api/presets/` | `username`(默认 `root`) |
|
||||
| `POST/PUT /api/ssh-key-presets/` | `username`(默认 `root`) |
|
||||
| `GET` 列表 | 每项含 `username` |
|
||||
|
||||
---
|
||||
|
||||
## 附录 H — 错误码与前端处理约定
|
||||
|
||||
| HTTP | 场景 | 前端处理 |
|
||||
|
||||
@@ -533,12 +533,17 @@ main.ts → App.vue(侧栏/搜索)→ router-view
|
||||
| 功能 | API |
|
||||
|------|-----|
|
||||
| CRUD | `/servers/` |
|
||||
| **快速添加(仅 IP)** | `POST /servers/add-by-ip` — 轮询全部密码/SSH 预设;成功返回 `matched_preset` / `matched_username` |
|
||||
| **连接失败列表** | `GET /servers/pending` |
|
||||
| **失败重试 / 删除** | `POST /servers/pending/{id}/retry`、`DELETE /servers/pending/{id}` |
|
||||
| 批量选择 | 安装/升级/卸载 Agent、健康检查、删除 |
|
||||
| CSV 导出 | 客户端生成(无密码列) |
|
||||
| CSV 导入 | `POST /servers/import` FormData |
|
||||
| 详情面板 | 系统信息 + `GET /servers/{id}/logs` |
|
||||
| 表单 | `ServerFormDialog` + `useServerFormDialog` |
|
||||
|
||||
详见附录 J(凭据轮询契约)。
|
||||
|
||||
### 12.3 TerminalPage `/terminal`
|
||||
|
||||
| 功能 | 说明 |
|
||||
@@ -589,6 +594,14 @@ main.ts → App.vue(侧栏/搜索)→ router-view
|
||||
|
||||
三 Tab:密码预设、SSH 密钥、DB 凭据;编辑时空密码表示不修改。
|
||||
|
||||
| Tab | API | 契约要点 |
|
||||
|-----|-----|----------|
|
||||
| 密码预设 | `GET/POST /presets/`、`PUT/DELETE /presets/{id}` | 字段 `name`、`username`(默认 `root`)、`encrypted_pw`;列表返回 `username` |
|
||||
| SSH 密钥 | `GET/POST /ssh-key-presets/`、`PUT/DELETE /ssh-key-presets/{id}` | 同上 `username`;私钥 `private_key` 仅创建/更新时传 |
|
||||
| DB 凭据 | `GET/POST /scripts/credentials`、`PUT/DELETE /scripts/credentials/{id}` | 脚本执行用 DB 连接,与 SSH 轮询无关 |
|
||||
|
||||
`username` 供 `credential_poller` 在 `add-by-ip` / retry 时与预设密码或私钥组合尝试 SSH。
|
||||
|
||||
### 12.8 SchedulesPage `/schedules`
|
||||
|
||||
Cron 调度推送;`runNow` → `POST /sync/files`;cron 前端正则校验。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Nexus — `nx` 上帝菜单巡检归档(2026-06-06)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 范围 | `deploy/nx`、`nexus-1panel.sh`、`update.sh`、安装向导、1Panel Docker 生产 |
|
||||
| 生产 | `azureuser@20.24.218.235` · `/opt/nexus` · `https://api.synaglobal.vip` |
|
||||
| 关联 SSOT | [nexus-1panel-operations-knowledge.md](nexus-1panel-operations-knowledge.md) · [deploy/README-1panel.md](../../deploy/README-1panel.md) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
2026-06-06 对 **1Panel + Docker 生产** 路径做连续巡检:修复 502、安装锁、网络、备份、cron、部署脚本分流、镜像与文档漂移。本文档为 **round1–10 归档索引**;细节见各轮 `docs/changelog/2026-06-06-nx-god-menu-audit-round*.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 巡检轮次一览
|
||||
|
||||
| 轮次 | 提交(代表) | 主题 | 关键交付 |
|
||||
|------|-------------|------|----------|
|
||||
| 预修 | `107b089` `dd66d99` | 502 / 安装锁 / 1panel-network | 锁持久化、`verify_nexus_1panel_network`、nx 菜单修补 |
|
||||
| 归档 | `58bdf82` | install.html 锁定归档 | `install.html` → `.bak`,外网 404 |
|
||||
| R2 | `687e059` | 全新安装 vs 卷冲突 | `FRESH_INSTALL` 清卷、验收 404 预期、回滚网络 |
|
||||
| R3 | `61b8d7d` | health_monitor Docker | `docker restart`、端口/Telegram 从容器读 |
|
||||
| R4 | `4e1347b`–`ba69bc3` | MySQL 备份 + cron | `mysql_dump_to_file.sh`、`install_ops_cron.sh`、`nx cron` |
|
||||
| R5 | `89865ec` | 部署运行时分流 | `detect_deploy_runtime.sh`、`upgrade.sh` Docker 委托 |
|
||||
| R6 | `c9e0879` | 守护配置对齐 | `host_deploy_root`、验收 cron/备份检查 |
|
||||
| R7 | `e5d4822` | 向导 UI | Layer 3 cron 步骤、guardian 分色 |
|
||||
| R8 | `72e46ce` | 镜像验收 | `check_container_image_features`、升级后 `nx verify` |
|
||||
| R9 | `fb9dbcb` | 运维文档 | README + 知识文档同步 round4–8 |
|
||||
| R10 | — | 本文档 | 归档索引 + 日常速查 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前 `nx` 能力矩阵
|
||||
|
||||
| 入口 | 用途 |
|
||||
|------|------|
|
||||
| `nx` / 菜单 | 安装、更新、重启、日志、备份、cron、验收 |
|
||||
| `nx update` | `git pull` + MySQL 备份 + 重建镜像 + 健康检查 +(TTY)cron 提示 + 验收 |
|
||||
| `nx verify` | `verify-1panel-install-wizard.sh` |
|
||||
| `nx cron` | `install_ops_cron.sh`(health_monitor + db_backup) |
|
||||
| `nx health` | 状态 + `/health` |
|
||||
| `bash deploy/upgrade.sh` | 自动识别 Docker → `nexus-1panel.sh upgrade` |
|
||||
|
||||
### 菜单项(已安装)
|
||||
|
||||
| 键 | 功能 |
|
||||
|----|------|
|
||||
| `[3]` | 状态 / health / install.html 归档 |
|
||||
| `[4]` | 一键更新 ★ |
|
||||
| `[5]`–`[8]` | 重启 / 日志 / Compose / 重建 |
|
||||
| `[9]` | 立即 MySQL 备份 |
|
||||
| `[e]` | 安装 cron |
|
||||
| `[v]` | 验收脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 三层守护(Docker 生产)
|
||||
|
||||
```
|
||||
Layer 1 Compose restart + healthcheck (容器内)
|
||||
Layer 2 Python self_monitor(30s) (容器内)
|
||||
Layer 3 health_monitor.sh(cron,宿主机) → docker restart + Telegram
|
||||
db_backup.sh(cron 03:00) → /var/backups/nexus/
|
||||
```
|
||||
|
||||
**Layer 3 必须在宿主机安装**:`sudo nx cron`(容器内无法写 crontab)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 日常运维速查
|
||||
|
||||
```bash
|
||||
cd /opt/nexus
|
||||
|
||||
# 更新(推荐)
|
||||
sudo nx update
|
||||
|
||||
# 验收(已安装应全 PASS,install.html → 404)
|
||||
sudo nx verify
|
||||
|
||||
# 手动备份
|
||||
sudo bash deploy/db_backup.sh
|
||||
|
||||
# 查看 cron
|
||||
sudo crontab -l | grep nexus
|
||||
|
||||
# 外网健康
|
||||
curl -sk https://api.synaglobal.vip/health
|
||||
```
|
||||
|
||||
### 升级选项
|
||||
|
||||
| 标志 | 含义 |
|
||||
|------|------|
|
||||
| `--no-backup` | 跳过 MySQL 备份 |
|
||||
| `--no-cache` | 无缓存重建(Dockerfile/entrypoint 变更后) |
|
||||
| `--require-backup` | 备份失败则中止 |
|
||||
| `--check` | 仅查 Git |
|
||||
|
||||
---
|
||||
|
||||
## 6. 生产验收标准(已安装)
|
||||
|
||||
`sudo nx verify` 关键项:
|
||||
|
||||
- `/health` → `ok`
|
||||
- `/app/install.html` → **HTTP 404**(已归档)
|
||||
- Nexus 在 `1panel-network`
|
||||
- Crontab 含 `nexus-health-monitor` / `nexus-mysql-backup`
|
||||
- 容器 `install.py` 含 `_host_deploy_root`
|
||||
- `NEXUS_HOST_ROOT=/opt/nexus`
|
||||
- HTTPS `/health` → `ok`
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知限制与后续
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| Supervisor 裸机 | `upgrade.sh` 保留 Supervisor 路径;1Panel 生产以 Docker 为准 |
|
||||
| 安装向导 UI | 改动在镜像内,需 `nx update` 重建后生效 |
|
||||
| 前端 14 页 | 本次巡检未改 SPA;见 `reports/audit-frontend-14p-*` |
|
||||
| 凭据轮询 | 并行功能见 `credential-polling-pending-servers` changelog |
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog 索引(本轮系列)
|
||||
|
||||
- [round2](../changelog/2026-06-06-nx-god-menu-audit-round2.md)
|
||||
- [round3 health_monitor](../changelog/2026-06-06-nx-god-menu-audit-round3-health-monitor.md)
|
||||
- [round4 mysql/cron](../changelog/2026-06-06-nx-god-menu-audit-round4-mysql-backup-cron.md)
|
||||
- [round5 runtime](../changelog/2026-06-06-nx-god-menu-audit-round5-deploy-runtime-detect.md)
|
||||
- [round6 guardian](../changelog/2026-06-06-nx-god-menu-audit-round6-guardian-verify-cron.md)
|
||||
- [round7 wizard UI](../changelog/2026-06-06-nx-god-menu-audit-round7-install-wizard-cron-prompt.md)
|
||||
- [round8 image verify](../changelog/2026-06-06-nx-god-menu-audit-round8-image-verify-upgrade.md)
|
||||
- [round9 docs](../changelog/2026-06-06-nx-god-menu-audit-round9-ops-docs.md)
|
||||
- [round10 summary](../changelog/2026-06-06-nx-god-menu-audit-round10-summary.md)
|
||||
|
||||
---
|
||||
|
||||
*巡检由 Agent 执行并留证;用户终验见 [2026-06-01-final-acceptance-checklist.md](2026-06-01-final-acceptance-checklist.md)。*
|
||||
@@ -5,7 +5,7 @@
|
||||
Nexus 全量 Code Review(批次 1~3)完成后,有两项被标记为「刻意未纳入」,需更大协议变更或 Agent 全站迁移:
|
||||
|
||||
1. WebSocket JWT 仍通过 query 传递(ADR-011)
|
||||
2. Agent 认证保留 global `API_KEY` 对未分配 `agent_api_key` 服务器的 legacy 回退
|
||||
2. ~~Agent 认证保留 global `API_KEY` 对未分配 `agent_api_key` 服务器的 legacy 回退~~ **已收口(2026-06-07 BL-06)**
|
||||
|
||||
**运营事实(2026-06-04 确认)**:后台仅 **一名** 操作员自用,无多人共享账号、无外包运维、无多租户。操作员即系统 owner,管理员身份视为完全可信。
|
||||
|
||||
@@ -56,44 +56,28 @@ Nexus 全量 Code Review(批次 1~3)完成后,有两项被标记为「
|
||||
|
||||
---
|
||||
|
||||
## 接受项 2:Agent global API_KEY legacy 回退
|
||||
## 接受项 2:Agent global API_KEY legacy 回退 — **已收口(2026-06-07)**
|
||||
|
||||
### 现状
|
||||
### 原状(2026-06-04 接受)
|
||||
|
||||
- 子机 Agent 通过 `X-API-Key` 调用 `/api/agent/heartbeat`(无 JWT、无后台登录)
|
||||
- 认证逻辑(`server/api/agent.py`):
|
||||
1. 服务器必须存在
|
||||
2. 若已分配 `agent_api_key` → 必须匹配 per-server key
|
||||
3. **Legacy**:未分配 per-server key → 仍接受 global `API_KEY`(写 warning 日志)
|
||||
- 批次 3 已收紧:未知 `server_id` 拒绝;DB 查服异常 fail-closed
|
||||
- 认证逻辑曾允许:未分配 `agent_api_key` 时回退接受 global `API_KEY`(写 warning 日志)
|
||||
|
||||
### 接受理由
|
||||
### 收口决定(BL-06)
|
||||
|
||||
- 与「后台几人用」无关,但单人运维下 global key 分发面可控(`.env`、安装脚本、子机 `config.json`)
|
||||
- 硬禁用 global 回退可能导致大量 legacy Agent 心跳 401,**可用性风险大于当前威胁**
|
||||
- 已有 per-server key 的服务器已与 global 隔离
|
||||
- **2026-06-07**:生产 0 台子机窗口,移除 global 回退,强制 per-server `agent_api_key`
|
||||
- 改动点:`server/api/agent.py`、`server/api/servers.py`(安装链路)、`server/application/services/script_service.py`(长任务回调)
|
||||
- global `API_KEY` 仍保留为必需环境变量(启动校验、加密等非 Agent 用途),**不再作为 Agent 认证通行证**
|
||||
|
||||
### 运维语义
|
||||
|
||||
- 新建服务器自动生成 `nxs-...` per-server key
|
||||
- `uninstall-agent` 会将 `agent_api_key` 置 `None`;重装前须 `POST /api/servers/{id}/agent-key` 重新生成
|
||||
|
||||
### 残余风险
|
||||
|
||||
- global `API_KEY` 若从备份、误提交、子机配置泄露,攻击者可对 **尚未分配 `agent_api_key`** 的服务器伪造心跳(污染在线状态/监控数据,非 RCE)
|
||||
- 攻击者需同时能访问中心 API 地址并持有 key
|
||||
|
||||
### 缓解(已实施)
|
||||
|
||||
- 未知 server_id 不再接受 global 冒充
|
||||
- 新装/生成 key 流程优先 per-server(`POST /servers/{id}/agent-key`)
|
||||
- legacy 使用 global 时服务器日志告警,便于逐步迁移
|
||||
|
||||
### 重评触发条件
|
||||
|
||||
- global key 疑似或确认泄露
|
||||
- 计划轮换 `API_KEY`
|
||||
- 新装 Agent 100% per-server 且存量迁移完成
|
||||
- 监控/告警数据完整性成为合规要求
|
||||
|
||||
### 决定
|
||||
|
||||
**保留 legacy global 回退;不排硬禁用。** 有空时可做摸底(`agent_api_key IS NULL` 数量)与渐进迁移,非阻塞项。
|
||||
- **已消除**:global key 伪造未迁移服务器心跳的面已关闭
|
||||
- 接受项 2 自 2026-06-07 起**作废**,不再作为开放风险项
|
||||
|
||||
---
|
||||
|
||||
@@ -114,6 +98,7 @@ Nexus 全量 Code Review(批次 1~3)完成后,有两项被标记为「
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-06-04 | 初始记录;operator 确认单人自用后台 |
|
||||
| 2026-06-07 | 接受项 2 收口:BL-06 强制 per-server agent_api_key,移除 global 回退 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# API 巡检 B1 — servers.py + webssh.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**范围**: `server/api/servers.py`(28 端点)、`server/api/webssh.py`(1 WS)
|
||||
**方法**: 8 维清单(认证/越权/注入/路径/凭据/输入/错误/审计)
|
||||
|
||||
## 结论
|
||||
|
||||
| 级别 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| P0 | 0 | — |
|
||||
| P1 | 0 | — |
|
||||
| P2 | 1 | 已修:`ServerCheck.server_ids` 无上限 → DoS |
|
||||
| H | 3 | 已记录/接受,见下表 |
|
||||
|
||||
**批次结论**: **PASS**(修复已合入工作区)
|
||||
|
||||
---
|
||||
|
||||
## 端点覆盖
|
||||
|
||||
全部 29 个路由均要求 `get_current_admin`(REST)或 WebSSH 专用 JWT(`purpose=webssh` + `server_id` 绑定)。
|
||||
|
||||
| 模块 | 端点 | 认证 | 审计 CUD | 备注 |
|
||||
|------|------|------|----------|------|
|
||||
| servers | CRUD `/` `/{id}` | JWT | ✓ | `_server_to_dict` 不返明文密码/私钥 |
|
||||
| servers | `/stats` `/categories` `/logs` | JWT | — | 只读;stats 有 online 封顶 |
|
||||
| servers | `/import` | JWT | ✓ | 1MB/500 行;CSV 注入防护 `_sanitize_csv_cell` |
|
||||
| servers | `/batch/*` | JWT | ✓ | `server_ids` max 50;SSH `shlex.quote` |
|
||||
| servers | `/add-by-ip` `/pending/*` | JWT | ✓(已补 retry 失败) | 轮询失败写 pending + 审计 |
|
||||
| servers | `/agent-key` `/agent-install-cmd` | JWT + 密码复核 | ✓ | 不回落全局 API_KEY |
|
||||
| servers | `/install-agent` | JWT | ✓ | 服务端 SSH 可用全局 KEY 作 fallback(H) |
|
||||
| servers | `/check` | JWT | — | **已修** max 50 server_ids |
|
||||
| webssh | `/ws/terminal/{id}` | JWT query | connect/disconnect | server_id 绑定;命令审计 + 危险命令检测 |
|
||||
|
||||
---
|
||||
|
||||
## FINDING 明细
|
||||
|
||||
### P2-01 — `POST /api/servers/check` 无 `server_ids` 上限(已修)
|
||||
|
||||
- **风险**: 传入数千 ID 触发并行 SSH 探测,资源耗尽。
|
||||
- **修复**: `ServerCheck.server_ids` 增加 `max_length=50`(与 `BatchAgentAction` 一致)。
|
||||
- **文件**: `server/api/schemas.py`
|
||||
|
||||
### H-01 — WebSSH 遗留 token 无 `tv` 字段时回退 `updated_at` 宽限
|
||||
|
||||
- **位置**: `webssh.py` `_verify_webssh_token`
|
||||
- **影响**: 改密后 5 秒内旧 WebSSH token 仍可能有效(新签发 token 均含 `tv`)。
|
||||
- **处置**: 接受(单人运维模型);新 token 路径已安全。
|
||||
|
||||
### H-02 — `install-agent` 无 per-server key 时回落 `settings.API_KEY`
|
||||
|
||||
- **位置**: `servers.py` `install_agent_remote`
|
||||
- **影响**: 仅用于 SSH 下发安装命令,不向 API 响应暴露全局 KEY。
|
||||
- **处置**: 接受;与 Agent 安装运维模型一致。
|
||||
|
||||
### H-03 — `retry_pending` 失败与 `delete_pending` 审计字段不完整(已修)
|
||||
|
||||
- **修复**: 失败重试写 `retry_pending_server_failed` 审计;删除前读取 pending 写入 `detail`。
|
||||
- **文件**: `server/api/servers.py`
|
||||
|
||||
---
|
||||
|
||||
## 安全亮点(无需改动)
|
||||
|
||||
- `_server_to_dict`: `password_set` / `ssh_key_private_set` / agent key 预览
|
||||
- `update_server`: `ALLOWED_FIELDS` 白名单,禁止 mass-assign `agent_api_key`
|
||||
- `_sudo_wrap`: 临时 sudoers 白名单命令 + 事后清理
|
||||
- `setup-files-sudo`: 密码经 stdin 喂 `sudo -S`,不进 argv
|
||||
- WebSSH: token `server_id` 与路径参数必须一致(防 IDOR)
|
||||
- CSV import: 大小/行数限制 + formula injection 防护
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_credential_poller.py tests/test_security_unit.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**下一批**: B2 — `sync_v2.py` + `files.py` + `remote_path_validation.py`
|
||||
@@ -0,0 +1,58 @@
|
||||
# API 巡检 B2 — sync_v2.py + files.py + remote_path_validation.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**范围**: 22 个 `/api/sync/*` 端点 + `GET /api/files/browse` + 路径校验模块
|
||||
|
||||
## 结论
|
||||
|
||||
| 级别 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| P0 | 0 | — |
|
||||
| P1 | 0 | — |
|
||||
| P2 | 1 | 已修:`SyncFiles`/`SyncVerify` `server_ids` 无上限 |
|
||||
| H | 1 | `_audit_sync` 写失败仅 warning(不阻断业务) |
|
||||
|
||||
**批次结论**: **PASS**
|
||||
|
||||
---
|
||||
|
||||
## 覆盖摘要
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 全部 sync 端点 JWT | ✓ 22/22 `get_current_admin` |
|
||||
| 远程路径 | ✓ `normalize_remote_abs_path` / `remote_join` |
|
||||
| 本地 staging | ✓ `ensure_under_nexus_upload` 限制 `/tmp/nexus_upload_*` |
|
||||
| Shell 拼接 | ✓ 普遍 `shlex.quote` |
|
||||
| 上传限制 | ✓ 单文件 100MB;ZIP 500MB |
|
||||
| 剪贴板 copy/move | ✓ `assert_clipboard_transfer_safe` 防 dest 在源子树内 |
|
||||
| files GET browse | ✓ ETag + 路径规范化 + 审计 |
|
||||
|
||||
---
|
||||
|
||||
## FINDING
|
||||
|
||||
### P2-02 — `SyncFiles` / `SyncVerify` `server_ids` 无 max_length(已修)
|
||||
|
||||
- **风险**: 超大列表导致海量协程调度与 DB 查询(verify 虽 Semaphore(5) 仍会创建 N 个 task)。
|
||||
- **修复**: `max_length=2000`(对齐平台子机规模上限)。
|
||||
- **文件**: `server/api/schemas.py`
|
||||
|
||||
### H-04 — `_audit_sync` 异常时 `logger.warning` 不向上抛
|
||||
|
||||
- **位置**: `sync_v2.py` `_audit_sync`
|
||||
- **处置**: 接受 — 审计失败不应阻断文件操作;已有 exc_info 日志。
|
||||
|
||||
---
|
||||
|
||||
## 安全亮点
|
||||
|
||||
- `file-ops` delete 使用 `rm -rf` 但路径经 `normalize_remote_abs_path` 校验
|
||||
- `upload` 文件名剥离 `/` `\`
|
||||
- `local-file-ops` rename 禁止 `..` / `.`
|
||||
- `write-file` 5MB 上限 + etag 乐观锁(409)
|
||||
- `read-file` 1MB 默认上限
|
||||
|
||||
---
|
||||
|
||||
**下一批**: B3 — auth + auth_jwt + dependencies
|
||||
@@ -0,0 +1,18 @@
|
||||
# API 巡检 B3 — auth.py + auth_jwt.py + dependencies.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
|
||||
## 结论: **PASS**(0 P0/P1/P2,无代码修改)
|
||||
|
||||
| 模块 | 端点/职责 | 结果 |
|
||||
|------|-----------|------|
|
||||
| `auth.py` | login/refresh/logout/TOTP/password/webssh-token/me | ✓ refresh HttpOnly cookie;改密/TOTP 需 JWT |
|
||||
| `auth_jwt.py` | JwtAuthMiddleware + `get_current_admin` | ✓ PUBLIC_PREFIXES 白名单;`tv` 版本校验 |
|
||||
| `dependencies.py` | DI + `check_dangerous_command` | ✓ 无 session 泄漏;危险命令 warning 日志 |
|
||||
|
||||
## 要点
|
||||
|
||||
- 登录失败 → 429 锁定(`auth_service`)
|
||||
- Refresh 重用检测 + `token_version` 递增(`test_auth_refresh_reuse.py` 覆盖)
|
||||
- WebSSH token 独立 `purpose=webssh` + `server_id` 绑定
|
||||
- `check_dangerous_command` 为**警告不阻断**(单人运维接受,与 Scripts/WebSSH 一致)
|
||||
@@ -0,0 +1,19 @@
|
||||
# API 巡检 B4 — scripts.py + terminal.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
|
||||
## 结论: **PASS**(P2×1 已修)
|
||||
|
||||
### P2-03 — `ScriptExecute.server_ids` 无上限(已修)
|
||||
|
||||
- **修复**: `max_length=2000`(`schemas.py`)
|
||||
- 引擎内部分批 50/批、并发 10,与文档一致
|
||||
|
||||
### 其他
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| JWT 全覆盖 | ✓ |
|
||||
| `check_dangerous_command` on exec | ✓ 警告级 |
|
||||
| 长任务回调 HTTPS 校验 | ✓ `master_callback_url` 单测 |
|
||||
| terminal quick-commands CRUD | ✓ JWT + 审计 |
|
||||
@@ -0,0 +1,16 @@
|
||||
# API 巡检 B5 — settings.py + search.py + assets.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
|
||||
## 结论: **PASS**(无代码修改)
|
||||
|
||||
| 模块 | 安全要点 | 结果 |
|
||||
|------|----------|------|
|
||||
| `settings.py` | reveal API/preset/ssh-key/telegram 均需 `_verify_reauth` | ✓ |
|
||||
| `settings.py` | 不可改 `SECRET_KEY`/`API_KEY`/`ENCRYPTION_KEY`/`DATABASE_URL` | ✓ 指南约束 |
|
||||
| `search.py` | `_escape_like` 防 SQL LIKE 注入 | ✓ |
|
||||
| `assets.py` | nodes/platforms/command-logs/ssh-sessions | ✓ 全 JWT |
|
||||
|
||||
### H — bing-wallpapers 公开 GET
|
||||
|
||||
- 已在 `PUBLIC_PREFIXES`;只读壁纸 URL,无敏感数据(与 2026-06-04 closure 一致)
|
||||
@@ -0,0 +1,16 @@
|
||||
# API 巡检 B6 — agent.py + install.py + websocket.py + health.py
|
||||
|
||||
**日期**: 2026-06-06
|
||||
|
||||
## 结论: **PASS**(无代码修改)
|
||||
|
||||
| 模块 | 认证 | 结果 |
|
||||
|------|------|------|
|
||||
| `agent.py` | `X-API-Key`(全局或 per-server) | ✓ 心跳/script-callback 鉴权 |
|
||||
| `install.py` | 仅安装模式;已安装 → 403 | ✓ 历史 F2a-08/09 已修 |
|
||||
| `websocket.py` | `/ws/` 在 PUBLIC_PREFIXES;sync/alert 内二次校验 token/batch_id | ✓ |
|
||||
| `health.py` | `/health` 公开 | ✓ 无敏感信息 |
|
||||
|
||||
### 安装向导边界
|
||||
|
||||
- 正常模式所有 `/api/install/*` 返回 403(除 status 探测逻辑在 install 模块内处理)
|
||||
@@ -0,0 +1,43 @@
|
||||
# API 全量巡检 — 总 Closure
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**计划**: B1→B6 按 8 维清单(认证/越权/注入/路径/凭据/输入/错误/审计)
|
||||
**策略**: 审计 + 当场修复(用户确认)
|
||||
|
||||
## 批次索引
|
||||
|
||||
| 批 | 模块 | 报告 | P0 | P1 | P2 已修 |
|
||||
|----|------|------|----|----|---------|
|
||||
| B1 | servers + webssh | [b1](2026-06-06-api-audit-b1-servers-webssh.md) | 0 | 0 | 1 |
|
||||
| B2 | sync + files | [b2](2026-06-06-api-audit-b2-sync-files.md) | 0 | 0 | 1 |
|
||||
| B3 | auth | [b3](2026-06-06-api-audit-b3-auth.md) | 0 | 0 | 0 |
|
||||
| B4 | scripts + terminal | [b4](2026-06-06-api-audit-b4-scripts-terminal.md) | 0 | 0 | 1 |
|
||||
| B5 | settings + search + assets | [b5](2026-06-06-api-audit-b5-settings-search-assets.md) | 0 | 0 | 0 |
|
||||
| B6 | agent + install + ws + health | [b6](2026-06-06-api-audit-b6-agent-install-ws-health.md) | 0 | 0 | 0 |
|
||||
|
||||
## 修复汇总
|
||||
|
||||
| ID | 问题 | 修复 |
|
||||
|----|------|------|
|
||||
| P2-01 | `ServerCheck.server_ids` 无上限 | `max_length=50` |
|
||||
| P2-02 | `SyncFiles`/`SyncVerify.server_ids` 无上限 | `max_length=2000` |
|
||||
| P2-03 | `ScriptExecute.server_ids` 无上限 | `max_length=2000` |
|
||||
| H-03 | pending retry 失败/删除审计不完整 | 补 audit + detail |
|
||||
|
||||
## 已接受 H(不阻断)
|
||||
|
||||
- WebSSH 遗留 token 无 `tv` 宽限 5s
|
||||
- `install-agent` 回落全局 `API_KEY`(仅 SSH 下发,不返前端)
|
||||
- `_audit_sync` 写失败仅日志
|
||||
- `check_dangerous_command` 警告不阻断执行
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py tests/test_auth_refresh_reuse.py tests/test_credential_poller.py -q
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- `docs/changelog/2026-06-06-api-audit-b1-servers-webssh.md`
|
||||
- `docs/changelog/2026-06-06-api-audit-b2-sync-files.md`
|
||||
@@ -0,0 +1,68 @@
|
||||
# 凭据轮询 / pending servers — 生产与健康检查验证
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**关联**: [2026-06-06-frontend-14p-contract-audit.md](2026-06-06-frontend-14p-contract-audit.md)
|
||||
|
||||
## 生产 API
|
||||
|
||||
```bash
|
||||
curl -sf https://api.synaglobal.vip/health
|
||||
# → ok
|
||||
|
||||
curl -sf https://api.synaglobal.vip/openapi.json | jq '.paths | keys[] | select(test("pending|add-by-ip"))'
|
||||
```
|
||||
|
||||
| 路径 | OpenAPI |
|
||||
|------|---------|
|
||||
| `/api/servers/add-by-ip` | ✓ |
|
||||
| `/api/servers/pending` | ✓ |
|
||||
| `/api/servers/pending/{pending_id}/retry` | ✓ |
|
||||
| `/api/servers/pending/{pending_id}` | ✓ |
|
||||
|
||||
`AddByIpRequest` 字段:`domain`, `port`, `name`, `agent_port`, `target_path`, `category`, `platform_id`, `node_id`。
|
||||
|
||||
## 生产前端(SPA)
|
||||
|
||||
构建产物已与本地 `vite build` 一致(主包 `index-o0-2k-vI.js` 哈希相同)。
|
||||
|
||||
| 分块 | 关键字探测 |
|
||||
|------|------------|
|
||||
| `ServersPage-Z2EpNr-B.js` | `add-by-ip`, `servers/pending`, `快速添加`, `matched_preset` |
|
||||
| `CredentialsPage-Cf9BpH3x.js` | `username`(24 处) |
|
||||
|
||||
**结论**: 后端与前端均已部署到生产,无需本轮再跑 `deploy-frontend.sh`(本地 SSH 别名 `nexus` 亦不可解析)。
|
||||
|
||||
## 单元 / 静态门控
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `pytest tests/test_credential_poller.py` | 7 passed |
|
||||
| `ruff check server/ --select F` | All checks passed |
|
||||
| `cd frontend && npx vite build` | ✓ built |
|
||||
|
||||
## 本地 `local_verify.sh`
|
||||
|
||||
**未通过** — 根因:宿主机进程无法完成 MySQL 握手(`OperationalError 2013`),但:
|
||||
|
||||
- Docker 内 `mysql` 服务名连接正常(`docker run --network nexus_default mysql:8.4 mysql -h mysql …` → OK)
|
||||
- 容器 `docker exec nexus-mysql-1` 内查询正常
|
||||
- 宿主机 `127.0.0.1:3306` / 桥接 IP `172.18.0.3:3306` 均 2013
|
||||
|
||||
属本机 Docker 端口转发 / 网络策略问题,与凭据轮询代码无关。
|
||||
|
||||
**建议本机恢复路径**:
|
||||
|
||||
1. `docker compose up -d nexus --build` — API 走容器内 `mysql` 主机名;或
|
||||
2. 修复宿主机→Docker MySQL 3306 转发后,对齐 `.env` 中 `NEXUS_DATABASE_URL` 与 `docker-compose` 卷内实际密码;或
|
||||
3. `NEXUS_TEST_ADMIN_PASSWORD=… python3 scripts/seed_local_admin.py` 后重跑 `bash scripts/local_verify.sh`
|
||||
|
||||
## 待用户终验(需登录生产控制台)
|
||||
|
||||
1. 凭据页:密码/SSH 预设填写 `username` 并保存
|
||||
2. 服务器页:「快速添加」输入测试 IP → 成功 toast 或失败弹窗 + 失败列表
|
||||
3. 失败列表:重试 / 删除
|
||||
|
||||
## 未提交工作区(文档 + 类型)
|
||||
|
||||
- 契约审计报告、功能指南 §12、附录 J、`types/api.ts` 共享类型
|
||||
- 代码功能已在 `4ba45ab` 合入;上述为审计收尾文档
|
||||
@@ -0,0 +1,106 @@
|
||||
# 外网攻击面加固 Backlog(audit_only 产出)
|
||||
|
||||
**日期**:2026-06-06
|
||||
**来源**:[`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)
|
||||
**状态**:BL-01~BL-07 **已实施/已接受**;BL-08 **不需要**(运维确认);BL-06 **已实施**(2026-06-07)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 建议优先
|
||||
|
||||
### BL-01 收紧 `PUBLIC_PREFIXES` 前缀匹配 ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | EXT-01 `/health/detail`、EXT-02 `bing-wallpapers/sync` 被前缀误放行 |
|
||||
| 方案 A | 将 `/health` 改为精确路径列表;`/api/settings/bing-wallpapers` 仅匹配 GET(或拆路由前缀) |
|
||||
| 方案 B | `_is_public_path` 改为精确匹配 + 显式允许方法 |
|
||||
| 涉及文件 | `server/api/auth_jwt.py` |
|
||||
| 验收 | 无 JWT 时 `GET /health/detail` → 401;`POST .../sync` → 401 |
|
||||
| 回滚 | 恢复 `PUBLIC_PREFIXES` 元组 |
|
||||
|
||||
### BL-02 `/health/detail` 强制鉴权(防御纵深) ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | 非 `/api/` 路径不走 JwtAuthMiddleware |
|
||||
| 方案 | handler 内显式 `if admin is None: raise 401`;或把 detail 移到 `/api/health/detail` |
|
||||
| 涉及文件 | `server/api/health.py`、`server/main.py` DbSessionMiddleware 路径列表 |
|
||||
| 验收 | 生产探测 401 |
|
||||
|
||||
### BL-03 `bing_wallpapers_sync` 拒绝 `admin is None` ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 方案 | `Depends(get_current_admin)` 返回 None 时 raise 401(与 BL-01 双保险) |
|
||||
| 涉及文件 | `server/api/settings.py` |
|
||||
|
||||
---
|
||||
|
||||
## H — 信息暴露与运维
|
||||
|
||||
### BL-04 生产关闭 OpenAPI / Swagger / ReDoc ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | EXT-03 全 API schema 公网可读 |
|
||||
| 方案 A | `FastAPI(..., docs_url=None, redoc_url=None, openapi_url=None)` |
|
||||
| 方案 B | Nginx/1Panel `location` deny 或 internal only |
|
||||
| 验收 | `GET /openapi.json` → 404 |
|
||||
| 备注 | 开发环境可保留文档 |
|
||||
|
||||
### BL-05 已安装后隐藏 install 指纹 ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 实现 | `_raise_not_found_if_locked()`;全部 install 端点锁定后 404 |
|
||||
| 涉及文件 | `server/api/install.py`、`tests/test_install_locked_404.py` |
|
||||
| 验收 | 锁定后所有 `/api/install/*` → 404 |
|
||||
|
||||
### BL-06 Agent per-server key 迁移 ✅ 已实施
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | global `API_KEY` legacy 回退(原 risk-acceptance 接受项 2) |
|
||||
| 方案 | 移除 `agent.py` / `servers.py` / `script_service.py` 中 global 回退;强制 per-server key |
|
||||
| 实施日期 | 2026-06-07(生产 0 台子机窗口) |
|
||||
| 验收 | `tests/test_agent_per_server_key.py`;global key 打 heartbeat → 401 |
|
||||
| changelog | `docs/changelog/2026-06-07-agent-per-server-key-enforced.md` |
|
||||
|
||||
### BL-07 WebSocket 外网探测与反代 ✅ 已澄清/接受
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 原问题 | 外网无 token 探测见 HTTP 403,误以为反代未转发 WS |
|
||||
| 结论 | 直连 8600 同为 403;1Panel 反代已带 Upgrade;**有效 JWT 可连** `/ws/alerts`、`/ws/sync` |
|
||||
| 根因 | `websocket.close(4001/4401)` 在 `accept()` 前 → 黑盒见 HTTP 403,非安全缺陷 |
|
||||
| 证据 | [`2026-06-06-external-hardening-deploy-verification.md`](2026-06-06-external-hardening-deploy-verification.md) §WebSocket |
|
||||
| 动作 | 更新 `security_probe_external.sh`:无 token 拒绝即 PASS;**无需改 Nginx** |
|
||||
|
||||
### BL-08 全站 IP 白名单 ❌ 不需要
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 决定 | 2026-06-06 运维确认:**不实施** |
|
||||
| 理由 | 单人自用;2000+ 子机 Agent 需放行 `/api/agent/*`,全站白名单 ROI 低 |
|
||||
| 维持 | 仅 login IP allowlist(`LOGIN_ALLOWLIST_ENABLED`)即可 |
|
||||
|
||||
---
|
||||
|
||||
## 验证清单(修复批次)
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
# 期望:health/detail 401;bing sync 401;openapi 404(若 BL-04)
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不建议本轮做
|
||||
|
||||
| 项 | 原因 |
|
||||
|----|------|
|
||||
| WS ticket 首帧鉴权 | 单人运维 ROI 低,已接受 |
|
||||
| 登录暴破 CAPTCHA | 已有 15min 锁定 |
|
||||
| 关闭 `/agent/install.sh` 公网 | 破坏远程安装流程 |
|
||||
@@ -0,0 +1,76 @@
|
||||
# 外网黑盒探测记录 — 生产 api.synaglobal.vip
|
||||
|
||||
**日期**:2026-06-06
|
||||
**目标**:`https://api.synaglobal.vip`
|
||||
**脚本**:`scripts/security_probe_external.sh`
|
||||
**约束**:无 JWT、无真实 API Key、无登录暴破、无破坏性写操作(除只读探测外仅单次错误登录与假 Key 请求)
|
||||
|
||||
## 执行命令
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
```
|
||||
|
||||
日志:`/tmp/nexus-external-probe/probe-20260606T160732Z.txt`
|
||||
|
||||
## 探测结果摘要
|
||||
|
||||
| 探测项 | 期望 | 实际 | 判定 |
|
||||
|--------|------|------|------|
|
||||
| `GET /health` | 200 纯文本 ok | 200 `ok` | PASS |
|
||||
| `GET /health/detail` | 401 | **200** + redis/mysql/ws 状态 | **FAIL (P2)** |
|
||||
| `GET /openapi.json` | 记录 | 200 全量 OpenAPI 3.1 | WARN (H) |
|
||||
| `GET /docs` | 记录 | 200 Swagger UI | WARN (H) |
|
||||
| `GET /redoc` | 记录 | 200 ReDoc | WARN (H) |
|
||||
| `GET /api/install/status` | 404(已锁定) | 404 空 body | PASS |
|
||||
| `GET /api/install/env-check` 等 | 403/404 | 403「安装向导已锁定」 | PASS |
|
||||
| `POST /api/install/*` | 403 | 422(探测 body 不满足 Pydantic;锁定逻辑在合法 body 前亦会 403) | INFO |
|
||||
| `GET /api/settings/bing-wallpapers` | 200 只读 URL | 200 urls[] | PASS |
|
||||
| `POST /api/settings/bing-wallpapers/sync` | 401 | **200** + downloaded=8 | **FAIL (P2)** |
|
||||
| `GET/POST /api/servers/` | 401 | 401 Missing Authorization | PASS |
|
||||
| `GET /api/audit/` | 401 | 401 | PASS |
|
||||
| `POST /api/agent/heartbeat` 无 Key | 拒绝 | 422(缺 Header) | PASS(仍拒绝) |
|
||||
| `POST /api/agent/heartbeat` 假 Key | 401 | 401 | PASS |
|
||||
| `POST /api/agent/script-callback` 假 Key | 401/404 | 422(缺字段;带 Key 时预期 401/404) | PASS |
|
||||
| `POST /api/auth/login` 错误用户 | 统一错误文案 | `用户名或密码错误` | PASS |
|
||||
| `GET /agent/install.sh` | 200 | 200 安装脚本 | INFO(设计如此) |
|
||||
| `WS /ws/alerts` 无 token | 4001 或拒绝 | **HTTP 403**(反代层) | INFO |
|
||||
| `WS /ws/alerts` 假 token | 4401 或拒绝 | **HTTP 403** | INFO |
|
||||
|
||||
## 响应片段(关键 FINDING)
|
||||
|
||||
### `/health/detail` 未授权可读
|
||||
|
||||
```http
|
||||
GET /health/detail HTTP/1.1
|
||||
Host: api.synaglobal.vip
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{"status":"ok","checks":{"python":"ok","redis":"ok","mysql":"ok","websocket_clients":0}}
|
||||
```
|
||||
|
||||
### `bing-wallpapers/sync` 未授权可触发外联同步
|
||||
|
||||
```http
|
||||
POST /api/settings/bing-wallpapers/sync HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{"urls":[...],"count":8,"downloaded":8}
|
||||
```
|
||||
|
||||
### OpenAPI 可访问(节选)
|
||||
|
||||
`GET /openapi.json` → 200,含全部管理 API path(含 `/api/servers/pending/add-by-ip` 等)。
|
||||
|
||||
## WebSocket 说明
|
||||
|
||||
外网经 `wss://api.synaglobal.vip/ws/*` 握手在到达应用前被反代返回 **403**,无法在公网直接观测应用层 close code **4001/4401**。应用代码(`server/api/websocket.py`)在无 token 时设计为 4001。
|
||||
|
||||
## 结论
|
||||
|
||||
- **0 个 P0/P1**(未授权 RCE、未授权写库、未授权读密文)
|
||||
- **2 个 P2**:`/health/detail` 组件指纹泄露;`bing-wallpapers/sync` 未授权触发外联下载
|
||||
- **多项 H**:OpenAPI/Swagger/ReDoc 暴露;WS 外网 403 无法验证 ADR-011 关闭码
|
||||
|
||||
详见 [`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)。
|
||||
@@ -0,0 +1,187 @@
|
||||
# 外网攻击面安全巡检报告(E1–E6)
|
||||
|
||||
**日期**:2026-06-06
|
||||
**范围**:生产 `https://api.synaglobal.vip` 无 JWT / 弱认证暴露面
|
||||
**信任边界**:已登录管理员视为可信;本轮不审计持 JWT 后的管理操作
|
||||
**模式**:audit_only(仅报告与 backlog,**本轮不修改应用代码**)
|
||||
**黑盒证据**:[`2026-06-06-external-attack-probe-production.md`](2026-06-06-external-attack-probe-production.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 攻击面资产表(E1)
|
||||
|
||||
来源:`server/api/auth_jwt.py` `PUBLIC_PREFIXES`、`server/main.py` 路由与静态挂载、OpenAPI 交叉核对。
|
||||
|
||||
### 1.1 JWT 中间件跳过路径(`PUBLIC_PREFIXES`)
|
||||
|
||||
| 前缀/路径 | 方法 | 二次鉴权 | 说明 |
|
||||
|-----------|------|----------|------|
|
||||
| `/api/auth/login` | POST | 无 | 暴破/枚举面;15min 锁定 |
|
||||
| `/api/auth/refresh` | POST | HttpOnly cookie | 刷新令牌 |
|
||||
| `/api/auth/logout` | POST | body refresh | 吊销 refresh |
|
||||
| `/api/agent/*` | POST | `X-API-Key` + per-server | heartbeat、script-callback |
|
||||
| `/api/install/*` | GET/POST | 安装锁 + install_token | 已锁定除 status 外 403 |
|
||||
| `/api/settings/bing-wallpaper(s)` | GET | 无 | 只读壁纸 URL |
|
||||
| `/api/settings/bing-wallpapers/sync` | POST | **应为 JWT**;见 §2 FINDING | 前缀匹配导致误放行 |
|
||||
| `/health` | GET | 无 | 纯文本 `ok` |
|
||||
| `/health/detail` | GET | **应为 JWT**;见 §2 FINDING | 前缀 `/health` 误匹配 |
|
||||
| `/ws/*` | WS | Query JWT / WebSSH token | ADR-011 |
|
||||
| `/openapi.json` `/docs` `/redoc` | GET | 无 | FastAPI 默认文档 |
|
||||
| 其余 `/api/*` | * | JwtAuthMiddleware Bearer | 401 无 token |
|
||||
|
||||
**注意**:`JwtAuthMiddleware` 仅拦截 `path.startswith("/api/")`;`/health/detail` 不走中间件,完全依赖 `Depends(get_current_admin)`,与 `PUBLIC_PREFIXES` 前缀匹配叠加后失效。
|
||||
|
||||
### 1.2 静态与前端壳
|
||||
|
||||
| 路径 | 认证 | 文件 |
|
||||
|------|------|------|
|
||||
| `/app/*` | 无(SPA);`AppAuth` 仅校验 refresh cookie 格式 | `web/app/` |
|
||||
| `/agent/install.sh` `agent.py` `agent.sh` `uninstall.sh` | 无 | `web/agent/` |
|
||||
| `/app/install.html` | 安装模式;锁定后归档为 `.bak` | 安装向导 |
|
||||
|
||||
### 1.3 Install API 端点(`prefix=/api/install`)
|
||||
|
||||
| 端点 | 方法 | 已锁定生产实测 |
|
||||
|------|------|----------------|
|
||||
| `/status` | GET | **404**(`_is_locked()`) |
|
||||
| `/env-check` | GET | 403 |
|
||||
| `/connection-check` | GET | 403 |
|
||||
| `/state` | GET | 403 |
|
||||
| `/test-credentials` | POST | 403(合法 body) |
|
||||
| `/init-db` | POST | 403 |
|
||||
| `/create-admin` | POST | 403 + install_token |
|
||||
| `/lock` | POST | 403 |
|
||||
|
||||
### 1.4 Agent API
|
||||
|
||||
| 端点 | 认证链 |
|
||||
|------|--------|
|
||||
| `POST /api/agent/heartbeat` | Header `X-API-Key` → `_verify_server_api_key(server_id)` |
|
||||
| `POST /api/agent/script-callback` | 同上 + `job_id`/`secret` + Redis 限速 |
|
||||
|
||||
控制面**无** `/api/agent/exec`(P0 RCE 已移除,注释见 `agent.py:378`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 生产黑盒探测(E2)
|
||||
|
||||
见 [`2026-06-06-external-attack-probe-production.md`](2026-06-06-external-attack-probe-production.md)。
|
||||
|
||||
### FINDING 汇总
|
||||
|
||||
| ID | 级别 | 问题 | 根因(代码) |
|
||||
|----|------|------|--------------|
|
||||
| EXT-01 | **P2** | `GET /health/detail` 无 JWT 返回组件状态 | `PUBLIC_PREFIXES` 含 `/health`,`_is_public_path` 使 `get_current_admin` 无凭证返回 `None`;handler 未校验 admin |
|
||||
| EXT-02 | **P2** | `POST /api/settings/bing-wallpapers/sync` 无 JWT 可触发 Bing 下载 | 同上:`/api/settings/bing-wallpapers` 前缀匹配 `/sync` 子路径 |
|
||||
| EXT-03 | **H** | `/openapi.json` `/docs` `/redoc` 公网可访问 | `main.py` 未设 `docs_url=None`;前缀在 PUBLIC |
|
||||
| EXT-04 | **H** | 外网 WS 握手 HTTP 403,无法验证 4001/4401 | 反代/Nginx 规则;应用层逻辑见 `websocket.py` |
|
||||
| — | PASS | 管理 API、假 Agent Key、锁定 install | JwtAuthMiddleware / `_reject_if_locked` |
|
||||
|
||||
**P0/P1:0**(无未授权 RCE、写库、读密文、重装成功)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent M2M 滥用链(E3)
|
||||
|
||||
### 3.1 认证逻辑
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Attacker
|
||||
participant API as Nexus_API
|
||||
participant Redis
|
||||
participant WS as WebSocket_clients
|
||||
|
||||
Note over Attacker: 持有泄露的 global API_KEY
|
||||
Attacker->>API: POST /api/agent/heartbeat<br/>X-API-Key + server_id=N
|
||||
API->>API: server 存在且无 agent_api_key?
|
||||
alt legacy 服务器
|
||||
API->>Redis: HSET heartbeat:N
|
||||
API->>WS: broadcast_alert 若超阈值
|
||||
API-->>Attacker: 200 ok
|
||||
else 已有 per-server key
|
||||
API-->>Attacker: 401
|
||||
end
|
||||
```
|
||||
|
||||
- **全局 Key 回退**:~~`server/api/agent.py:84-92`~~ **已移除(BL-06,2026-06-07)** — Agent 仅接受 per-server `agent_api_key`。
|
||||
- **影响**:伪造心跳需持有目标服务器的 per-server key;global `API_KEY` 不再通行。
|
||||
- **script-callback**:需有效 `job_id` + `secret`(一次性)+ per-server key;`check_script_callback_rate` 每 job 10/min、每 IP 60/min(`script_callback_rate.py`)。
|
||||
- **生产探测**:假 Key 或 global Key → **401**。
|
||||
|
||||
**与 risk-acceptance 关系**:接受项 2 已于 2026-06-07 收口,见 [`risk-acceptance-single-operator.md`](../project/risk-acceptance-single-operator.md) §接受项 2。
|
||||
|
||||
---
|
||||
|
||||
## 4. 安装向导边界(E4)
|
||||
|
||||
### 4.1 令牌逻辑(代码)
|
||||
|
||||
- `_verify_install_token`:`secrets.compare_digest` + 状态文件 `install_token`(`install.py:933-946`)。
|
||||
- 无状态文件 → 403;暴力需猜 256-bit 级 token,**不可行**。
|
||||
- `create-admin` / `lock` 需 token 或已完成步骤。
|
||||
|
||||
### 4.2 已安装 + 锁定(生产)
|
||||
|
||||
- **BL-05(2026-06-07)**:全部 `/api/install/*` → **404** `Not found`(含 status、env-check、init-db、lock 等),无中文指纹。
|
||||
- **无法**外网重装(init-db/create-admin 被锁)。
|
||||
|
||||
### 4.3 残余 H
|
||||
|
||||
- 未锁定环境下 `/status` 会返回 `installed/locked/configured`(仅安装模式相关)。
|
||||
- OpenAPI 仍暴露 install 路径 schema(EXT-03)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 认证与会话(E5)
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 登录暴破 | `MAX_LOGIN_FAILURES` + 15min 锁定(`auth_service.py:122-130`);**本轮未跑循环暴破** |
|
||||
| IP allowlist | **仅 `login()`**(`auth_service.py:98-120`);拿到 JWT 后**不受** allowlist 约束 |
|
||||
| 用户名枚举 | 生产单次错误登录:`用户名或密码错误`(不存在与密码错误文案统一)— PASS |
|
||||
| Refresh | HttpOnly + Secure + SameSite=Lax;重用检测见 `tests/test_auth_refresh_reuse.py` |
|
||||
| Logout CSRF | SameSite=Lax 降低跨站 POST 风险 |
|
||||
|
||||
**外网主要面**:`/api/auth/login` 暴破(有锁定)、refresh cookie 窃取(需 XSS/本机恶意软件)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 总结与验收
|
||||
|
||||
| 验收项 | 状态 |
|
||||
|--------|------|
|
||||
| 生产黑盒脚本 + 证据 | ✅ `scripts/security_probe_external.sh` + probe 报告 |
|
||||
| PUBLIC_PREFIXES + static 覆盖 | ✅ §1 |
|
||||
| 0 未解释 P0/P1 | ✅ |
|
||||
| 加固 backlog 独立 | ✅ [`2026-06-06-external-attack-hardening-backlog.md`](2026-06-06-external-attack-hardening-backlog.md) |
|
||||
|
||||
### 分级统计
|
||||
|
||||
- **P0**:0
|
||||
- **P1**:0
|
||||
- **P2**:2(EXT-01、EXT-02)
|
||||
- **H**:2+(EXT-03、EXT-04、OpenAPI 指纹、Agent legacy 已接受)
|
||||
|
||||
### 与 B1–B6 API 巡检关系
|
||||
|
||||
| 已完成 B 轮 | 本轮 E 轮 |
|
||||
|-------------|-----------|
|
||||
| 鉴权后逻辑、输入上限 | 无 JWT 暴露面 |
|
||||
| 当场修复若干 schema 上限 | audit_only,修复进 backlog |
|
||||
|
||||
---
|
||||
|
||||
## 7. 加固项状态(2026-06-07 更新)
|
||||
|
||||
| ID | 项 | 状态 |
|
||||
|----|-----|------|
|
||||
| BL-06 | Agent per-server key 迁移 | **已实施**(2026-06-07):移除 global `API_KEY` 回退,强制 per-server `agent_api_key`;见 changelog `2026-06-07-agent-per-server-key-enforced.md` |
|
||||
| BL-07 | WebSocket 探测口径 | **已接受**:无 token 外网 HTTP 403 为应用拒绝握手;登录后 WS 正常;见 deploy-verification §WebSocket |
|
||||
| BL-08 | 全站 IP 白名单 | **不需要**(运维确认不实施;维持仅 login allowlist) |
|
||||
|
||||
**已实施加固**:BL-01~BL-06(见 backlog 与 changelog)。
|
||||
|
||||
---
|
||||
|
||||
**下一步**:部署后重跑 `security_probe_external.sh`;新增第一台子机时走「创建 → 远程安装 Agent」验证 per-server 心跳。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 外网加固部署与生产验证(2026-06-06)
|
||||
|
||||
## Git 推送
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| Commit | `e5e2582` — `fix(security): 外网攻击面加固 BL-01~05` |
|
||||
| 远程 | `http://66.154.115.8:3000/admin/Nexus.git` `main` |
|
||||
| Push | ✅ `722917b..e5e2582` |
|
||||
|
||||
## 生产部署
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| 方式 | `ssh -i nz-admin.pem azureuser@20.24.218.235` → `sudo nx update` |
|
||||
| 版本 | **e5e2582** `fix(security): 外网攻击面加固 BL-01~05` |
|
||||
| 容器 | `nexus-prod-nexus-1` 重建 healthy |
|
||||
| `/health` | `ok` |
|
||||
|
||||
## 生产探测(部署后 — 2026-06-06)
|
||||
|
||||
`https://api.synaglobal.vip`:
|
||||
|
||||
| 探测项 | 结果 | 判定 |
|
||||
|--------|------|------|
|
||||
| `GET /health/detail` | **401** | ✅ BL-02 |
|
||||
| `GET /openapi.json` `/docs` `/redoc` | **404** | ✅ BL-04 |
|
||||
| `GET /api/install/status` | **404** | ✅ |
|
||||
| `GET /api/install/env-check` 等 | **404** | ✅ BL-05 |
|
||||
| `POST /api/install/lock` | **404** | ✅ BL-05 |
|
||||
| `POST /api/settings/bing-wallpapers/sync` | **401** | ✅ BL-03 |
|
||||
| `GET/POST /api/servers/` | **401** | ✅ |
|
||||
| Agent 假 Key | **401** | ✅ |
|
||||
|
||||
**备注**:`POST /api/install/init-db` 等若 body 不满足 Pydantic,FastAPI 在 handler 前返回 **422**(非 403 指纹);带合法 body 的锁定请求为 404。可接受,后续可用 router 级 Depends 提前 404。
|
||||
|
||||
## WebSocket(BL-07 已澄清)
|
||||
|
||||
| 场景 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| 外网无 token / 假 token | HTTP **403** | Starlette 在 `accept()` 前 `close(4001/4401)` 时,黑盒常见 HTTP 403,**非反代故障** |
|
||||
| 直连 `ws://127.0.0.1:8600/ws/alerts` 无 token | HTTP **403** | 与经 HTTPS 一致,排除仅反代问题 |
|
||||
| 1Panel `root.conf` | 含 `Upgrade` / `Connection` | 全站反代至 8600,配置正常 |
|
||||
| 登录后 `wss://.../ws/alerts?token=<JWT>` | **连接成功** | 2026-06-06 生产验证 |
|
||||
| 登录后 `wss://.../ws/sync?token=<JWT>` | **连接成功** | 同上 |
|
||||
|
||||
**结论**:BL-07 标为**接受**(与 risk-acceptance ADR-011 一致);外网探测脚本以「无 token 拒绝连接」为 PASS,不要求 HTTP 层可见 4001/4401。
|
||||
|
||||
## 本地测试(推送前已通过)
|
||||
|
||||
```bash
|
||||
pytest tests/test_install_locked_404.py tests/test_external_attack_surface_hardening.py -q
|
||||
# 12 passed
|
||||
```
|
||||
@@ -0,0 +1,239 @@
|
||||
# 前端 14 页 API 契约审计(含凭据轮询)
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**基线**: [audit-frontend-14p-2026-06-04-closure.md](audit-frontend-14p-2026-06-04-closure.md)(43 H,0 P0/P1)
|
||||
**增量范围**: 凭据轮询 `add-by-ip` / `pending_servers`;预设 `username` 字段
|
||||
**方法**: 静态对照 `frontend/src/pages/*` + composables ↔ `server/api/*.py` 路由与响应形
|
||||
|
||||
## 结论
|
||||
|
||||
| 维度 | 结果 |
|
||||
|------|------|
|
||||
| 14 页 API 路径 | **PASS** — 全部端点在后端存在且 HTTP 方法一致 |
|
||||
| 新增契约(Servers / Credentials) | **PASS** — 请求/响应字段前后端对齐 |
|
||||
| 类型集中化 | **PASS** — `PendingServerItem` / `AddByIpResponse` / `PollErrorItem` 已入 `types/api.ts` |
|
||||
| 单元测试 | **PASS** — `tests/test_credential_poller.py` 7 passed |
|
||||
| 本地门控 | **WARN** — `local_verify.sh` 因本地 MySQL 断连未跑通(环境问题,非契约缺口) |
|
||||
| 生产 SPA 部署 | **PASS** — `ServersPage`/`CredentialsPage` 分块已含新契约;见 [production-verification](2026-06-06-credential-polling-production-verification.md) |
|
||||
|
||||
**新 FINDING**: 0 × P0/P1,0 × P2(契约层)
|
||||
|
||||
---
|
||||
|
||||
## 1. 凭据轮询契约(增量)
|
||||
|
||||
### 1.1 后端
|
||||
|
||||
| 方法 | 路径 | 实现 |
|
||||
|------|------|------|
|
||||
| POST | `/api/servers/add-by-ip` | `servers.py` → `poll_credentials` → 成功 `create_server` / 失败 `pending_servers` |
|
||||
| GET | `/api/servers/pending` | `PendingServerRepositoryImpl.list_all` |
|
||||
| POST | `/api/servers/pending/{id}/retry` | 再轮询;域名已存在 → 409 |
|
||||
| DELETE | `/api/servers/pending/{id}` | 204 |
|
||||
|
||||
预设 `username`: `settings.py` `GET/POST/PUT` `/presets/`、`/ssh-key-presets/`。
|
||||
|
||||
### 1.2 前端
|
||||
|
||||
| 组件 | 调用 |
|
||||
|------|------|
|
||||
| `ServersPage.vue` | 上述四端点;`onMounted` 加载 pending |
|
||||
| `CredentialsPage.vue` | 预设 CRUD 含 `username`(默认 `root`) |
|
||||
|
||||
### 1.3 响应形对照
|
||||
|
||||
| 字段 | 后端 | 前端消费 |
|
||||
|------|------|----------|
|
||||
| `success` | ✓ | 分支成功/失败 |
|
||||
| `server` | `Server` 序列化 | 刷新列表 |
|
||||
| `matched_preset` / `matched_username` | ✓ | 成功 toast |
|
||||
| `pending_id` | ✓ | 失败后可查列表 |
|
||||
| `errors[]` | `PollError` 列表 | 失败对话框表格 |
|
||||
| `message` | 聚合摘要 | 对话框标题区 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 十四页 API 矩阵
|
||||
|
||||
前缀均为 `/api`;`http` 客户端已含前缀,下表写相对路径。
|
||||
|
||||
### 2.1 DashboardPage `/`
|
||||
|
||||
| 端点 | 方法 | 后端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| `/servers/stats` | GET | `servers.py` | OK |
|
||||
| `/alert-history/` | GET | `alert_history.py` | OK |
|
||||
| `/audit/` | GET | `audit.py` | OK |
|
||||
| `/servers/` | GET | `servers.py` | OK |
|
||||
| `/settings/bing-wallpapers/sync` | POST | `settings.py` | OK |
|
||||
|
||||
### 2.2 ServersPage `/servers`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/servers/` | GET/POST/PUT/DELETE | OK |
|
||||
| `/servers/stats` | GET | OK |
|
||||
| `/servers/check` | POST | OK |
|
||||
| `/servers/batch/install-agent` 等 | POST | OK |
|
||||
| `/servers/{id}/logs` | GET | OK |
|
||||
| `/servers/add-by-ip` | POST | **新增 OK** |
|
||||
| `/servers/pending` | GET | **新增 OK** |
|
||||
| `/servers/pending/{id}/retry` | POST | **新增 OK** |
|
||||
| `/servers/pending/{id}` | DELETE | **新增 OK** |
|
||||
| `/presets/`、`/ssh-key-presets/`、`/assets/nodes` | GET | OK(表单 composable) |
|
||||
|
||||
### 2.3 TerminalPage `/terminal`
|
||||
|
||||
| 端点 | 方法 | 位置 | 状态 |
|
||||
|------|------|------|------|
|
||||
| `/auth/webssh-token` | POST | `useTerminalSessions` | OK |
|
||||
| `/servers/` | GET | `useTerminalSessions` | OK |
|
||||
| `/terminal/quick-commands` | GET/POST/PUT/DELETE | `useTerminalQuickCommands` | OK |
|
||||
| `/terminal/quick-commands/reorder` | PUT | `useTerminalQuickCommands` | OK |
|
||||
| WebSocket `/ws/terminal` | WS | 页内 | OK(RISK 已接受) |
|
||||
|
||||
### 2.4 FilesPage `/files`
|
||||
|
||||
| 端点 | 方法 | Composable | 状态 |
|
||||
|------|------|------------|------|
|
||||
| `/files/browse` | GET | `useFilesBrowse`(首选) | OK |
|
||||
| `/sync/browse` | POST | `useFilesBrowse` 回退 | OK |
|
||||
| `/servers/{id}/files-capability` | GET | `useFilesPermissions` | OK |
|
||||
| `/servers/{id}/setup-files-sudo` | POST | `useFilesPermissions` | OK |
|
||||
| `/sync/read-file`、`write-file`、`file-ops`、`chmod`、`compress`、`decompress`、`file-clipboard`、`upload` | POST | `useFilesActions` 等 | OK |
|
||||
|
||||
### 2.5 PushPage `/push`
|
||||
|
||||
| 端点 | 方法 | Composable | 状态 |
|
||||
|------|------|------------|------|
|
||||
| `/servers/` | GET | `usePushServers` | OK |
|
||||
| `/sync/preview` | POST | `usePushPreview` | OK |
|
||||
| `/sync/files` | POST | `usePushProgress` | OK |
|
||||
| `/sync/cancel` | POST | `usePushProgress` | OK |
|
||||
| `/servers/logs` | GET | `usePushLogs` | OK |
|
||||
| `/sync/reconcile-stale-logs` | POST | `usePushLogs` | OK |
|
||||
| `/sync/diagnose` | POST | `usePushLogs` | OK |
|
||||
| WebSocket `/ws/sync` | WS | `usePushProgress` | OK(RISK 已接受) |
|
||||
|
||||
### 2.6 ScriptsPage `/scripts`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/scripts/` | GET/POST/PUT/DELETE | OK |
|
||||
| `/scripts/exec` | POST | OK |
|
||||
| `/scripts/executions` | GET | OK |
|
||||
| `/scripts/executions/{id}` | GET | OK |
|
||||
| `/scripts/executions/{id}/stop` | POST | OK |
|
||||
|
||||
### 2.7 CredentialsPage `/credentials`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/presets/` | GET/POST/PUT/DELETE | OK;**username 新增** |
|
||||
| `/ssh-key-presets/` | GET/POST/PUT/DELETE | OK;**username 新增** |
|
||||
| `/scripts/credentials` | GET/POST/PUT/DELETE | OK |
|
||||
|
||||
### 2.8 SchedulesPage `/schedules`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/schedules/` | GET/POST/PUT/DELETE | OK |
|
||||
| `/sync/files` | POST(runNow) | OK |
|
||||
|
||||
### 2.9 RetriesPage `/retries`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/retries/` | GET | OK |
|
||||
| `/retries/{id}/retry` | POST | OK |
|
||||
| `/retries/{id}` | DELETE | OK |
|
||||
|
||||
### 2.10 CommandsPage `/commands`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/assets/command-logs` | GET | OK |
|
||||
| `/assets/ssh-sessions` | GET | OK |
|
||||
|
||||
### 2.11 AlertsPage `/alerts`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/alert-history/stats` | GET | OK |
|
||||
| `/alert-history/` | GET | OK |
|
||||
|
||||
### 2.12 AuditPage `/audit`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/audit/` | GET | OK |
|
||||
|
||||
### 2.13 SettingsPage `/settings`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/settings/` | GET | OK |
|
||||
| `/settings/{key}` | PUT | OK |
|
||||
| `/settings/telegram/test` | POST | OK |
|
||||
| `/settings/telegram/reveal-token` | POST | OK |
|
||||
| `/settings/api-key/reveal` | POST | OK |
|
||||
| `/settings/ip-allowlist` | GET/POST | OK |
|
||||
| `/settings/ip-allowlist/toggle` | POST | OK |
|
||||
| `/auth/password` | PUT | OK |
|
||||
| `/auth/totp/setup` | POST | OK |
|
||||
| `/auth/totp/enable` | POST | OK |
|
||||
| `/auth/totp/disable` | POST | OK |
|
||||
|
||||
### 2.14 LoginPage `/login`
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/settings/bing-wallpapers` | GET | OK(公开只读) |
|
||||
| `/auth/login` | POST | `stores/auth`(非页内直接调) | OK |
|
||||
|
||||
### 2.15 全局(App / auth store)
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/search/` | GET | `App.vue` | OK |
|
||||
| `/auth/me` | GET | `stores/auth` | OK |
|
||||
| `/auth/logout` | POST | `stores/auth` | OK |
|
||||
|
||||
---
|
||||
|
||||
## 3. 与 2026-06-04 closure 的差异
|
||||
|
||||
| 变更 | 说明 |
|
||||
|------|------|
|
||||
| ServersPage +4 端点 | 凭据轮询快速添加与失败队列 |
|
||||
| CredentialsPage 预设 | `username` 字段贯通 DB / API / UI |
|
||||
| `types/api.ts` | 新增 pending 相关类型 |
|
||||
| 功能指南 §12.2、§12.7 | 已更新 |
|
||||
| 附录 J | 新增凭据轮询专章 |
|
||||
|
||||
跨页 RISK(WS JWT、危险命令、bing 公开 GET)**无变化**,仍见 `risk-acceptance-single-operator.md`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证记录
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_credential_poller.py -q # 7 passed
|
||||
cd frontend && npx vite build # OK
|
||||
```
|
||||
|
||||
```bash
|
||||
# 待环境恢复后
|
||||
bash scripts/local_verify.sh
|
||||
cd frontend && NEXUS_E2E_BASE=http://127.0.0.1:8600 npm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 后续建议
|
||||
|
||||
1. 生产部署前端构建物(`ServersPage` / `CredentialsPage` 新 UI)。
|
||||
2. 可选 E2E:快速添加 IP → 失败列表 → 重试/删除。
|
||||
3. `acceptance_catalog.py` 可增加 `add-by-ip` 条目(若纳入 L4 回归)。
|
||||
|
||||
**维护**: 改 API 时同步更新本报告对应行、附录 J、功能指南 §12。
|
||||
@@ -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)。
|
||||
@@ -0,0 +1,100 @@
|
||||
# 生产验证 — Layer 3 宿主机巡检 Telegram 打通
|
||||
|
||||
**日期**: 2026-06-07
|
||||
**Commit**: `f9934bd` — feat(ops-patrol): Layer 3 巡检与 Telegram 配置打通
|
||||
**环境**: https://api.synaglobal.vip
|
||||
|
||||
## 部署
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| 门控 | 7/7 PASS |
|
||||
| push | `66a4cba..f9934bd main → main` |
|
||||
| deploy-production | ✓ Docker 重建,容器 healthy |
|
||||
| 远程版本 | `f9934bd` |
|
||||
|
||||
## 健康检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `GET /health` | `ok` |
|
||||
| `GET /app/` | HTTP 200 |
|
||||
| 外网 HTTPS | HTTP 200 |
|
||||
|
||||
## Layer 3 巡检
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| crontab `nexus-health-monitor` | ✓ 已安装(部署脚本自动 `install_ops_cron.sh`) |
|
||||
| `health_monitor.sh` 手动执行 | exit 0 |
|
||||
| `/var/log/nexus_health.log` | 空(/health 正常,无告警写入) |
|
||||
|
||||
## Telegram / env 同步
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| DB `telegram_bot_token` | 未配置 |
|
||||
| DB `telegram_chat_id` | 未配置 |
|
||||
| `/app/.env` NEXUS_TELEGRAM_* | 未写入 |
|
||||
| `read_telegram_env_status()` | `env_synced: false` |
|
||||
|
||||
**说明**: 生产尚未在设置页配置 Telegram;配置并保存后应自动 sync,或点「同步巡检 Telegram」。未 sync 时 `health_monitor.sh` 可通过 MySQL 回退(需 DB 有值)。
|
||||
|
||||
## API
|
||||
|
||||
| 端点 | 未认证 |
|
||||
|------|--------|
|
||||
| `GET /api/settings/ops-patrol/status` | 401(预期) |
|
||||
|
||||
## 待用户终验
|
||||
|
||||
1. 登录设置页 → 配置 Telegram → 确认 Layer 3 区块显示「巡检 Telegram 就绪」
|
||||
2. 点「同步巡检 Telegram」→ `.env 已同步`
|
||||
3. (可选)临时停容器验证 Telegram 重启告警
|
||||
|
||||
## 结论
|
||||
|
||||
**部署成功**;cron 与 health_monitor 已就绪。Telegram 告警需用户在 UI 配置后终验。
|
||||
|
||||
---
|
||||
|
||||
## 用户配置后复查(2026-06-07)
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| 内存 `TELEGRAM_BOT_TOKEN` / `CHAT_ID` | ✓ 已配置 |
|
||||
| 容器 `/app/.env` NEXUS_TELEGRAM_* | ✓ 已同步 |
|
||||
| `read_telegram_env_status().env_synced` | **true** |
|
||||
| 宿主机 `docker/.env.prod` Telegram | 未写入(容器无 host 写权限;不影响) |
|
||||
| `health_monitor` 读取 Telegram | ✓ **READY via container .env** |
|
||||
| MySQL DB 回退 | ✓ `db_fallback_ok True` |
|
||||
| cron `nexus-health-monitor` | ✓ 运行中 |
|
||||
|
||||
**Layer 3 巡检 Telegram 闭环已就绪。** /health 连续失败时将能推送 Telegram 并自动重启。
|
||||
|
||||
---
|
||||
|
||||
## 模拟巡检告警(2026-06-07)
|
||||
|
||||
### 发现与修复
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| Bug | `bump_fail_count` 在 `flock` 子 shell 内递增,`FAIL` 恒为 0,永不触发重启/Telegram |
|
||||
| Fix | `764703d` — 子 shell 写文件后 `cat` 回传计数 |
|
||||
| 部署 | 已上线生产 |
|
||||
|
||||
### 演练(iptables 阻断 127.0.0.1:8600,容器保持运行)
|
||||
|
||||
| 步骤 | 结果 |
|
||||
|------|------|
|
||||
| run1 / run2 | fail=1 → fail=2 |
|
||||
| run3 | fail=3,`last_restart` 已写入,**docker restart 执行** |
|
||||
| 解除阻断后 | `/health` ok,容器 `Up (healthy)` |
|
||||
|
||||
**Telegram**:第 3 次失败应已推送「连续 3 次无响应 / 正在自动重启」及「已恢复」类消息(请用户在 Telegram 确认)。
|
||||
|
||||
### 注意
|
||||
|
||||
- `docker pause` 不适合演练:`docker exec` 无法读 Telegram 配置
|
||||
- 演练方式:阻断宿主机 localhost 端口或真实进程故障(容器仍 Running)
|
||||
@@ -0,0 +1,51 @@
|
||||
# 生产验证 — 设置页 Telegram 全流程修复
|
||||
|
||||
**日期**:2026-06-07
|
||||
**版本**:`540a7fc`
|
||||
**环境**:https://api.synaglobal.vip
|
||||
|
||||
## 部署
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| 门控 | 7/7 PASS |
|
||||
| Push | `2e911dc..540a7fc main` |
|
||||
| 生产升级 | `sudo nx upgrade` 成功 |
|
||||
| 容器 | `nexus-prod-nexus-1` healthy |
|
||||
| `/health` | ok |
|
||||
| `/app/` | HTTP 200 |
|
||||
| 前端 bundle | `SettingsPage-rMrgLXrw.js`(镜像内 vite build) |
|
||||
|
||||
## API 冒烟(无 JWT)
|
||||
|
||||
| 端点 | 期望 | 实测 |
|
||||
|------|------|------|
|
||||
| `POST /api/settings/telegram/test` | 401 | 401 |
|
||||
| `GET /api/settings/telegram/chats` | 401 | 401 |
|
||||
|
||||
## 功能变更(需浏览器终验)
|
||||
|
||||
1. **保存 Telegram 配置** — Telegram 卡片内独立按钮
|
||||
2. **检测 Chat ID** — 先 persist Token,getUpdates 前清除 Webhook
|
||||
3. **发送测试消息** — 先 persist Token + Chat ID,失败时显示 Telegram API 具体错误
|
||||
4. **8 项告警开关** — CPU/内存/磁盘/恢复/时钟/Redis/MySQL/重启
|
||||
5. **查看 Bot Token / API Key** — 免二次密码(JWT + 审计)
|
||||
|
||||
## 用户终验步骤
|
||||
|
||||
1. 打开 https://api.synaglobal.vip/app/#/settings ,**Ctrl+Shift+R** 强制刷新
|
||||
2. 填写 Bot Token → 向 Bot 发消息 → **检测 Chat ID** → 选择对话
|
||||
3. **发送测试消息** → 应收 Nexus 测试推送
|
||||
4. 切换任意告警开关 → 刷新页面确认状态保持
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **Layer-3 cron**(`health_monitor.sh`)仍读 `NEXUS_TELEGRAM_*`(`.env.prod`),与 UI 保存的 MySQL 配置独立;应用内告警走 MySQL 热更新即可。
|
||||
- 生产 DB 若尚未配置 Telegram,测试发送会提示「未配置」直至用户在设置页完成配置。
|
||||
|
||||
## 本地测试
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_settings_telegram.py -q # 6 passed
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
@@ -38,6 +38,10 @@
|
||||
- [x] 0 P0/P1 新 FINDING
|
||||
- [x] Playwright E2E 15 路由已绿(L4)
|
||||
|
||||
## 增量审计(2026-06-06)
|
||||
|
||||
凭据轮询 / pending servers + 预设 `username`:[2026-06-06-frontend-14p-contract-audit.md](2026-06-06-frontend-14p-contract-audit.md) — 契约 **PASS**,0 新 P0/P1。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -343,7 +343,7 @@ import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||
import type { ServerApiItem } from '@/types/api'
|
||||
import type { AddByIpResponse, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||
import { normalizeServerIds, countBatchSuccess, type BatchAgentResultItem } from '@/utils/serverSelection'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -372,33 +372,6 @@ const deleting = ref(false)
|
||||
const deletingServer = ref<ServerApiItem | null>(null)
|
||||
|
||||
// ── Quick add by IP + pending failure list ──
|
||||
interface PollErrorItem {
|
||||
preset_type: string
|
||||
preset_name: string
|
||||
username: string
|
||||
error: string
|
||||
}
|
||||
|
||||
interface PendingServerItem {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
port: number
|
||||
attempts: number
|
||||
last_error: string
|
||||
last_attempt_at: string | null
|
||||
}
|
||||
|
||||
interface AddByIpResponse {
|
||||
success: boolean
|
||||
server?: ServerApiItem
|
||||
pending_id?: number
|
||||
errors?: PollErrorItem[]
|
||||
message?: string
|
||||
matched_preset?: string
|
||||
matched_username?: string
|
||||
}
|
||||
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
|
||||
@@ -63,12 +63,84 @@
|
||||
class="mb-3"
|
||||
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
|
||||
:type="showToken ? 'text' : 'password'"
|
||||
:append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append-inner="revealTelegramToken"
|
||||
:append-inner-icon="showToken ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="toggleTelegramTokenVisibility"
|
||||
/>
|
||||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
||||
</p>
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
||||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveTelegramConfig" :loading="savingTelegram">保存 Telegram 配置</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<div class="text-subtitle-2 mb-1">告警通知项目</div>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
关闭后该类告警仍记录日志,但不发送 Telegram 消息。Bot Token 未配置时全部静默。
|
||||
</p>
|
||||
<template v-for="(groupItems, groupName) in notifyToggleGroups" :key="groupName">
|
||||
<div class="text-caption text-medium-emphasis font-weight-medium mt-2 mb-1">{{ groupName }}</div>
|
||||
<v-switch
|
||||
v-for="item in groupItems"
|
||||
:key="item.key"
|
||||
:model-value="notifyToggles[item.key] ?? true"
|
||||
:label="item.label"
|
||||
:hint="item.desc"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="mb-1"
|
||||
:disabled="notifyToggleSaving === item.key"
|
||||
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
每分钟由宿主机 cron 执行 <code>health_monitor.sh</code>:连续 3 次 /health 失败则自动重启并推送 Telegram。
|
||||
配置保存后会自动同步至 .env;亦可手动同步。未同步时脚本会从数据库读取 Telegram 配置。
|
||||
</p>
|
||||
<div class="d-flex flex-wrap align-center ga-2 mb-3">
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="patrolStatus?.telegram_layer3_ready ? 'success' : 'warning'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ patrolStatus?.telegram_layer3_ready ? '巡检 Telegram 就绪' : '请先配置 Telegram' }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="patrolStatus"
|
||||
size="small"
|
||||
:color="patrolStatus.telegram_env_synced ? 'success' : 'grey'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ patrolStatus.telegram_env_synced ? '.env 已同步' : '.env 未同步(可用 DB 回退)' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-sync"
|
||||
@click="syncPatrolTelegram"
|
||||
:loading="syncingPatrol"
|
||||
:disabled="!patrolStatus?.telegram_app_configured"
|
||||
>
|
||||
同步巡检 Telegram
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" @click="loadPatrolStatus" :loading="patrolLoading">
|
||||
刷新状态
|
||||
</v-btn>
|
||||
</div>
|
||||
<p v-if="patrolStatus" class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||
宿主机安装 cron:<code>{{ patrolStatus.cron_install_command }}</code> · 日志:<code>{{ patrolStatus.health_log_path }}</code>
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -148,7 +220,7 @@
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field :model-value="showApiKey ? apiKeyValue : '••••••••'" label="API Key" variant="outlined" density="compact" readonly :type="showApiKey ? 'text' : 'password'" style="max-width: 320px" />
|
||||
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" />
|
||||
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" :loading="revealingApiKey" />
|
||||
<v-btn variant="text" size="small" icon="mdi-content-copy" @click="copyApiKey" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -158,32 +230,113 @@
|
||||
<v-card elevation="0" border rounded="lg">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-ip-network</v-icon>
|
||||
IP 白名单
|
||||
登录 IP 白名单
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-switch v-model="ipAllowlistEnabled" label="启用 IP 白名单" color="primary" density="compact" hide-details class="mb-3" @update:model-value="toggleAllowlist" />
|
||||
<v-textarea v-model="ipList" label="IP 列表(每行一个)" variant="outlined" density="compact" rows="4" class="mb-2" />
|
||||
<v-btn size="small" variant="tonal" @click="saveAllowlist" :loading="savingAllowlist">保存</v-btn>
|
||||
<v-switch
|
||||
v-model="ipAllowlistEnabled"
|
||||
label="启用 IP 白名单(仅白名单 IP 可登录)"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
@update:model-value="toggleAllowlist"
|
||||
/>
|
||||
|
||||
<div class="text-subtitle-2 mb-2">代理订阅</div>
|
||||
<p class="text-caption text-medium-emphasis mb-2">
|
||||
填写 SS/VMess/VLESS 等订阅链接,保存后每 2 小时自动刷新节点 IP 并合并进白名单。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="subscriptionUrl"
|
||||
label="订阅 URL"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="https://example.com/sub?token=..."
|
||||
/>
|
||||
<div class="d-flex flex-wrap ga-2 mb-3">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-cloud-download" @click="parseSubscription" :loading="parsingSubscription">解析预览</v-btn>
|
||||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveAllowlist" :loading="savingAllowlist">保存白名单</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="subscriptionIps.length" class="mb-4">
|
||||
<div class="text-caption text-medium-emphasis mb-2">
|
||||
订阅节点 {{ subscriptionIps.length }} 个
|
||||
<span v-if="allowlistLastRefresh"> · 上次刷新 {{ allowlistLastRefresh }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap ga-1">
|
||||
<v-chip v-for="ip in subscriptionIpsPreview" :key="ip" size="x-small" variant="tonal" label>{{ ip }}</v-chip>
|
||||
<v-chip v-if="subscriptionIps.length > subscriptionIpsPreview.length" size="x-small" variant="text" label>
|
||||
+{{ subscriptionIps.length - subscriptionIpsPreview.length }} 更多
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="subscriptionUrl" class="text-caption text-medium-emphasis mb-4">
|
||||
{{ allowlistLastRefresh ? `上次刷新 ${allowlistLastRefresh},暂无节点` : '等待首次刷新…保存订阅 URL 后将自动拉取' }}
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<div class="text-subtitle-2 mb-2">手动 IP</div>
|
||||
<v-textarea
|
||||
v-model="manualIpList"
|
||||
label="手动 IP / CIDR(每行一个)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="4"
|
||||
class="mb-2"
|
||||
placeholder="1.2.3.4 10.0.0.0/24"
|
||||
/>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
手动 IP 与订阅节点合并生效;共 {{ allowlistTotalCount }} 条(去重后)。
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- API Key Reveal Dialog -->
|
||||
<v-dialog v-model="showRevealDialog" max-width="400">
|
||||
<!-- Subscription parse preview -->
|
||||
<v-dialog v-model="subParseDialog" max-width="520">
|
||||
<v-card border>
|
||||
<v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
|
||||
<v-card-title>订阅解析预览</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{ revealTarget === 'telegram' ? '请输入当前密码以查看 Telegram Bot Token' : '请输入当前密码以查看 API Key' }}
|
||||
<div class="text-body-2 mb-2">共解析出 <strong>{{ subParseHosts.length }}</strong> 个节点 IP / 域名:</div>
|
||||
<div class="d-flex flex-wrap ga-1 mb-2" style="max-height: 240px; overflow-y: auto">
|
||||
<v-chip v-for="h in subParseHosts" :key="h" size="small" variant="tonal" label>{{ h }}</v-chip>
|
||||
</div>
|
||||
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
|
||||
<div class="text-caption text-medium-emphasis">点击「保存白名单」后才会写入订阅 URL 并触发后台刷新。</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="doRevealSecret" :loading="revealingKey">确认</v-btn>
|
||||
<v-btn variant="text" @click="subParseDialog = false">关闭</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="subParseDialog = false; saveAllowlist()">保存白名单</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Telegram Chat ID picker -->
|
||||
<v-dialog v-model="tgChatDialog" max-width="480">
|
||||
<v-card border>
|
||||
<v-card-title>选择 Chat ID</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
||||
未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。
|
||||
</div>
|
||||
<v-list v-else density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="chat in tgChats"
|
||||
:key="chat.id"
|
||||
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
||||
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||||
@click="applyTelegramChat(chat)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="tgChatDialog = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -207,13 +360,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
|
||||
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
|
||||
import type { SettingsResponse, AllowlistResponse, SettingItem } from '@/types/api'
|
||||
import type {
|
||||
SettingsResponse,
|
||||
AllowlistResponse,
|
||||
SettingItem,
|
||||
TelegramChatItem,
|
||||
TelegramChatsResponse,
|
||||
OpsPatrolStatusResponse,
|
||||
OpsPatrolSyncResponse,
|
||||
SubscriptionParseResponse,
|
||||
IpAllowlistSaveRequest,
|
||||
} from '@/types/api'
|
||||
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
@@ -231,7 +395,30 @@ const telegramTokenDraft = ref('')
|
||||
const telegramTokenSet = ref(false)
|
||||
const saving = ref(false)
|
||||
const testingTg = ref(false)
|
||||
const detectingTgChats = ref(false)
|
||||
const tgChatDialog = ref(false)
|
||||
const tgChats = ref<TelegramChatItem[]>([])
|
||||
const showToken = ref(false)
|
||||
const savingTelegram = ref(false)
|
||||
const notifyToggles = ref<Record<string, boolean>>({})
|
||||
const notifyToggleSaving = ref<string | null>(null)
|
||||
const patrolStatus = ref<OpsPatrolStatusResponse | null>(null)
|
||||
const patrolLoading = ref(false)
|
||||
const syncingPatrol = ref(false)
|
||||
|
||||
const notifyToggleGroups = computed(() => {
|
||||
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
|
||||
for (const item of NOTIFY_TOGGLE_ITEMS) {
|
||||
if (!groups[item.group]) groups[item.group] = []
|
||||
groups[item.group].push(item)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function parseNotifyEnabled(val: unknown): boolean {
|
||||
const s = String(val ?? 'true').trim().toLowerCase()
|
||||
return !['false', '0', 'no', 'off'].includes(s)
|
||||
}
|
||||
|
||||
// ── Password ──
|
||||
const pwForm = ref({ current: '', new_pw: '', confirm: '', totp_code: '' })
|
||||
@@ -250,15 +437,22 @@ const disablingTotp = ref(false)
|
||||
// ── API Key ──
|
||||
const showApiKey = ref(false)
|
||||
const apiKeyValue = ref('')
|
||||
const showRevealDialog = ref(false)
|
||||
const revealTarget = ref<'api_key' | 'telegram'>('api_key')
|
||||
const revealPassword = ref('')
|
||||
const revealingKey = ref(false)
|
||||
const revealingApiKey = ref(false)
|
||||
const revealingTelegramToken = ref(false)
|
||||
|
||||
// ── IP Allowlist ──
|
||||
const ipAllowlistEnabled = ref(false)
|
||||
const ipList = ref('')
|
||||
const subscriptionUrl = ref('')
|
||||
const manualIpList = ref('')
|
||||
const subscriptionIps = ref<string[]>([])
|
||||
const allowlistLastRefresh = ref<string | null>(null)
|
||||
const allowlistTotalCount = ref(0)
|
||||
const savingAllowlist = ref(false)
|
||||
const parsingSubscription = ref(false)
|
||||
const subParseDialog = ref(false)
|
||||
const subParseHosts = ref<string[]>([])
|
||||
|
||||
const subscriptionIpsPreview = computed(() => subscriptionIps.value.slice(0, 24))
|
||||
|
||||
// ── Data loading ──
|
||||
async function loadSettings() {
|
||||
@@ -274,6 +468,10 @@ async function loadSettings() {
|
||||
telegramTokenDraft.value = ''
|
||||
continue
|
||||
}
|
||||
if (key && NOTIFY_TOGGLE_ITEMS.some(n => n.key === key)) {
|
||||
notifyToggles.value[key] = parseNotifyEnabled(item.value)
|
||||
continue
|
||||
}
|
||||
if (key && knownKeys.includes(key)) {
|
||||
const val = item.value
|
||||
// Type-cast numeric strings back to numbers for number inputs
|
||||
@@ -295,7 +493,13 @@ async function loadAllowlist() {
|
||||
try {
|
||||
const res = await http.get<AllowlistResponse>('/settings/ip-allowlist')
|
||||
ipAllowlistEnabled.value = res.enabled || false
|
||||
ipList.value = ([...(res.manual_ips || []), ...(res.subscription_ips || [])]).join('\n')
|
||||
subscriptionUrl.value = res.subscription_url || ''
|
||||
manualIpList.value = (res.manual_ips || []).join('\n')
|
||||
subscriptionIps.value = res.subscription_ips || []
|
||||
allowlistLastRefresh.value = res.last_refresh || null
|
||||
allowlistTotalCount.value = res.total_count ?? (
|
||||
new Set([...(res.manual_ips || []), ...(res.subscription_ips || [])]).size
|
||||
)
|
||||
} catch {
|
||||
snackbar('加载白名单失败', 'error')
|
||||
}
|
||||
@@ -304,36 +508,149 @@ async function loadAllowlist() {
|
||||
// ── Actions ──
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
try {
|
||||
const entries = Object.entries(settings.value).filter(([key]) =>
|
||||
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_chat_id'].includes(key)
|
||||
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow'].includes(key)
|
||||
)
|
||||
if (tokenUpdate) {
|
||||
entries.push(['telegram_bot_token', tokenUpdate])
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
await http.put(`/settings/${key}`, { value })
|
||||
}
|
||||
snackbar('设置已保存')
|
||||
if (tokenUpdate) {
|
||||
telegramTokenSet.value = true
|
||||
telegramTokenDraft.value = ''
|
||||
showToken.value = false
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '保存失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function persistTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||||
|
||||
if (options?.requireToken && !tokenUpdate && !telegramTokenSet.value) {
|
||||
snackbar('请先填写 Bot Token', 'error')
|
||||
return false
|
||||
}
|
||||
if (options?.requireChatId && !chatId) {
|
||||
snackbar('请先填写或检测 Chat ID', 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
if (tokenUpdate) {
|
||||
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
|
||||
telegramTokenSet.value = true
|
||||
telegramTokenDraft.value = ''
|
||||
showToken.value = false
|
||||
}
|
||||
if (chatId) {
|
||||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadPatrolStatus() {
|
||||
patrolLoading.value = true
|
||||
try {
|
||||
patrolStatus.value = await http.get<OpsPatrolStatusResponse>('/settings/ops-patrol/status')
|
||||
} catch {
|
||||
patrolStatus.value = null
|
||||
} finally {
|
||||
patrolLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPatrolTelegram() {
|
||||
syncingPatrol.value = true
|
||||
try {
|
||||
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||
const res = await http.post<OpsPatrolSyncResponse>('/settings/ops-patrol/sync-telegram', {})
|
||||
patrolStatus.value = {
|
||||
...(patrolStatus.value || {} as OpsPatrolStatusResponse),
|
||||
telegram_layer3_ready: res.telegram_layer3_ready,
|
||||
telegram_env_synced: res.env_status.env_synced,
|
||||
env_status: res.env_status,
|
||||
telegram_app_configured: true,
|
||||
}
|
||||
snackbar('巡检 Telegram 已同步至 .env')
|
||||
await loadPatrolStatus()
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '同步失败', 'error')
|
||||
} finally {
|
||||
syncingPatrol.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTelegramConfig() {
|
||||
savingTelegram.value = true
|
||||
try {
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||||
if (!tokenUpdate && !chatId && !telegramTokenSet.value) {
|
||||
snackbar('请先填写 Bot Token 或 Chat ID', 'warning')
|
||||
return
|
||||
}
|
||||
await persistTelegramConfig()
|
||||
await loadPatrolStatus()
|
||||
snackbar('Telegram 配置已保存')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
savingTelegram.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(key: string, enabled: boolean) {
|
||||
const prev = notifyToggles.value[key]
|
||||
notifyToggles.value[key] = enabled
|
||||
notifyToggleSaving.value = key
|
||||
try {
|
||||
await http.put(`/settings/${key}`, { value: enabled ? 'true' : 'false' })
|
||||
} catch (e: unknown) {
|
||||
notifyToggles.value[key] = prev
|
||||
snackbar(e instanceof Error ? e.message : '保存失败,已撤销', 'error')
|
||||
} finally {
|
||||
notifyToggleSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function testTelegram() {
|
||||
testingTg.value = true
|
||||
try {
|
||||
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||
await http.post('/settings/telegram/test')
|
||||
snackbar('测试消息已发送')
|
||||
} catch (e: any) { snackbar(e.message || '发送失败', 'error') }
|
||||
finally { testingTg.value = false }
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '发送失败', 'error')
|
||||
} finally { testingTg.value = false }
|
||||
}
|
||||
|
||||
async function detectTelegramChats() {
|
||||
detectingTgChats.value = true
|
||||
try {
|
||||
if (!await persistTelegramConfig({ requireToken: true })) return
|
||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||||
tgChats.value = res.chats || []
|
||||
if (!tgChats.value.length) {
|
||||
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning')
|
||||
return
|
||||
}
|
||||
tgChatDialog.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||||
} finally {
|
||||
detectingTgChats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTelegramChat(chat: TelegramChatItem) {
|
||||
const chatId = String(chat.id)
|
||||
settings.value.telegram_chat_id = chatId
|
||||
tgChatDialog.value = false
|
||||
try {
|
||||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||||
snackbar(`已选择 Chat ID: ${chatId}`)
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
@@ -415,83 +732,110 @@ async function revealApiKey() {
|
||||
showApiKey.value = false
|
||||
return
|
||||
}
|
||||
revealTarget.value = 'api_key'
|
||||
showRevealDialog.value = true
|
||||
revealPassword.value = ''
|
||||
}
|
||||
|
||||
async function revealTelegramToken() {
|
||||
if (showToken.value && telegramTokenDraft.value) {
|
||||
showToken.value = false
|
||||
telegramTokenDraft.value = ''
|
||||
return
|
||||
}
|
||||
revealTarget.value = 'telegram'
|
||||
showRevealDialog.value = true
|
||||
revealPassword.value = ''
|
||||
}
|
||||
|
||||
async function doRevealSecret() {
|
||||
if (!revealPassword.value) {
|
||||
snackbar('请输入密码', 'error')
|
||||
return
|
||||
}
|
||||
revealingKey.value = true
|
||||
revealingApiKey.value = true
|
||||
try {
|
||||
if (revealTarget.value === 'telegram') {
|
||||
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {
|
||||
current_password: revealPassword.value,
|
||||
})
|
||||
telegramTokenDraft.value = res.value
|
||||
showToken.value = true
|
||||
} else {
|
||||
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', {
|
||||
current_password: revealPassword.value,
|
||||
})
|
||||
apiKeyValue.value = res.api_key
|
||||
showApiKey.value = true
|
||||
}
|
||||
showRevealDialog.value = false
|
||||
revealPassword.value = ''
|
||||
const res = await http.post<{ value: string }>('/settings/api-key/reveal', {})
|
||||
apiKeyValue.value = res.value
|
||||
showApiKey.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '验证失败', 'error')
|
||||
snackbar(e instanceof Error ? e.message : '获取 API Key 失败', 'error')
|
||||
} finally {
|
||||
revealingKey.value = false
|
||||
revealingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRevealApiKey() {
|
||||
revealTarget.value = 'api_key'
|
||||
await doRevealSecret()
|
||||
async function toggleTelegramTokenVisibility() {
|
||||
if (showToken.value) {
|
||||
showToken.value = false
|
||||
if (telegramTokenSet.value) {
|
||||
telegramTokenDraft.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
if (telegramTokenDraft.value.trim()) {
|
||||
showToken.value = true
|
||||
return
|
||||
}
|
||||
if (!telegramTokenSet.value) {
|
||||
showToken.value = true
|
||||
return
|
||||
}
|
||||
revealingTelegramToken.value = true
|
||||
try {
|
||||
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {})
|
||||
telegramTokenDraft.value = res.value
|
||||
showToken.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '获取 Bot Token 失败', 'error')
|
||||
} finally {
|
||||
revealingTelegramToken.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
if (apiKeyValue.value) {
|
||||
navigator.clipboard.writeText(apiKeyValue.value)
|
||||
snackbar('已复制到剪贴板')
|
||||
if (!showApiKey.value || !apiKeyValue.value) {
|
||||
snackbar('请先点击眼睛图标显示 API Key', 'warning')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(apiKeyValue.value)
|
||||
snackbar('已复制到剪贴板')
|
||||
}
|
||||
|
||||
async function toggleAllowlist() {
|
||||
try {
|
||||
await http.post('/settings/ip-allowlist/toggle', { enabled: ipAllowlistEnabled.value })
|
||||
} catch (e: any) { snackbar(e.message || '操作失败', 'error') }
|
||||
await http.post(`/settings/ip-allowlist/toggle?enabled=${ipAllowlistEnabled.value ? 'true' : 'false'}`)
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '操作失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSubscription() {
|
||||
const url = subscriptionUrl.value.trim()
|
||||
if (!url) {
|
||||
snackbar('请先填写订阅 URL', 'error')
|
||||
return
|
||||
}
|
||||
parsingSubscription.value = true
|
||||
try {
|
||||
const res = await http.post<SubscriptionParseResponse>('/settings/ip-allowlist/parse-subscription', { url })
|
||||
subParseHosts.value = res.hosts || []
|
||||
if (!subParseHosts.value.length) {
|
||||
snackbar('未能解析出任何节点', 'warning')
|
||||
return
|
||||
}
|
||||
subParseDialog.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '解析失败', 'error')
|
||||
} finally {
|
||||
parsingSubscription.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAllowlist() {
|
||||
savingAllowlist.value = true
|
||||
try {
|
||||
const ips = ipList.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
await http.post('/settings/ip-allowlist', { ips })
|
||||
const manual_ips = manualIpList.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
const payload: IpAllowlistSaveRequest = {
|
||||
manual_ips,
|
||||
subscription_url: subscriptionUrl.value.trim(),
|
||||
enabled: ipAllowlistEnabled.value,
|
||||
}
|
||||
await http.post('/settings/ip-allowlist', payload)
|
||||
snackbar('白名单已保存')
|
||||
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
|
||||
finally { savingAllowlist.value = false }
|
||||
subParseDialog.value = false
|
||||
await loadAllowlist()
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
savingAllowlist.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadAllowlist()
|
||||
loadPatrolStatus()
|
||||
auth.fetchProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,62 @@ export interface SettingItem {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** Telegram getUpdates chat detection */
|
||||
export interface TelegramChatItem {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface TelegramChatsResponse {
|
||||
chats: TelegramChatItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface OpsPatrolEnvStatus {
|
||||
app_env_token: boolean
|
||||
app_env_chat_id: boolean
|
||||
host_env_prod_token: boolean
|
||||
host_env_prod_chat_id: boolean
|
||||
env_synced: boolean
|
||||
}
|
||||
|
||||
export interface OpsPatrolStatusResponse {
|
||||
telegram_app_configured: boolean
|
||||
telegram_layer3_ready: boolean
|
||||
telegram_env_synced: boolean
|
||||
env_status: OpsPatrolEnvStatus
|
||||
db_fallback_enabled: boolean
|
||||
cron_install_command: string
|
||||
health_log_path: string
|
||||
}
|
||||
|
||||
export interface OpsPatrolSyncResponse {
|
||||
synced: Record<string, boolean | string>
|
||||
env_status: OpsPatrolEnvStatus
|
||||
telegram_layer3_ready: boolean
|
||||
}
|
||||
|
||||
/** Telegram notification toggle (settings key → label) */
|
||||
export interface NotifyToggleItem {
|
||||
key: string
|
||||
label: string
|
||||
desc: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
|
||||
{ key: 'notify_alert_cpu', label: 'CPU 超阈值告警', desc: 'CPU 使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘告警解除时推送', group: '服务器资源' },
|
||||
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
|
||||
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
|
||||
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
|
||||
{ key: 'notify_restart', label: 'Nexus 后端重启', desc: 'Python 服务崩溃重启成功/失败时推送', group: '系统' },
|
||||
]
|
||||
|
||||
/** IP Allowlist response */
|
||||
export interface AllowlistResponse {
|
||||
enabled: boolean
|
||||
@@ -34,6 +90,17 @@ export interface AllowlistResponse {
|
||||
last_refresh?: string | null
|
||||
}
|
||||
|
||||
export interface SubscriptionParseResponse {
|
||||
hosts: string[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface IpAllowlistSaveRequest {
|
||||
manual_ips?: string[]
|
||||
subscription_url?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** File browse response from POST /api/sync/browse */
|
||||
export interface BrowseResponse {
|
||||
path?: string
|
||||
@@ -109,6 +176,42 @@ export interface ServerApiItem {
|
||||
_source?: string
|
||||
}
|
||||
|
||||
/** Credential poll error from add-by-ip / pending retry */
|
||||
export interface PollErrorItem {
|
||||
preset_type: string
|
||||
preset_name: string
|
||||
username: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/** Pending server row from GET /api/servers/pending */
|
||||
export interface PendingServerItem {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
port: number
|
||||
attempts: number
|
||||
last_error: string
|
||||
last_attempt_at: string | null
|
||||
agent_port?: number
|
||||
target_path?: string
|
||||
category?: string | null
|
||||
platform_id?: number | null
|
||||
node_id?: number | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
/** Response from POST /api/servers/add-by-ip and pending retry */
|
||||
export interface AddByIpResponse {
|
||||
success: boolean
|
||||
server?: ServerApiItem
|
||||
pending_id?: number
|
||||
errors?: PollErrorItem[]
|
||||
message?: string
|
||||
matched_preset?: string
|
||||
matched_username?: string
|
||||
}
|
||||
|
||||
/** Alert history item from /api/alert-history/ */
|
||||
export interface AlertLogItem {
|
||||
id: number
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env bash
|
||||
# security_probe_external.sh — Read-only external attack-surface probes (no credentials).
|
||||
# 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"
|
||||
|
||||
log() { echo "$@" | tee -a "$OUT"; }
|
||||
plog() { echo "$@" | tee -a "$OUT" >&2; }
|
||||
pass() { log " [PASS] $*"; }
|
||||
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 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 code_file
|
||||
tmp="$(mktemp)"
|
||||
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
|
||||
local code
|
||||
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 ""
|
||||
plog "── $method $path → HTTP $code"
|
||||
plog " body: ${snippet:-<empty>}"
|
||||
rm -f "$tmp"
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
log "═══ Nexus external attack-surface probe ═══"
|
||||
log "BASE=$BASE"
|
||||
log "OUT=$OUT"
|
||||
log "time=$(date -u -Iseconds)"
|
||||
|
||||
# ── Health ──
|
||||
c=$(probe GET /health)
|
||||
[[ "$c" == "200" ]] && pass "GET /health → 200" || fail "GET /health code=$c"
|
||||
|
||||
c=$(probe GET /health/detail)
|
||||
[[ "$c" == "401" ]] && pass "GET /health/detail requires JWT" || fail "GET /health/detail code=$c (expected 401) — component leak if 200"
|
||||
|
||||
# ── OpenAPI / docs ──
|
||||
c=$(probe GET /openapi.json)
|
||||
[[ "$c" == "200" ]] && warn "GET /openapi.json accessible (schema disclosure)" || info "GET /openapi.json code=$c"
|
||||
|
||||
c=$(probe GET /docs)
|
||||
[[ "$c" == "200" ]] && warn "GET /docs accessible" || info "GET /docs code=$c"
|
||||
|
||||
c=$(probe GET /redoc)
|
||||
[[ "$c" == "200" ]] && warn "GET /redoc accessible" || info "GET /redoc code=$c"
|
||||
|
||||
# ── Install API ──
|
||||
c=$(probe GET /api/install/status)
|
||||
if [[ "$c" == "404" ]]; then
|
||||
pass "GET /api/install/status hidden when locked (404)"
|
||||
elif [[ "$c" == "200" ]]; then
|
||||
warn "GET /api/install/status returns 200 (install state leak)"
|
||||
else
|
||||
info "GET /api/install/status code=$c"
|
||||
fi
|
||||
|
||||
for ep in env-check connection-check state; do
|
||||
c=$(probe GET "/api/install/$ep")
|
||||
[[ "$c" == "404" ]] && pass "GET /api/install/$ep hidden when locked (404)" || fail "GET /api/install/$ep code=$c (expected 404)"
|
||||
done
|
||||
|
||||
c=$(probe POST /api/install/init-db '{"db_host":"x"}')
|
||||
[[ "$c" == "404" ]] && pass "POST /api/install/init-db hidden when locked (404)" || fail "POST /api/install/init-db code=$c (expected 404)"
|
||||
|
||||
c=$(probe POST /api/install/create-admin '{"username":"x","password":"x","install_token":"x"}')
|
||||
[[ "$c" == "404" ]] && pass "POST /api/install/create-admin hidden when locked (404)" || fail "POST /api/install/create-admin code=$c (expected 404)"
|
||||
|
||||
c=$(probe POST /api/install/test-credentials '{}')
|
||||
[[ "$c" == "404" ]] && pass "POST /api/install/test-credentials hidden when locked (404)" || fail "POST /api/install/test-credentials code=$c (expected 404)"
|
||||
|
||||
c=$(probe POST /api/install/lock '{}')
|
||||
[[ "$c" == "404" ]] && pass "POST /api/install/lock hidden when locked (404)" || fail "POST /api/install/lock code=$c (expected 404)"
|
||||
|
||||
# ── Public settings ──
|
||||
c=$(probe GET /api/settings/bing-wallpapers)
|
||||
[[ "$c" == "200" ]] && pass "GET /api/settings/bing-wallpapers public read-only" || fail "code=$c"
|
||||
|
||||
c=$(probe POST /api/settings/bing-wallpapers/sync '{}')
|
||||
[[ "$c" == "401" ]] && pass "POST bing-wallpapers/sync requires JWT" || fail "POST bing-wallpapers/sync code=$c (expected 401)"
|
||||
|
||||
# ── Protected admin API ──
|
||||
c=$(probe GET /api/servers/)
|
||||
[[ "$c" == "401" ]] && pass "GET /api/servers/ requires JWT" || fail "code=$c"
|
||||
|
||||
c=$(probe POST /api/servers/ '{"name":"x","domain":"1.2.3.4"}')
|
||||
[[ "$c" == "401" ]] && pass "POST /api/servers/ requires JWT" || fail "code=$c"
|
||||
|
||||
c=$(probe GET /api/audit/)
|
||||
[[ "$c" == "401" ]] && pass "GET /api/audit/ requires JWT" || fail "code=$c"
|
||||
|
||||
# ── Agent ──
|
||||
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}')
|
||||
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat no key rejected ($c)" || fail "code=$c"
|
||||
|
||||
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API-Key: invalid-probe-key-000")
|
||||
[[ "$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":"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"
|
||||
|
||||
# ── Auth login (single probe) ──
|
||||
login_tmp="$(mktemp)"
|
||||
curl -sS -o "$login_tmp" --max-time 15 -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"nonexistent_probe_user","password":"wrong"}' \
|
||||
"${BASE}/api/auth/login" 2>>"$OUT" || true
|
||||
login_body="$(cat "$login_tmp")"
|
||||
rm -f "$login_tmp"
|
||||
info "login wrong user: $login_body"
|
||||
[[ "$login_body" == *"用户名或密码错误"* ]] && pass "login message unified (no user enumeration)" || warn "login message may leak enumeration"
|
||||
|
||||
# ── Static agent ──
|
||||
c=$(probe GET /agent/install.sh)
|
||||
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"
|
||||
|
||||
# ── WebSocket ──
|
||||
WS_PY="$(resolve_ws_python || true)"
|
||||
if [[ -n "${WS_PY}" ]]; then
|
||||
log ""
|
||||
log "── WebSocket probes (python=${WS_PY})"
|
||||
"${WS_PY}" - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
|
||||
import asyncio, sys
|
||||
import websockets
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
async def probe_ws(path, token=None):
|
||||
url = f"{ws_base}{path}"
|
||||
if token is not None:
|
||||
url += f"?token={token}"
|
||||
try:
|
||||
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
|
||||
print(f" [FAIL] {path} connected without expected close")
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "4001" in msg or "Missing JWT" in msg:
|
||||
print(f" [PASS] {path} no token → rejected ({msg[:80]})")
|
||||
elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
|
||||
print(f" [PASS] {path} bad token → rejected ({msg[:80]})")
|
||||
elif "403" in msg:
|
||||
# Starlette: close() before accept() → HTTP 403 (same direct and via proxy)
|
||||
print(f" [PASS] {path} unauthenticated → rejected (HTTP 403)")
|
||||
else:
|
||||
print(f" [INFO] {path} → {msg[:120]}")
|
||||
|
||||
async def main():
|
||||
await probe_ws("/ws/alerts")
|
||||
await probe_ws("/ws/alerts", "invalid.jwt.token")
|
||||
await probe_ws("/ws/sync")
|
||||
await probe_ws("/ws/terminal/1", "invalid.jwt.token")
|
||||
|
||||
asyncio.run(main())
|
||||
PY
|
||||
else
|
||||
warn "WebSocket probes skipped: no python with websockets (set NEXUS_PROBE_PYTHON or install venv)"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "═══ Probe complete ═══"
|
||||
log "Full log: $OUT"
|
||||
+26
-39
@@ -21,6 +21,7 @@ from server.api.dependencies import get_db, get_server_service
|
||||
from server.api.schemas import AgentHeartbeat, AgentScriptCallback
|
||||
from server.api.websocket import broadcast_alert, broadcast_recovery
|
||||
from server.application.services.server_service import ServerService
|
||||
from server.domain.models import Server
|
||||
from server.config import settings
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.telegram import send_telegram_system_alert
|
||||
@@ -38,32 +39,24 @@ REDIS_ALERT_KEY_PREFIX = "alerts:"
|
||||
|
||||
|
||||
def _verify_api_key(x_api_key: str = Header(...)):
|
||||
"""Verify Agent API key — accepts either global or per-server key.
|
||||
|
||||
For heartbeat endpoints, the global key is checked first; if it doesn't
|
||||
match, the request is still allowed through so the handler can verify
|
||||
the per-server key (which requires server_id from the payload).
|
||||
"""Extract Agent API key header. Actual auth is per-server in the handler.
|
||||
|
||||
server_id lives in the payload (not the header), so per-server verification
|
||||
runs inside the route via _verify_server_api_key(). Here we only ensure the
|
||||
header is present; an empty key is rejected early.
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(status_code=401, detail="Missing API key")
|
||||
# If the global key matches, allow immediately
|
||||
if secrets.compare_digest(x_api_key, settings.API_KEY):
|
||||
return x_api_key
|
||||
# Non-matching key — allow through for per-server verification in handler.
|
||||
# The handler MUST call _verify_server_api_key() before processing.
|
||||
# This two-step approach is needed because server_id is in the payload,
|
||||
# not the header, so per-server verification can't happen in a Depends().
|
||||
return x_api_key
|
||||
|
||||
|
||||
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
|
||||
"""Verify API key against per-server agent_api_key, with limited global fallback.
|
||||
async def _verify_server_api_key(
|
||||
server_id: int, provided_key: str, service: ServerService
|
||||
) -> Optional[Server]:
|
||||
"""Load server and verify per-server agent_api_key (single DB read).
|
||||
|
||||
Authentication priority:
|
||||
1. Server must exist
|
||||
2. If server has agent_api_key → must match exactly
|
||||
3. Legacy only: no per-server key → global API_KEY (logged for migration)
|
||||
Returns the Server on success; None if missing, misconfigured, wrong key, or DB error.
|
||||
No global API_KEY fallback (BL-06: per-server enforced).
|
||||
"""
|
||||
try:
|
||||
server = await service.get_server(server_id)
|
||||
@@ -73,24 +66,25 @@ async def _verify_server_api_key(server_id: int, provided_key: str, service: Ser
|
||||
server_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return None
|
||||
|
||||
if not server:
|
||||
return False
|
||||
return None
|
||||
|
||||
if server.agent_api_key:
|
||||
return secrets.compare_digest(provided_key, server.agent_api_key)
|
||||
|
||||
if secrets.compare_digest(provided_key, settings.API_KEY):
|
||||
if not server.agent_api_key:
|
||||
logger.warning(
|
||||
"Server %s (%s) used global API_KEY for agent auth — "
|
||||
"assign per-server key via POST /api/servers/%s/agent-key",
|
||||
"Server %s (%s) rejected: no agent_api_key set (global fallback removed). "
|
||||
"Generate via POST /api/servers/%s/agent-key",
|
||||
server_id,
|
||||
server.name,
|
||||
server_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
return None
|
||||
|
||||
if not secrets.compare_digest(provided_key, server.agent_api_key):
|
||||
return None
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _threshold_for(thresholds: dict, metric: str) -> float:
|
||||
@@ -186,17 +180,10 @@ async def receive_heartbeat(
|
||||
system_info = payload.system_info or {}
|
||||
agent_version = payload.agent_version or ""
|
||||
|
||||
# ── Per-server API key verification ──
|
||||
if not await _verify_server_api_key(server_id, api_key, service):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key for this server")
|
||||
|
||||
server = await service.get_server(server_id)
|
||||
# ── Per-server API key verification (single get_server) ──
|
||||
server = await _verify_server_api_key(server_id, api_key, service)
|
||||
if not server:
|
||||
logger.warning(
|
||||
"Heartbeat discarded: unknown server_id=%s (unregistered host)",
|
||||
server_id,
|
||||
)
|
||||
return {"status": "discarded", "reason": "unknown_server"}
|
||||
raise HTTPException(status_code=401, detail="Invalid API key for this server")
|
||||
|
||||
# ── 1. Write to Redis (real-time data) ──
|
||||
|
||||
@@ -319,7 +306,7 @@ async def script_job_callback(
|
||||
):
|
||||
"""Receive long-task completion from child host (curl after nohup script exits).
|
||||
|
||||
Authenticated by X-API-Key (per-server or global) + one-time job_id + secret.
|
||||
Authenticated by X-API-Key (per-server agent_api_key) + one-time job_id + secret.
|
||||
"""
|
||||
if not await _verify_server_api_key(payload.server_id, api_key, service):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key for this server")
|
||||
|
||||
+49
-14
@@ -34,25 +34,45 @@ logger = logging.getLogger("nexus.auth_jwt")
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# Routes that bypass JWT auth (Agent heartbeat uses API Key, login obviously doesn't need JWT)
|
||||
PUBLIC_PREFIXES = (
|
||||
# Exact paths that bypass JWT (no prefix bleed — /health must not match /health/detail)
|
||||
PUBLIC_EXACT_PATHS = frozenset({
|
||||
"/api/auth/login",
|
||||
"/api/auth/refresh",
|
||||
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
|
||||
"/api/agent/", # Agent uses X-API-Key header
|
||||
"/api/install/", # install wizard (403 when already installed, except /status)
|
||||
"/api/settings/bing-wallpapers", # login page wallpaper slideshow
|
||||
"/api/settings/bing-wallpaper", # single wallpaper (backward compat)
|
||||
"/api/auth/logout",
|
||||
"/api/settings/bing-wallpapers",
|
||||
"/api/settings/bing-wallpaper",
|
||||
"/health",
|
||||
})
|
||||
|
||||
# Prefix paths — trailing slash required in definition to avoid accidental broad match
|
||||
PUBLIC_PREFIXES = (
|
||||
"/api/agent/",
|
||||
"/api/install/",
|
||||
"/ws/",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
)
|
||||
|
||||
# OpenAPI/Swagger paths — only public when EXPOSE_API_DOCS is enabled
|
||||
_API_DOC_PATHS = frozenset({"/docs", "/redoc", "/openapi.json"})
|
||||
|
||||
def _is_public_path(path: str) -> bool:
|
||||
"""Check if the request path is public (doesn't require JWT)"""
|
||||
|
||||
def _api_docs_exposed() -> bool:
|
||||
flag = (getattr(settings, "EXPOSE_API_DOCS", "false") or "false").lower()
|
||||
return flag in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
def _is_public_path(path: str, method: str = "GET") -> bool:
|
||||
"""Check if the request path is public (doesn't require JWT).
|
||||
|
||||
Uses exact match for single-segment public endpoints; prefix match only for
|
||||
agent/install/ws trees. Prevents /health/detail and bing-wallpapers/sync bleed.
|
||||
"""
|
||||
if path in PUBLIC_EXACT_PATHS:
|
||||
if path in ("/api/settings/bing-wallpapers", "/api/settings/bing-wallpaper"):
|
||||
if method.upper() not in ("GET", "HEAD", "OPTIONS"):
|
||||
return False
|
||||
return True
|
||||
if _api_docs_exposed() and path in _API_DOC_PATHS:
|
||||
return True
|
||||
for prefix in PUBLIC_PREFIXES:
|
||||
if path.startswith(prefix):
|
||||
return True
|
||||
@@ -111,7 +131,7 @@ class JwtAuthMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if _is_public_path(path):
|
||||
if _is_public_path(path, method):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
@@ -157,7 +177,7 @@ async def get_current_admin(
|
||||
For protected routes, raises 401 if no/invalid credentials.
|
||||
"""
|
||||
# Skip JWT for public routes — return None (route handler decides what to do)
|
||||
if _is_public_path(request.url.path):
|
||||
if _is_public_path(request.url.path, request.method):
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
@@ -183,6 +203,21 @@ async def get_current_admin(
|
||||
return admin
|
||||
|
||||
|
||||
async def require_current_admin(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Admin:
|
||||
"""Like get_current_admin but never returns None — for handlers that must have JWT."""
|
||||
admin = await get_current_admin(request, credentials)
|
||||
if admin is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return admin
|
||||
|
||||
|
||||
async def _verify_token(token: str, request: Request) -> Optional[Admin]:
|
||||
"""Verify JWT access token → return Admin or None"""
|
||||
try:
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.auth_jwt import require_current_admin
|
||||
from server.domain.models import Admin
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
@@ -33,7 +33,7 @@ async def health_check():
|
||||
|
||||
|
||||
@router.get("/health/detail")
|
||||
async def health_detail(admin: Admin = Depends(get_current_admin)):
|
||||
async def health_detail(admin: Admin = Depends(require_current_admin)):
|
||||
"""Detailed health diagnostics — requires admin JWT authentication.
|
||||
|
||||
Returns Redis/MySQL/WebSocket status for admin dashboard use.
|
||||
|
||||
+35
-18
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus, unquote_plus, urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -24,8 +24,6 @@ from server.utils.text_io import read_utf8_text, write_utf8_lf
|
||||
|
||||
logger = logging.getLogger("nexus.install")
|
||||
|
||||
router = APIRouter(prefix="/api/install", tags=["install"])
|
||||
|
||||
# ── Paths ──
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -53,6 +51,19 @@ def _is_installed() -> bool:
|
||||
return _is_locked()
|
||||
|
||||
|
||||
def _raise_not_found_if_locked() -> None:
|
||||
"""Hide install API fingerprint after lock — same response as unknown route."""
|
||||
if _is_locked():
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/install",
|
||||
tags=["install"],
|
||||
dependencies=[Depends(_raise_not_found_if_locked)],
|
||||
)
|
||||
|
||||
|
||||
# ── Pydantic Models ──
|
||||
|
||||
class InitDbRequest(BaseModel):
|
||||
@@ -863,6 +874,7 @@ _INSTALL_STATE_PUBLIC_KEYS = frozenset({
|
||||
"pool_size",
|
||||
"max_overflow",
|
||||
"install_dir",
|
||||
"host_deploy_root",
|
||||
"docker_mode",
|
||||
"guardian_results",
|
||||
"installed",
|
||||
@@ -902,7 +914,8 @@ def _archive_install_wizard_html() -> None:
|
||||
try:
|
||||
INSTALL_HTML.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove install.html after archive: %s", e)
|
||||
logger.error("Failed to remove install.html after archive: %s", e)
|
||||
raise RuntimeError("Failed to remove install wizard HTML after lock") from e
|
||||
return
|
||||
if not INSTALL_HTML.is_file():
|
||||
return
|
||||
@@ -910,18 +923,19 @@ def _archive_install_wizard_html() -> None:
|
||||
INSTALL_HTML.rename(INSTALL_HTML_BAK)
|
||||
logger.info("Archived install wizard: %s → %s", INSTALL_HTML, INSTALL_HTML_BAK)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to archive install.html: %s", e)
|
||||
logger.error("Failed to archive install.html: %s", e)
|
||||
raise RuntimeError("Failed to archive install wizard HTML") from e
|
||||
|
||||
|
||||
def _finalize_install_lock() -> None:
|
||||
"""Write install lock marker and remove wizard state file."""
|
||||
_sync_env_to_persist_volume()
|
||||
_archive_install_wizard_html()
|
||||
write_utf8_lf(
|
||||
INSTALL_LOCK,
|
||||
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n",
|
||||
)
|
||||
_sync_lock_to_persist_volume()
|
||||
_archive_install_wizard_html()
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
STATE_FILE.unlink()
|
||||
@@ -950,8 +964,7 @@ def _verify_install_token(provided: str) -> None:
|
||||
@router.get("/status")
|
||||
async def install_status():
|
||||
"""Check whether Nexus is already installed."""
|
||||
if _is_locked():
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
_raise_not_found_if_locked()
|
||||
return {
|
||||
"installed": _is_installed(),
|
||||
"locked": _is_locked(),
|
||||
@@ -960,12 +973,8 @@ async def install_status():
|
||||
|
||||
|
||||
def _reject_if_locked():
|
||||
"""Block install wizard mutations after step 5 lock."""
|
||||
if _is_locked():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="安装向导已锁定。仅可查询 /api/install/status",
|
||||
)
|
||||
"""Block install wizard after step 5 lock (404 — no product fingerprint)."""
|
||||
_raise_not_found_if_locked()
|
||||
|
||||
|
||||
def _reject_if_env_exists():
|
||||
@@ -1261,6 +1270,7 @@ async def init_db(req: InitDbRequest):
|
||||
|
||||
# Configure process guardian (best-effort)
|
||||
install_dir = str(ROOT_DIR)
|
||||
host_deploy_root = _host_deploy_root(install_dir)
|
||||
guardian_results = _configure_guardian(install_dir, req.api_port)
|
||||
|
||||
# Save install state for step 4
|
||||
@@ -1277,6 +1287,7 @@ async def init_db(req: InitDbRequest):
|
||||
"pool_size": pool_size,
|
||||
"max_overflow": max_overflow,
|
||||
"install_dir": install_dir,
|
||||
"host_deploy_root": host_deploy_root,
|
||||
"site_url": site_url,
|
||||
"api_port": req.api_port,
|
||||
"redis_host": req.redis_host,
|
||||
@@ -1297,6 +1308,7 @@ async def init_db(req: InitDbRequest):
|
||||
"max_overflow": max_overflow,
|
||||
"tables_created": 14,
|
||||
"guardian_results": guardian_results,
|
||||
"host_deploy_root": host_deploy_root,
|
||||
"bt_panel": _detect_bt_panel(),
|
||||
"docker_mode": _is_docker_install_mode(),
|
||||
}
|
||||
@@ -1394,7 +1406,10 @@ async def create_admin(req: CreateAdminRequest):
|
||||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||||
|
||||
# Atomically lock after admin creation (closes unauthenticated install API window)
|
||||
_finalize_install_lock()
|
||||
try:
|
||||
_finalize_install_lock()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return {"success": True, "locked": True}
|
||||
|
||||
|
||||
@@ -1406,8 +1421,7 @@ async def lock_install():
|
||||
This prevents unauthenticated callers from locking the installer
|
||||
before the legitimate admin has finished setup.
|
||||
"""
|
||||
if _is_locked():
|
||||
return {"success": True, "already_locked": True}
|
||||
_raise_not_found_if_locked()
|
||||
if not _has_env():
|
||||
raise HTTPException(400, "请先完成步骤3:数据库初始化")
|
||||
|
||||
@@ -1435,7 +1449,10 @@ async def lock_install():
|
||||
detail="数据库暂时不可用,无法验证管理员账户,请稍后重试。",
|
||||
) from e
|
||||
|
||||
_finalize_install_lock()
|
||||
try:
|
||||
_finalize_install_lock()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return {"success": True}
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class ServerUpdate(BaseModel):
|
||||
|
||||
|
||||
class ServerCheck(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1, max_length=50)
|
||||
|
||||
|
||||
class ServerImportResult(BaseModel):
|
||||
@@ -148,7 +148,7 @@ class FileUpload(BaseModel):
|
||||
# ── Sync ──
|
||||
|
||||
class SyncFiles(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
target_path: Optional[str] = None
|
||||
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
||||
@@ -299,7 +299,7 @@ class ReconcileStaleLogs(BaseModel):
|
||||
|
||||
class SyncVerify(BaseModel):
|
||||
"""Post-push verification: compare local source with remote target via md5sum."""
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
|
||||
source_path: str = Field(..., min_length=1)
|
||||
target_path: Optional[str] = None
|
||||
max_files: int = Field(200, ge=1, le=500)
|
||||
@@ -467,7 +467,7 @@ class ScriptUpdate(BaseModel):
|
||||
class ScriptExecute(BaseModel):
|
||||
script_id: Optional[int] = None
|
||||
command: str = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1)
|
||||
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
|
||||
credential_id: Optional[int] = None
|
||||
timeout: int = Field(60, ge=1, le=600)
|
||||
long_task: bool = False
|
||||
|
||||
+24
-4
@@ -624,9 +624,14 @@ async def batch_install_agent(
|
||||
if not server:
|
||||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||||
server_name = server.name
|
||||
api_key = server.agent_api_key or settings.API_KEY
|
||||
api_key = server.agent_api_key
|
||||
if not api_key:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error="请先生成 Agent API Key")
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid,
|
||||
server_name=server_name,
|
||||
success=False,
|
||||
error="未设置 Agent API Key,请先在服务器详情生成",
|
||||
)
|
||||
ssh_user = (server.username or "root").strip()
|
||||
install_cmd = (
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||||
@@ -1169,6 +1174,17 @@ async def retry_pending_server(
|
||||
pending.last_attempt_at = datetime.now(timezone.utc)
|
||||
await pending_repo.update(pending)
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="retry_pending_server_failed",
|
||||
target_type="pending_server",
|
||||
target_id=pending.id,
|
||||
detail=f"凭据轮询重试失败 地址={pending.domain}:{pending.port or 22} 第{pending.attempts}次",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
@@ -1187,6 +1203,9 @@ async def delete_pending_server(
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
repo = PendingServerRepositoryImpl(db)
|
||||
pending = await repo.get_by_id(pending_id)
|
||||
if not pending:
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
if not await repo.delete(pending_id):
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
|
||||
@@ -1197,6 +1216,7 @@ async def delete_pending_server(
|
||||
action="delete_pending_server",
|
||||
target_type="pending_server",
|
||||
target_id=pending_id,
|
||||
detail=f"删除待连接 地址={pending.domain}:{pending.port or 22}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
@@ -1617,9 +1637,9 @@ async def install_agent_remote(
|
||||
detail="NEXUS_API_BASE_URL 未配置,无法生成安装命令。请在系统设置中配置主站对外 URL。",
|
||||
)
|
||||
|
||||
api_key = server.agent_api_key or settings.API_KEY
|
||||
api_key = server.agent_api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="请先为该服务器生成 Agent API Key")
|
||||
raise HTTPException(status_code=400, detail="未设置 Agent API Key,请先为该服务器生成")
|
||||
|
||||
agent_port = server.agent_port or 8601
|
||||
ssh_user = (server.username or "root").strip()
|
||||
|
||||
+122
-16
@@ -19,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.auth_jwt import get_current_admin, require_current_admin
|
||||
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, PresetUpdate, SettingUpdatePayload, ApiKeyRevealRequest, SshKeyPresetCreate, SshKeyPresetUpdate
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
|
||||
@@ -253,7 +253,7 @@ async def bing_wallpapers():
|
||||
|
||||
|
||||
@router.post("/bing-wallpapers/sync", response_model=dict)
|
||||
async def bing_wallpapers_sync(admin: Admin = Depends(get_current_admin)):
|
||||
async def bing_wallpapers_sync(admin: Admin = Depends(require_current_admin)):
|
||||
"""Admin-only: fetch latest Bing images into local cache."""
|
||||
downloaded = await _sync_bing_wallpapers_from_network()
|
||||
result = _cached_wallpaper_urls()
|
||||
@@ -291,14 +291,11 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
||||
|
||||
@router.post("/api-key/reveal", response_model=dict)
|
||||
async def reveal_api_key(
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal global API_KEY after re-entering account password."""
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
"""Reveal global API_KEY for authenticated admin (JWT required)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("api_key")
|
||||
if value is None:
|
||||
@@ -380,6 +377,14 @@ async def set_setting(
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
if key in ("telegram_bot_token", "telegram_chat_id"):
|
||||
try:
|
||||
from server.application.services.ops_patrol_sync import sync_telegram_to_env
|
||||
|
||||
sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
|
||||
except Exception as exc:
|
||||
_logger.warning("Layer-3 Telegram env sync failed after %s update: %s", key, exc)
|
||||
|
||||
return {"key": setting.key, "value": setting.value}
|
||||
|
||||
|
||||
@@ -901,15 +906,96 @@ async def alert_stats(
|
||||
}
|
||||
|
||||
|
||||
# ── Layer-3 Host Patrol (health_monitor.sh) ──
|
||||
|
||||
@router.get("/ops-patrol/status", response_model=dict)
|
||||
async def ops_patrol_status(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Report Telegram readiness for Layer-3 cron patrol and env sync state."""
|
||||
from server.config import settings as _settings
|
||||
from server.application.services.ops_patrol_sync import read_telegram_env_status
|
||||
|
||||
app_configured = bool(_settings.TELEGRAM_BOT_TOKEN and _settings.TELEGRAM_CHAT_ID)
|
||||
env_status = read_telegram_env_status()
|
||||
return {
|
||||
"telegram_app_configured": app_configured,
|
||||
"telegram_layer3_ready": app_configured,
|
||||
"telegram_env_synced": env_status["env_synced"],
|
||||
"env_status": env_status,
|
||||
"db_fallback_enabled": True,
|
||||
"cron_install_command": "sudo nx cron",
|
||||
"health_log_path": "/var/log/nexus_health.log",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ops-patrol/sync-telegram", response_model=dict)
|
||||
async def ops_patrol_sync_telegram(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Sync DB Telegram credentials to .env files readable by health_monitor.sh."""
|
||||
from server.config import settings as _settings
|
||||
from server.application.services.ops_patrol_sync import (
|
||||
read_telegram_env_status,
|
||||
sync_telegram_to_env,
|
||||
)
|
||||
|
||||
if not _settings.TELEGRAM_BOT_TOKEN or not _settings.TELEGRAM_CHAT_ID:
|
||||
raise HTTPException(status_code=400, detail="请先在上方保存 Bot Token 与 Chat ID")
|
||||
|
||||
result = sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
|
||||
if result.get("skipped"):
|
||||
raise HTTPException(status_code=400, detail="Telegram 未配置,无法同步")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="sync_patrol_telegram",
|
||||
target_type="setting",
|
||||
detail=f"app_env={result.get('app_env')} host_env_prod={result.get('host_env_prod')}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
env_status = read_telegram_env_status()
|
||||
return {
|
||||
"synced": result,
|
||||
"env_status": env_status,
|
||||
"telegram_layer3_ready": True,
|
||||
}
|
||||
|
||||
|
||||
# ── Telegram Test + Chat ID Detection ──
|
||||
|
||||
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
|
||||
"""Remove active webhook so getUpdates can receive messages."""
|
||||
wh_resp = await client.get(f"{base_url}/getWebhookInfo")
|
||||
if wh_resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Telegram getWebhookInfo 返回 {wh_resp.status_code}")
|
||||
wh_data = wh_resp.json()
|
||||
if not wh_data.get("ok"):
|
||||
desc = wh_data.get("description") or "getWebhookInfo 失败"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
if wh_data.get("result", {}).get("url"):
|
||||
del_resp = await client.post(
|
||||
f"{base_url}/deleteWebhook",
|
||||
params={"drop_pending_updates": "false"},
|
||||
)
|
||||
del_data = del_resp.json()
|
||||
if not del_data.get("ok"):
|
||||
desc = del_data.get("description") or "deleteWebhook 失败"
|
||||
raise HTTPException(status_code=502, detail=f"无法清除 Webhook: {desc}")
|
||||
|
||||
|
||||
@router.post("/telegram/test", response_model=dict)
|
||||
async def telegram_test_send(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Send a test Telegram message to verify Bot Token + Chat ID configuration."""
|
||||
from server.infrastructure.telegram import send_telegram
|
||||
import httpx
|
||||
from server.config import settings as _settings
|
||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||
|
||||
if not _settings.TELEGRAM_BOT_TOKEN:
|
||||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||||
@@ -918,26 +1004,41 @@ async def telegram_test_send(
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
ok = await send_telegram(
|
||||
message = (
|
||||
f"✅ <b>Nexus 测试消息</b>\n"
|
||||
f"Bot 配置正确,告警通知已就绪\n"
|
||||
f"时间: {now}"
|
||||
)
|
||||
if ok:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{_settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": _settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||
|
||||
if resp.status_code == 200 and data.get("ok"):
|
||||
return {"success": True}
|
||||
raise HTTPException(status_code=502, detail="Telegram API 返回错误,请检查 Bot Token 和 Chat ID")
|
||||
desc = data.get("description") or f"HTTP {resp.status_code}"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
|
||||
|
||||
@router.post("/telegram/reveal-token", response_model=dict)
|
||||
async def reveal_telegram_token(
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal Telegram Bot Token after re-entering account password."""
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
"""Reveal Telegram Bot Token for authenticated admin (JWT required)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("telegram_bot_token")
|
||||
if value is None:
|
||||
@@ -973,13 +1074,18 @@ async def telegram_get_chats(
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||
await _telegram_clear_webhook_if_set(client, base_url)
|
||||
resp = await client.get(
|
||||
f"{TELEGRAM_API_BASE}/bot{token}/getUpdates",
|
||||
params={"limit": 50, "allowed_updates": ["message", "channel_post"]},
|
||||
f"{base_url}/getUpdates",
|
||||
params={"limit": 50},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
desc = data.get("description") or "getUpdates 失败"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Sync Telegram credentials for Layer-3 host patrol (health_monitor.sh)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from server.infrastructure.env_file import read_env_var, upsert_env_vars
|
||||
|
||||
logger = logging.getLogger("nexus.ops_patrol")
|
||||
|
||||
NEXUS_ROOT = Path(__file__).resolve().parents[3]
|
||||
ENV_FILE = NEXUS_ROOT / ".env"
|
||||
|
||||
_TELEGRAM_ENV_KEYS = (
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN",
|
||||
"NEXUS_TELEGRAM_CHAT_ID",
|
||||
)
|
||||
|
||||
|
||||
def _persist_env_path() -> Path:
|
||||
raw = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
|
||||
return Path(raw) / ".env"
|
||||
|
||||
|
||||
def _host_env_prod_path() -> Path | None:
|
||||
host_root = os.environ.get("NEXUS_HOST_ROOT", "").strip()
|
||||
if not host_root:
|
||||
return None
|
||||
return Path(host_root) / "docker" / ".env.prod"
|
||||
|
||||
|
||||
def _build_updates(token: str | None, chat_id: str | None) -> dict[str, str]:
|
||||
updates: dict[str, str] = {}
|
||||
if token and token.strip():
|
||||
updates["NEXUS_TELEGRAM_BOT_TOKEN"] = token.strip()
|
||||
if chat_id and str(chat_id).strip():
|
||||
updates["NEXUS_TELEGRAM_CHAT_ID"] = str(chat_id).strip()
|
||||
return updates
|
||||
|
||||
|
||||
def _sync_to_persist_volume() -> bool:
|
||||
if not ENV_FILE.is_file():
|
||||
return False
|
||||
dest = _persist_env_path()
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ENV_FILE, dest)
|
||||
dest.chmod(0o600)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to sync .env to persist volume %s: %s", dest, exc)
|
||||
return False
|
||||
|
||||
|
||||
def read_telegram_env_status() -> dict[str, bool]:
|
||||
"""Report whether Layer-3 readable env files contain Telegram credentials."""
|
||||
app_token = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_BOT_TOKEN"))
|
||||
app_chat = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_CHAT_ID"))
|
||||
|
||||
host_token = False
|
||||
host_chat = False
|
||||
host_path = _host_env_prod_path()
|
||||
if host_path is not None:
|
||||
host_token = bool(read_env_var(host_path, "NEXUS_TELEGRAM_BOT_TOKEN"))
|
||||
host_chat = bool(read_env_var(host_path, "NEXUS_TELEGRAM_CHAT_ID"))
|
||||
|
||||
env_synced = (app_token and app_chat) or (host_token and host_chat)
|
||||
return {
|
||||
"app_env_token": app_token,
|
||||
"app_env_chat_id": app_chat,
|
||||
"host_env_prod_token": host_token,
|
||||
"host_env_prod_chat_id": host_chat,
|
||||
"env_synced": env_synced,
|
||||
}
|
||||
|
||||
|
||||
def sync_telegram_to_env(token: str | None, chat_id: str | None) -> dict[str, bool | str]:
|
||||
"""Write Telegram credentials to /app/.env, persist volume, and host .env.prod."""
|
||||
updates = _build_updates(token, chat_id)
|
||||
if not updates:
|
||||
return {
|
||||
"app_env": False,
|
||||
"persist_env": False,
|
||||
"host_env_prod": False,
|
||||
"skipped": "telegram_not_configured",
|
||||
}
|
||||
|
||||
result: dict[str, bool | str] = {
|
||||
"app_env": False,
|
||||
"persist_env": False,
|
||||
"host_env_prod": False,
|
||||
}
|
||||
|
||||
try:
|
||||
if upsert_env_vars(ENV_FILE, updates):
|
||||
ENV_FILE.chmod(0o600)
|
||||
result["app_env"] = True
|
||||
result["persist_env"] = _sync_to_persist_volume()
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to update %s: %s", ENV_FILE, exc)
|
||||
|
||||
host_path = _host_env_prod_path()
|
||||
if host_path is not None:
|
||||
try:
|
||||
if host_path.is_file() and upsert_env_vars(host_path, updates):
|
||||
host_path.chmod(0o600)
|
||||
result["host_env_prod"] = True
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to update host .env.prod %s: %s", host_path, exc)
|
||||
|
||||
return result
|
||||
@@ -43,11 +43,11 @@ class ScriptService:
|
||||
|
||||
async def _agent_api_key_for_server(self, server_id: int) -> str:
|
||||
server = await self.server_repo.get_by_id(server_id)
|
||||
if server and server.agent_api_key:
|
||||
return server.agent_api_key
|
||||
if not settings.API_KEY:
|
||||
raise ValueError("API_KEY 未配置,无法注册长任务回调")
|
||||
return settings.API_KEY
|
||||
if not server or not server.agent_api_key:
|
||||
raise ValueError(
|
||||
f"服务器 {server_id} 未设置 Agent API Key,无法注册长任务回调,请先生成"
|
||||
)
|
||||
return server.agent_api_key
|
||||
|
||||
# ── Script CRUD ──
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ class Settings(BaseSettings):
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
TELEGRAM_CHAT_ID: str = ""
|
||||
|
||||
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
|
||||
EXPOSE_API_DOCS: str = "false"
|
||||
|
||||
# Login IP allowlist master switch — "true" = enforce allowlist, "false" = allow all
|
||||
LOGIN_ALLOWLIST_ENABLED: str = "false"
|
||||
# Subscription URL for auto-refreshing subscription IPs (every 2h)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Shared .env read/write helpers (UTF-8 LF, quoted values)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from server.utils.text_io import read_utf8_text, write_utf8_lf
|
||||
|
||||
_ENV_KEY_RE = re.compile(r"^([A-Z0-9_]+)=(.*)$")
|
||||
|
||||
|
||||
def escape_env_value(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def read_env_var(path: Path, key: str) -> str | None:
|
||||
"""Return unquoted value for key, or None if missing/empty."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
for line in read_utf8_text(path).splitlines():
|
||||
m = _ENV_KEY_RE.match(line.strip())
|
||||
if not m or m.group(1) != key:
|
||||
continue
|
||||
raw = m.group(2).strip()
|
||||
if not raw:
|
||||
return None
|
||||
if len(raw) >= 2 and raw[0] == raw[-1] == '"':
|
||||
inner = raw[1:-1]
|
||||
return inner.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def upsert_env_vars(path: Path, updates: dict[str, str]) -> bool:
|
||||
"""Upsert keys in a .env file. Creates file if missing. Returns True if written."""
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
lines: list[str] = []
|
||||
if path.is_file():
|
||||
lines = read_utf8_text(path).splitlines()
|
||||
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
m = _ENV_KEY_RE.match(line.strip())
|
||||
if m and m.group(1) in updates:
|
||||
key = m.group(1)
|
||||
out.append(f"{key}={escape_env_value(updates[key])}")
|
||||
seen.add(key)
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in seen:
|
||||
out.append(f"{key}={escape_env_value(value)}")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = "\n".join(out)
|
||||
if body and not body.endswith("\n"):
|
||||
body += "\n"
|
||||
write_utf8_lf(path, body)
|
||||
return True
|
||||
@@ -342,11 +342,23 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"{settings.SYSTEM_NAME} shutting down — background tasks cancelled")
|
||||
|
||||
|
||||
def _expose_api_docs() -> bool:
|
||||
flag = (getattr(settings, "EXPOSE_API_DOCS", "false") or "false").lower()
|
||||
return flag in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
_docs_url = "/docs" if _expose_api_docs() else None
|
||||
_redoc_url = "/redoc" if _expose_api_docs() else None
|
||||
_openapi_url = "/openapi.json" if _expose_api_docs() else None
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.SYSTEM_NAME if not is_install_mode() else "Nexus Installer",
|
||||
description=f"{settings.SYSTEM_TITLE}" if not is_install_mode() else "Nexus Installation Wizard",
|
||||
version="6.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=_docs_url,
|
||||
redoc_url=_redoc_url,
|
||||
openapi_url=_openapi_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ Nexus 负载压测脚本
|
||||
用法:
|
||||
NEXUS_API_KEY=your-key python tests/load_test.py [--concurrency 50] [--duration 30]
|
||||
NEXUS_TEST_BASE=http://127.0.0.1:8600 (可选)
|
||||
|
||||
注意 (BL-06): /api/agent/heartbeat 仅接受 per-server agent_api_key,不再接受 global API_KEY。
|
||||
心跳压测需设置该服务器的 per-server key(与 SERVER_ID 对应),或跳过 heartbeat worker。
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
用法:
|
||||
NEXUS_API_KEY=your-key python tests/quick_load.py 50 30
|
||||
NEXUS_TEST_BASE=http://127.0.0.1:8600 (可选)
|
||||
|
||||
注意 (BL-06): 本脚本不测 heartbeat;若扩展心跳压测,须用 per-server agent_api_key,global API_KEY 已无效。
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""BL-06: Agent auth must use per-server agent_api_key only (no global API_KEY fallback)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from server.api.agent import _verify_server_api_key
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.config import settings
|
||||
|
||||
|
||||
def _fake_server(*, agent_api_key=None, name="test-srv"):
|
||||
srv = MagicMock()
|
||||
srv.name = name
|
||||
srv.agent_api_key = agent_api_key
|
||||
return srv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_matches_per_server(mock_service):
|
||||
mock_service.get_server.return_value = _fake_server(agent_api_key="nxs-abc")
|
||||
result = await _verify_server_api_key(1, "nxs-abc", mock_service)
|
||||
assert result is not None
|
||||
assert result.agent_api_key == "nxs-abc"
|
||||
mock_service.get_server.assert_called_once_with(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_single_get_server_per_auth_check(mock_service):
|
||||
"""Regression: auth path must not double-query the same server_id (BL-06 patrol)."""
|
||||
mock_service.get_server.return_value = _fake_server(agent_api_key="nxs-once")
|
||||
await _verify_server_api_key(7, "nxs-once", mock_service)
|
||||
mock_service.get_server.assert_called_once_with(7)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_rejects_wrong_key(mock_service):
|
||||
mock_service.get_server.return_value = _fake_server(agent_api_key="nxs-abc")
|
||||
assert await _verify_server_api_key(1, "nxs-wrong", mock_service) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_rejects_global_api_key(mock_service, monkeypatch):
|
||||
monkeypatch.setattr(settings, "API_KEY", "global-secret-key")
|
||||
mock_service.get_server.return_value = _fake_server(agent_api_key="nxs-abc")
|
||||
assert await _verify_server_api_key(1, "global-secret-key", mock_service) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_no_per_server_key_rejects_global(mock_service, monkeypatch):
|
||||
monkeypatch.setattr(settings, "API_KEY", "global-secret-key")
|
||||
mock_service.get_server.return_value = _fake_server(agent_api_key=None)
|
||||
assert await _verify_server_api_key(1, "global-secret-key", mock_service) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_server_not_found(mock_service):
|
||||
mock_service.get_server.return_value = None
|
||||
assert await _verify_server_api_key(99, "any-key", mock_service) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_server_key_db_error_fail_closed(mock_service):
|
||||
mock_service.get_server.side_effect = RuntimeError("db down")
|
||||
assert await _verify_server_api_key(1, "any-key", mock_service) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_service_agent_key_requires_per_server():
|
||||
repo = AsyncMock()
|
||||
repo.get_by_id.return_value = _fake_server(agent_api_key=None)
|
||||
svc = ScriptService(
|
||||
script_repo=AsyncMock(),
|
||||
execution_repo=AsyncMock(),
|
||||
credential_repo=AsyncMock(),
|
||||
server_repo=repo,
|
||||
audit_repo=AsyncMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="未设置 Agent API Key"):
|
||||
await svc._agent_api_key_for_server(42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_service_agent_key_returns_per_server():
|
||||
repo = AsyncMock()
|
||||
repo.get_by_id.return_value = _fake_server(agent_api_key="nxs-callback")
|
||||
svc = ScriptService(
|
||||
script_repo=AsyncMock(),
|
||||
execution_repo=AsyncMock(),
|
||||
credential_repo=AsyncMock(),
|
||||
server_repo=repo,
|
||||
audit_repo=AsyncMock(),
|
||||
)
|
||||
assert await svc._agent_api_key_for_server(1) == "nxs-callback"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Regression tests for external attack-surface hardening (BL-01–BL-04)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPublicPathMatching:
|
||||
def test_health_detail_not_public(self):
|
||||
from server.api.auth_jwt import _is_public_path
|
||||
|
||||
assert _is_public_path("/health") is True
|
||||
assert _is_public_path("/health/detail") is False
|
||||
|
||||
def test_bing_wallpapers_get_public_post_sync_protected(self):
|
||||
from server.api.auth_jwt import _is_public_path
|
||||
|
||||
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
|
||||
assert _is_public_path("/api/settings/bing-wallpaper", "GET") is True
|
||||
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
|
||||
|
||||
def test_openapi_docs_off_by_default(self):
|
||||
from server.api.auth_jwt import _is_public_path
|
||||
|
||||
assert _is_public_path("/openapi.json") is False
|
||||
assert _is_public_path("/docs") is False
|
||||
assert _is_public_path("/redoc") is False
|
||||
|
||||
def test_agent_install_ws_prefixes_still_public(self):
|
||||
from server.api.auth_jwt import _is_public_path
|
||||
|
||||
assert _is_public_path("/api/agent/heartbeat") is True
|
||||
assert _is_public_path("/api/install/status") is True
|
||||
assert _is_public_path("/ws/alerts") is True
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""BL-05: locked install API returns 404 (no fingerprint) on all endpoints."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def locked():
|
||||
with patch("server.api.install._is_locked", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unlocked():
|
||||
with patch("server.api.install._is_locked", return_value=False):
|
||||
yield
|
||||
|
||||
|
||||
def _assert_not_found(exc: HTTPException) -> None:
|
||||
assert exc.status_code == 404
|
||||
assert exc.detail == "Not found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_locked_404(locked):
|
||||
from server.api.install import install_status
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await install_status()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_unlocked_200(unlocked):
|
||||
from server.api.install import install_status
|
||||
|
||||
with patch("server.api.install._has_env", return_value=False):
|
||||
result = await install_status()
|
||||
assert result["locked"] is False
|
||||
assert "configured" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_check_locked_404(locked):
|
||||
from server.api.install import env_check
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await env_check()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_check_locked_404(locked):
|
||||
from server.api.install import connection_check
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await connection_check()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_locked_404(locked):
|
||||
from server.api.install import get_install_state
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_install_state()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_endpoint_locked_404(locked):
|
||||
from server.api.install import lock_install
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await lock_install()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_db_locked_404(locked):
|
||||
from server.api.install import init_db, InitDbRequest
|
||||
|
||||
req = InitDbRequest(
|
||||
db_host="localhost",
|
||||
db_port="3306",
|
||||
db_name="nexus",
|
||||
db_user="root",
|
||||
db_pass="x",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await init_db(req)
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
def test_reject_if_locked_raises_404():
|
||||
from server.api.install import _reject_if_locked
|
||||
|
||||
with patch("server.api.install._is_locked", return_value=True):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_reject_if_locked()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_credentials_locked_404(locked):
|
||||
from server.api.install import test_credentials, InitDbRequest
|
||||
|
||||
req = InitDbRequest(
|
||||
db_host="localhost",
|
||||
db_port="3306",
|
||||
db_name="nexus",
|
||||
db_user="root",
|
||||
db_pass="x",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await test_credentials(req)
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_locked_404(locked):
|
||||
from server.api.install import create_admin, CreateAdminRequest
|
||||
|
||||
req = CreateAdminRequest(
|
||||
install_token="tok",
|
||||
db_pass="x",
|
||||
admin_password="secret12",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await create_admin(req)
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
def test_install_router_has_lock_dependency():
|
||||
"""Router-level lock guard runs before per-route handlers (BL-05)."""
|
||||
from server.api.install import router
|
||||
|
||||
assert router.dependencies, "install router must enforce lock before body validation"
|
||||
@@ -63,7 +63,9 @@ class TestJWTAuth:
|
||||
assert _is_public_path("/api/agent/heartbeat") is True
|
||||
assert _is_public_path("/health") is True
|
||||
assert _is_public_path("/ws/alerts") is True
|
||||
assert _is_public_path("/docs") is True
|
||||
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
|
||||
assert _is_public_path("/docs") is False
|
||||
assert _is_public_path("/health/detail") is False
|
||||
|
||||
def test_protected_path_detection(self):
|
||||
from server.api.auth_jwt import _is_public_path
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for Layer-3 ops patrol Telegram env sync."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from server.infrastructure.env_file import escape_env_value, read_env_var, upsert_env_vars
|
||||
from server.application.services import ops_patrol_sync
|
||||
|
||||
|
||||
def test_escape_env_value_quotes_special_chars():
|
||||
assert escape_env_value('abc"def') == '"abc\\"def"'
|
||||
assert escape_env_value("line\nbreak") == '"line\\nbreak"'
|
||||
|
||||
|
||||
def test_upsert_env_vars_creates_and_updates(tmp_path: Path):
|
||||
env = tmp_path / ".env"
|
||||
upsert_env_vars(env, {"NEXUS_TELEGRAM_BOT_TOKEN": "123:ABC"})
|
||||
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "123:ABC"
|
||||
|
||||
upsert_env_vars(env, {
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN": "456:XYZ",
|
||||
"NEXUS_TELEGRAM_CHAT_ID": "-1001",
|
||||
})
|
||||
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "456:XYZ"
|
||||
assert read_env_var(env, "NEXUS_TELEGRAM_CHAT_ID") == "-1001"
|
||||
|
||||
|
||||
def test_sync_telegram_to_env_writes_app_env(tmp_path: Path, monkeypatch):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("# test\nNEXUS_PORT=8600\n", encoding="utf-8")
|
||||
persist = tmp_path / "persist"
|
||||
persist.mkdir()
|
||||
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
|
||||
monkeypatch.setenv("NEXUS_PERSIST_DIR", str(persist))
|
||||
|
||||
result = ops_patrol_sync.sync_telegram_to_env("111:TOKEN", "-999")
|
||||
assert result["app_env"] is True
|
||||
assert result["persist_env"] is True
|
||||
assert read_env_var(env_file, "NEXUS_TELEGRAM_BOT_TOKEN") == "111:TOKEN"
|
||||
assert read_env_var(persist / ".env", "NEXUS_TELEGRAM_CHAT_ID") == "-999"
|
||||
|
||||
|
||||
def test_sync_telegram_skips_when_empty():
|
||||
result = ops_patrol_sync.sync_telegram_to_env("", "")
|
||||
assert result.get("skipped") == "telegram_not_configured"
|
||||
|
||||
|
||||
def test_read_telegram_env_status(tmp_path: Path, monkeypatch):
|
||||
env_file = tmp_path / ".env"
|
||||
upsert_env_vars(env_file, {
|
||||
"NEXUS_TELEGRAM_BOT_TOKEN": "t",
|
||||
"NEXUS_TELEGRAM_CHAT_ID": "c",
|
||||
})
|
||||
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
|
||||
status = ops_patrol_sync.read_telegram_env_status()
|
||||
assert status["env_synced"] is True
|
||||
assert status["app_env_token"] is True
|
||||
assert status["app_env_chat_id"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_patrol_status_endpoint():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from server.api.settings import ops_patrol_status
|
||||
from server.config import settings
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123:ABC"
|
||||
settings.TELEGRAM_CHAT_ID = "-100"
|
||||
admin = MagicMock()
|
||||
|
||||
res = await ops_patrol_status(admin=admin)
|
||||
assert res["telegram_app_configured"] is True
|
||||
assert res["telegram_layer3_ready"] is True
|
||||
assert res["db_fallback_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_patrol_sync_400_when_telegram_missing():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from server.api.settings import ops_patrol_sync_telegram
|
||||
from server.config import settings
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = ""
|
||||
settings.TELEGRAM_CHAT_ID = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await ops_patrol_sync_telegram(
|
||||
request=MagicMock(client=MagicMock(host="127.0.0.1")),
|
||||
admin=MagicMock(username="admin"),
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
@@ -21,6 +21,27 @@ def test_dangerous_command_safe_empty():
|
||||
assert check_dangerous_command("echo hello") == []
|
||||
|
||||
|
||||
def test_server_check_server_ids_max_fifty():
|
||||
from pydantic import ValidationError
|
||||
|
||||
from server.api.schemas import ServerCheck
|
||||
|
||||
ServerCheck(server_ids=list(range(1, 51)))
|
||||
with pytest.raises(ValidationError):
|
||||
ServerCheck(server_ids=list(range(1, 52)))
|
||||
|
||||
|
||||
def test_sync_files_server_ids_max_two_thousand():
|
||||
from pydantic import ValidationError
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncVerify
|
||||
|
||||
SyncFiles(server_ids=list(range(1, 2001)), source_path="/tmp/nexus_upload_x/a")
|
||||
SyncVerify(server_ids=[1], source_path="/tmp/nexus_upload_x/a")
|
||||
with pytest.raises(ValidationError):
|
||||
SyncFiles(server_ids=list(range(1, 2002)), source_path="/tmp/nexus_upload_x/a")
|
||||
|
||||
|
||||
def test_parse_server_ids_invalid_json():
|
||||
assert _parse_server_ids_json("not-json") == []
|
||||
|
||||
@@ -126,6 +147,8 @@ def test_public_path_install_and_logout():
|
||||
|
||||
assert _is_public_path("/api/install/status") is True
|
||||
assert _is_public_path("/api/auth/logout") is True
|
||||
assert _is_public_path("/health/detail") is False
|
||||
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
|
||||
|
||||
|
||||
def _cdn_offenders_in_text(path: Path, text: str) -> bool:
|
||||
@@ -450,7 +473,8 @@ def test_install_reject_when_locked(monkeypatch, tmp_path):
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
install_api._reject_if_locked()
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail == "Not found"
|
||||
|
||||
|
||||
def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Tests for Telegram settings endpoints and notify toggle hot-reload."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from server.config import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_400_when_token_missing():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = ""
|
||||
settings.TELEGRAM_CHAT_ID = ""
|
||||
admin = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
assert exc.value.status_code == 400
|
||||
assert "TELEGRAM_BOT_TOKEN" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_400_when_chat_id_missing():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
settings.TELEGRAM_CHAT_ID = ""
|
||||
admin = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
assert exc.value.status_code == 400
|
||||
assert "TELEGRAM_CHAT_ID" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_surfaces_api_error():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
settings.TELEGRAM_CHAT_ID = "999"
|
||||
admin = MagicMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 400
|
||||
mock_resp.json.return_value = {"ok": False, "description": "Bad Request: chat not found"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert "chat not found" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_chats_surfaces_api_error_when_ok_false():
|
||||
from server.api.settings import telegram_get_chats
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
admin = MagicMock()
|
||||
|
||||
wh_resp = MagicMock()
|
||||
wh_resp.status_code = 200
|
||||
wh_resp.json.return_value = {"ok": True, "result": {"url": ""}}
|
||||
|
||||
updates_resp = MagicMock()
|
||||
updates_resp.status_code = 200
|
||||
updates_resp.json.return_value = {"ok": False, "description": "Unauthorized"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
|
||||
mock_client.post = AsyncMock()
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_get_chats(admin=admin)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert "Unauthorized" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_chats_deletes_webhook_before_get_updates():
|
||||
from server.api.settings import telegram_get_chats
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
admin = MagicMock()
|
||||
|
||||
wh_resp = MagicMock()
|
||||
wh_resp.status_code = 200
|
||||
wh_resp.json.return_value = {"ok": True, "result": {"url": "https://example.com/hook"}}
|
||||
|
||||
del_resp = MagicMock()
|
||||
del_resp.json.return_value = {"ok": True}
|
||||
|
||||
updates_resp = MagicMock()
|
||||
updates_resp.status_code = 200
|
||||
updates_resp.json.return_value = {
|
||||
"ok": True,
|
||||
"result": [
|
||||
{
|
||||
"message": {
|
||||
"chat": {"id": 42, "type": "private", "first_name": "Test"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
|
||||
mock_client.post = AsyncMock(return_value=del_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = await telegram_get_chats(admin=admin)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["chats"][0]["id"] == 42
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert "deleteWebhook" in call_args[0][0]
|
||||
assert call_args[1]["params"]["drop_pending_updates"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_toggle_put_hot_reload():
|
||||
from server.api.schemas import SettingUpdatePayload
|
||||
from server.api.settings import set_setting
|
||||
from server.domain.models import Setting
|
||||
|
||||
settings.NOTIFY_ALERT_CPU = "true"
|
||||
admin = MagicMock(username="admin")
|
||||
request = MagicMock()
|
||||
request.client.host = "127.0.0.1"
|
||||
db = MagicMock()
|
||||
|
||||
mock_setting = Setting(key="notify_alert_cpu", value="false")
|
||||
|
||||
with patch("server.api.settings.SettingRepositoryImpl") as repo_cls, patch(
|
||||
"server.api.settings.AuditLogRepositoryImpl"
|
||||
) as audit_cls:
|
||||
repo = repo_cls.return_value
|
||||
repo.set = AsyncMock(return_value=mock_setting)
|
||||
audit_cls.return_value.create = AsyncMock()
|
||||
|
||||
await set_setting(
|
||||
"notify_alert_cpu",
|
||||
SettingUpdatePayload(value="false"),
|
||||
request,
|
||||
admin,
|
||||
db,
|
||||
)
|
||||
|
||||
assert settings.NOTIFY_ALERT_CPU == "false"
|
||||
@@ -126,6 +126,7 @@ class TestJWTAuth:
|
||||
assert _is_public_path("/api/agent/heartbeat") is True
|
||||
assert _is_public_path("/health") is True
|
||||
assert _is_public_path("/ws/alerts") is True
|
||||
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
|
||||
|
||||
def test_protected_path_detection(self):
|
||||
"""Protected paths are correctly identified"""
|
||||
@@ -137,6 +138,9 @@ class TestJWTAuth:
|
||||
assert _is_public_path("/api/assets/platforms") is False
|
||||
assert _is_public_path("/api/auth/me") is False
|
||||
assert _is_public_path("/api/auth/totp/setup") is False
|
||||
assert _is_public_path("/health/detail") is False
|
||||
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
|
||||
assert _is_public_path("/openapi.json") is False
|
||||
|
||||
|
||||
class TestAsyncSSHPool:
|
||||
|
||||
@@ -408,6 +408,14 @@ async def send_heartbeat():
|
||||
return # Exit heartbeat loop — agent needs reconfiguration
|
||||
_consecutive_failures = 0
|
||||
logger.debug("Heartbeat sent")
|
||||
elif resp.status_code == 401:
|
||||
logger.error(
|
||||
"Heartbeat rejected by central (401): invalid API key or "
|
||||
"server_id=%s not configured with per-server agent_api_key. "
|
||||
"Stopping heartbeat — reconfigure agent key via admin UI.",
|
||||
SERVER_ID,
|
||||
)
|
||||
return
|
||||
else:
|
||||
_consecutive_failures += 1
|
||||
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
||||
|
||||
+53
-14
@@ -402,7 +402,7 @@
|
||||
<div class="font-semibold text-sm mb-2" :class="guardianAllOk ? 'text-green-700' : 'text-amber-700'"
|
||||
x-text="guardianAllOk ? (isDockerInstall ? '✓ 1Panel Docker 进程守护已就绪' : '✓ 进程守护已自动配置') : (isDockerInstall ? '⚠ Docker 守护检查有项需处理' : '⚠ 进程守护部分配置失败')"></div>
|
||||
<template x-for="r in (initResult?.guardian_results || [])" :key="r">
|
||||
<div class="text-xs leading-relaxed" x-text="r"></div>
|
||||
<div class="text-xs leading-relaxed py-0.5" :class="guardianLineClass(r)" x-text="r"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -418,13 +418,24 @@
|
||||
<div class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p>Compose 已配置 <code class="bg-slate-100 px-1 rounded">restart: unless-stopped</code> + 容器 <code class="bg-slate-100 px-1 rounded">healthcheck</code>(每 30s 探测 <code class="bg-slate-100 px-1 rounded">/health</code>)。</p>
|
||||
<p>查看状态: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">docker ps</code> · 日志: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">docker logs -f nexus-prod-nexus-1</code></p>
|
||||
<p>异常重启: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">cd /opt/nexus && nx update</code></p>
|
||||
<p>异常重启: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="hostDeployRoot"></span> && nx update</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
|
||||
应用内自检(Layer 2)
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
<p>完成本向导后,Nexus 正常模式自动运行 <code class="bg-slate-100 px-1 rounded">self_monitor</code>(每 30s 检查 Redis/MySQL/WebSocket)。</p>
|
||||
<p>验收: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">curl -s http://127.0.0.1:<span x-text="form.api_port"></span>/health</code> → <code class="bg-slate-100 px-1 rounded">ok</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
|
||||
OpenResty 反向代理(Layer 3 · 宿主机)
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
@@ -462,24 +473,25 @@ location /health {
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
|
||||
应用内自检(Layer 2)
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
|
||||
宿主机巡检与备份(Layer 3 · cron)
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
<p>完成本向导后,Nexus 正常模式自动运行 <code class="bg-slate-100 px-1 rounded">self_monitor</code>(每 30s 检查 Redis/MySQL/WebSocket)。</p>
|
||||
<p>验收: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">curl -s http://127.0.0.1:<span x-text="form.api_port"></span>/health</code> → <code class="bg-slate-100 px-1 rounded">ok</code></p>
|
||||
<div class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p>在<b>宿主机</b>(非容器内)安装 Layer 3 外部守护:每分钟 <code class="bg-slate-100 px-1 rounded">health_monitor</code>,每日 03:00 MySQL 备份。</p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo nx cron</code></p>
|
||||
<p>或: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo bash <span x-text="hostDeployRoot"></span>/deploy/install_ops_cron.sh</code></p>
|
||||
<p>验收: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">bash <span x-text="hostDeployRoot"></span>/deploy/verify-1panel-install-wizard.sh</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div class="font-bold text-sm text-slate-800 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
|
||||
日常运维
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">nx update</code> — 拉代码、重建镜像、同步安装向导</p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">bash deploy/verify-1panel-install-wizard.sh</code> — 一键验收</p>
|
||||
<p>可选宿主机 crontab: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">* * * * * /opt/nexus/deploy/health_monitor.sh</code></p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="hostDeployRoot"></span> && nx update</code> — 拉代码、备份 MySQL、重建镜像</p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">nx</code> 菜单 — 状态、日志、手动备份、重启</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -671,9 +683,10 @@ location / {
|
||||
</div>
|
||||
<div x-show="!guardianAllOk" class="text-xs text-[var(--text-muted)]">
|
||||
<p>配置 crontab:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
|
||||
<p>一键安装(health_monitor + db_backup):</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">sudo bash <span x-text="installDir"></span>/deploy/install_ops_cron.sh</pre></div>
|
||||
</div>
|
||||
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
|
||||
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — Crontab 已写入 health_monitor 与 db_backup</div>
|
||||
<div class="text-xs text-[var(--text-muted)] mt-2">
|
||||
<b>3层守护机制:</b><br>
|
||||
Layer 1: Supervisor — Python崩溃自动重启<br>
|
||||
@@ -770,6 +783,27 @@ function installWizard() {
|
||||
return !!(this.dockerMode || this.dockerDefaults);
|
||||
},
|
||||
|
||||
get hostDeployRoot() {
|
||||
if (this.initResult?.host_deploy_root) return this.initResult.host_deploy_root;
|
||||
const results = this.initResult?.guardian_results || [];
|
||||
for (const r of results) {
|
||||
const m = r.match(/cd ([^\s&]+) && nx update/);
|
||||
if (m) return m[1];
|
||||
const m2 = r.match(/sudo bash ([^\s]+)\/deploy\/install_ops_cron/);
|
||||
if (m2) return m2[1];
|
||||
}
|
||||
return '/opt/nexus';
|
||||
},
|
||||
|
||||
guardianLineClass(line) {
|
||||
if (!line) return 'text-slate-600';
|
||||
if (line.includes('✗')) return 'text-red-700 font-medium';
|
||||
if (line.includes('⚠')) return 'text-amber-700';
|
||||
if (line.startsWith('ℹ')) return 'text-slate-600';
|
||||
if (line.includes('✓')) return 'text-green-700';
|
||||
return 'text-slate-600';
|
||||
},
|
||||
|
||||
get guardianAllOk() {
|
||||
const results = this.initResult?.guardian_results || [];
|
||||
if (!results.length) return false;
|
||||
@@ -854,7 +888,12 @@ function installWizard() {
|
||||
redis_db: d.redis_db || this.form.redis_db,
|
||||
});
|
||||
if (d.install_dir) this.installDir = d.install_dir;
|
||||
if (d.pool_size) this.initResult = { pool_size: d.pool_size, max_overflow: d.max_overflow, guardian_results: d.guardian_results || [] };
|
||||
if (d.pool_size) this.initResult = {
|
||||
pool_size: d.pool_size,
|
||||
max_overflow: d.max_overflow,
|
||||
guardian_results: d.guardian_results || [],
|
||||
host_deploy_root: d.host_deploy_root || null,
|
||||
};
|
||||
if (d.docker_mode) this.dockerMode = true;
|
||||
if (d.step >= 4 && d.has_token && !this.installToken) {
|
||||
this.tokenSessionMissing = true;
|
||||
|
||||
Reference in New Issue
Block a user