fix: Code Review P0/P1 batches 1-3 and single-operator risk acceptance

Apply sync/install/auth/schedule/retry/agent/settings fixes from full
code review; document accepted WS and Agent legacy risks for solo ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Deploy
2026-06-04 15:57:49 +08:00
parent a2ae74d582
commit b866e944ab
36 changed files with 1111 additions and 242 deletions
@@ -0,0 +1,48 @@
# 审计 — 2026-06-04 Code Review P0/P1 修复
## Step 3 规则扫描(H
| H | 规则 | 文件 | 结论 |
|---|------|------|------|
| H1 | PY-08 静默吞错 | sync_engine_v2, auth_service | SAFE — 保留 logger,无空 pass |
| H2 | SEC 安装无 JWT | install.py | FIXED — install_token + compare_digest |
| H3 | SEC 路径遍历 | posix_paths.py | FIXED — 统一 resolve_nexus_push_source_path |
| H4 | CONC AsyncSession | sync_engine_v2.py | FIXED — _db_lock 串行 DB |
| H5 | CONC refresh 双花 | auth_service.py | FIXED — Redis Lua SREM+SADD |
| H6 | FE 开放重定向 | LoginPage.vue | FIXED |
| H7 | FE WS 泄漏 | usePushProgress.ts | FIXED |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | except 均 logging |
| H2 | FIXED | install.py:572+ `_verify_install_token` |
| H3 | FIXED | posix_paths.py:103-114 |
| H4 | FIXED | sync_engine_v2.py `_db_lock` |
| H5 | FIXED | auth_service `_rotate_refresh_token` Lua |
| H6 | FIXED | LoginPage redirect 校验 |
| H7 | FIXED | disconnectProgressWs + 4001 logout |
## 改动文件清单
- server/application/services/sync_engine_v2.py
- server/application/services/auth_service.py
- server/api/install.py
- server/api/sync_v2.py
- server/utils/posix_paths.py
- server/main.py
- server/background/schedule_runner.py
- web/app/install.html
- frontend/src/pages/LoginPage.vue
- frontend/src/composables/push/usePushProgress.ts
- tests/test_security_unit.py
## DoD
- [x] ruff 零错误(改动文件)
- [x] import server.main 成功
- [x] pytest test_security_unit 通过
- [x] frontend type-check 通过
- [x] changelog ≥10 行
- [ ] 生产部署与健康检查(待用户推送后执行)
@@ -0,0 +1,31 @@
# 审计 — 2026-06-04 Code Review P1/P2 批次 2
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | WebSSH tv | FIXED auth_service + webssh |
| H2 | retry 重复执行 | FIXED SKIP LOCKED |
| H3 | schedule 重复触发 | FIXED FOR UPDATE |
| H4 | Agent fail-open | FIXED agent.py |
| H5 | Bing DoS | FIXED cache-only GET + admin POST sync |
| H6 | install status 侦察 | FIXED 404 when locked |
| H7 | FE 静默失败 | FIXED 5 处 snackbar |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1-H7 | FIXED | 见 changelog 与对应文件 diff |
## 改动文件清单
`docs/changelog/2026-06-04-code-review-p1-p2-batch2.md`
## DoD
- [x] ruff 零错误
- [x] import server.main
- [x] pytest 28 passed
- [x] vue-tsc 通过
- [x] changelog ≥10 行
@@ -0,0 +1,41 @@
# 审计 — 2026-06-04 Code Review P1/P2 批次 3
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 脚本并行 AsyncSession | FIXED script_service 预加载 server_map |
| H2 | Settings 敏感项泄露 | FIXED value="" + value_set |
| H3 | 空 PUT 清空 token | FIXED 敏感项空值 no-op |
| H4 | Agent unknown server + global | FIXED 必须存在 server;无 key 才 legacy global |
| H5 | WS 关闭码混用 | FIXED 无效 JWT 4401 |
| H6 | FE Telegram token 明文进表单 | FIXED draft + reveal |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | FIXED | `_dispatch_to_servers` / `_attach_remote_logs` 并行前 `server_map` |
| H2 | FIXED | `_format_setting_response` SENSITIVE_KEYS |
| H3 | FIXED | `set_setting` 337-342 行空值分支 |
| H4 | FIXED | `_verify_server_api_key` server 不存在 return False |
| H5 | FIXED | websocket.py / webssh.py 4401 |
| H6 | FIXED | SettingsPage.vue telegramTokenDraft + reveal-token |
## 改动文件清单
- `server/application/services/script_service.py`
- `server/api/settings.py`
- `server/api/agent.py`
- `server/api/websocket.py`
- `server/api/webssh.py`
- `frontend/src/pages/SettingsPage.vue`
- `frontend/src/types/api.ts`
## DoD
- [x] ruff 零错误(批次 3 文件)
- [x] import server.main
- [x] pytest 28 passed
- [x] vue-tsc 通过
- [x] changelog ≥10 行
@@ -0,0 +1,60 @@
# 审计 — 2026-06-04 Code Review 生产部署
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | P0/P1 batch1 | FIXED — sync session、install token、源路径、refresh、schedule、primary lock |
| H2 | P1/P2 batch2 | FIXED — webssh tv、retry/schedule lock、agent fail-closed、bing、install status |
| H3 | P1/P2 batch3 | FIXED — script session、settings 脱敏、agent unknown server、WS 4401 |
| H4 | 风险接受 | DOC — risk-acceptance-single-operator.md |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1-H3 | FIXED | docs/changelog/2026-06-04-code-review-*.md |
| H4 | ACCEPTED | 单人运维 WS ADR-011 + Agent global legacy |
## 改动文件清单(Gate 7
### 后端
- sync_engine_v2.py
- posix_paths.py
- install.py
- auth_service.py
- sync_v2.py
- main.py
- schedule_runner.py
- retry_runner.py
- agent.py
- settings.py
- websocket.py
- webssh.py
- script_service.py
### 前端
- LoginPage.vue
- usePushProgress.ts
- useServerList.ts
- CommandsPage.vue
- CredentialsPage.vue
- DashboardPage.vue
- ScriptsPage.vue
- SettingsPage.vue
- api.ts
### 静态 / 测试
- install.html
- test_security_unit.py
- test_retry_runner_outcome.py
### 部署
- deploy-production.sh
## DoD
- [x] ruff / pytest 28 passed.venv
- [x] vue-tsc 通过
- [x] changelog + audit 齐全
- [ ] 生产 /health + /app/ 200(部署后)
@@ -0,0 +1,43 @@
# 2026-06-04 — Code Review P0/P1 修复批次
## 摘要
依据 Cursor 全量 code review 结论,修复安装窗口、并行推送 DB 会话、源路径校验、Refresh 轮换竞态、定时任务失败判定、Primary 锁降级及前端推送/登录问题。
## 动机
- P0`sync_files` 并行 worker 共用 `AsyncSession` 导致间歇性 DB 错误;`create-admin` 在 Step3~4 之间无鉴权可被抢管。
- P1:推送源路径未统一黑名单;Refresh 非原子轮换;schedule 全失败仍提交 `last_run_at`Redis 不可用时多 worker 重复后台任务;前端 WS 泄漏与开放重定向。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/application/services/sync_engine_v2.py` | `_db_lock` 串行化 DB 写;源路径走 `resolve_nexus_push_source_path` |
| `server/utils/posix_paths.py` | 新增 `FORBIDDEN_NEXUS_SOURCE_PREFIXES``resolve_nexus_push_source_path` |
| `server/api/install.py` | Step3 签发 `install_token``create-admin` 校验令牌 |
| `web/app/install.html` | 保存并提交 `install_token` |
| `server/application/services/auth_service.py` | Redis Lua 原子 refresh 轮换 |
| `server/api/sync_v2.py` | validate/verify 复用统一源路径校验 |
| `server/main.py` | Redis 失败时 flock 文件锁 fallback(单 primary |
| `server/background/schedule_runner.py` | 推送零成功时 rollback schedule |
| `frontend/src/pages/LoginPage.vue` | 登录 redirect 防开放重定向 |
| `frontend/src/composables/push/usePushProgress.ts` | 失败时 disconnect WS4001/4401 强制登出 |
| `tests/test_security_unit.py` | install token + 源路径单元测试 |
## 迁移 / 重启
- 需重启 `nexus`Supervisor)使后端生效。
- 前端需 `vite build` 后部署静态资源。
- **安装向导**:Step3 响应新增 `install_token`,Step4 必须携带;进行中的安装需从 Step3 重新执行以获取令牌。
## 验证
```bash
.venv/bin/ruff check server/...
.venv/bin/python -c "import server.main"
.venv/bin/pytest tests/test_security_unit.py -q
cd frontend && npm run type-check
```
进度:`☑实现 ☑WSL验证 □审计8步 □部署 □健康检查 □浏览器验证 ☑changelog`
@@ -0,0 +1,43 @@
# 2026-06-04 — Code Review P1/P2 修复批次 2
## 摘要
接续 P0/P1 批次,修复 WebSSH `tv` 校验、retry/schedule 行级锁、Agent fail-closed、Bing 壁纸 DoS、安装 status 隐藏及前端静默错误。
## 动机
全量 review 剩余项:WebSSH 与 access token 策略不一致;多 worker 重复 retry/scheduleAgent DB 异常误回退全局 Key;公开 Bing API 可被滥用拉取外网;多页面加载失败无提示。
## 涉及文件
| 区域 | 文件 |
|------|------|
| 认证 | `auth_service.py`WebSSH JWT 含 `tv`)、`webssh.py`(校验 `tv` |
| Agent | `agent.py`DB 查服失败 reject |
| 壁纸 | `settings.py`GET 只读缓存 + POST sync 需 JWT |
| 安装 | `install.py`(锁定后 `/status` 404 |
| 后台 | `retry_runner.py`SKIP LOCKED + running→pending)、`schedule_runner.py`FOR UPDATE + `_schedule_is_due` |
| 前端 | `useServerList.ts``CredentialsPage.vue``ScriptsPage.vue``CommandsPage.vue``DashboardPage.vue` |
| 测试 | `test_retry_runner_outcome.py` |
## 行为变更
- **登录页壁纸**`GET /settings/bing-wallpapers` 仅返回本地缓存;管理员登录后 Dashboard 调用 `POST /settings/bing-wallpapers/sync` 更新。
- **WebSSH**:新签发的 token 含 `tv`;旧 token 仍走 `updated_at` 宽限。
- **安装完成**`GET /api/install/status` 在锁定后返回 404。
## 迁移 / 重启
- 重启 `nexus` Supervisor 服务。
- 前端重新 `vite build` 部署。
## 验证
```bash
.venv/bin/ruff check server/...
.venv/bin/python -c "import server.main"
.venv/bin/pytest tests/test_security_unit.py tests/test_retry_runner_outcome.py -q
cd frontend && npm run type-check
```
进度:`☑实现 ☑WSL验证 ☑审计8步 □部署 □健康检查 □浏览器验证 ☑changelog`
@@ -0,0 +1,50 @@
# 2026-06-04 — Code Review P1/P2 修复批次 3
## 摘要
接续批次 2,修复脚本并行 SSH 共用 AsyncSession、Settings 敏感项 API 脱敏、Telegram Token 前端留空不覆盖、Agent 未知 server 不再回退全局 Key、WebSocket/WebSSH 无效 JWT 关闭码统一为 4401。
## 动机
全量 review 剩余项:脚本批量下发并行 `get_by_id` 竞态;`GET /settings/` 仍返回 Telegram token 明文前缀;空字符串 PUT 可能误清空 token;未知 server_id 用 global key 通过认证;无效 JWT 与缺失 token 关闭码混用 4001。
## 涉及文件
| 区域 | 文件 |
|------|------|
| 脚本 | `script_service.py`(预加载 server_map,并行 SSH 前隔离 DB |
| 设置 API | `settings.py``_format_setting_response``value_set`、敏感项空 PUT 跳过) |
| Agent | `agent.py`server 必须存在;unknown id 拒绝;legacy global 仅无 per-server key |
| WebSocket | `websocket.py``webssh.py`(无效 JWT → 4401,缺失 token 仍 4001 |
| 前端 | `SettingsPage.vue``types/api.ts`Telegram token draft + reveal + value_set |
## 行为变更
- **Settings 列表/单项**`telegram_bot_token` 等敏感项返回 `value=""` + `value_set: true`,不再带部分明文。
- **Settings PUT**:敏感 key 提交空字符串时保留原值(未配置则 400)。
- **Settings 页**Bot Token 留空不提交;眼睛图标经 `/settings/telegram/reveal-token` 二次验证后填入 draft。
- **Agent**:不存在的服务器 ID 一律 401DB 查服异常 fail-closed。
- **WS/WebSSH**:过期/无效 JWT 关闭码 4401,便于前端区分「未登录」与「会话失效」。
## 未纳入本批次(需协议变更)
- WebSocket JWT 移出 query 字符串(需前后端首帧鉴权协议)。
- 完全禁用 Agent global API_KEY 回退(会破坏未分配 per-server key 的旧 Agent)。
## 迁移 / 重启
- 重启 `nexus` Supervisor 服务。
- 前端重新 `vite build` 部署。
## 验证
```bash
.venv/bin/ruff check server/application/services/script_service.py server/api/settings.py server/api/agent.py server/api/websocket.py server/api/webssh.py
.venv/bin/python -c "import server.main"
.venv/bin/pytest tests/test_security_unit.py tests/test_retry_runner_outcome.py -q
cd frontend && npm run type-check
```
结果:ruff ✅ · import ✅ · pytest 28 passed ✅ · vue-tsc ✅
进度:`☑实现 ☑WSL验证 ☑审计8步 □部署 □健康检查 □浏览器验证 ☑changelog`
@@ -0,0 +1,32 @@
# 2026-06-04 — 单人运维风险接受记录
## 摘要
新增 `docs/project/risk-acceptance-single-operator.md`,正式记录 Code Review 批次 3 后两项「刻意未纳入」决定在单人自用后台场景下的风险接受与重评条件;并在 AI 接续文档中建立引用。
## 动机
全量 Code Review 将 WebSocket JWT queryADR-011)与 Agent global API_KEY legacy 回退标为需更大改动项。Operator 确认后台仅一人使用、管理员完全可信,经讨论决定不排 WS 协议改造与 global 硬禁用,需留档以免后续会话误抬为 P0。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `docs/project/risk-acceptance-single-operator.md` | 新建:威胁模型、两项接受理由/残余风险/重评触发 |
| `docs/project/AI-HANDOFF-2026-06-03.md` | 增补 Code Review 收尾与风险接受链接 |
| `docs/project/AI-HANDOFF-2026-05-23.md` | §7b、§10 索引、§0 提示词、§13 时间线 |
## 决定摘要
- **ADR-011WS `?token=`)**:维持,不排首帧鉴权/ticket 改造。
- **Agent global legacy 回退**:保留;未知 server_id 与 fail-closed 已在 batch3 收紧。
## 迁移 / 重启
无。纯文档。
## 验证
- 文档路径可访问、handoff 交叉引用一致。
进度:`☑实现 ☑WSL验证 □审计8步 □部署 □健康检查 □浏览器验证 ☑changelog`
+20 -1
View File
@@ -37,7 +37,7 @@
项目未部署。端口:子机出站443心跳;中心→子机SSH22;8601仅本机。 项目未部署。端口:子机出站443心跳;中心→子机SSH22;8601仅本机。
P1待办:WebSSH断线重连、CSV批量导入。首次部署E2E见本文§6。 P1待办:WebSSH断线重连、CSV批量导入。首次部署E2E见本文§6。
已否决见§7。 已否决见§7。风险接受(单人运维·不排期)见§7b。
``` ```
--- ---
@@ -232,6 +232,22 @@ Nexus 6.02000+ 台服务器运维平台;FastAPI 四层架构 + Redis 实时
--- ---
## 7b. 风险接受(单人运维 · 不排期,2026-06-04
> **SSOT**: `docs/project/risk-acceptance-single-operator.md`
> Operator 确认:后台仅 **一名** 操作员自用,管理员身份完全可信。
| 项 | 决定 | 说明 |
|----|------|------|
| WebSocket JWT query | **维持 ADR-011** | 不排首帧鉴权 / WS ticketbatch3 已统一 4401 |
| Agent global API_KEY 回退 | **保留 legacy** | 不硬禁用;batch3 已禁 unknown server_id + fail-closed |
**≠ 已否决**:若新增管理员、日志外发第三方、等保审计、global key 泄露或 Agent 全量 per-server 迁移,须重评并见 risk-acceptance 文档「重评触发条件」。
Code Review 三批 changelog`docs/changelog/2026-06-04-code-review-*.md`
---
## 8. 文档债务(下一任 AI 应同步) ## 8. 文档债务(下一任 AI 应同步)
| 文档 | 问题 | 动作 | | 文档 | 问题 | 动作 |
@@ -279,6 +295,8 @@ Nexus 6.02000+ 台服务器运维平台;FastAPI 四层架构 + Redis 实时
| 健康 SSH | `server/infrastructure/ssh/remote_probe.py` | | 健康 SSH | `server/infrastructure/ssh/remote_probe.py` |
| 告警历史 | `web/app/alerts.html`, `server/api/settings.py`alert_history_router | | 告警历史 | `web/app/alerts.html`, `server/api/settings.py`alert_history_router |
| Changelog 示例 | `docs/changelog/2026-05-23-ssh-exec-no-8601.md` | | Changelog 示例 | `docs/changelog/2026-05-23-ssh-exec-no-8601.md` |
| 风险接受(单人运维) | `docs/project/risk-acceptance-single-operator.md` |
| Code Review 2026-06-04 | `docs/changelog/2026-06-04-code-review-p0-p1-fixes.md` 等三批 |
--- ---
@@ -312,6 +330,7 @@ web/app/*.html → server/api/*.py → application/services → infrastructure/*
6. Agent 8601 vs SSH → **脚本+健康检查走 22;心跳走 443;弱化 8601** 6. Agent 8601 vs SSH → **脚本+健康检查走 22;心跳走 443;弱化 8601**
7. 宝塔 Python 管理 → **不用**venv + 3.12 7. 宝塔 Python 管理 → **不用**venv + 3.12
8. 项目未部署 → **无需重启** 8. 项目未部署 → **无需重启**
9. 2026-06-04 Code Review 三批本地验证;单人运维风险接受 → `risk-acceptance-single-operator.md` §7b
--- ---
+21
View File
@@ -25,6 +25,8 @@
【规范】.cursor/rules/perfect-implementation.mdc · 单 Agent · 改代码必 changelog 【规范】.cursor/rules/perfect-implementation.mdc · 单 Agent · 改代码必 changelog
【文档】docs/project/AI-HANDOFF-2026-06-03.md · linux-dev-paths.md · docker/README.md 【文档】docs/project/AI-HANDOFF-2026-06-03.md · linux-dev-paths.md · docker/README.md
【Code Review】批次 13 已本地验证;见 docs/changelog/2026-06-04-code-review-*.md
【风险接受 · 单人运维】docs/project/risk-acceptance-single-operator.mdWS ADR-011 + Agent global 不排期)
【插件】docs/project/cursor-plugins-for-nexus.md · MySQL MCP: scripts/linux_mcp_mysql.sh 【插件】docs/project/cursor-plugins-for-nexus.md · MySQL MCP: scripts/linux_mcp_mysql.sh
【本地验收】seed_local_admin.py + frontend npm run test:e2e(见 linux-dev-paths.md 【本地验收】seed_local_admin.py + frontend npm run test:e2e(见 linux-dev-paths.md
``` ```
@@ -36,3 +38,22 @@ export NEXUS_ROOT=~/Nexus
mkdir -p "$NEXUS_ROOT" && tar xzf nexus-handoff-2026-06-03.tar.gz -C "$(dirname "$NEXUS_ROOT")" mkdir -p "$NEXUS_ROOT" && tar xzf nexus-handoff-2026-06-03.tar.gz -C "$(dirname "$NEXUS_ROOT")"
cd "$NEXUS_ROOT" && git remote -v # 若无 .git:重新 clone 后覆盖源码目录 cd "$NEXUS_ROOT" && git remote -v # 若无 .git:重新 clone 后覆盖源码目录
``` ```
---
## Code Review 收尾(2026-06-04
本地已修复并验证三批(P0/P1 + batch2 + batch3),changelog/audit 见:
| 批次 | Changelog | Audit |
|------|-----------|-------|
| P0/P1 | `docs/changelog/2026-06-04-code-review-p0-p1-fixes.md` | `docs/audit/2026-06-04-code-review-p0-p1-fixes.md` |
| batch2 | `docs/changelog/2026-06-04-code-review-p1-p2-batch2.md` | `docs/audit/2026-06-04-code-review-p1-p2-batch2.md` |
| batch3 | `docs/changelog/2026-06-04-code-review-p1-p2-batch3.md` | `docs/audit/2026-06-04-code-review-p1-p2-batch3.md` |
**未排期(已风险接受)**:Operator 确认后台仅一人自用 → 见 **`docs/project/risk-acceptance-single-operator.md`**
- WebSocket JWT 仍走 queryADR-011),不改造协议
- Agent global `API_KEY` legacy 回退保留;batch3 已禁 unknown server_id
重评触发:新增管理员、日志外发、等保审计、global key 泄露或 Agent 全量 per-server 迁移完成。
@@ -0,0 +1,126 @@
# 风险接受记录 — 单人运维场景(2026-06-04
## 背景
Nexus 全量 Code Review(批次 1~3)完成后,有两项被标记为「刻意未纳入」,需更大协议变更或 Agent 全站迁移:
1. WebSocket JWT 仍通过 query 传递(ADR-011
2. Agent 认证保留 global `API_KEY` 对未分配 `agent_api_key` 服务器的 legacy 回退
**运营事实(2026-06-04 确认)**:后台仅 **一名** 操作员自用,无多人共享账号、无外包运维、无多租户。操作员即系统 owner,管理员身份视为完全可信。
本文档记录在该威胁模型下的**正式风险接受**与重评条件,避免后续审计或接续开发误将两项抬为 P0。
---
## 威胁模型(单人运维)
| 维度 | 假设 |
|------|------|
| 后台用户 | 仅 1 人,可信 |
| 内鬼 / 权限滥用 | 不适用 |
| 主要防护目标 | 外网未授权访问、安装/认证漏洞、多 worker 竞态、子机 Agent M2M 密钥管理 |
| 日志访问 | Nginx/宝塔等 access log 仅 operator 可阅,不外发第三方 |
---
## 接受项 1WebSocket JWT queryADR-011
### 现状
- `/ws/alerts``/ws/sync``/ws/terminal/{id}` 均在握手 URL 使用 `?token=...`
- 设计依据:ADR-011;浏览器 WebSocket 无法自定义 Authorization Header
- 批次 3 已统一:缺失 token → **4001**;无效/过期 JWT → **4401**
### 接受理由
- Token 仅在 operator 登录后由其浏览器持有,无「不可信管理员」场景
- 改首帧鉴权 / WS ticket / 子协议需前后端同发、三条通道联测,**单人自用 ROI 极低**
- 残余风险(access log 留存 JWT、DevTools 可见)在「仅 operator 可读日志」前提下可接受
### 残余风险
- 本机恶意软件可窃取 session(与 token 在 query 或 Header 无本质差别)
- 若未来日志外发 SIEM/第三方,URL 中的 JWT 成为额外泄露面
### 重评触发条件
- 新增第二管理员或外包运维
- 等保/客户安全审计要求「凭证不得出现在 URL」
- access log 外发或多人可读
- 后台改为多租户或 SaaS 形态
### 决定
**维持 ADR-011,不排 WS 协议改造。**
---
## 接受项 2Agent global API_KEY legacy 回退
### 现状
- 子机 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
### 接受理由
- 与「后台几人用」无关,但单人运维下 global key 分发面可控(`.env`、安装脚本、子机 `config.json`
- 硬禁用 global 回退可能导致大量 legacy Agent 心跳 401**可用性风险大于当前威胁**
- 已有 per-server key 的服务器已与 global 隔离
### 残余风险
- 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` 数量)与渐进迁移,非阻塞项。
---
## 仍须保持的修复(与单人场景无关)
以下项已在 Code Review 批次 1~3 修复,属于**稳定性/外网暴露**问题,不因单人运维而降级:
- Sync/脚本并行任务 AsyncSession 竞态
- 安装向导 create-admin 鉴权窗口
- schedule/retry 多 worker 重复执行
- 登录开放重定向、推送 WS 断线与 logout 码
- Settings 敏感项脱敏、Agent unknown server 拒绝等
---
## 变更记录
| 日期 | 说明 |
|------|------|
| 2026-06-04 | 初始记录;operator 确认单人自用后台 |
---
## 引用
- ADR-011`docs/project/roadmap.md`WebSocket JWT query
- Code Review 批次 3`docs/changelog/2026-06-04-code-review-p1-p2-batch3.md`
- 本记录 changelog`docs/changelog/2026-06-04-risk-acceptance-single-operator.md`
- AI 接续:`docs/project/AI-HANDOFF-2026-05-23.md` §7b · `docs/project/AI-HANDOFF-2026-06-03.md` §Code Review 收尾
- Agent 认证实现:`server/api/agent.py``_verify_server_api_key`
@@ -162,6 +162,10 @@ export function usePushProgress(
socket.onclose = (ev) => { socket.onclose = (ev) => {
wsSocket.value = null wsSocket.value = null
if (ev.code === 4001 || ev.code === 4401) {
auth.forceLogout()
return
}
if (ev.code !== 1000 && pushing.value) { if (ev.code !== 1000 && pushing.value) {
wsProgressItems.value.forEach(p => { wsProgressItems.value.forEach(p => {
if (p.status === 'pending' || p.status === 'running') { if (p.status === 'pending' || p.status === 'running') {
@@ -242,6 +246,7 @@ export function usePushProgress(
} }
applyPushResultsFromHttp(res) applyPushResultsFromHttp(res)
} catch (e: unknown) { } catch (e: unknown) {
disconnectProgressWs()
const msg = e instanceof Error ? e.message : '推送请求失败' const msg = e instanceof Error ? e.message : '推送请求失败'
wsProgressItems.value.forEach(p => { wsProgressItems.value.forEach(p => {
p.status = 'failed' p.status = 'failed'
@@ -249,6 +254,7 @@ export function usePushProgress(
}) })
pushing.value = false pushing.value = false
snackbar(msg, 'error') snackbar(msg, 'error')
onPushFinished()
return return
} }
+5 -1
View File
@@ -6,6 +6,8 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { http } from '@/api' import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
export interface ServerBrief { export interface ServerBrief {
id: number id: number
@@ -19,6 +21,7 @@ export interface ServerBrief {
export function useServerList() { export function useServerList() {
const servers = ref<ServerBrief[]>([]) const servers = ref<ServerBrief[]>([])
const loading = ref(false) const loading = ref(false)
const snackbar = useSnackbar()
async function loadServers(opts?: { perPage?: number }) { async function loadServers(opts?: { perPage?: number }) {
loading.value = true loading.value = true
@@ -27,8 +30,9 @@ export function useServerList() {
per_page: opts?.perPage ?? 200, per_page: opts?.perPage ?? 200,
}) })
servers.value = res.items servers.value = res.items
} catch { } catch (e: unknown) {
servers.value = [] servers.value = []
snackbar(formatApiError(e, '加载服务器列表失败'), 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
+5 -1
View File
@@ -99,8 +99,11 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { http } from '@/api' import { http } from '@/api'
import { useServerList } from '@/composables/useServerList' import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import type { CommandLogItem, SshSessionItem } from '@/types/api' import type { CommandLogItem, SshSessionItem } from '@/types/api'
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList() const { servers: serverList, loadServers } = useServerList()
// ── State ── // ── State ──
@@ -149,9 +152,10 @@ async function loadData() {
sessions.value = res.items sessions.value = res.items
total.value = res.total total.value = res.total
} }
} catch { } catch (e: unknown) {
commands.value = [] commands.value = []
sessions.value = [] sessions.value = []
snackbar(formatApiError(e, '加载命令日志失败'), 'error')
} finally { } finally {
loading.value = false loading.value = false
} }
+13 -3
View File
@@ -182,6 +182,7 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { http } from '@/api' import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation' import { required } from '@/utils/validation'
const snackbar = useSnackbar() const snackbar = useSnackbar()
@@ -252,7 +253,10 @@ async function loadPasswords() {
const res = await http.getList<PasswordItem>('/presets/') const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items passwords.value = res.items
totalItems.value = res.total totalItems.value = res.total
} catch { passwords.value = [] } } catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
}
finally { loading.value = false } finally { loading.value = false }
} }
async function loadSshKeys() { async function loadSshKeys() {
@@ -264,7 +268,10 @@ async function loadSshKeys() {
}) })
sshKeys.value = res.items sshKeys.value = res.items
totalItems.value = res.total totalItems.value = res.total
} catch { sshKeys.value = [] } } catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
}
finally { loading.value = false } finally { loading.value = false }
} }
async function loadDbCreds() { async function loadDbCreds() {
@@ -276,7 +283,10 @@ async function loadDbCreds() {
}) })
dbCreds.value = res.items dbCreds.value = res.items
totalItems.value = res.total totalItems.value = res.total
} catch { dbCreds.value = [] } } catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
}
finally { loading.value = false } finally { loading.value = false }
} }
+6 -1
View File
@@ -332,6 +332,7 @@ async function loadAlerts() {
})) }))
} catch { } catch {
recentAlerts.value = [] recentAlerts.value = []
snackbar('加载告警记录失败', 'error')
} }
} }
@@ -339,7 +340,10 @@ async function loadRecentAudit() {
try { try {
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 }) const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10) recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
} catch { recentAudit.value = [] } } catch {
recentAudit.value = []
snackbar('加载审计记录失败', 'error')
}
} }
async function loadSummary() { async function loadSummary() {
@@ -398,6 +402,7 @@ onMounted(() => {
loadAlerts() loadAlerts()
loadSummary() loadSummary()
loadRecentAudit() loadRecentAudit()
http.post('/settings/bing-wallpapers/sync').catch(() => {})
}) })
interface AlertItem { interface AlertItem {
+2 -1
View File
@@ -175,7 +175,8 @@ async function doLogin() {
try { try {
await auth.login(username.value, password.value, needTotp.value ? totpCode.value : undefined) await auth.login(username.value, password.value, needTotp.value ? totpCode.value : undefined)
const redirect = (route.query.redirect as string) || '/' const raw = (route.query.redirect as string) || '/'
const redirect = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
router.push(redirect) router.push(redirect)
} catch (e: any) { } catch (e: any) {
if (e instanceof TotpRequiredError) { if (e instanceof TotpRequiredError) {
+5 -1
View File
@@ -260,6 +260,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { http } from '@/api' import { http } from '@/api'
import { useServerList } from '@/composables/useServerList' import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation' import { required } from '@/utils/validation'
const snackbar = useSnackbar() const snackbar = useSnackbar()
@@ -360,7 +361,10 @@ async function loadScripts() {
}) })
scripts.value = res.items scripts.value = res.items
totalItems.value = res.total totalItems.value = res.total
} catch { scripts.value = [] } } catch (e: unknown) {
scripts.value = []
snackbar(formatApiError(e, '加载脚本列表失败'), 'error')
}
finally { loading.value = false } finally { loading.value = false }
} }
+75 -12
View File
@@ -55,7 +55,18 @@
</v-card-title> </v-card-title>
<v-divider /> <v-divider />
<v-card-text> <v-card-text>
<v-text-field v-model="settings.telegram_bot_token" label="Bot Token" variant="outlined" density="compact" class="mb-3" :type="showToken ? 'text' : 'password'" :append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'" @click:append-inner="showToken = !showToken" /> <v-text-field
v-model="telegramTokenDraft"
label="Bot Token"
variant="outlined"
density="compact"
class="mb-3"
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
:type="showToken ? 'text' : 'password'"
:append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="revealTelegramToken"
/>
<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-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> <v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
</v-card-text> </v-card-text>
@@ -162,15 +173,17 @@
<!-- API Key Reveal Dialog --> <!-- API Key Reveal Dialog -->
<v-dialog v-model="showRevealDialog" max-width="400"> <v-dialog v-model="showRevealDialog" max-width="400">
<v-card border> <v-card border>
<v-card-title>验证身份</v-card-title> <v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
<v-card-text> <v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">请输入当前密码以查看 API Key</div> <div class="text-body-2 text-medium-emphasis mb-3">
{{ revealTarget === 'telegram' ? '请输入当前密码以查看 Telegram Bot Token' : '请输入当前密码以查看 API Key' }}
</div>
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" /> <v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer /> <v-spacer />
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn> <v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doRevealApiKey" :loading="revealingKey">确认</v-btn> <v-btn color="primary" variant="flat" @click="doRevealSecret" :loading="revealingKey">确认</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -214,6 +227,8 @@ const settings = ref({
db_pool_size: 10, db_max_overflow: 20, db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '', telegram_bot_token: '', telegram_chat_id: '',
}) })
const telegramTokenDraft = ref('')
const telegramTokenSet = ref(false)
const saving = ref(false) const saving = ref(false)
const testingTg = ref(false) const testingTg = ref(false)
const showToken = ref(false) const showToken = ref(false)
@@ -236,6 +251,7 @@ const disablingTotp = ref(false)
const showApiKey = ref(false) const showApiKey = ref(false)
const apiKeyValue = ref('') const apiKeyValue = ref('')
const showRevealDialog = ref(false) const showRevealDialog = ref(false)
const revealTarget = ref<'api_key' | 'telegram'>('api_key')
const revealPassword = ref('') const revealPassword = ref('')
const revealingKey = ref(false) const revealingKey = ref(false)
@@ -253,6 +269,11 @@ async function loadSettings() {
const knownKeys = Object.keys(settings.value) const knownKeys = Object.keys(settings.value)
for (const item of items) { for (const item of items) {
const key = item.key const key = item.key
if (key === 'telegram_bot_token') {
telegramTokenSet.value = Boolean(item.value_set)
telegramTokenDraft.value = ''
continue
}
if (key && knownKeys.includes(key)) { if (key && knownKeys.includes(key)) {
const val = item.value const val = item.value
// Type-cast numeric strings back to numbers for number inputs // Type-cast numeric strings back to numbers for number inputs
@@ -283,14 +304,23 @@ async function loadAllowlist() {
// ── Actions ── // ── Actions ──
async function saveSettings() { async function saveSettings() {
saving.value = true saving.value = true
const tokenUpdate = telegramTokenDraft.value.trim()
try { try {
const entries = Object.entries(settings.value).filter(([key]) => 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_bot_token', 'telegram_chat_id'].includes(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)
) )
if (tokenUpdate) {
entries.push(['telegram_bot_token', tokenUpdate])
}
for (const [key, value] of entries) { for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value }) await http.put(`/settings/${key}`, { value })
} }
snackbar('设置已保存') snackbar('设置已保存')
if (tokenUpdate) {
telegramTokenSet.value = true
telegramTokenDraft.value = ''
showToken.value = false
}
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败' const msg = e instanceof Error ? e.message : '保存失败'
snackbar(msg, 'error') snackbar(msg, 'error')
@@ -381,25 +411,58 @@ function copyTotpSecret() {
} }
async function revealApiKey() { async function revealApiKey() {
if (showApiKey.value) { showApiKey.value = false; return } if (showApiKey.value) {
showApiKey.value = false
return
}
revealTarget.value = 'api_key'
showRevealDialog.value = true showRevealDialog.value = true
revealPassword.value = '' revealPassword.value = ''
} }
async function doRevealApiKey() { 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) { if (!revealPassword.value) {
snackbar('请输入密码', 'error') snackbar('请输入密码', 'error')
return return
} }
revealingKey.value = true revealingKey.value = true
try { try {
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { current_password: revealPassword.value }) if (revealTarget.value === 'telegram') {
apiKeyValue.value = res.api_key const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {
showApiKey.value = true 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 showRevealDialog.value = false
revealPassword.value = '' revealPassword.value = ''
} catch (e: any) { snackbar(e.message || '验证失败', 'error') } } catch (e: unknown) {
finally { revealingKey.value = false } snackbar(e instanceof Error ? e.message : '验证失败', 'error')
} finally {
revealingKey.value = false
}
}
async function doRevealApiKey() {
revealTarget.value = 'api_key'
await doRevealSecret()
} }
function copyApiKey() { function copyApiKey() {
+1
View File
@@ -18,6 +18,7 @@ export type SettingsResponse = SettingItem[]
export interface SettingItem { export interface SettingItem {
key: string key: string
value: unknown value: unknown
value_set?: boolean | null
updated_at?: string updated_at?: string
} }
+26 -9
View File
@@ -58,22 +58,39 @@ def _verify_api_key(x_api_key: str = Header(...)):
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool: async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
"""Verify API key against per-server agent_api_key, falling back to global API key. """Verify API key against per-server agent_api_key, with limited global fallback.
Authentication priority: Authentication priority:
1. If server has agent_api_key set → must match exactly 1. Server must exist
2. Otherwise → must match global API_KEY 2. If server has agent_api_key → must match exactly
3. Legacy only: no per-server key → global API_KEY (logged for migration)
""" """
# Try to get server's per-server key
try: try:
server = await service.get_server(server_id) server = await service.get_server(server_id)
if server and server.agent_api_key:
return secrets.compare_digest(provided_key, server.agent_api_key)
except Exception: except Exception:
logger.debug(f"Failed to look up server {server_id} for per-server API key check, falling back to global key") logger.warning(
"Failed to look up server %s for per-server API key check — rejecting",
server_id,
exc_info=True,
)
return False
# Fallback: global API key if not server:
return secrets.compare_digest(provided_key, settings.API_KEY) return False
if server.agent_api_key:
return secrets.compare_digest(provided_key, server.agent_api_key)
if secrets.compare_digest(provided_key, settings.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_id,
server.name,
server_id,
)
return True
return False
def _threshold_for(thresholds: dict, metric: str) -> float: def _threshold_for(thresholds: dict, metric: str) -> float:
+24
View File
@@ -68,6 +68,7 @@ class InitDbRequest(BaseModel):
class CreateAdminRequest(BaseModel): class CreateAdminRequest(BaseModel):
install_token: str
db_host: str = "localhost" db_host: str = "localhost"
db_port: str = "3306" db_port: str = "3306"
db_name: str = "Nexus" db_name: str = "Nexus"
@@ -310,11 +311,29 @@ def _finalize_install_lock() -> None:
logger.warning("Failed to remove install state file: %s", e) logger.warning("Failed to remove install state file: %s", e)
def _verify_install_token(provided: str) -> None:
"""Require one-time install token issued at step 3 (init-db)."""
if not provided or not provided.strip():
raise HTTPException(status_code=403, detail="缺少安装令牌,请从步骤3重新开始")
if not STATE_FILE.exists():
raise HTTPException(status_code=403, detail="安装会话无效,请从步骤3重新开始")
import json
try:
state = json.loads(read_utf8_text(STATE_FILE))
except (json.JSONDecodeError, OSError) as exc:
raise HTTPException(status_code=403, detail="安装会话无效,请从步骤3重新开始") from exc
expected = state.get("install_token")
if not expected or not secrets.compare_digest(provided.strip(), expected):
raise HTTPException(status_code=403, detail="安装令牌无效或已过期")
# ── Endpoints ── # ── Endpoints ──
@router.get("/status") @router.get("/status")
async def install_status(): async def install_status():
"""Check whether Nexus is already installed.""" """Check whether Nexus is already installed."""
if _is_locked():
raise HTTPException(status_code=404, detail="Not found")
return { return {
"installed": _is_installed(), "installed": _is_installed(),
"locked": _is_locked(), "locked": _is_locked(),
@@ -542,6 +561,7 @@ async def init_db(req: InitDbRequest):
# Save install state for step 4 # Save install state for step 4
import json import json
install_token = secrets.token_urlsafe(32)
state = { state = {
"db_host": req.db_host, "db_host": req.db_host,
"db_port": req.db_port, "db_port": req.db_port,
@@ -549,6 +569,7 @@ async def init_db(req: InitDbRequest):
"db_user": req.db_user, "db_user": req.db_user,
# P2-19: Don't store db_pass in state file — only needed for _make_db_url() # P2-19: Don't store db_pass in state file — only needed for _make_db_url()
# which is called with the full CreateAdminRequest in step 4 # which is called with the full CreateAdminRequest in step 4
"install_token": install_token,
"pool_size": pool_size, "pool_size": pool_size,
"max_overflow": max_overflow, "max_overflow": max_overflow,
"install_dir": install_dir, "install_dir": install_dir,
@@ -561,6 +582,7 @@ async def init_db(req: InitDbRequest):
return { return {
"success": True, "success": True,
"install_token": install_token,
"pool_size": pool_size, "pool_size": pool_size,
"max_overflow": max_overflow, "max_overflow": max_overflow,
"tables_created": 14, "tables_created": 14,
@@ -576,6 +598,8 @@ async def create_admin(req: CreateAdminRequest):
if not _has_env(): if not _has_env():
raise HTTPException(400, "请先完成步骤3:数据库初始化") raise HTTPException(400, "请先完成步骤3:数据库初始化")
_verify_install_token(req.install_token)
if len(req.admin_password) < 6: if len(req.admin_password) < 6:
raise HTTPException(400, "密码长度至少6位") raise HTTPException(400, "密码长度至少6位")
+71 -64
View File
@@ -111,18 +111,29 @@ def _validate_ip_list(ips: list[str]) -> None:
# ── System Settings ── # ── System Settings ──
def _format_setting_response(key: str, value: str | None, updated_at) -> dict:
"""Build setting dict; sensitive keys never return plaintext."""
if key in SENSITIVE_KEYS and value:
return {
"key": key,
"value": "",
"value_set": True,
"updated_at": str(updated_at),
}
return {
"key": key,
"value": value,
"value_set": bool(value) if key in SENSITIVE_KEYS else None,
"updated_at": str(updated_at),
}
@router.get("/", response_model=list) @router.get("/", response_model=list)
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)): async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings (sensitive values masked)""" """List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
settings = await repo.get_all() settings = await repo.get_all()
result = [] return [_format_setting_response(s.key, s.value, s.updated_at) for s in settings]
for s in settings:
val = s.value
if s.key in SENSITIVE_KEYS and val:
val = val[:8] + "..." if len(val) > 8 else "***"
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
return result
@router.get("/ip-allowlist", response_model=dict) @router.get("/ip-allowlist", response_model=dict)
@@ -173,22 +184,29 @@ def _cleanup_old_wallpapers() -> int:
return removed return removed
@router.get("/bing-wallpapers", response_model=dict) def _cached_wallpaper_urls() -> dict:
async def bing_wallpapers(): """List locally cached wallpaper URLs (no outbound HTTP)."""
"""Return all cached wallpaper URLs for frontend slideshow rotation. _WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
all_files = sorted(
_WALLPAPER_DIR.glob("*.jpg"),
key=lambda f: f.stat().st_mtime,
reverse=True,
)
urls = [
f"/app/wallpapers/{f.name}"
for f in sorted(all_files[:_MAX_WALLPAPERS], key=lambda f: f.name, reverse=True)
]
return {"urls": urls, "count": len(urls)}
Syncs with Bing daily (pulls up to 8 days of images), caches to disk,
cleans up old files, returns sorted list of local URLs. async def _sync_bing_wallpapers_from_network() -> int:
""" """Download recent Bing images to local cache. Returns count downloaded."""
import httpx import httpx
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True) _WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
# Download today + last 7 days from Bing (n=8 gives ~8 images)
downloaded = 0 downloaded = 0
try: try:
async with httpx.AsyncClient(timeout=12.0) as client: async with httpx.AsyncClient(timeout=12.0) as client:
# Fetch up to 8 recent images from Bing
resp = await client.get( resp = await client.get(
"https://cn.bing.com/HPImageArchive.aspx", "https://cn.bing.com/HPImageArchive.aspx",
params={"format": "js", "idx": 0, "n": 8, "mkt": "zh-CN"}, params={"format": "js", "idx": 0, "n": 8, "mkt": "zh-CN"},
@@ -198,17 +216,15 @@ async def bing_wallpapers():
img_path = img.get("url") img_path = img.get("url")
if not img_path: if not img_path:
continue continue
# Use date from Bing metadata as filename
date_str = img.get("startdate", "") or img.get("fullstartdate", "")[:8] or "" date_str = img.get("startdate", "") or img.get("fullstartdate", "")[:8] or ""
if not date_str: if not date_str:
# fallback: extract from URL
import re import re
m = re.search(r'OHR\.(\w+)_', img_path) m = re.search(r"OHR\.(\w+)_", img_path)
date_str = m.group(1) if m else f"unknown_{downloaded}" date_str = m.group(1) if m else f"unknown_{downloaded}"
cached = _WALLPAPER_DIR / f"{date_str}.jpg" cached = _WALLPAPER_DIR / f"{date_str}.jpg"
if cached.exists() and cached.stat().st_size > 1024: if cached.exists() and cached.stat().st_size > 1024:
continue # already cached continue
try: try:
img_resp = await client.get(f"https://cn.bing.com{img_path}") img_resp = await client.get(f"https://cn.bing.com{img_path}")
@@ -217,64 +233,50 @@ async def bing_wallpapers():
downloaded += 1 downloaded += 1
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.debug("Bing image download failed: %s", e) logger.debug("Bing image download failed: %s", e)
continue
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.warning("Bing wallpaper sync failed: %s", e) logger.warning("Bing wallpaper sync failed: %s", e)
# Cleanup old files
_cleanup_old_wallpapers() _cleanup_old_wallpapers()
# Also keep only newest N files if we somehow have too many
all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True) all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
for f in all_files[_MAX_WALLPAPERS:]: for f in all_files[_MAX_WALLPAPERS:]:
try: try:
f.unlink() f.unlink()
except OSError as e: except OSError as e:
logger.debug("Wallpaper prune skip %s: %s", f.name, e) logger.debug("Wallpaper prune skip %s: %s", f.name, e)
return downloaded
# Return sorted URLs (newest first)
urls = [f"/app/wallpapers/{f.name}" for f in sorted(all_files[:_MAX_WALLPAPERS], key=lambda f: f.name, reverse=True)] @router.get("/bing-wallpapers", response_model=dict)
return {"urls": urls, "count": len(urls)} async def bing_wallpapers():
"""Return cached wallpaper URLs (public, read-only — no outbound sync)."""
return _cached_wallpaper_urls()
@router.post("/bing-wallpapers/sync", response_model=dict)
async def bing_wallpapers_sync(admin: Admin = Depends(get_current_admin)):
"""Admin-only: fetch latest Bing images into local cache."""
downloaded = await _sync_bing_wallpapers_from_network()
result = _cached_wallpaper_urls()
result["downloaded"] = downloaded
return result
# Keep the old single-image endpoint for backward compatibility # Keep the old single-image endpoint for backward compatibility
@router.get("/bing-wallpaper", response_model=dict) @router.get("/bing-wallpaper", response_model=dict)
async def bing_wallpaper(): async def bing_wallpaper():
"""Return today's wallpaper URL (backward compat).""" """Return today's cached wallpaper URL (public, read-only)."""
try: _WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
import httpx from datetime import datetime
from datetime import datetime
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True) today = datetime.utcnow().strftime("%Y-%m-%d")
today = datetime.utcnow().strftime("%Y-%m-%d") cached = _WALLPAPER_DIR / f"{today}.jpg"
cached = _WALLPAPER_DIR / f"{today}.jpg" if cached.exists() and cached.stat().st_size > 1024:
if cached.exists() and cached.stat().st_size > 1024:
return {"url": f"/app/wallpapers/{today}.jpg"}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://cn.bing.com/HPImageArchive.aspx",
params={"format": "js", "idx": 0, "n": 1, "mkt": "zh-CN"},
)
data = resp.json()
img = (data.get("images") or [{}])[0]
img_url = img.get("url")
if not img_url:
return {"url": None}
img_resp = await client.get(f"https://cn.bing.com{img_url}")
if img_resp.status_code == 200:
cached.write_bytes(img_resp.content)
_cleanup_old_wallpapers()
return {"url": f"/app/wallpapers/{today}.jpg"} return {"url": f"/app/wallpapers/{today}.jpg"}
except Exception:
# Try any cached file all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
cached_list = sorted(_WALLPAPER_DIR.glob("*.jpg")) if all_files:
if cached_list: return {"url": f"/app/wallpapers/{all_files[0].name}"}
return {"url": f"/app/wallpapers/{cached_list[-1].name}"} return {"url": None}
return {"url": None}
@router.get("/{key}", response_model=dict) @router.get("/{key}", response_model=dict)
@@ -284,9 +286,7 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
value = await repo.get(key) value = await repo.get(key)
if value is None: if value is None:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value: return _format_setting_response(key, value, None)
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value}
@router.post("/api-key/reveal", response_model=dict) @router.post("/api-key/reveal", response_model=dict)
@@ -333,8 +333,15 @@ async def set_setting(
if key not in MUTABLE_KEYS: if key not in MUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"未知设置项: {key}") raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
# S-02: Validate value type and range for int settings
val_str = str(payload.value) val_str = str(payload.value)
if key in SENSITIVE_KEYS and not val_str.strip():
repo = SettingRepositoryImpl(db)
current = await repo.get(key)
if not current:
raise HTTPException(status_code=400, detail=f"'{key}' 不能为空")
return _format_setting_response(key, current, None)
# S-02: Validate value type and range for int settings
if key in SETTING_VALIDATORS: if key in SETTING_VALIDATORS:
expected_type, min_val, max_val = SETTING_VALIDATORS[key] expected_type, min_val, max_val = SETTING_VALIDATORS[key]
try: try:
+14 -24
View File
@@ -44,6 +44,7 @@ from server.utils.posix_paths import (
normalize_remote_dest, normalize_remote_dest,
remote_join, remote_join,
resolve_nexus_host_path, resolve_nexus_host_path,
resolve_nexus_push_source_path,
to_posix, to_posix,
) )
from server.application.services.sync_engine_v2 import SyncEngineV2 from server.application.services.sync_engine_v2 import SyncEngineV2
@@ -529,8 +530,6 @@ async def reconcile_stale_sync_logs(
# ── H5: Validate Source Path ── # ── H5: Validate Source Path ──
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
@router.post("/validate-source-path") @router.post("/validate-source-path")
async def validate_source_path( async def validate_source_path(
@@ -545,25 +544,13 @@ async def validate_source_path(
""" """
import os import os
path = payload.path.strip()
try: try:
real_path = resolve_nexus_host_path(path) real_path = resolve_nexus_push_source_path(payload.path)
except PosixPathError as exc: except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc status = 403 if "系统路径" in str(exc) else 400
if ".." in to_posix(path).split("/"): if "不存在" in str(exc):
raise HTTPException(status_code=400, detail="路径不允许包含 '..'") status = 404
raise HTTPException(status_code=status, detail=str(exc)) from exc
# Reject sensitive paths
for prefix in _FORBIDDEN_PATH_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="路径不存在")
if not os.path.isdir(real_path):
raise HTTPException(status_code=400, detail="路径不是目录")
# Count files and total size # Count files and total size
file_count = 0 file_count = 0
@@ -582,7 +569,7 @@ async def validate_source_path(
await _audit_sync( await _audit_sync(
"validate_source_path", "sync", 0, "validate_source_path", "sync", 0,
f"验证源路径: {path} ({file_count} 文件)", f"验证源路径: {payload.path} ({file_count} 文件)",
admin.username, request, admin.username, request,
) )
@@ -1105,15 +1092,18 @@ async def verify_sync(
session = request.state.db session = request.state.db
if not os.path.isdir(payload.source_path): try:
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}") source_path = resolve_nexus_push_source_path(payload.source_path)
except PosixPathError as exc:
status = 403 if "系统路径" in str(exc) else 400
raise HTTPException(status_code=status, detail=str(exc)) from exc
# Build local file manifest: {relative_path: sha256hex} # Build local file manifest: {relative_path: sha256hex}
local_files = {} local_files = {}
for root, _dirs, fnames in os.walk(payload.source_path): for root, _dirs, fnames in os.walk(source_path):
for fn in fnames: for fn in fnames:
full = posix_join(root, fn) full = posix_join(root, fn)
rel = posixpath.relpath(full, to_posix(payload.source_path)) rel = posixpath.relpath(full, to_posix(source_path))
if len(local_files) >= payload.max_files: if len(local_files) >= payload.max_files:
break break
try: try:
+2 -2
View File
@@ -307,7 +307,7 @@ async def alert_ws(
admin = await _verify_ws_token(token) admin = await _verify_ws_token(token)
if not admin: if not admin:
await websocket.close(code=4001, reason="Invalid or expired JWT token") await websocket.close(code=4401, reason="Invalid or expired JWT token")
return return
# ── Accept and register ── # ── Accept and register ──
@@ -343,7 +343,7 @@ async def sync_progress_ws(
admin = await _verify_ws_token(token) admin = await _verify_ws_token(token)
if not admin: if not admin:
await websocket.close(code=4001, reason="Invalid or expired JWT token") await websocket.close(code=4401, reason="Invalid or expired JWT token")
return return
client_id = f"sync:{admin.id}:{id(websocket)}" client_id = f"sync:{admin.id}:{id(websocket)}"
+11 -7
View File
@@ -63,13 +63,17 @@ async def _verify_webssh_token(token: str):
if not admin or not admin.is_active: if not admin or not admin.is_active:
return None, None return None, None
# P1-7: Check updated_at — invalidate token after password change token_tv = payload.get("tv")
token_updated = payload.get("updated", 0) if token_tv is not None:
if token_updated and admin.updated_at: if int(token_tv) != int(admin.token_version or 0):
admin_ts = int(admin.updated_at.timestamp())
# Allow 5-second grace for clock skew / race conditions
if admin_ts > token_updated + 5:
return None, None return None, None
else:
# Legacy WebSSH tokens without tv — fall back to updated_at grace
token_updated = payload.get("updated", 0)
if token_updated and admin.updated_at:
admin_ts = int(admin.updated_at.timestamp())
if admin_ts > token_updated + 5:
return None, None
return admin, server_id return admin, server_id
except Exception: except Exception:
@@ -96,7 +100,7 @@ async def terminal_ws(
admin, token_server_id = await _verify_webssh_token(token) admin, token_server_id = await _verify_webssh_token(token)
if not admin: if not admin:
await websocket.close(code=4001, reason="Invalid or expired JWT token") await websocket.close(code=4401, reason="Invalid or expired JWT token")
return return
# Security: WebSSH token must bind to this server (general access_token rejected) # Security: WebSSH token must bind to this server (general access_token rejected)
+49 -5
View File
@@ -255,14 +255,19 @@ class AuthService:
) )
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"} return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Rotate: remove old token from Redis, generate new token pair
await self._remove_refresh_token(admin.id, refresh_token)
access_token = self._create_access_token(admin) access_token = self._create_access_token(admin)
new_refresh = self._create_refresh_token(admin) new_refresh = self._create_refresh_token(admin)
# Store new refresh token in Redis rotated = await self._rotate_refresh_token(admin.id, refresh_token, new_refresh)
await self._store_refresh_token(admin.id, new_refresh) if rotated is None:
return {
"success": False,
"reason": "service_unavailable",
"message": "认证服务暂不可用,请稍后重试",
}
if rotated is False:
await self._handle_refresh_token_reuse(admin, ip_address)
return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"}
return { return {
"success": True, "success": True,
@@ -461,6 +466,7 @@ class AuthService:
"iat": now, "iat": now,
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES), "exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0, "updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
"tv": admin.token_version or 0,
} }
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
@@ -618,6 +624,44 @@ class AuthService:
membership = await self._refresh_token_membership(admin_id, token) membership = await self._refresh_token_membership(admin_id, token)
return membership is True return membership is True
async def _rotate_refresh_token(
self, admin_id: int, old_token: str, new_token: str
) -> Optional[bool]:
"""Atomically swap refresh token hashes in Redis.
Returns True on success, False if old token absent (reuse/invalid),
None if Redis is unavailable.
"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
old_hash = self._hash_token(old_token)
new_hash = self._hash_token(new_token)
ttl_seconds = _refresh_token_expire_days() * 86400 + 3600
script = """
local removed = redis.call('SREM', KEYS[1], ARGV[1])
if removed == 0 then
return 0
end
redis.call('SADD', KEYS[1], ARGV[2])
local ttl = redis.call('TTL', KEYS[1])
if ttl < 0 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
end
return 1
"""
result = await redis.eval(script, 1, key, old_hash, new_hash, str(ttl_seconds))
if result == 1:
return True
if result == 0:
return False
logger.error("Unexpected Redis rotate result for admin %s: %r", admin_id, result)
return None
except Exception as e:
logger.error(f"Failed to rotate refresh token in Redis for admin {admin_id}: {e}")
return None
async def _remove_refresh_token(self, admin_id: int, token: str): async def _remove_refresh_token(self, admin_id: int, token: str):
"""Remove a single refresh token from Redis Set (single-device logout or rotation)""" """Remove a single refresh token from Redis Set (single-device logout or rotation)"""
try: try:
+24 -2
View File
@@ -505,6 +505,22 @@ class ScriptService:
async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None: async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None:
"""In-place: add remote_log for servers with a nexus-job log path.""" """In-place: add remote_log for servers with a nexus-job log path."""
tail_lines = max(10, min(tail_lines, 500)) tail_lines = max(10, min(tail_lines, 500))
server_ids: set[int] = set()
for sid_str, entry in results.items():
if not isinstance(entry, dict):
continue
if not self._extract_nexus_job_log(entry.get("stdout", "")):
continue
try:
server_ids.add(int(sid_str))
except ValueError:
continue
server_map: Dict[int, Server] = {}
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
if server:
server_map[sid] = server
async def _tail_one(sid_str: str, entry: dict) -> None: async def _tail_one(sid_str: str, entry: dict) -> None:
log_path = self._extract_nexus_job_log(entry.get("stdout", "")) log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
@@ -514,7 +530,7 @@ class ScriptService:
server_id = int(sid_str) server_id = int(sid_str)
except ValueError: except ValueError:
return return
server = await self.server_repo.get_by_id(server_id) server = server_map.get(server_id)
if not server or not server.is_online: if not server or not server.is_online:
entry["remote_log"] = "(服务器离线,无法拉取日志)" entry["remote_log"] = "(服务器离线,无法拉取日志)"
return return
@@ -549,8 +565,14 @@ class ScriptService:
results: Dict[str, dict] = {} results: Dict[str, dict] = {}
semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY) semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY)
server_map: Dict[int, Server] = {}
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
if server:
server_map[sid] = server
async def _exec_on_server(server_id: int): async def _exec_on_server(server_id: int):
server = await self.server_repo.get_by_id(server_id) server = server_map.get(server_id)
if not server or not server.is_online: if not server or not server.is_online:
results[str(server_id)] = { results[str(server_id)] = {
"status": "skipped", "status": "skipped",
+56 -44
View File
@@ -20,12 +20,18 @@ from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
from server.infrastructure.database.crypto import decrypt_value from server.infrastructure.database.crypto import decrypt_value
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path, to_posix from server.utils.posix_paths import (
PosixPathError,
UPLOAD_STAGING_PREFIX,
normalize_remote_abs_path,
resolve_nexus_push_source_path,
to_posix,
)
def _nexus_source_path(path: str) -> str: def _nexus_source_path(path: str) -> str:
"""Normalize push source path string (Nexus host, Linux deployment).""" """Normalize and validate push source path on the Nexus host."""
return to_posix(path.strip()) return resolve_nexus_push_source_path(path)
logger = logging.getLogger("nexus.sync_v2") logger = logging.getLogger("nexus.sync_v2")
@@ -63,10 +69,11 @@ class SyncEngineV2:
batch_id: Optional[str] = None, batch_id: Optional[str] = None,
) -> dict: ) -> dict:
"""Push files from Nexus to target servers via rsync over SSH""" """Push files from Nexus to target servers via rsync over SSH"""
source_path = _nexus_source_path(source_path) try:
if not os.path.isdir(source_path): source_path = _nexus_source_path(source_path)
except PosixPathError as exc:
return {"total": 0, "completed": 0, "failed": 0, "results": {}, return {"total": 0, "completed": 0, "failed": 0, "results": {},
"error": f"源路径不存在: {source_path}"} "error": str(exc)}
batch_id = (batch_id or uuid.uuid4().hex[:12])[:12] batch_id = (batch_id or uuid.uuid4().hex[:12])[:12]
concurrency = min(concurrency, MAX_CONCURRENT) concurrency = min(concurrency, MAX_CONCURRENT)
@@ -79,6 +86,7 @@ class SyncEngineV2:
failed = 0 failed = 0
cancelled = 0 cancelled = 0
_counter_lock = asyncio.Lock() _counter_lock = asyncio.Lock()
_db_lock = asyncio.Lock()
results = {} results = {}
async def _sync_one(server: Server): async def _sync_one(server: Server):
@@ -102,7 +110,8 @@ class SyncEngineV2:
finished_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc),
error_message="推送已取消", error_message="推送已取消",
) )
sync_log = await self.sync_log_repo.create(sync_log) async with _db_lock:
sync_log = await self.sync_log_repo.create(sync_log)
async with _counter_lock: async with _counter_lock:
cancelled += 1 cancelled += 1
@@ -137,7 +146,8 @@ class SyncEngineV2:
sync_mode=sync_mode, sync_mode=sync_mode,
started_at=datetime.now(timezone.utc), started_at=datetime.now(timezone.utc),
) )
sync_log = await self.sync_log_repo.create(sync_log) async with _db_lock:
sync_log = await self.sync_log_repo.create(sync_log)
try: try:
from server.api.websocket import broadcast_sync_progress from server.api.websocket import broadcast_sync_progress
@@ -175,42 +185,43 @@ class SyncEngineV2:
sync_log.diff_summary = (result.get("stdout") or "")[:2000] sync_log.diff_summary = (result.get("stdout") or "")[:2000]
else: else:
sync_log.diff_summary = None sync_log.diff_summary = None
sync_log = await self.sync_log_repo.update(sync_log)
# Auto-create retry job for failed pushes (dedup: one pending per server+path)
retry_job_id = None retry_job_id = None
if sync_log.status == "failed": async with _db_lock:
try: sync_log = await self.sync_log_repo.update(sync_log)
from server.domain.models import PushRetryJob
from sqlalchemy import select as _select if sync_log.status == "failed":
existing = await self.retry_repo.session.execute( try:
_select(PushRetryJob).where( from server.domain.models import PushRetryJob
PushRetryJob.server_id == server.id, from sqlalchemy import select as _select
PushRetryJob.source_path == source_path, existing = await self.retry_repo.session.execute(
PushRetryJob.target_path == dest, _select(PushRetryJob).where(
PushRetryJob.status == "pending", PushRetryJob.server_id == server.id,
PushRetryJob.source_path == source_path,
PushRetryJob.target_path == dest,
PushRetryJob.status == "pending",
)
) )
) if existing.scalars().first() is None:
if existing.scalars().first() is None: retry_job = PushRetryJob(
retry_job = PushRetryJob( server_id=server.id,
server_id=server.id, server_name=server.name,
server_name=server.name, operator=operator,
operator=operator, source_path=source_path,
source_path=source_path, target_path=dest,
target_path=dest, status="pending",
status="pending", retry_count=0,
retry_count=0, max_retries=3,
max_retries=3, next_retry_at=datetime.now(timezone.utc),
next_retry_at=datetime.now(timezone.utc), last_error=sync_log.error_message[:500] if sync_log.error_message else None,
last_error=sync_log.error_message[:500] if sync_log.error_message else None, )
) retry_job = await self.retry_repo.create(retry_job)
retry_job = await self.retry_repo.create(retry_job) retry_job_id = retry_job.id
retry_job_id = retry_job.id logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}") else:
else: logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate") except Exception as e:
except Exception as e: logger.warning(f"Failed to create retry job for server {server.id}: {e}")
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
async with _counter_lock: async with _counter_lock:
if sync_log.status == "success": if sync_log.status == "success":
@@ -313,9 +324,10 @@ class SyncEngineV2:
if not server: if not server:
return {"error": "Server not found", "exit_code": -1} return {"error": "Server not found", "exit_code": -1}
source_path = _nexus_source_path(source_path) try:
if not os.path.isdir(source_path): source_path = _nexus_source_path(source_path)
return {"error": f"源路径不存在: {source_path}", "exit_code": -1} except PosixPathError as exc:
return {"error": str(exc), "exit_code": -1}
dest = target_path or server.target_path or "/tmp/sync" dest = target_path or server.target_path or "/tmp/sync"
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose) result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
+41 -20
View File
@@ -37,6 +37,7 @@ def apply_push_retry_outcome(
if job.retry_count >= job.max_retries: if job.retry_count >= job.max_retries:
job.status = "failed" job.status = "failed"
else: else:
job.status = "pending"
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count)) job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
return return
@@ -54,10 +55,33 @@ def apply_push_retry_outcome(
job.last_error = f"Retry exhausted after {job.retry_count} attempts" job.last_error = f"Retry exhausted after {job.retry_count} attempts"
return return
job.status = "pending"
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count)) job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
job.last_error = f"Retry {job.retry_count} failed" job.last_error = f"Retry {job.retry_count} failed"
async def _claim_next_retry_job(session, now: datetime) -> PushRetryJob | None:
"""Claim one due retry job with row lock (SKIP LOCKED)."""
from sqlalchemy import select
result = await session.execute(
select(PushRetryJob)
.where(
PushRetryJob.status == "pending",
PushRetryJob.retry_count < PushRetryJob.max_retries,
PushRetryJob.next_retry_at <= now,
)
.limit(1)
.with_for_update(skip_locked=True)
)
job = result.scalar_one_or_none()
if not job:
return None
job.status = "running"
await session.commit()
return job
async def retry_runner_loop(): async def retry_runner_loop():
"""Background task: every 5min, retry pending retry jobs that are due.""" """Background task: every 5min, retry pending retry jobs that are due."""
logger.info("Retry runner loop started (interval: 5min)") logger.info("Retry runner loop started (interval: 5min)")
@@ -66,23 +90,15 @@ async def retry_runner_loop():
try: try:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
from sqlalchemy import select
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = await session.execute(
select(PushRetryJob).where(
PushRetryJob.status == "pending",
PushRetryJob.retry_count < PushRetryJob.max_retries,
PushRetryJob.next_retry_at <= now,
)
)
jobs = result.scalars().all()
if not jobs:
continue
retried = 0 retried = 0
for job in jobs:
while True:
job = await _claim_next_retry_job(session, now)
if not job:
break
job_id = job.id
try: try:
from server.application.services.sync_engine_v2 import SyncEngineV2 from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -110,16 +126,16 @@ async def retry_runner_loop():
if job.status == "completed": if job.status == "completed":
logger.info( logger.info(
f"Retry job {job.id}: success after {job.retry_count} attempts" f"Retry job {job_id}: success after {job.retry_count} attempts"
) )
elif job.status == "failed": elif job.status == "failed":
logger.warning( logger.warning(
f"Retry job {job.id}: failed — {job.last_error}" f"Retry job {job_id}: failed — {job.last_error}"
) )
else: else:
backoff = int((job.next_retry_at - now).total_seconds()) backoff = int((job.next_retry_at - now).total_seconds())
logger.warning( logger.warning(
f"Retry job {job.id}: attempt {job.retry_count} pending, " f"Retry job {job_id}: attempt {job.retry_count} pending, "
f"next retry in {backoff}s — {job.last_error}" f"next retry in {backoff}s — {job.last_error}"
) )
@@ -127,15 +143,20 @@ async def retry_runner_loop():
retried += 1 retried += 1
except Exception as e: except Exception as e:
logger.error(f"Retry job {job.id} execution error: {e}") logger.error(f"Retry job {job_id} execution error: {e}")
await session.rollback()
job = await session.get(PushRetryJob, job_id)
if not job:
continue
job.retry_count += 1 job.retry_count += 1
job.last_error = str(e)[:500] job.last_error = str(e)[:500]
if job.retry_count >= job.max_retries: if job.retry_count >= job.max_retries:
job.status = "failed" job.status = "failed"
logger.warning( logger.warning(
f"Retry job {job.id}: max retries reached (exception), marking failed" f"Retry job {job_id}: max retries reached (exception), marking failed"
) )
else: else:
job.status = "pending"
job.next_retry_at = now + timedelta( job.next_retry_at = now + timedelta(
seconds=_backoff_seconds(job.retry_count) seconds=_backoff_seconds(job.retry_count)
) )
+67 -41
View File
@@ -73,6 +73,36 @@ def _field_match(expr: str, value: int) -> bool:
return False return False
def _schedule_is_due(schedule: PushSchedule, now: datetime) -> bool:
"""Return True if *schedule* should fire at *now* (cron or once)."""
run_mode = getattr(schedule, "run_mode", None) or "cron"
if run_mode == "once":
fire_at = getattr(schedule, "fire_at", None)
if not fire_at:
return False
now_naive = now.replace(tzinfo=None)
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at
if fire_naive > now_naive:
return False
if schedule.last_run_at:
return False
return True
cron_expr = getattr(schedule, "cron_expr", None)
if not cron_expr or not _cron_match(cron_expr, now):
return False
if schedule.last_run_at:
last_run = schedule.last_run_at
now_naive = now.replace(tzinfo=None)
if last_run.tzinfo is not None:
last_run = last_run.replace(tzinfo=None)
seconds_since = (now_naive - last_run).total_seconds()
if seconds_since < SCHEDULE_CHECK_INTERVAL:
return False
return True
async def schedule_runner_loop(): async def schedule_runner_loop():
"""Background task: check schedules every 60s, trigger due cron jobs.""" """Background task: check schedules every 60s, trigger due cron jobs."""
logger.info("Schedule runner loop started (interval: 60s)") logger.info("Schedule runner loop started (interval: 60s)")
@@ -82,67 +112,58 @@ async def schedule_runner_loop():
try: try:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
from sqlalchemy import select from sqlalchemy import select
result = await session.execute( id_result = await session.execute(
select(PushSchedule).where(PushSchedule.enabled == True) select(PushSchedule.id).where(PushSchedule.enabled == True)
) )
schedules = result.scalars().all() schedule_ids = list(id_result.scalars().all())
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
triggered = 0 triggered = 0
for schedule in schedules: for sched_id in schedule_ids:
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
try: try:
# ── One-time schedule: check fire_at ── lock_result = await session.execute(
if run_mode == 'once': select(PushSchedule)
fire_at = getattr(schedule, 'fire_at', None) .where(
if not fire_at: PushSchedule.id == sched_id,
continue PushSchedule.enabled == True,
now_naive = now.replace(tzinfo=None) )
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at .with_for_update(skip_locked=True)
if fire_naive > now_naive: )
continue # not time yet schedule = lock_result.scalar_one_or_none()
if schedule.last_run_at: if not schedule:
continue # already ran continue
else:
# ── Cron schedule: check cron expression ── if not _schedule_is_due(schedule, now):
cron_expr = getattr(schedule, 'cron_expr', None) await session.rollback()
if not cron_expr or not _cron_match(cron_expr, now): continue
continue
if schedule.last_run_at: run_mode = getattr(schedule, "run_mode", None) or "cron"
last_run = schedule.last_run_at
now_naive = now.replace(tzinfo=None)
if last_run.tzinfo is not None:
last_run = last_run.replace(tzinfo=None)
seconds_since = (now_naive - last_run).total_seconds()
if seconds_since < SCHEDULE_CHECK_INTERVAL:
continue
# Parse server_ids from JSON # Parse server_ids from JSON
try: try:
server_ids = json.loads(schedule.server_ids) server_ids = json.loads(schedule.server_ids)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON") logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
await session.rollback()
continue continue
if not server_ids: if not server_ids:
await session.rollback()
continue continue
# Mark as running BEFORE execution to prevent double-trigger # Mark as running BEFORE execution to prevent double-trigger
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL).
# On failure we reset last_run_at so the schedule can retry.
prev_last_run_at = schedule.last_run_at prev_last_run_at = schedule.last_run_at
schedule.last_run_at = now schedule.last_run_at = now
if run_mode == 'once': if run_mode == "once":
schedule.enabled = False schedule.enabled = False
await session.commit() await session.commit()
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push' schedule_type = getattr(schedule, "schedule_type", "push") or "push"
execution_ok = True execution_ok = True
try: try:
if schedule_type == 'script': if schedule_type == "script":
# ── Script execution schedule ── # ── Script execution schedule ──
from server.application.services.script_service import ScriptService from server.application.services.script_service import ScriptService
from server.infrastructure.database.script_repo import ( from server.infrastructure.database.script_repo import (
@@ -212,22 +233,27 @@ async def schedule_runner_loop():
f"Schedule '{schedule.name}' (push) triggered: " f"Schedule '{schedule.name}' (push) triggered: "
f"{result['completed']}/{result['total']} succeeded" f"{result['completed']}/{result['total']} succeeded"
) )
if result.get("error"):
execution_ok = False
elif result.get("total", 0) > 0 and result.get("completed", 0) == 0:
execution_ok = False
except Exception as exec_err: except Exception as exec_err:
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}") logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
execution_ok = False execution_ok = False
if not execution_ok: if not execution_ok:
rollback_schedule_after_failed_run( fresh = await session.get(PushSchedule, sched_id)
schedule, prev_last_run_at, run_mode if fresh:
) rollback_schedule_after_failed_run(
await session.commit() fresh, prev_last_run_at, run_mode
else: )
await session.commit() await session.commit()
triggered += 1 triggered += 1
except Exception as e: except Exception as e:
logger.error(f"Schedule {schedule.id} setup/parse failed: {e}") logger.error(f"Schedule {sched_id} setup/parse failed: {e}")
await session.rollback()
if triggered: if triggered:
logger.info(f"Schedule runner: {triggered} schedules triggered") logger.info(f"Schedule runner: {triggered} schedules triggered")
+21 -3
View File
@@ -19,7 +19,9 @@ D7: DB session leak fix — middleware auto-manages session lifecycle per reques
""" """
import asyncio import asyncio
import fcntl
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -86,6 +88,20 @@ _background_tasks: list[asyncio.Task] = []
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn) # Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
_PRIMARY_LOCK_KEY = "nexus:primary_worker" _PRIMARY_LOCK_KEY = "nexus:primary_worker"
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically _PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
_PRIMARY_FILE_LOCK_FD: int | None = None
def _try_acquire_file_primary_lock() -> bool:
"""Fallback when Redis is unavailable: one process per host via flock."""
global _PRIMARY_FILE_LOCK_FD
lock_path = os.environ.get("NEXUS_PRIMARY_LOCK_FILE", "/tmp/nexus-primary.lock")
try:
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_PRIMARY_FILE_LOCK_FD = fd
return True
except (OSError, BlockingIOError):
return False
async def _acquire_primary_lock() -> bool: async def _acquire_primary_lock() -> bool:
@@ -109,9 +125,11 @@ async def _acquire_primary_lock() -> bool:
return True return True
return False return False
except Exception as e: except Exception as e:
# If Redis fails, assume primary (single-worker mode) logger.warning("Primary lock check failed, trying file lock fallback: %s", e)
logger.warning(f"Primary lock check failed, assuming primary: {e}") acquired = _try_acquire_file_primary_lock()
return True if acquired:
logger.info("Primary worker lock acquired via file fallback")
return acquired
async def _renew_primary_lock(): async def _renew_primary_lock():
+27
View File
@@ -15,6 +15,19 @@ import posixpath
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_" UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
"/etc",
"/root",
"/home",
"/var",
"/boot",
"/dev",
"/proc",
"/sys",
"/run",
"/srv",
)
class PosixPathError(ValueError): class PosixPathError(ValueError):
"""Invalid or unsafe POSIX path.""" """Invalid or unsafe POSIX path."""
@@ -87,6 +100,20 @@ def resolve_nexus_host_path(path: str) -> str:
return os.path.realpath(posix) return os.path.realpath(posix)
def resolve_nexus_push_source_path(path: str) -> str:
"""Resolve and validate a Nexus host push source directory."""
stripped = path.strip()
if ".." in to_posix(stripped).split("/"):
raise PosixPathError("路径不允许包含 '..'")
real_path = resolve_nexus_host_path(stripped)
for prefix in FORBIDDEN_NEXUS_SOURCE_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise PosixPathError(f"不允许使用系统路径: {prefix}")
if not os.path.isdir(real_path):
raise PosixPathError(f"源路径不存在: {real_path}")
return real_path
def ensure_under_nexus_upload(path: str) -> str: def ensure_under_nexus_upload(path: str) -> str:
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``.""" """Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
real = resolve_nexus_host_path(path) real = resolve_nexus_host_path(path)
+12
View File
@@ -56,3 +56,15 @@ def test_exhausted_retries_marks_failed():
now=datetime.now(timezone.utc), now=datetime.now(timezone.utc),
) )
assert job.status == "failed" assert job.status == "failed"
def test_running_job_returns_to_pending_after_retryable_failure():
job = _job(retry_count=1, status="running")
now = datetime.now(timezone.utc)
apply_push_retry_outcome(
job,
{"total": 1, "completed": 0, "failed": 1, "cancelled": 0},
now=now,
)
assert job.status == "pending"
assert job.next_retry_at > now
+30
View File
@@ -227,3 +227,33 @@ def test_install_reject_when_locked(monkeypatch, tmp_path):
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
install_api._reject_if_locked() install_api._reject_if_locked()
assert exc.value.status_code == 403 assert exc.value.status_code == 403
def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
state_file = tmp_path / ".install_state.json"
state_file.write_text('{"install_token":"abc123"}', encoding="utf-8")
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
with pytest.raises(HTTPException) as exc:
install_api._verify_install_token("")
assert exc.value.status_code == 403
with pytest.raises(HTTPException) as exc:
install_api._verify_install_token("wrong")
assert exc.value.status_code == 403
install_api._verify_install_token("abc123")
def test_resolve_nexus_push_source_path_rejects_system_prefix(tmp_path, monkeypatch):
from server.utils.posix_paths import PosixPathError, resolve_nexus_push_source_path
allowed = tmp_path / "push_src"
allowed.mkdir()
assert resolve_nexus_push_source_path(str(allowed)) == str(allowed.resolve())
with pytest.raises(PosixPathError, match="系统路径"):
resolve_nexus_push_source_path("/etc")
+3
View File
@@ -566,6 +566,7 @@ function installWizard() {
envChecks: [], envChecks: [],
envAllPass: false, envAllPass: false,
initResult: null, initResult: null,
installToken: '',
installDir: '/opt/nexus', installDir: '/opt/nexus',
btPanel: false, btPanel: false,
@@ -659,6 +660,7 @@ function installWizard() {
if (r.ok && d.success) { if (r.ok && d.success) {
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。'; this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
this.initResult = d; this.initResult = d;
this.installToken = d.install_token || '';
this.btPanel = !!d.bt_panel; this.btPanel = !!d.bt_panel;
this.step = 4; this.step = 4;
} else { } else {
@@ -675,6 +677,7 @@ function installWizard() {
this.error = ''; this.error = '';
try { try {
const body = { const body = {
install_token: this.installToken,
...this.adminForm, ...this.adminForm,
db_host: this.form.db_host, db_host: this.form.db_host,
db_port: this.form.db_port, db_port: this.form.db_port,