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:
@@ -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 WS;4001/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/schedule;Agent 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 一律 401;DB 查服异常 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 query(ADR-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-011(WS `?token=`)**:维持,不排首帧鉴权/ticket 改造。
|
||||
- **Agent global legacy 回退**:保留;未知 server_id 与 fail-closed 已在 batch3 收紧。
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。纯文档。
|
||||
|
||||
## 验证
|
||||
|
||||
- 文档路径可访问、handoff 交叉引用一致。
|
||||
|
||||
进度:`☑实现 ☑WSL验证 □审计8步 □部署 □健康检查 □浏览器验证 ☑changelog`
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
项目未部署。端口:子机出站443心跳;中心→子机SSH22;8601仅本机。
|
||||
P1待办:WebSSH断线重连、CSV批量导入。首次部署E2E见本文§6。
|
||||
已否决见§7。
|
||||
已否决见§7。风险接受(单人运维·不排期)见§7b。
|
||||
```
|
||||
|
||||
---
|
||||
@@ -232,6 +232,22 @@ Nexus 6.0:2000+ 台服务器运维平台;FastAPI 四层架构 + Redis 实时
|
||||
|
||||
---
|
||||
|
||||
## 7b. 风险接受(单人运维 · 不排期,2026-06-04)
|
||||
|
||||
> **SSOT**: `docs/project/risk-acceptance-single-operator.md`
|
||||
> Operator 确认:后台仅 **一名** 操作员自用,管理员身份完全可信。
|
||||
|
||||
| 项 | 决定 | 说明 |
|
||||
|----|------|------|
|
||||
| WebSocket JWT query | **维持 ADR-011** | 不排首帧鉴权 / WS ticket;batch3 已统一 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.0:2000+ 台服务器运维平台;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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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】批次 1~3 已本地验证;见 docs/changelog/2026-06-04-code-review-*.md
|
||||
【风险接受 · 单人运维】docs/project/risk-acceptance-single-operator.md(WS 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 仍走 query(ADR-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 可阅,不外发第三方 |
|
||||
|
||||
---
|
||||
|
||||
## 接受项 1:WebSocket JWT query(ADR-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 协议改造。**
|
||||
|
||||
---
|
||||
|
||||
## 接受项 2:Agent 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -18,6 +18,7 @@ export type SettingsResponse = SettingItem[]
|
||||
export interface SettingItem {
|
||||
key: string
|
||||
value: unknown
|
||||
value_set?: boolean | null
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
|
||||
+26
-9
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user