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仅本机。
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 应同步)
| 文档 | 问题 | 动作 |
@@ -279,6 +295,8 @@ Nexus 6.02000+ 台服务器运维平台;FastAPI 四层架构 + Redis 实时
| 健康 SSH | `server/infrastructure/ssh/remote_probe.py` |
| 告警历史 | `web/app/alerts.html`, `server/api/settings.py`alert_history_router |
| 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**
7. 宝塔 Python 管理 → **不用**venv + 3.12
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
【文档】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
【本地验收】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")"
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) => {
wsSocket.value = null
if (ev.code === 4001 || ev.code === 4401) {
auth.forceLogout()
return
}
if (ev.code !== 1000 && pushing.value) {
wsProgressItems.value.forEach(p => {
if (p.status === 'pending' || p.status === 'running') {
@@ -242,6 +246,7 @@ export function usePushProgress(
}
applyPushResultsFromHttp(res)
} catch (e: unknown) {
disconnectProgressWs()
const msg = e instanceof Error ? e.message : '推送请求失败'
wsProgressItems.value.forEach(p => {
p.status = 'failed'
@@ -249,6 +254,7 @@ export function usePushProgress(
})
pushing.value = false
snackbar(msg, 'error')
onPushFinished()
return
}
+5 -1
View File
@@ -6,6 +6,8 @@
*/
import { ref } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
export interface ServerBrief {
id: number
@@ -19,6 +21,7 @@ export interface ServerBrief {
export function useServerList() {
const servers = ref<ServerBrief[]>([])
const loading = ref(false)
const snackbar = useSnackbar()
async function loadServers(opts?: { perPage?: number }) {
loading.value = true
@@ -27,8 +30,9 @@ export function useServerList() {
per_page: opts?.perPage ?? 200,
})
servers.value = res.items
} catch {
} catch (e: unknown) {
servers.value = []
snackbar(formatApiError(e, '加载服务器列表失败'), 'error')
} finally {
loading.value = false
}
+5 -1
View File
@@ -99,8 +99,11 @@
import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import type { CommandLogItem, SshSessionItem } from '@/types/api'
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
// ── State ──
@@ -149,9 +152,10 @@ async function loadData() {
sessions.value = res.items
total.value = res.total
}
} catch {
} catch (e: unknown) {
commands.value = []
sessions.value = []
snackbar(formatApiError(e, '加载命令日志失败'), 'error')
} finally {
loading.value = false
}
+13 -3
View File
@@ -182,6 +182,7 @@
import { ref, watch, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
const snackbar = useSnackbar()
@@ -252,7 +253,10 @@ async function loadPasswords() {
const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items
totalItems.value = res.total
} catch { passwords.value = [] }
} catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
}
finally { loading.value = false }
}
async function loadSshKeys() {
@@ -264,7 +268,10 @@ async function loadSshKeys() {
})
sshKeys.value = res.items
totalItems.value = res.total
} catch { sshKeys.value = [] }
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
}
finally { loading.value = false }
}
async function loadDbCreds() {
@@ -276,7 +283,10 @@ async function loadDbCreds() {
})
dbCreds.value = res.items
totalItems.value = res.total
} catch { dbCreds.value = [] }
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
}
finally { loading.value = false }
}
+6 -1
View File
@@ -332,6 +332,7 @@ async function loadAlerts() {
}))
} catch {
recentAlerts.value = []
snackbar('加载告警记录失败', 'error')
}
}
@@ -339,7 +340,10 @@ async function loadRecentAudit() {
try {
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
} catch { recentAudit.value = [] }
} catch {
recentAudit.value = []
snackbar('加载审计记录失败', 'error')
}
}
async function loadSummary() {
@@ -398,6 +402,7 @@ onMounted(() => {
loadAlerts()
loadSummary()
loadRecentAudit()
http.post('/settings/bing-wallpapers/sync').catch(() => {})
})
interface AlertItem {
+2 -1
View File
@@ -175,7 +175,8 @@ async function doLogin() {
try {
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)
} catch (e: any) {
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 { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
const snackbar = useSnackbar()
@@ -360,7 +361,10 @@ async function loadScripts() {
})
scripts.value = res.items
totalItems.value = res.total
} catch { scripts.value = [] }
} catch (e: unknown) {
scripts.value = []
snackbar(formatApiError(e, '加载脚本列表失败'), 'error')
}
finally { loading.value = false }
}
+75 -12
View File
@@ -55,7 +55,18 @@
</v-card-title>
<v-divider />
<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-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
</v-card-text>
@@ -162,15 +173,17 @@
<!-- API Key Reveal Dialog -->
<v-dialog v-model="showRevealDialog" max-width="400">
<v-card border>
<v-card-title>验证身份</v-card-title>
<v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
<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-card-text>
<v-card-actions>
<v-spacer />
<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>
</v-dialog>
@@ -214,6 +227,8 @@ const settings = ref({
db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '',
})
const telegramTokenDraft = ref('')
const telegramTokenSet = ref(false)
const saving = ref(false)
const testingTg = ref(false)
const showToken = ref(false)
@@ -236,6 +251,7 @@ const disablingTotp = ref(false)
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)
@@ -253,6 +269,11 @@ async function loadSettings() {
const knownKeys = Object.keys(settings.value)
for (const item of items) {
const key = item.key
if (key === 'telegram_bot_token') {
telegramTokenSet.value = Boolean(item.value_set)
telegramTokenDraft.value = ''
continue
}
if (key && knownKeys.includes(key)) {
const val = item.value
// Type-cast numeric strings back to numbers for number inputs
@@ -283,14 +304,23 @@ 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_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) {
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')
@@ -381,25 +411,58 @@ function copyTotpSecret() {
}
async function revealApiKey() {
if (showApiKey.value) { showApiKey.value = false; return }
if (showApiKey.value) {
showApiKey.value = false
return
}
revealTarget.value = 'api_key'
showRevealDialog.value = true
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) {
snackbar('请输入密码', 'error')
return
}
revealingKey.value = true
try {
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { current_password: revealPassword.value })
apiKeyValue.value = res.api_key
showApiKey.value = true
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 = ''
} catch (e: any) { snackbar(e.message || '验证失败', 'error') }
finally { revealingKey.value = false }
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '验证失败', 'error')
} finally {
revealingKey.value = false
}
}
async function doRevealApiKey() {
revealTarget.value = 'api_key'
await doRevealSecret()
}
function copyApiKey() {
+1
View File
@@ -18,6 +18,7 @@ export type SettingsResponse = SettingItem[]
export interface SettingItem {
key: string
value: unknown
value_set?: boolean | null
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:
"""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:
1. If server has agent_api_key set → must match exactly
2. Otherwise → must match global API_KEY
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)
"""
# Try to get server's per-server key
try:
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:
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
return secrets.compare_digest(provided_key, settings.API_KEY)
if not server:
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:
+24
View File
@@ -68,6 +68,7 @@ class InitDbRequest(BaseModel):
class CreateAdminRequest(BaseModel):
install_token: str
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
@@ -310,11 +311,29 @@ def _finalize_install_lock() -> None:
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 ──
@router.get("/status")
async def install_status():
"""Check whether Nexus is already installed."""
if _is_locked():
raise HTTPException(status_code=404, detail="Not found")
return {
"installed": _is_installed(),
"locked": _is_locked(),
@@ -542,6 +561,7 @@ async def init_db(req: InitDbRequest):
# Save install state for step 4
import json
install_token = secrets.token_urlsafe(32)
state = {
"db_host": req.db_host,
"db_port": req.db_port,
@@ -549,6 +569,7 @@ async def init_db(req: InitDbRequest):
"db_user": req.db_user,
# 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
"install_token": install_token,
"pool_size": pool_size,
"max_overflow": max_overflow,
"install_dir": install_dir,
@@ -561,6 +582,7 @@ async def init_db(req: InitDbRequest):
return {
"success": True,
"install_token": install_token,
"pool_size": pool_size,
"max_overflow": max_overflow,
"tables_created": 14,
@@ -576,6 +598,8 @@ async def create_admin(req: CreateAdminRequest):
if not _has_env():
raise HTTPException(400, "请先完成步骤3:数据库初始化")
_verify_install_token(req.install_token)
if len(req.admin_password) < 6:
raise HTTPException(400, "密码长度至少6位")
+71 -64
View File
@@ -111,18 +111,29 @@ def _validate_ip_list(ips: list[str]) -> None:
# ── 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)
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
settings = await repo.get_all()
result = []
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
return [_format_setting_response(s.key, s.value, s.updated_at) for s in settings]
@router.get("/ip-allowlist", response_model=dict)
@@ -173,22 +184,29 @@ def _cleanup_old_wallpapers() -> int:
return removed
@router.get("/bing-wallpapers", response_model=dict)
async def bing_wallpapers():
"""Return all cached wallpaper URLs for frontend slideshow rotation.
def _cached_wallpaper_urls() -> dict:
"""List locally cached wallpaper URLs (no outbound HTTP)."""
_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
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
# Download today + last 7 days from Bing (n=8 gives ~8 images)
downloaded = 0
try:
async with httpx.AsyncClient(timeout=12.0) as client:
# Fetch up to 8 recent images from Bing
resp = await client.get(
"https://cn.bing.com/HPImageArchive.aspx",
params={"format": "js", "idx": 0, "n": 8, "mkt": "zh-CN"},
@@ -198,17 +216,15 @@ async def bing_wallpapers():
img_path = img.get("url")
if not img_path:
continue
# Use date from Bing metadata as filename
date_str = img.get("startdate", "") or img.get("fullstartdate", "")[:8] or ""
if not date_str:
# fallback: extract from URL
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}"
cached = _WALLPAPER_DIR / f"{date_str}.jpg"
if cached.exists() and cached.stat().st_size > 1024:
continue # already cached
continue
try:
img_resp = await client.get(f"https://cn.bing.com{img_path}")
@@ -217,64 +233,50 @@ async def bing_wallpapers():
downloaded += 1
except httpx.HTTPError as e:
logger.debug("Bing image download failed: %s", e)
continue
except httpx.HTTPError as e:
logger.warning("Bing wallpaper sync failed: %s", e)
# Cleanup old files
_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)
for f in all_files[_MAX_WALLPAPERS:]:
try:
f.unlink()
except OSError as 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)]
return {"urls": urls, "count": len(urls)}
@router.get("/bing-wallpapers", response_model=dict)
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
@router.get("/bing-wallpaper", response_model=dict)
async def bing_wallpaper():
"""Return today's wallpaper URL (backward compat)."""
try:
import httpx
from datetime import datetime
"""Return today's cached wallpaper URL (public, read-only)."""
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
from datetime import datetime
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
today = datetime.utcnow().strftime("%Y-%m-%d")
cached = _WALLPAPER_DIR / f"{today}.jpg"
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()
today = datetime.utcnow().strftime("%Y-%m-%d")
cached = _WALLPAPER_DIR / f"{today}.jpg"
if cached.exists() and cached.stat().st_size > 1024:
return {"url": f"/app/wallpapers/{today}.jpg"}
except Exception:
# Try any cached file
cached_list = sorted(_WALLPAPER_DIR.glob("*.jpg"))
if cached_list:
return {"url": f"/app/wallpapers/{cached_list[-1].name}"}
return {"url": None}
all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
if all_files:
return {"url": f"/app/wallpapers/{all_files[0].name}"}
return {"url": None}
@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)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value:
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value}
return _format_setting_response(key, value, None)
@router.post("/api-key/reveal", response_model=dict)
@@ -333,8 +333,15 @@ async def set_setting(
if key not in MUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
# S-02: Validate value type and range for int settings
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:
expected_type, min_val, max_val = SETTING_VALIDATORS[key]
try:
+14 -24
View File
@@ -44,6 +44,7 @@ from server.utils.posix_paths import (
normalize_remote_dest,
remote_join,
resolve_nexus_host_path,
resolve_nexus_push_source_path,
to_posix,
)
from server.application.services.sync_engine_v2 import SyncEngineV2
@@ -529,8 +530,6 @@ async def reconcile_stale_sync_logs(
# ── H5: Validate Source Path ──
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
@router.post("/validate-source-path")
async def validate_source_path(
@@ -545,25 +544,13 @@ async def validate_source_path(
"""
import os
path = payload.path.strip()
try:
real_path = resolve_nexus_host_path(path)
real_path = resolve_nexus_push_source_path(payload.path)
except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ".." in to_posix(path).split("/"):
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
# 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="路径不是目录")
status = 403 if "系统路径" in str(exc) else 400
if "不存在" in str(exc):
status = 404
raise HTTPException(status_code=status, detail=str(exc)) from exc
# Count files and total size
file_count = 0
@@ -582,7 +569,7 @@ async def validate_source_path(
await _audit_sync(
"validate_source_path", "sync", 0,
f"验证源路径: {path} ({file_count} 文件)",
f"验证源路径: {payload.path} ({file_count} 文件)",
admin.username, request,
)
@@ -1105,15 +1092,18 @@ async def verify_sync(
session = request.state.db
if not os.path.isdir(payload.source_path):
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
try:
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}
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:
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:
break
try:
+2 -2
View File
@@ -307,7 +307,7 @@ async def alert_ws(
admin = await _verify_ws_token(token)
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
# ── Accept and register ──
@@ -343,7 +343,7 @@ async def sync_progress_ws(
admin = await _verify_ws_token(token)
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
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:
return None, None
# P1-7: Check updated_at — invalidate token after password change
token_updated = payload.get("updated", 0)
if token_updated and admin.updated_at:
admin_ts = int(admin.updated_at.timestamp())
# Allow 5-second grace for clock skew / race conditions
if admin_ts > token_updated + 5:
token_tv = payload.get("tv")
if token_tv is not None:
if int(token_tv) != int(admin.token_version or 0):
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
except Exception:
@@ -96,7 +100,7 @@ async def terminal_ws(
admin, token_server_id = await _verify_webssh_token(token)
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
# 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": "无效的刷新令牌"}
# 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)
new_refresh = self._create_refresh_token(admin)
# Store new refresh token in Redis
await self._store_refresh_token(admin.id, new_refresh)
rotated = await self._rotate_refresh_token(admin.id, refresh_token, 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 {
"success": True,
@@ -461,6 +466,7 @@ class AuthService:
"iat": now,
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
"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")
@@ -618,6 +624,44 @@ class AuthService:
membership = await self._refresh_token_membership(admin_id, token)
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):
"""Remove a single refresh token from Redis Set (single-device logout or rotation)"""
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:
"""In-place: add remote_log for servers with a nexus-job log path."""
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:
log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
@@ -514,7 +530,7 @@ class ScriptService:
server_id = int(sid_str)
except ValueError:
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:
entry["remote_log"] = "(服务器离线,无法拉取日志)"
return
@@ -549,8 +565,14 @@ class ScriptService:
results: Dict[str, dict] = {}
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):
server = await self.server_repo.get_by_id(server_id)
server = server_map.get(server_id)
if not server or not server.is_online:
results[str(server_id)] = {
"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.push_schedule_repo import PushRetryJobRepositoryImpl
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:
"""Normalize push source path string (Nexus host, Linux deployment)."""
return to_posix(path.strip())
"""Normalize and validate push source path on the Nexus host."""
return resolve_nexus_push_source_path(path)
logger = logging.getLogger("nexus.sync_v2")
@@ -63,10 +69,11 @@ class SyncEngineV2:
batch_id: Optional[str] = None,
) -> dict:
"""Push files from Nexus to target servers via rsync over SSH"""
source_path = _nexus_source_path(source_path)
if not os.path.isdir(source_path):
try:
source_path = _nexus_source_path(source_path)
except PosixPathError as exc:
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]
concurrency = min(concurrency, MAX_CONCURRENT)
@@ -79,6 +86,7 @@ class SyncEngineV2:
failed = 0
cancelled = 0
_counter_lock = asyncio.Lock()
_db_lock = asyncio.Lock()
results = {}
async def _sync_one(server: Server):
@@ -102,7 +110,8 @@ class SyncEngineV2:
finished_at=datetime.now(timezone.utc),
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:
cancelled += 1
@@ -137,7 +146,8 @@ class SyncEngineV2:
sync_mode=sync_mode,
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:
from server.api.websocket import broadcast_sync_progress
@@ -175,42 +185,43 @@ class SyncEngineV2:
sync_log.diff_summary = (result.get("stdout") or "")[:2000]
else:
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
if sync_log.status == "failed":
try:
from server.domain.models import PushRetryJob
from sqlalchemy import select as _select
existing = await self.retry_repo.session.execute(
_select(PushRetryJob).where(
PushRetryJob.server_id == server.id,
PushRetryJob.source_path == source_path,
PushRetryJob.target_path == dest,
PushRetryJob.status == "pending",
async with _db_lock:
sync_log = await self.sync_log_repo.update(sync_log)
if sync_log.status == "failed":
try:
from server.domain.models import PushRetryJob
from sqlalchemy import select as _select
existing = await self.retry_repo.session.execute(
_select(PushRetryJob).where(
PushRetryJob.server_id == server.id,
PushRetryJob.source_path == source_path,
PushRetryJob.target_path == dest,
PushRetryJob.status == "pending",
)
)
)
if existing.scalars().first() is None:
retry_job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=dest,
status="pending",
retry_count=0,
max_retries=3,
next_retry_at=datetime.now(timezone.utc),
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_id = retry_job.id
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
else:
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
except Exception as e:
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
if existing.scalars().first() is None:
retry_job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=dest,
status="pending",
retry_count=0,
max_retries=3,
next_retry_at=datetime.now(timezone.utc),
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_id = retry_job.id
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
else:
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
except Exception as e:
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
async with _counter_lock:
if sync_log.status == "success":
@@ -313,9 +324,10 @@ class SyncEngineV2:
if not server:
return {"error": "Server not found", "exit_code": -1}
source_path = _nexus_source_path(source_path)
if not os.path.isdir(source_path):
return {"error": f"源路径不存在: {source_path}", "exit_code": -1}
try:
source_path = _nexus_source_path(source_path)
except PosixPathError as exc:
return {"error": str(exc), "exit_code": -1}
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)
+41 -20
View File
@@ -37,6 +37,7 @@ def apply_push_retry_outcome(
if job.retry_count >= job.max_retries:
job.status = "failed"
else:
job.status = "pending"
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
return
@@ -54,10 +55,33 @@ def apply_push_retry_outcome(
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
return
job.status = "pending"
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
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():
"""Background task: every 5min, retry pending retry jobs that are due."""
logger.info("Retry runner loop started (interval: 5min)")
@@ -66,23 +90,15 @@ async def retry_runner_loop():
try:
async with AsyncSessionLocal() as session:
from sqlalchemy import select
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
for job in jobs:
while True:
job = await _claim_next_retry_job(session, now)
if not job:
break
job_id = job.id
try:
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -110,16 +126,16 @@ async def retry_runner_loop():
if job.status == "completed":
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":
logger.warning(
f"Retry job {job.id}: failed — {job.last_error}"
f"Retry job {job_id}: failed — {job.last_error}"
)
else:
backoff = int((job.next_retry_at - now).total_seconds())
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}"
)
@@ -127,15 +143,20 @@ async def retry_runner_loop():
retried += 1
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.last_error = str(e)[:500]
if job.retry_count >= job.max_retries:
job.status = "failed"
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:
job.status = "pending"
job.next_retry_at = now + timedelta(
seconds=_backoff_seconds(job.retry_count)
)
+67 -41
View File
@@ -73,6 +73,36 @@ def _field_match(expr: str, value: int) -> bool:
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():
"""Background task: check schedules every 60s, trigger due cron jobs."""
logger.info("Schedule runner loop started (interval: 60s)")
@@ -82,67 +112,58 @@ async def schedule_runner_loop():
try:
async with AsyncSessionLocal() as session:
from sqlalchemy import select
result = await session.execute(
select(PushSchedule).where(PushSchedule.enabled == True)
id_result = await session.execute(
select(PushSchedule.id).where(PushSchedule.enabled == True)
)
schedules = result.scalars().all()
schedule_ids = list(id_result.scalars().all())
now = datetime.now(timezone.utc)
triggered = 0
for schedule in schedules:
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
for sched_id in schedule_ids:
try:
# ── One-time schedule: check fire_at ──
if run_mode == 'once':
fire_at = getattr(schedule, 'fire_at', None)
if not fire_at:
continue
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:
continue # not time yet
if schedule.last_run_at:
continue # already ran
else:
# ── Cron schedule: check cron expression ──
cron_expr = getattr(schedule, 'cron_expr', None)
if not cron_expr or not _cron_match(cron_expr, now):
continue
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:
continue
lock_result = await session.execute(
select(PushSchedule)
.where(
PushSchedule.id == sched_id,
PushSchedule.enabled == True,
)
.with_for_update(skip_locked=True)
)
schedule = lock_result.scalar_one_or_none()
if not schedule:
continue
if not _schedule_is_due(schedule, now):
await session.rollback()
continue
run_mode = getattr(schedule, "run_mode", None) or "cron"
# Parse server_ids from JSON
try:
server_ids = json.loads(schedule.server_ids)
except (json.JSONDecodeError, TypeError):
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
await session.rollback()
continue
if not server_ids:
await session.rollback()
continue
# 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
schedule.last_run_at = now
if run_mode == 'once':
if run_mode == "once":
schedule.enabled = False
await session.commit()
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push'
schedule_type = getattr(schedule, "schedule_type", "push") or "push"
execution_ok = True
try:
if schedule_type == 'script':
if schedule_type == "script":
# ── Script execution schedule ──
from server.application.services.script_service import ScriptService
from server.infrastructure.database.script_repo import (
@@ -212,22 +233,27 @@ async def schedule_runner_loop():
f"Schedule '{schedule.name}' (push) triggered: "
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:
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
execution_ok = False
if not execution_ok:
rollback_schedule_after_failed_run(
schedule, prev_last_run_at, run_mode
)
await session.commit()
else:
fresh = await session.get(PushSchedule, sched_id)
if fresh:
rollback_schedule_after_failed_run(
fresh, prev_last_run_at, run_mode
)
await session.commit()
triggered += 1
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:
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 fcntl
import logging
import os
from contextlib import asynccontextmanager
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)
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
_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:
@@ -109,9 +125,11 @@ async def _acquire_primary_lock() -> bool:
return True
return False
except Exception as e:
# If Redis fails, assume primary (single-worker mode)
logger.warning(f"Primary lock check failed, assuming primary: {e}")
return True
logger.warning("Primary lock check failed, trying file lock fallback: %s", e)
acquired = _try_acquire_file_primary_lock()
if acquired:
logger.info("Primary worker lock acquired via file fallback")
return acquired
async def _renew_primary_lock():
+27
View File
@@ -15,6 +15,19 @@ import posixpath
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
"/etc",
"/root",
"/home",
"/var",
"/boot",
"/dev",
"/proc",
"/sys",
"/run",
"/srv",
)
class PosixPathError(ValueError):
"""Invalid or unsafe POSIX path."""
@@ -87,6 +100,20 @@ def resolve_nexus_host_path(path: str) -> str:
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:
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
real = resolve_nexus_host_path(path)
+12
View File
@@ -56,3 +56,15 @@ def test_exhausted_retries_marks_failed():
now=datetime.now(timezone.utc),
)
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:
install_api._reject_if_locked()
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: [],
envAllPass: false,
initResult: null,
installToken: '',
installDir: '/opt/nexus',
btPanel: false,
@@ -659,6 +660,7 @@ function installWizard() {
if (r.ok && d.success) {
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
this.initResult = d;
this.installToken = d.install_token || '';
this.btPanel = !!d.bt_panel;
this.step = 4;
} else {
@@ -675,6 +677,7 @@ function installWizard() {
this.error = '';
try {
const body = {
install_token: this.installToken,
...this.adminForm,
db_host: this.form.db_host,
db_port: this.form.db_port,