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仅本机。
|
项目未部署。端口:子机出站443心跳;中心→子机SSH22;8601仅本机。
|
||||||
P1待办:WebSSH断线重连、CSV批量导入。首次部署E2E见本文§6。
|
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 应同步)
|
## 8. 文档债务(下一任 AI 应同步)
|
||||||
|
|
||||||
| 文档 | 问题 | 动作 |
|
| 文档 | 问题 | 动作 |
|
||||||
@@ -279,6 +295,8 @@ Nexus 6.0:2000+ 台服务器运维平台;FastAPI 四层架构 + Redis 实时
|
|||||||
| 健康 SSH | `server/infrastructure/ssh/remote_probe.py` |
|
| 健康 SSH | `server/infrastructure/ssh/remote_probe.py` |
|
||||||
| 告警历史 | `web/app/alerts.html`, `server/api/settings.py`(alert_history_router) |
|
| 告警历史 | `web/app/alerts.html`, `server/api/settings.py`(alert_history_router) |
|
||||||
| Changelog 示例 | `docs/changelog/2026-05-23-ssh-exec-no-8601.md` |
|
| Changelog 示例 | `docs/changelog/2026-05-23-ssh-exec-no-8601.md` |
|
||||||
|
| 风险接受(单人运维) | `docs/project/risk-acceptance-single-operator.md` |
|
||||||
|
| Code Review 2026-06-04 | `docs/changelog/2026-06-04-code-review-p0-p1-fixes.md` 等三批 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -312,6 +330,7 @@ web/app/*.html → server/api/*.py → application/services → infrastructure/*
|
|||||||
6. Agent 8601 vs SSH → **脚本+健康检查走 22;心跳走 443;弱化 8601**
|
6. Agent 8601 vs SSH → **脚本+健康检查走 22;心跳走 443;弱化 8601**
|
||||||
7. 宝塔 Python 管理 → **不用**;venv + 3.12
|
7. 宝塔 Python 管理 → **不用**;venv + 3.12
|
||||||
8. 项目未部署 → **无需重启**
|
8. 项目未部署 → **无需重启**
|
||||||
|
9. 2026-06-04 Code Review 三批本地验证;单人运维风险接受 → `risk-acceptance-single-operator.md` §7b
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
【规范】.cursor/rules/perfect-implementation.mdc · 单 Agent · 改代码必 changelog
|
【规范】.cursor/rules/perfect-implementation.mdc · 单 Agent · 改代码必 changelog
|
||||||
|
|
||||||
【文档】docs/project/AI-HANDOFF-2026-06-03.md · linux-dev-paths.md · docker/README.md
|
【文档】docs/project/AI-HANDOFF-2026-06-03.md · linux-dev-paths.md · docker/README.md
|
||||||
|
【Code Review】批次 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
|
【插件】docs/project/cursor-plugins-for-nexus.md · MySQL MCP: scripts/linux_mcp_mysql.sh
|
||||||
【本地验收】seed_local_admin.py + frontend npm run test:e2e(见 linux-dev-paths.md)
|
【本地验收】seed_local_admin.py + frontend npm run test:e2e(见 linux-dev-paths.md)
|
||||||
```
|
```
|
||||||
@@ -36,3 +38,22 @@ export NEXUS_ROOT=~/Nexus
|
|||||||
mkdir -p "$NEXUS_ROOT" && tar xzf nexus-handoff-2026-06-03.tar.gz -C "$(dirname "$NEXUS_ROOT")"
|
mkdir -p "$NEXUS_ROOT" && tar xzf nexus-handoff-2026-06-03.tar.gz -C "$(dirname "$NEXUS_ROOT")"
|
||||||
cd "$NEXUS_ROOT" && git remote -v # 若无 .git:重新 clone 后覆盖源码目录
|
cd "$NEXUS_ROOT" && git remote -v # 若无 .git:重新 clone 后覆盖源码目录
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Review 收尾(2026-06-04)
|
||||||
|
|
||||||
|
本地已修复并验证三批(P0/P1 + batch2 + batch3),changelog/audit 见:
|
||||||
|
|
||||||
|
| 批次 | Changelog | Audit |
|
||||||
|
|------|-----------|-------|
|
||||||
|
| P0/P1 | `docs/changelog/2026-06-04-code-review-p0-p1-fixes.md` | `docs/audit/2026-06-04-code-review-p0-p1-fixes.md` |
|
||||||
|
| batch2 | `docs/changelog/2026-06-04-code-review-p1-p2-batch2.md` | `docs/audit/2026-06-04-code-review-p1-p2-batch2.md` |
|
||||||
|
| batch3 | `docs/changelog/2026-06-04-code-review-p1-p2-batch3.md` | `docs/audit/2026-06-04-code-review-p1-p2-batch3.md` |
|
||||||
|
|
||||||
|
**未排期(已风险接受)**:Operator 确认后台仅一人自用 → 见 **`docs/project/risk-acceptance-single-operator.md`**
|
||||||
|
|
||||||
|
- WebSocket JWT 仍走 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) => {
|
socket.onclose = (ev) => {
|
||||||
wsSocket.value = null
|
wsSocket.value = null
|
||||||
|
if (ev.code === 4001 || ev.code === 4401) {
|
||||||
|
auth.forceLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (ev.code !== 1000 && pushing.value) {
|
if (ev.code !== 1000 && pushing.value) {
|
||||||
wsProgressItems.value.forEach(p => {
|
wsProgressItems.value.forEach(p => {
|
||||||
if (p.status === 'pending' || p.status === 'running') {
|
if (p.status === 'pending' || p.status === 'running') {
|
||||||
@@ -242,6 +246,7 @@ export function usePushProgress(
|
|||||||
}
|
}
|
||||||
applyPushResultsFromHttp(res)
|
applyPushResultsFromHttp(res)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
disconnectProgressWs()
|
||||||
const msg = e instanceof Error ? e.message : '推送请求失败'
|
const msg = e instanceof Error ? e.message : '推送请求失败'
|
||||||
wsProgressItems.value.forEach(p => {
|
wsProgressItems.value.forEach(p => {
|
||||||
p.status = 'failed'
|
p.status = 'failed'
|
||||||
@@ -249,6 +254,7 @@ export function usePushProgress(
|
|||||||
})
|
})
|
||||||
pushing.value = false
|
pushing.value = false
|
||||||
snackbar(msg, 'error')
|
snackbar(msg, 'error')
|
||||||
|
onPushFinished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
|
import { formatApiError } from '@/utils/apiError'
|
||||||
|
|
||||||
export interface ServerBrief {
|
export interface ServerBrief {
|
||||||
id: number
|
id: number
|
||||||
@@ -19,6 +21,7 @@ export interface ServerBrief {
|
|||||||
export function useServerList() {
|
export function useServerList() {
|
||||||
const servers = ref<ServerBrief[]>([])
|
const servers = ref<ServerBrief[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
|
||||||
async function loadServers(opts?: { perPage?: number }) {
|
async function loadServers(opts?: { perPage?: number }) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -27,8 +30,9 @@ export function useServerList() {
|
|||||||
per_page: opts?.perPage ?? 200,
|
per_page: opts?.perPage ?? 200,
|
||||||
})
|
})
|
||||||
servers.value = res.items
|
servers.value = res.items
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
servers.value = []
|
servers.value = []
|
||||||
|
snackbar(formatApiError(e, '加载服务器列表失败'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,11 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useServerList } from '@/composables/useServerList'
|
import { useServerList } from '@/composables/useServerList'
|
||||||
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import type { CommandLogItem, SshSessionItem } from '@/types/api'
|
import type { CommandLogItem, SshSessionItem } from '@/types/api'
|
||||||
|
|
||||||
|
const snackbar = useSnackbar()
|
||||||
const { servers: serverList, loadServers } = useServerList()
|
const { servers: serverList, loadServers } = useServerList()
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
@@ -149,9 +152,10 @@ async function loadData() {
|
|||||||
sessions.value = res.items
|
sessions.value = res.items
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
commands.value = []
|
commands.value = []
|
||||||
sessions.value = []
|
sessions.value = []
|
||||||
|
snackbar(formatApiError(e, '加载命令日志失败'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@
|
|||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import { required } from '@/utils/validation'
|
import { required } from '@/utils/validation'
|
||||||
|
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -252,7 +253,10 @@ async function loadPasswords() {
|
|||||||
const res = await http.getList<PasswordItem>('/presets/')
|
const res = await http.getList<PasswordItem>('/presets/')
|
||||||
passwords.value = res.items
|
passwords.value = res.items
|
||||||
totalItems.value = res.total
|
totalItems.value = res.total
|
||||||
} catch { passwords.value = [] }
|
} catch (e: unknown) {
|
||||||
|
passwords.value = []
|
||||||
|
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
|
||||||
|
}
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function loadSshKeys() {
|
async function loadSshKeys() {
|
||||||
@@ -264,7 +268,10 @@ async function loadSshKeys() {
|
|||||||
})
|
})
|
||||||
sshKeys.value = res.items
|
sshKeys.value = res.items
|
||||||
totalItems.value = res.total
|
totalItems.value = res.total
|
||||||
} catch { sshKeys.value = [] }
|
} catch (e: unknown) {
|
||||||
|
sshKeys.value = []
|
||||||
|
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
|
||||||
|
}
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function loadDbCreds() {
|
async function loadDbCreds() {
|
||||||
@@ -276,7 +283,10 @@ async function loadDbCreds() {
|
|||||||
})
|
})
|
||||||
dbCreds.value = res.items
|
dbCreds.value = res.items
|
||||||
totalItems.value = res.total
|
totalItems.value = res.total
|
||||||
} catch { dbCreds.value = [] }
|
} catch (e: unknown) {
|
||||||
|
dbCreds.value = []
|
||||||
|
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
|
||||||
|
}
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ async function loadAlerts() {
|
|||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
recentAlerts.value = []
|
recentAlerts.value = []
|
||||||
|
snackbar('加载告警记录失败', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,10 @@ async function loadRecentAudit() {
|
|||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
|
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
|
||||||
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
|
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
|
||||||
} catch { recentAudit.value = [] }
|
} catch {
|
||||||
|
recentAudit.value = []
|
||||||
|
snackbar('加载审计记录失败', 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
@@ -398,6 +402,7 @@ onMounted(() => {
|
|||||||
loadAlerts()
|
loadAlerts()
|
||||||
loadSummary()
|
loadSummary()
|
||||||
loadRecentAudit()
|
loadRecentAudit()
|
||||||
|
http.post('/settings/bing-wallpapers/sync').catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
interface AlertItem {
|
interface AlertItem {
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ async function doLogin() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value, needTotp.value ? totpCode.value : undefined)
|
await auth.login(username.value, password.value, needTotp.value ? totpCode.value : undefined)
|
||||||
const redirect = (route.query.redirect as string) || '/'
|
const raw = (route.query.redirect as string) || '/'
|
||||||
|
const redirect = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof TotpRequiredError) {
|
if (e instanceof TotpRequiredError) {
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useServerList } from '@/composables/useServerList'
|
import { useServerList } from '@/composables/useServerList'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import { required } from '@/utils/validation'
|
import { required } from '@/utils/validation'
|
||||||
|
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -360,7 +361,10 @@ async function loadScripts() {
|
|||||||
})
|
})
|
||||||
scripts.value = res.items
|
scripts.value = res.items
|
||||||
totalItems.value = res.total
|
totalItems.value = res.total
|
||||||
} catch { scripts.value = [] }
|
} catch (e: unknown) {
|
||||||
|
scripts.value = []
|
||||||
|
snackbar(formatApiError(e, '加载脚本列表失败'), 'error')
|
||||||
|
}
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,18 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="settings.telegram_bot_token" label="Bot Token" variant="outlined" density="compact" class="mb-3" :type="showToken ? 'text' : 'password'" :append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'" @click:append-inner="showToken = !showToken" />
|
<v-text-field
|
||||||
|
v-model="telegramTokenDraft"
|
||||||
|
label="Bot Token"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-3"
|
||||||
|
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
|
||||||
|
:type="showToken ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
|
@click:append-inner="revealTelegramToken"
|
||||||
|
/>
|
||||||
|
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -162,15 +173,17 @@
|
|||||||
<!-- API Key Reveal Dialog -->
|
<!-- API Key Reveal Dialog -->
|
||||||
<v-dialog v-model="showRevealDialog" max-width="400">
|
<v-dialog v-model="showRevealDialog" max-width="400">
|
||||||
<v-card border>
|
<v-card border>
|
||||||
<v-card-title>验证身份</v-card-title>
|
<v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="text-body-2 text-medium-emphasis mb-3">请输入当前密码以查看 API Key</div>
|
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||||
|
{{ revealTarget === 'telegram' ? '请输入当前密码以查看 Telegram Bot Token' : '请输入当前密码以查看 API Key' }}
|
||||||
|
</div>
|
||||||
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
|
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
|
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
|
||||||
<v-btn color="primary" variant="flat" @click="doRevealApiKey" :loading="revealingKey">确认</v-btn>
|
<v-btn color="primary" variant="flat" @click="doRevealSecret" :loading="revealingKey">确认</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -214,6 +227,8 @@ const settings = ref({
|
|||||||
db_pool_size: 10, db_max_overflow: 20,
|
db_pool_size: 10, db_max_overflow: 20,
|
||||||
telegram_bot_token: '', telegram_chat_id: '',
|
telegram_bot_token: '', telegram_chat_id: '',
|
||||||
})
|
})
|
||||||
|
const telegramTokenDraft = ref('')
|
||||||
|
const telegramTokenSet = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testingTg = ref(false)
|
const testingTg = ref(false)
|
||||||
const showToken = ref(false)
|
const showToken = ref(false)
|
||||||
@@ -236,6 +251,7 @@ const disablingTotp = ref(false)
|
|||||||
const showApiKey = ref(false)
|
const showApiKey = ref(false)
|
||||||
const apiKeyValue = ref('')
|
const apiKeyValue = ref('')
|
||||||
const showRevealDialog = ref(false)
|
const showRevealDialog = ref(false)
|
||||||
|
const revealTarget = ref<'api_key' | 'telegram'>('api_key')
|
||||||
const revealPassword = ref('')
|
const revealPassword = ref('')
|
||||||
const revealingKey = ref(false)
|
const revealingKey = ref(false)
|
||||||
|
|
||||||
@@ -253,6 +269,11 @@ async function loadSettings() {
|
|||||||
const knownKeys = Object.keys(settings.value)
|
const knownKeys = Object.keys(settings.value)
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = item.key
|
const key = item.key
|
||||||
|
if (key === 'telegram_bot_token') {
|
||||||
|
telegramTokenSet.value = Boolean(item.value_set)
|
||||||
|
telegramTokenDraft.value = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (key && knownKeys.includes(key)) {
|
if (key && knownKeys.includes(key)) {
|
||||||
const val = item.value
|
const val = item.value
|
||||||
// Type-cast numeric strings back to numbers for number inputs
|
// Type-cast numeric strings back to numbers for number inputs
|
||||||
@@ -283,14 +304,23 @@ async function loadAllowlist() {
|
|||||||
// ── Actions ──
|
// ── Actions ──
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||||
try {
|
try {
|
||||||
const entries = Object.entries(settings.value).filter(([key]) =>
|
const entries = Object.entries(settings.value).filter(([key]) =>
|
||||||
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_bot_token', 'telegram_chat_id'].includes(key)
|
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_chat_id'].includes(key)
|
||||||
)
|
)
|
||||||
|
if (tokenUpdate) {
|
||||||
|
entries.push(['telegram_bot_token', tokenUpdate])
|
||||||
|
}
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
await http.put(`/settings/${key}`, { value })
|
await http.put(`/settings/${key}`, { value })
|
||||||
}
|
}
|
||||||
snackbar('设置已保存')
|
snackbar('设置已保存')
|
||||||
|
if (tokenUpdate) {
|
||||||
|
telegramTokenSet.value = true
|
||||||
|
telegramTokenDraft.value = ''
|
||||||
|
showToken.value = false
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : '保存失败'
|
const msg = e instanceof Error ? e.message : '保存失败'
|
||||||
snackbar(msg, 'error')
|
snackbar(msg, 'error')
|
||||||
@@ -381,25 +411,58 @@ function copyTotpSecret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revealApiKey() {
|
async function revealApiKey() {
|
||||||
if (showApiKey.value) { showApiKey.value = false; return }
|
if (showApiKey.value) {
|
||||||
|
showApiKey.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealTarget.value = 'api_key'
|
||||||
showRevealDialog.value = true
|
showRevealDialog.value = true
|
||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRevealApiKey() {
|
async function revealTelegramToken() {
|
||||||
|
if (showToken.value && telegramTokenDraft.value) {
|
||||||
|
showToken.value = false
|
||||||
|
telegramTokenDraft.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealTarget.value = 'telegram'
|
||||||
|
showRevealDialog.value = true
|
||||||
|
revealPassword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRevealSecret() {
|
||||||
if (!revealPassword.value) {
|
if (!revealPassword.value) {
|
||||||
snackbar('请输入密码', 'error')
|
snackbar('请输入密码', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revealingKey.value = true
|
revealingKey.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { current_password: revealPassword.value })
|
if (revealTarget.value === 'telegram') {
|
||||||
apiKeyValue.value = res.api_key
|
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {
|
||||||
showApiKey.value = true
|
current_password: revealPassword.value,
|
||||||
|
})
|
||||||
|
telegramTokenDraft.value = res.value
|
||||||
|
showToken.value = true
|
||||||
|
} else {
|
||||||
|
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', {
|
||||||
|
current_password: revealPassword.value,
|
||||||
|
})
|
||||||
|
apiKeyValue.value = res.api_key
|
||||||
|
showApiKey.value = true
|
||||||
|
}
|
||||||
showRevealDialog.value = false
|
showRevealDialog.value = false
|
||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
} catch (e: any) { snackbar(e.message || '验证失败', 'error') }
|
} catch (e: unknown) {
|
||||||
finally { revealingKey.value = false }
|
snackbar(e instanceof Error ? e.message : '验证失败', 'error')
|
||||||
|
} finally {
|
||||||
|
revealingKey.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRevealApiKey() {
|
||||||
|
revealTarget.value = 'api_key'
|
||||||
|
await doRevealSecret()
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyApiKey() {
|
function copyApiKey() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type SettingsResponse = SettingItem[]
|
|||||||
export interface SettingItem {
|
export interface SettingItem {
|
||||||
key: string
|
key: string
|
||||||
value: unknown
|
value: unknown
|
||||||
|
value_set?: boolean | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-9
@@ -58,22 +58,39 @@ def _verify_api_key(x_api_key: str = Header(...)):
|
|||||||
|
|
||||||
|
|
||||||
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
|
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
|
||||||
"""Verify API key against per-server agent_api_key, falling back to global API key.
|
"""Verify API key against per-server agent_api_key, with limited global fallback.
|
||||||
|
|
||||||
Authentication priority:
|
Authentication priority:
|
||||||
1. If server has agent_api_key set → must match exactly
|
1. Server must exist
|
||||||
2. Otherwise → must match global API_KEY
|
2. If server has agent_api_key → must match exactly
|
||||||
|
3. Legacy only: no per-server key → global API_KEY (logged for migration)
|
||||||
"""
|
"""
|
||||||
# Try to get server's per-server key
|
|
||||||
try:
|
try:
|
||||||
server = await service.get_server(server_id)
|
server = await service.get_server(server_id)
|
||||||
if server and server.agent_api_key:
|
|
||||||
return secrets.compare_digest(provided_key, server.agent_api_key)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug(f"Failed to look up server {server_id} for per-server API key check, falling back to global key")
|
logger.warning(
|
||||||
|
"Failed to look up server %s for per-server API key check — rejecting",
|
||||||
|
server_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
# Fallback: global API key
|
if not server:
|
||||||
return secrets.compare_digest(provided_key, settings.API_KEY)
|
return False
|
||||||
|
|
||||||
|
if server.agent_api_key:
|
||||||
|
return secrets.compare_digest(provided_key, server.agent_api_key)
|
||||||
|
|
||||||
|
if secrets.compare_digest(provided_key, settings.API_KEY):
|
||||||
|
logger.warning(
|
||||||
|
"Server %s (%s) used global API_KEY for agent auth — "
|
||||||
|
"assign per-server key via POST /api/servers/%s/agent-key",
|
||||||
|
server_id,
|
||||||
|
server.name,
|
||||||
|
server_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _threshold_for(thresholds: dict, metric: str) -> float:
|
def _threshold_for(thresholds: dict, metric: str) -> float:
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class InitDbRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreateAdminRequest(BaseModel):
|
class CreateAdminRequest(BaseModel):
|
||||||
|
install_token: str
|
||||||
db_host: str = "localhost"
|
db_host: str = "localhost"
|
||||||
db_port: str = "3306"
|
db_port: str = "3306"
|
||||||
db_name: str = "Nexus"
|
db_name: str = "Nexus"
|
||||||
@@ -310,11 +311,29 @@ def _finalize_install_lock() -> None:
|
|||||||
logger.warning("Failed to remove install state file: %s", e)
|
logger.warning("Failed to remove install state file: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_install_token(provided: str) -> None:
|
||||||
|
"""Require one-time install token issued at step 3 (init-db)."""
|
||||||
|
if not provided or not provided.strip():
|
||||||
|
raise HTTPException(status_code=403, detail="缺少安装令牌,请从步骤3重新开始")
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
raise HTTPException(status_code=403, detail="安装会话无效,请从步骤3重新开始")
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
state = json.loads(read_utf8_text(STATE_FILE))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="安装会话无效,请从步骤3重新开始") from exc
|
||||||
|
expected = state.get("install_token")
|
||||||
|
if not expected or not secrets.compare_digest(provided.strip(), expected):
|
||||||
|
raise HTTPException(status_code=403, detail="安装令牌无效或已过期")
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ──
|
# ── Endpoints ──
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def install_status():
|
async def install_status():
|
||||||
"""Check whether Nexus is already installed."""
|
"""Check whether Nexus is already installed."""
|
||||||
|
if _is_locked():
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {
|
return {
|
||||||
"installed": _is_installed(),
|
"installed": _is_installed(),
|
||||||
"locked": _is_locked(),
|
"locked": _is_locked(),
|
||||||
@@ -542,6 +561,7 @@ async def init_db(req: InitDbRequest):
|
|||||||
|
|
||||||
# Save install state for step 4
|
# Save install state for step 4
|
||||||
import json
|
import json
|
||||||
|
install_token = secrets.token_urlsafe(32)
|
||||||
state = {
|
state = {
|
||||||
"db_host": req.db_host,
|
"db_host": req.db_host,
|
||||||
"db_port": req.db_port,
|
"db_port": req.db_port,
|
||||||
@@ -549,6 +569,7 @@ async def init_db(req: InitDbRequest):
|
|||||||
"db_user": req.db_user,
|
"db_user": req.db_user,
|
||||||
# P2-19: Don't store db_pass in state file — only needed for _make_db_url()
|
# P2-19: Don't store db_pass in state file — only needed for _make_db_url()
|
||||||
# which is called with the full CreateAdminRequest in step 4
|
# which is called with the full CreateAdminRequest in step 4
|
||||||
|
"install_token": install_token,
|
||||||
"pool_size": pool_size,
|
"pool_size": pool_size,
|
||||||
"max_overflow": max_overflow,
|
"max_overflow": max_overflow,
|
||||||
"install_dir": install_dir,
|
"install_dir": install_dir,
|
||||||
@@ -561,6 +582,7 @@ async def init_db(req: InitDbRequest):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
"install_token": install_token,
|
||||||
"pool_size": pool_size,
|
"pool_size": pool_size,
|
||||||
"max_overflow": max_overflow,
|
"max_overflow": max_overflow,
|
||||||
"tables_created": 14,
|
"tables_created": 14,
|
||||||
@@ -576,6 +598,8 @@ async def create_admin(req: CreateAdminRequest):
|
|||||||
if not _has_env():
|
if not _has_env():
|
||||||
raise HTTPException(400, "请先完成步骤3:数据库初始化")
|
raise HTTPException(400, "请先完成步骤3:数据库初始化")
|
||||||
|
|
||||||
|
_verify_install_token(req.install_token)
|
||||||
|
|
||||||
if len(req.admin_password) < 6:
|
if len(req.admin_password) < 6:
|
||||||
raise HTTPException(400, "密码长度至少6位")
|
raise HTTPException(400, "密码长度至少6位")
|
||||||
|
|
||||||
|
|||||||
+71
-64
@@ -111,18 +111,29 @@ def _validate_ip_list(ips: list[str]) -> None:
|
|||||||
|
|
||||||
# ── System Settings ──
|
# ── System Settings ──
|
||||||
|
|
||||||
|
def _format_setting_response(key: str, value: str | None, updated_at) -> dict:
|
||||||
|
"""Build setting dict; sensitive keys never return plaintext."""
|
||||||
|
if key in SENSITIVE_KEYS and value:
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"value": "",
|
||||||
|
"value_set": True,
|
||||||
|
"updated_at": str(updated_at),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"value_set": bool(value) if key in SENSITIVE_KEYS else None,
|
||||||
|
"updated_at": str(updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list)
|
@router.get("/", response_model=list)
|
||||||
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||||
"""List all system settings (sensitive values masked)"""
|
"""List all system settings (sensitive values masked)"""
|
||||||
repo = SettingRepositoryImpl(db)
|
repo = SettingRepositoryImpl(db)
|
||||||
settings = await repo.get_all()
|
settings = await repo.get_all()
|
||||||
result = []
|
return [_format_setting_response(s.key, s.value, s.updated_at) for s in settings]
|
||||||
for s in settings:
|
|
||||||
val = s.value
|
|
||||||
if s.key in SENSITIVE_KEYS and val:
|
|
||||||
val = val[:8] + "..." if len(val) > 8 else "***"
|
|
||||||
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ip-allowlist", response_model=dict)
|
@router.get("/ip-allowlist", response_model=dict)
|
||||||
@@ -173,22 +184,29 @@ def _cleanup_old_wallpapers() -> int:
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bing-wallpapers", response_model=dict)
|
def _cached_wallpaper_urls() -> dict:
|
||||||
async def bing_wallpapers():
|
"""List locally cached wallpaper URLs (no outbound HTTP)."""
|
||||||
"""Return all cached wallpaper URLs for frontend slideshow rotation.
|
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_files = sorted(
|
||||||
|
_WALLPAPER_DIR.glob("*.jpg"),
|
||||||
|
key=lambda f: f.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
urls = [
|
||||||
|
f"/app/wallpapers/{f.name}"
|
||||||
|
for f in sorted(all_files[:_MAX_WALLPAPERS], key=lambda f: f.name, reverse=True)
|
||||||
|
]
|
||||||
|
return {"urls": urls, "count": len(urls)}
|
||||||
|
|
||||||
Syncs with Bing daily (pulls up to 8 days of images), caches to disk,
|
|
||||||
cleans up old files, returns sorted list of local URLs.
|
async def _sync_bing_wallpapers_from_network() -> int:
|
||||||
"""
|
"""Download recent Bing images to local cache. Returns count downloaded."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
|
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Download today + last 7 days from Bing (n=8 gives ~8 images)
|
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
# Fetch up to 8 recent images from Bing
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"https://cn.bing.com/HPImageArchive.aspx",
|
"https://cn.bing.com/HPImageArchive.aspx",
|
||||||
params={"format": "js", "idx": 0, "n": 8, "mkt": "zh-CN"},
|
params={"format": "js", "idx": 0, "n": 8, "mkt": "zh-CN"},
|
||||||
@@ -198,17 +216,15 @@ async def bing_wallpapers():
|
|||||||
img_path = img.get("url")
|
img_path = img.get("url")
|
||||||
if not img_path:
|
if not img_path:
|
||||||
continue
|
continue
|
||||||
# Use date from Bing metadata as filename
|
|
||||||
date_str = img.get("startdate", "") or img.get("fullstartdate", "")[:8] or ""
|
date_str = img.get("startdate", "") or img.get("fullstartdate", "")[:8] or ""
|
||||||
if not date_str:
|
if not date_str:
|
||||||
# fallback: extract from URL
|
|
||||||
import re
|
import re
|
||||||
m = re.search(r'OHR\.(\w+)_', img_path)
|
m = re.search(r"OHR\.(\w+)_", img_path)
|
||||||
date_str = m.group(1) if m else f"unknown_{downloaded}"
|
date_str = m.group(1) if m else f"unknown_{downloaded}"
|
||||||
|
|
||||||
cached = _WALLPAPER_DIR / f"{date_str}.jpg"
|
cached = _WALLPAPER_DIR / f"{date_str}.jpg"
|
||||||
if cached.exists() and cached.stat().st_size > 1024:
|
if cached.exists() and cached.stat().st_size > 1024:
|
||||||
continue # already cached
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
img_resp = await client.get(f"https://cn.bing.com{img_path}")
|
img_resp = await client.get(f"https://cn.bing.com{img_path}")
|
||||||
@@ -217,64 +233,50 @@ async def bing_wallpapers():
|
|||||||
downloaded += 1
|
downloaded += 1
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.debug("Bing image download failed: %s", e)
|
logger.debug("Bing image download failed: %s", e)
|
||||||
continue
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.warning("Bing wallpaper sync failed: %s", e)
|
logger.warning("Bing wallpaper sync failed: %s", e)
|
||||||
|
|
||||||
# Cleanup old files
|
|
||||||
_cleanup_old_wallpapers()
|
_cleanup_old_wallpapers()
|
||||||
|
|
||||||
# Also keep only newest N files if we somehow have too many
|
|
||||||
all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
|
all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||||
for f in all_files[_MAX_WALLPAPERS:]:
|
for f in all_files[_MAX_WALLPAPERS:]:
|
||||||
try:
|
try:
|
||||||
f.unlink()
|
f.unlink()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.debug("Wallpaper prune skip %s: %s", f.name, e)
|
logger.debug("Wallpaper prune skip %s: %s", f.name, e)
|
||||||
|
return downloaded
|
||||||
|
|
||||||
# Return sorted URLs (newest first)
|
|
||||||
urls = [f"/app/wallpapers/{f.name}" for f in sorted(all_files[:_MAX_WALLPAPERS], key=lambda f: f.name, reverse=True)]
|
@router.get("/bing-wallpapers", response_model=dict)
|
||||||
return {"urls": urls, "count": len(urls)}
|
async def bing_wallpapers():
|
||||||
|
"""Return cached wallpaper URLs (public, read-only — no outbound sync)."""
|
||||||
|
return _cached_wallpaper_urls()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bing-wallpapers/sync", response_model=dict)
|
||||||
|
async def bing_wallpapers_sync(admin: Admin = Depends(get_current_admin)):
|
||||||
|
"""Admin-only: fetch latest Bing images into local cache."""
|
||||||
|
downloaded = await _sync_bing_wallpapers_from_network()
|
||||||
|
result = _cached_wallpaper_urls()
|
||||||
|
result["downloaded"] = downloaded
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Keep the old single-image endpoint for backward compatibility
|
# Keep the old single-image endpoint for backward compatibility
|
||||||
@router.get("/bing-wallpaper", response_model=dict)
|
@router.get("/bing-wallpaper", response_model=dict)
|
||||||
async def bing_wallpaper():
|
async def bing_wallpaper():
|
||||||
"""Return today's wallpaper URL (backward compat)."""
|
"""Return today's cached wallpaper URL (public, read-only)."""
|
||||||
try:
|
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
import httpx
|
from datetime import datetime
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
_WALLPAPER_DIR.mkdir(parents=True, exist_ok=True)
|
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
cached = _WALLPAPER_DIR / f"{today}.jpg"
|
||||||
cached = _WALLPAPER_DIR / f"{today}.jpg"
|
if cached.exists() and cached.stat().st_size > 1024:
|
||||||
|
|
||||||
if cached.exists() and cached.stat().st_size > 1024:
|
|
||||||
return {"url": f"/app/wallpapers/{today}.jpg"}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
"https://cn.bing.com/HPImageArchive.aspx",
|
|
||||||
params={"format": "js", "idx": 0, "n": 1, "mkt": "zh-CN"},
|
|
||||||
)
|
|
||||||
data = resp.json()
|
|
||||||
img = (data.get("images") or [{}])[0]
|
|
||||||
img_url = img.get("url")
|
|
||||||
if not img_url:
|
|
||||||
return {"url": None}
|
|
||||||
|
|
||||||
img_resp = await client.get(f"https://cn.bing.com{img_url}")
|
|
||||||
if img_resp.status_code == 200:
|
|
||||||
cached.write_bytes(img_resp.content)
|
|
||||||
|
|
||||||
_cleanup_old_wallpapers()
|
|
||||||
return {"url": f"/app/wallpapers/{today}.jpg"}
|
return {"url": f"/app/wallpapers/{today}.jpg"}
|
||||||
except Exception:
|
|
||||||
# Try any cached file
|
all_files = sorted(_WALLPAPER_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||||
cached_list = sorted(_WALLPAPER_DIR.glob("*.jpg"))
|
if all_files:
|
||||||
if cached_list:
|
return {"url": f"/app/wallpapers/{all_files[0].name}"}
|
||||||
return {"url": f"/app/wallpapers/{cached_list[-1].name}"}
|
return {"url": None}
|
||||||
return {"url": None}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}", response_model=dict)
|
@router.get("/{key}", response_model=dict)
|
||||||
@@ -284,9 +286,7 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
|||||||
value = await repo.get(key)
|
value = await repo.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
if key in SENSITIVE_KEYS and value:
|
return _format_setting_response(key, value, None)
|
||||||
value = value[:8] + "..." if len(value) > 8 else "***"
|
|
||||||
return {"key": key, "value": value}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api-key/reveal", response_model=dict)
|
@router.post("/api-key/reveal", response_model=dict)
|
||||||
@@ -333,8 +333,15 @@ async def set_setting(
|
|||||||
if key not in MUTABLE_KEYS:
|
if key not in MUTABLE_KEYS:
|
||||||
raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
|
raise HTTPException(status_code=403, detail=f"未知设置项: {key}")
|
||||||
|
|
||||||
# S-02: Validate value type and range for int settings
|
|
||||||
val_str = str(payload.value)
|
val_str = str(payload.value)
|
||||||
|
if key in SENSITIVE_KEYS and not val_str.strip():
|
||||||
|
repo = SettingRepositoryImpl(db)
|
||||||
|
current = await repo.get(key)
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{key}' 不能为空")
|
||||||
|
return _format_setting_response(key, current, None)
|
||||||
|
|
||||||
|
# S-02: Validate value type and range for int settings
|
||||||
if key in SETTING_VALIDATORS:
|
if key in SETTING_VALIDATORS:
|
||||||
expected_type, min_val, max_val = SETTING_VALIDATORS[key]
|
expected_type, min_val, max_val = SETTING_VALIDATORS[key]
|
||||||
try:
|
try:
|
||||||
|
|||||||
+14
-24
@@ -44,6 +44,7 @@ from server.utils.posix_paths import (
|
|||||||
normalize_remote_dest,
|
normalize_remote_dest,
|
||||||
remote_join,
|
remote_join,
|
||||||
resolve_nexus_host_path,
|
resolve_nexus_host_path,
|
||||||
|
resolve_nexus_push_source_path,
|
||||||
to_posix,
|
to_posix,
|
||||||
)
|
)
|
||||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||||
@@ -529,8 +530,6 @@ async def reconcile_stale_sync_logs(
|
|||||||
|
|
||||||
# ── H5: Validate Source Path ──
|
# ── H5: Validate Source Path ──
|
||||||
|
|
||||||
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate-source-path")
|
@router.post("/validate-source-path")
|
||||||
async def validate_source_path(
|
async def validate_source_path(
|
||||||
@@ -545,25 +544,13 @@ async def validate_source_path(
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
path = payload.path.strip()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
real_path = resolve_nexus_host_path(path)
|
real_path = resolve_nexus_push_source_path(payload.path)
|
||||||
except PosixPathError as exc:
|
except PosixPathError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
status = 403 if "系统路径" in str(exc) else 400
|
||||||
if ".." in to_posix(path).split("/"):
|
if "不存在" in str(exc):
|
||||||
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
|
status = 404
|
||||||
|
raise HTTPException(status_code=status, detail=str(exc)) from exc
|
||||||
# Reject sensitive paths
|
|
||||||
for prefix in _FORBIDDEN_PATH_PREFIXES:
|
|
||||||
if real_path == prefix or real_path.startswith(prefix + "/"):
|
|
||||||
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
|
|
||||||
|
|
||||||
if not os.path.exists(real_path):
|
|
||||||
raise HTTPException(status_code=404, detail="路径不存在")
|
|
||||||
|
|
||||||
if not os.path.isdir(real_path):
|
|
||||||
raise HTTPException(status_code=400, detail="路径不是目录")
|
|
||||||
|
|
||||||
# Count files and total size
|
# Count files and total size
|
||||||
file_count = 0
|
file_count = 0
|
||||||
@@ -582,7 +569,7 @@ async def validate_source_path(
|
|||||||
|
|
||||||
await _audit_sync(
|
await _audit_sync(
|
||||||
"validate_source_path", "sync", 0,
|
"validate_source_path", "sync", 0,
|
||||||
f"验证源路径: {path} ({file_count} 文件)",
|
f"验证源路径: {payload.path} ({file_count} 文件)",
|
||||||
admin.username, request,
|
admin.username, request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1105,15 +1092,18 @@ async def verify_sync(
|
|||||||
|
|
||||||
session = request.state.db
|
session = request.state.db
|
||||||
|
|
||||||
if not os.path.isdir(payload.source_path):
|
try:
|
||||||
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
|
source_path = resolve_nexus_push_source_path(payload.source_path)
|
||||||
|
except PosixPathError as exc:
|
||||||
|
status = 403 if "系统路径" in str(exc) else 400
|
||||||
|
raise HTTPException(status_code=status, detail=str(exc)) from exc
|
||||||
|
|
||||||
# Build local file manifest: {relative_path: sha256hex}
|
# Build local file manifest: {relative_path: sha256hex}
|
||||||
local_files = {}
|
local_files = {}
|
||||||
for root, _dirs, fnames in os.walk(payload.source_path):
|
for root, _dirs, fnames in os.walk(source_path):
|
||||||
for fn in fnames:
|
for fn in fnames:
|
||||||
full = posix_join(root, fn)
|
full = posix_join(root, fn)
|
||||||
rel = posixpath.relpath(full, to_posix(payload.source_path))
|
rel = posixpath.relpath(full, to_posix(source_path))
|
||||||
if len(local_files) >= payload.max_files:
|
if len(local_files) >= payload.max_files:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ async def alert_ws(
|
|||||||
|
|
||||||
admin = await _verify_ws_token(token)
|
admin = await _verify_ws_token(token)
|
||||||
if not admin:
|
if not admin:
|
||||||
await websocket.close(code=4001, reason="Invalid or expired JWT token")
|
await websocket.close(code=4401, reason="Invalid or expired JWT token")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Accept and register ──
|
# ── Accept and register ──
|
||||||
@@ -343,7 +343,7 @@ async def sync_progress_ws(
|
|||||||
|
|
||||||
admin = await _verify_ws_token(token)
|
admin = await _verify_ws_token(token)
|
||||||
if not admin:
|
if not admin:
|
||||||
await websocket.close(code=4001, reason="Invalid or expired JWT token")
|
await websocket.close(code=4401, reason="Invalid or expired JWT token")
|
||||||
return
|
return
|
||||||
|
|
||||||
client_id = f"sync:{admin.id}:{id(websocket)}"
|
client_id = f"sync:{admin.id}:{id(websocket)}"
|
||||||
|
|||||||
+11
-7
@@ -63,13 +63,17 @@ async def _verify_webssh_token(token: str):
|
|||||||
if not admin or not admin.is_active:
|
if not admin or not admin.is_active:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# P1-7: Check updated_at — invalidate token after password change
|
token_tv = payload.get("tv")
|
||||||
token_updated = payload.get("updated", 0)
|
if token_tv is not None:
|
||||||
if token_updated and admin.updated_at:
|
if int(token_tv) != int(admin.token_version or 0):
|
||||||
admin_ts = int(admin.updated_at.timestamp())
|
|
||||||
# Allow 5-second grace for clock skew / race conditions
|
|
||||||
if admin_ts > token_updated + 5:
|
|
||||||
return None, None
|
return None, None
|
||||||
|
else:
|
||||||
|
# Legacy WebSSH tokens without tv — fall back to updated_at grace
|
||||||
|
token_updated = payload.get("updated", 0)
|
||||||
|
if token_updated and admin.updated_at:
|
||||||
|
admin_ts = int(admin.updated_at.timestamp())
|
||||||
|
if admin_ts > token_updated + 5:
|
||||||
|
return None, None
|
||||||
|
|
||||||
return admin, server_id
|
return admin, server_id
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -96,7 +100,7 @@ async def terminal_ws(
|
|||||||
|
|
||||||
admin, token_server_id = await _verify_webssh_token(token)
|
admin, token_server_id = await _verify_webssh_token(token)
|
||||||
if not admin:
|
if not admin:
|
||||||
await websocket.close(code=4001, reason="Invalid or expired JWT token")
|
await websocket.close(code=4401, reason="Invalid or expired JWT token")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Security: WebSSH token must bind to this server (general access_token rejected)
|
# Security: WebSSH token must bind to this server (general access_token rejected)
|
||||||
|
|||||||
@@ -255,14 +255,19 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
||||||
|
|
||||||
# Rotate: remove old token from Redis, generate new token pair
|
|
||||||
await self._remove_refresh_token(admin.id, refresh_token)
|
|
||||||
|
|
||||||
access_token = self._create_access_token(admin)
|
access_token = self._create_access_token(admin)
|
||||||
new_refresh = self._create_refresh_token(admin)
|
new_refresh = self._create_refresh_token(admin)
|
||||||
|
|
||||||
# Store new refresh token in Redis
|
rotated = await self._rotate_refresh_token(admin.id, refresh_token, new_refresh)
|
||||||
await self._store_refresh_token(admin.id, new_refresh)
|
if rotated is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reason": "service_unavailable",
|
||||||
|
"message": "认证服务暂不可用,请稍后重试",
|
||||||
|
}
|
||||||
|
if rotated is False:
|
||||||
|
await self._handle_refresh_token_reuse(admin, ip_address)
|
||||||
|
return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -461,6 +466,7 @@ class AuthService:
|
|||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
|
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
|
||||||
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
||||||
|
"tv": admin.token_version or 0,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
@@ -618,6 +624,44 @@ class AuthService:
|
|||||||
membership = await self._refresh_token_membership(admin_id, token)
|
membership = await self._refresh_token_membership(admin_id, token)
|
||||||
return membership is True
|
return membership is True
|
||||||
|
|
||||||
|
async def _rotate_refresh_token(
|
||||||
|
self, admin_id: int, old_token: str, new_token: str
|
||||||
|
) -> Optional[bool]:
|
||||||
|
"""Atomically swap refresh token hashes in Redis.
|
||||||
|
|
||||||
|
Returns True on success, False if old token absent (reuse/invalid),
|
||||||
|
None if Redis is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from server.infrastructure.redis.client import get_redis
|
||||||
|
redis = get_redis()
|
||||||
|
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
||||||
|
old_hash = self._hash_token(old_token)
|
||||||
|
new_hash = self._hash_token(new_token)
|
||||||
|
ttl_seconds = _refresh_token_expire_days() * 86400 + 3600
|
||||||
|
script = """
|
||||||
|
local removed = redis.call('SREM', KEYS[1], ARGV[1])
|
||||||
|
if removed == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
redis.call('SADD', KEYS[1], ARGV[2])
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
if ttl < 0 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
"""
|
||||||
|
result = await redis.eval(script, 1, key, old_hash, new_hash, str(ttl_seconds))
|
||||||
|
if result == 1:
|
||||||
|
return True
|
||||||
|
if result == 0:
|
||||||
|
return False
|
||||||
|
logger.error("Unexpected Redis rotate result for admin %s: %r", admin_id, result)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to rotate refresh token in Redis for admin {admin_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def _remove_refresh_token(self, admin_id: int, token: str):
|
async def _remove_refresh_token(self, admin_id: int, token: str):
|
||||||
"""Remove a single refresh token from Redis Set (single-device logout or rotation)"""
|
"""Remove a single refresh token from Redis Set (single-device logout or rotation)"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -505,6 +505,22 @@ class ScriptService:
|
|||||||
async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None:
|
async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None:
|
||||||
"""In-place: add remote_log for servers with a nexus-job log path."""
|
"""In-place: add remote_log for servers with a nexus-job log path."""
|
||||||
tail_lines = max(10, min(tail_lines, 500))
|
tail_lines = max(10, min(tail_lines, 500))
|
||||||
|
server_ids: set[int] = set()
|
||||||
|
for sid_str, entry in results.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if not self._extract_nexus_job_log(entry.get("stdout", "")):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
server_ids.add(int(sid_str))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
server_map: Dict[int, Server] = {}
|
||||||
|
for sid in server_ids:
|
||||||
|
server = await self.server_repo.get_by_id(sid)
|
||||||
|
if server:
|
||||||
|
server_map[sid] = server
|
||||||
|
|
||||||
async def _tail_one(sid_str: str, entry: dict) -> None:
|
async def _tail_one(sid_str: str, entry: dict) -> None:
|
||||||
log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
|
log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
|
||||||
@@ -514,7 +530,7 @@ class ScriptService:
|
|||||||
server_id = int(sid_str)
|
server_id = int(sid_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
server = await self.server_repo.get_by_id(server_id)
|
server = server_map.get(server_id)
|
||||||
if not server or not server.is_online:
|
if not server or not server.is_online:
|
||||||
entry["remote_log"] = "(服务器离线,无法拉取日志)"
|
entry["remote_log"] = "(服务器离线,无法拉取日志)"
|
||||||
return
|
return
|
||||||
@@ -549,8 +565,14 @@ class ScriptService:
|
|||||||
results: Dict[str, dict] = {}
|
results: Dict[str, dict] = {}
|
||||||
semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY)
|
semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY)
|
||||||
|
|
||||||
|
server_map: Dict[int, Server] = {}
|
||||||
|
for sid in server_ids:
|
||||||
|
server = await self.server_repo.get_by_id(sid)
|
||||||
|
if server:
|
||||||
|
server_map[sid] = server
|
||||||
|
|
||||||
async def _exec_on_server(server_id: int):
|
async def _exec_on_server(server_id: int):
|
||||||
server = await self.server_repo.get_by_id(server_id)
|
server = server_map.get(server_id)
|
||||||
if not server or not server.is_online:
|
if not server or not server.is_online:
|
||||||
results[str(server_id)] = {
|
results[str(server_id)] = {
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
|||||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||||
from server.infrastructure.database.crypto import decrypt_value
|
from server.infrastructure.database.crypto import decrypt_value
|
||||||
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path, to_posix
|
from server.utils.posix_paths import (
|
||||||
|
PosixPathError,
|
||||||
|
UPLOAD_STAGING_PREFIX,
|
||||||
|
normalize_remote_abs_path,
|
||||||
|
resolve_nexus_push_source_path,
|
||||||
|
to_posix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _nexus_source_path(path: str) -> str:
|
def _nexus_source_path(path: str) -> str:
|
||||||
"""Normalize push source path string (Nexus host, Linux deployment)."""
|
"""Normalize and validate push source path on the Nexus host."""
|
||||||
return to_posix(path.strip())
|
return resolve_nexus_push_source_path(path)
|
||||||
|
|
||||||
logger = logging.getLogger("nexus.sync_v2")
|
logger = logging.getLogger("nexus.sync_v2")
|
||||||
|
|
||||||
@@ -63,10 +69,11 @@ class SyncEngineV2:
|
|||||||
batch_id: Optional[str] = None,
|
batch_id: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Push files from Nexus to target servers via rsync over SSH"""
|
"""Push files from Nexus to target servers via rsync over SSH"""
|
||||||
source_path = _nexus_source_path(source_path)
|
try:
|
||||||
if not os.path.isdir(source_path):
|
source_path = _nexus_source_path(source_path)
|
||||||
|
except PosixPathError as exc:
|
||||||
return {"total": 0, "completed": 0, "failed": 0, "results": {},
|
return {"total": 0, "completed": 0, "failed": 0, "results": {},
|
||||||
"error": f"源路径不存在: {source_path}"}
|
"error": str(exc)}
|
||||||
|
|
||||||
batch_id = (batch_id or uuid.uuid4().hex[:12])[:12]
|
batch_id = (batch_id or uuid.uuid4().hex[:12])[:12]
|
||||||
concurrency = min(concurrency, MAX_CONCURRENT)
|
concurrency = min(concurrency, MAX_CONCURRENT)
|
||||||
@@ -79,6 +86,7 @@ class SyncEngineV2:
|
|||||||
failed = 0
|
failed = 0
|
||||||
cancelled = 0
|
cancelled = 0
|
||||||
_counter_lock = asyncio.Lock()
|
_counter_lock = asyncio.Lock()
|
||||||
|
_db_lock = asyncio.Lock()
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
async def _sync_one(server: Server):
|
async def _sync_one(server: Server):
|
||||||
@@ -102,7 +110,8 @@ class SyncEngineV2:
|
|||||||
finished_at=datetime.now(timezone.utc),
|
finished_at=datetime.now(timezone.utc),
|
||||||
error_message="推送已取消",
|
error_message="推送已取消",
|
||||||
)
|
)
|
||||||
sync_log = await self.sync_log_repo.create(sync_log)
|
async with _db_lock:
|
||||||
|
sync_log = await self.sync_log_repo.create(sync_log)
|
||||||
|
|
||||||
async with _counter_lock:
|
async with _counter_lock:
|
||||||
cancelled += 1
|
cancelled += 1
|
||||||
@@ -137,7 +146,8 @@ class SyncEngineV2:
|
|||||||
sync_mode=sync_mode,
|
sync_mode=sync_mode,
|
||||||
started_at=datetime.now(timezone.utc),
|
started_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
sync_log = await self.sync_log_repo.create(sync_log)
|
async with _db_lock:
|
||||||
|
sync_log = await self.sync_log_repo.create(sync_log)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from server.api.websocket import broadcast_sync_progress
|
from server.api.websocket import broadcast_sync_progress
|
||||||
@@ -175,42 +185,43 @@ class SyncEngineV2:
|
|||||||
sync_log.diff_summary = (result.get("stdout") or "")[:2000]
|
sync_log.diff_summary = (result.get("stdout") or "")[:2000]
|
||||||
else:
|
else:
|
||||||
sync_log.diff_summary = None
|
sync_log.diff_summary = None
|
||||||
sync_log = await self.sync_log_repo.update(sync_log)
|
|
||||||
|
|
||||||
# Auto-create retry job for failed pushes (dedup: one pending per server+path)
|
|
||||||
retry_job_id = None
|
retry_job_id = None
|
||||||
if sync_log.status == "failed":
|
async with _db_lock:
|
||||||
try:
|
sync_log = await self.sync_log_repo.update(sync_log)
|
||||||
from server.domain.models import PushRetryJob
|
|
||||||
from sqlalchemy import select as _select
|
if sync_log.status == "failed":
|
||||||
existing = await self.retry_repo.session.execute(
|
try:
|
||||||
_select(PushRetryJob).where(
|
from server.domain.models import PushRetryJob
|
||||||
PushRetryJob.server_id == server.id,
|
from sqlalchemy import select as _select
|
||||||
PushRetryJob.source_path == source_path,
|
existing = await self.retry_repo.session.execute(
|
||||||
PushRetryJob.target_path == dest,
|
_select(PushRetryJob).where(
|
||||||
PushRetryJob.status == "pending",
|
PushRetryJob.server_id == server.id,
|
||||||
|
PushRetryJob.source_path == source_path,
|
||||||
|
PushRetryJob.target_path == dest,
|
||||||
|
PushRetryJob.status == "pending",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
if existing.scalars().first() is None:
|
||||||
if existing.scalars().first() is None:
|
retry_job = PushRetryJob(
|
||||||
retry_job = PushRetryJob(
|
server_id=server.id,
|
||||||
server_id=server.id,
|
server_name=server.name,
|
||||||
server_name=server.name,
|
operator=operator,
|
||||||
operator=operator,
|
source_path=source_path,
|
||||||
source_path=source_path,
|
target_path=dest,
|
||||||
target_path=dest,
|
status="pending",
|
||||||
status="pending",
|
retry_count=0,
|
||||||
retry_count=0,
|
max_retries=3,
|
||||||
max_retries=3,
|
next_retry_at=datetime.now(timezone.utc),
|
||||||
next_retry_at=datetime.now(timezone.utc),
|
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
|
||||||
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
|
)
|
||||||
)
|
retry_job = await self.retry_repo.create(retry_job)
|
||||||
retry_job = await self.retry_repo.create(retry_job)
|
retry_job_id = retry_job.id
|
||||||
retry_job_id = retry_job.id
|
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
|
||||||
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
|
else:
|
||||||
else:
|
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
|
||||||
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
|
||||||
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
|
|
||||||
|
|
||||||
async with _counter_lock:
|
async with _counter_lock:
|
||||||
if sync_log.status == "success":
|
if sync_log.status == "success":
|
||||||
@@ -313,9 +324,10 @@ class SyncEngineV2:
|
|||||||
if not server:
|
if not server:
|
||||||
return {"error": "Server not found", "exit_code": -1}
|
return {"error": "Server not found", "exit_code": -1}
|
||||||
|
|
||||||
source_path = _nexus_source_path(source_path)
|
try:
|
||||||
if not os.path.isdir(source_path):
|
source_path = _nexus_source_path(source_path)
|
||||||
return {"error": f"源路径不存在: {source_path}", "exit_code": -1}
|
except PosixPathError as exc:
|
||||||
|
return {"error": str(exc), "exit_code": -1}
|
||||||
|
|
||||||
dest = target_path or server.target_path or "/tmp/sync"
|
dest = target_path or server.target_path or "/tmp/sync"
|
||||||
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
|
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def apply_push_retry_outcome(
|
|||||||
if job.retry_count >= job.max_retries:
|
if job.retry_count >= job.max_retries:
|
||||||
job.status = "failed"
|
job.status = "failed"
|
||||||
else:
|
else:
|
||||||
|
job.status = "pending"
|
||||||
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -54,10 +55,33 @@ def apply_push_retry_outcome(
|
|||||||
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
|
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
|
||||||
return
|
return
|
||||||
|
|
||||||
|
job.status = "pending"
|
||||||
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
||||||
job.last_error = f"Retry {job.retry_count} failed"
|
job.last_error = f"Retry {job.retry_count} failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def _claim_next_retry_job(session, now: datetime) -> PushRetryJob | None:
|
||||||
|
"""Claim one due retry job with row lock (SKIP LOCKED)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(PushRetryJob)
|
||||||
|
.where(
|
||||||
|
PushRetryJob.status == "pending",
|
||||||
|
PushRetryJob.retry_count < PushRetryJob.max_retries,
|
||||||
|
PushRetryJob.next_retry_at <= now,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
job.status = "running"
|
||||||
|
await session.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
async def retry_runner_loop():
|
async def retry_runner_loop():
|
||||||
"""Background task: every 5min, retry pending retry jobs that are due."""
|
"""Background task: every 5min, retry pending retry jobs that are due."""
|
||||||
logger.info("Retry runner loop started (interval: 5min)")
|
logger.info("Retry runner loop started (interval: 5min)")
|
||||||
@@ -66,23 +90,15 @@ async def retry_runner_loop():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
from sqlalchemy import select
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(PushRetryJob).where(
|
|
||||||
PushRetryJob.status == "pending",
|
|
||||||
PushRetryJob.retry_count < PushRetryJob.max_retries,
|
|
||||||
PushRetryJob.next_retry_at <= now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
jobs = result.scalars().all()
|
|
||||||
|
|
||||||
if not jobs:
|
|
||||||
continue
|
|
||||||
|
|
||||||
retried = 0
|
retried = 0
|
||||||
for job in jobs:
|
|
||||||
|
while True:
|
||||||
|
job = await _claim_next_retry_job(session, now)
|
||||||
|
if not job:
|
||||||
|
break
|
||||||
|
|
||||||
|
job_id = job.id
|
||||||
try:
|
try:
|
||||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||||
@@ -110,16 +126,16 @@ async def retry_runner_loop():
|
|||||||
|
|
||||||
if job.status == "completed":
|
if job.status == "completed":
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Retry job {job.id}: success after {job.retry_count} attempts"
|
f"Retry job {job_id}: success after {job.retry_count} attempts"
|
||||||
)
|
)
|
||||||
elif job.status == "failed":
|
elif job.status == "failed":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Retry job {job.id}: failed — {job.last_error}"
|
f"Retry job {job_id}: failed — {job.last_error}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
backoff = int((job.next_retry_at - now).total_seconds())
|
backoff = int((job.next_retry_at - now).total_seconds())
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Retry job {job.id}: attempt {job.retry_count} pending, "
|
f"Retry job {job_id}: attempt {job.retry_count} pending, "
|
||||||
f"next retry in {backoff}s — {job.last_error}"
|
f"next retry in {backoff}s — {job.last_error}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,15 +143,20 @@ async def retry_runner_loop():
|
|||||||
retried += 1
|
retried += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Retry job {job.id} execution error: {e}")
|
logger.error(f"Retry job {job_id} execution error: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
job = await session.get(PushRetryJob, job_id)
|
||||||
|
if not job:
|
||||||
|
continue
|
||||||
job.retry_count += 1
|
job.retry_count += 1
|
||||||
job.last_error = str(e)[:500]
|
job.last_error = str(e)[:500]
|
||||||
if job.retry_count >= job.max_retries:
|
if job.retry_count >= job.max_retries:
|
||||||
job.status = "failed"
|
job.status = "failed"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Retry job {job.id}: max retries reached (exception), marking failed"
|
f"Retry job {job_id}: max retries reached (exception), marking failed"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
job.status = "pending"
|
||||||
job.next_retry_at = now + timedelta(
|
job.next_retry_at = now + timedelta(
|
||||||
seconds=_backoff_seconds(job.retry_count)
|
seconds=_backoff_seconds(job.retry_count)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,36 @@ def _field_match(expr: str, value: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_is_due(schedule: PushSchedule, now: datetime) -> bool:
|
||||||
|
"""Return True if *schedule* should fire at *now* (cron or once)."""
|
||||||
|
run_mode = getattr(schedule, "run_mode", None) or "cron"
|
||||||
|
|
||||||
|
if run_mode == "once":
|
||||||
|
fire_at = getattr(schedule, "fire_at", None)
|
||||||
|
if not fire_at:
|
||||||
|
return False
|
||||||
|
now_naive = now.replace(tzinfo=None)
|
||||||
|
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at
|
||||||
|
if fire_naive > now_naive:
|
||||||
|
return False
|
||||||
|
if schedule.last_run_at:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
cron_expr = getattr(schedule, "cron_expr", None)
|
||||||
|
if not cron_expr or not _cron_match(cron_expr, now):
|
||||||
|
return False
|
||||||
|
if schedule.last_run_at:
|
||||||
|
last_run = schedule.last_run_at
|
||||||
|
now_naive = now.replace(tzinfo=None)
|
||||||
|
if last_run.tzinfo is not None:
|
||||||
|
last_run = last_run.replace(tzinfo=None)
|
||||||
|
seconds_since = (now_naive - last_run).total_seconds()
|
||||||
|
if seconds_since < SCHEDULE_CHECK_INTERVAL:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def schedule_runner_loop():
|
async def schedule_runner_loop():
|
||||||
"""Background task: check schedules every 60s, trigger due cron jobs."""
|
"""Background task: check schedules every 60s, trigger due cron jobs."""
|
||||||
logger.info("Schedule runner loop started (interval: 60s)")
|
logger.info("Schedule runner loop started (interval: 60s)")
|
||||||
@@ -82,67 +112,58 @@ async def schedule_runner_loop():
|
|||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
result = await session.execute(
|
id_result = await session.execute(
|
||||||
select(PushSchedule).where(PushSchedule.enabled == True)
|
select(PushSchedule.id).where(PushSchedule.enabled == True)
|
||||||
)
|
)
|
||||||
schedules = result.scalars().all()
|
schedule_ids = list(id_result.scalars().all())
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
triggered = 0
|
triggered = 0
|
||||||
|
|
||||||
for schedule in schedules:
|
for sched_id in schedule_ids:
|
||||||
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── One-time schedule: check fire_at ──
|
lock_result = await session.execute(
|
||||||
if run_mode == 'once':
|
select(PushSchedule)
|
||||||
fire_at = getattr(schedule, 'fire_at', None)
|
.where(
|
||||||
if not fire_at:
|
PushSchedule.id == sched_id,
|
||||||
continue
|
PushSchedule.enabled == True,
|
||||||
now_naive = now.replace(tzinfo=None)
|
)
|
||||||
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at
|
.with_for_update(skip_locked=True)
|
||||||
if fire_naive > now_naive:
|
)
|
||||||
continue # not time yet
|
schedule = lock_result.scalar_one_or_none()
|
||||||
if schedule.last_run_at:
|
if not schedule:
|
||||||
continue # already ran
|
continue
|
||||||
else:
|
|
||||||
# ── Cron schedule: check cron expression ──
|
if not _schedule_is_due(schedule, now):
|
||||||
cron_expr = getattr(schedule, 'cron_expr', None)
|
await session.rollback()
|
||||||
if not cron_expr or not _cron_match(cron_expr, now):
|
continue
|
||||||
continue
|
|
||||||
if schedule.last_run_at:
|
run_mode = getattr(schedule, "run_mode", None) or "cron"
|
||||||
last_run = schedule.last_run_at
|
|
||||||
now_naive = now.replace(tzinfo=None)
|
|
||||||
if last_run.tzinfo is not None:
|
|
||||||
last_run = last_run.replace(tzinfo=None)
|
|
||||||
seconds_since = (now_naive - last_run).total_seconds()
|
|
||||||
if seconds_since < SCHEDULE_CHECK_INTERVAL:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Parse server_ids from JSON
|
# Parse server_ids from JSON
|
||||||
try:
|
try:
|
||||||
server_ids = json.loads(schedule.server_ids)
|
server_ids = json.loads(schedule.server_ids)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
|
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
|
||||||
|
await session.rollback()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not server_ids:
|
if not server_ids:
|
||||||
|
await session.rollback()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Mark as running BEFORE execution to prevent double-trigger
|
# Mark as running BEFORE execution to prevent double-trigger
|
||||||
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL).
|
|
||||||
# On failure we reset last_run_at so the schedule can retry.
|
|
||||||
prev_last_run_at = schedule.last_run_at
|
prev_last_run_at = schedule.last_run_at
|
||||||
schedule.last_run_at = now
|
schedule.last_run_at = now
|
||||||
if run_mode == 'once':
|
if run_mode == "once":
|
||||||
schedule.enabled = False
|
schedule.enabled = False
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push'
|
schedule_type = getattr(schedule, "schedule_type", "push") or "push"
|
||||||
execution_ok = True
|
execution_ok = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if schedule_type == 'script':
|
if schedule_type == "script":
|
||||||
# ── Script execution schedule ──
|
# ── Script execution schedule ──
|
||||||
from server.application.services.script_service import ScriptService
|
from server.application.services.script_service import ScriptService
|
||||||
from server.infrastructure.database.script_repo import (
|
from server.infrastructure.database.script_repo import (
|
||||||
@@ -212,22 +233,27 @@ async def schedule_runner_loop():
|
|||||||
f"Schedule '{schedule.name}' (push) triggered: "
|
f"Schedule '{schedule.name}' (push) triggered: "
|
||||||
f"{result['completed']}/{result['total']} succeeded"
|
f"{result['completed']}/{result['total']} succeeded"
|
||||||
)
|
)
|
||||||
|
if result.get("error"):
|
||||||
|
execution_ok = False
|
||||||
|
elif result.get("total", 0) > 0 and result.get("completed", 0) == 0:
|
||||||
|
execution_ok = False
|
||||||
except Exception as exec_err:
|
except Exception as exec_err:
|
||||||
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
|
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
|
||||||
execution_ok = False
|
execution_ok = False
|
||||||
|
|
||||||
if not execution_ok:
|
if not execution_ok:
|
||||||
rollback_schedule_after_failed_run(
|
fresh = await session.get(PushSchedule, sched_id)
|
||||||
schedule, prev_last_run_at, run_mode
|
if fresh:
|
||||||
)
|
rollback_schedule_after_failed_run(
|
||||||
await session.commit()
|
fresh, prev_last_run_at, run_mode
|
||||||
else:
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
triggered += 1
|
triggered += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Schedule {schedule.id} setup/parse failed: {e}")
|
logger.error(f"Schedule {sched_id} setup/parse failed: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
if triggered:
|
if triggered:
|
||||||
logger.info(f"Schedule runner: {triggered} schedules triggered")
|
logger.info(f"Schedule runner: {triggered} schedules triggered")
|
||||||
|
|||||||
+21
-3
@@ -19,7 +19,9 @@ D7: DB session leak fix — middleware auto-manages session lifecycle per reques
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import fcntl
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -86,6 +88,20 @@ _background_tasks: list[asyncio.Task] = []
|
|||||||
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
|
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
|
||||||
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
|
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
|
||||||
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
|
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
|
||||||
|
_PRIMARY_FILE_LOCK_FD: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _try_acquire_file_primary_lock() -> bool:
|
||||||
|
"""Fallback when Redis is unavailable: one process per host via flock."""
|
||||||
|
global _PRIMARY_FILE_LOCK_FD
|
||||||
|
lock_path = os.environ.get("NEXUS_PRIMARY_LOCK_FILE", "/tmp/nexus-primary.lock")
|
||||||
|
try:
|
||||||
|
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
_PRIMARY_FILE_LOCK_FD = fd
|
||||||
|
return True
|
||||||
|
except (OSError, BlockingIOError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _acquire_primary_lock() -> bool:
|
async def _acquire_primary_lock() -> bool:
|
||||||
@@ -109,9 +125,11 @@ async def _acquire_primary_lock() -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If Redis fails, assume primary (single-worker mode)
|
logger.warning("Primary lock check failed, trying file lock fallback: %s", e)
|
||||||
logger.warning(f"Primary lock check failed, assuming primary: {e}")
|
acquired = _try_acquire_file_primary_lock()
|
||||||
return True
|
if acquired:
|
||||||
|
logger.info("Primary worker lock acquired via file fallback")
|
||||||
|
return acquired
|
||||||
|
|
||||||
|
|
||||||
async def _renew_primary_lock():
|
async def _renew_primary_lock():
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import posixpath
|
|||||||
|
|
||||||
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
|
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
|
||||||
|
|
||||||
|
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
|
||||||
|
"/etc",
|
||||||
|
"/root",
|
||||||
|
"/home",
|
||||||
|
"/var",
|
||||||
|
"/boot",
|
||||||
|
"/dev",
|
||||||
|
"/proc",
|
||||||
|
"/sys",
|
||||||
|
"/run",
|
||||||
|
"/srv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PosixPathError(ValueError):
|
class PosixPathError(ValueError):
|
||||||
"""Invalid or unsafe POSIX path."""
|
"""Invalid or unsafe POSIX path."""
|
||||||
@@ -87,6 +100,20 @@ def resolve_nexus_host_path(path: str) -> str:
|
|||||||
return os.path.realpath(posix)
|
return os.path.realpath(posix)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_nexus_push_source_path(path: str) -> str:
|
||||||
|
"""Resolve and validate a Nexus host push source directory."""
|
||||||
|
stripped = path.strip()
|
||||||
|
if ".." in to_posix(stripped).split("/"):
|
||||||
|
raise PosixPathError("路径不允许包含 '..'")
|
||||||
|
real_path = resolve_nexus_host_path(stripped)
|
||||||
|
for prefix in FORBIDDEN_NEXUS_SOURCE_PREFIXES:
|
||||||
|
if real_path == prefix or real_path.startswith(prefix + "/"):
|
||||||
|
raise PosixPathError(f"不允许使用系统路径: {prefix}")
|
||||||
|
if not os.path.isdir(real_path):
|
||||||
|
raise PosixPathError(f"源路径不存在: {real_path}")
|
||||||
|
return real_path
|
||||||
|
|
||||||
|
|
||||||
def ensure_under_nexus_upload(path: str) -> str:
|
def ensure_under_nexus_upload(path: str) -> str:
|
||||||
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
|
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
|
||||||
real = resolve_nexus_host_path(path)
|
real = resolve_nexus_host_path(path)
|
||||||
|
|||||||
@@ -56,3 +56,15 @@ def test_exhausted_retries_marks_failed():
|
|||||||
now=datetime.now(timezone.utc),
|
now=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
assert job.status == "failed"
|
assert job.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_job_returns_to_pending_after_retryable_failure():
|
||||||
|
job = _job(retry_count=1, status="running")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
apply_push_retry_outcome(
|
||||||
|
job,
|
||||||
|
{"total": 1, "completed": 0, "failed": 1, "cancelled": 0},
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
assert job.status == "pending"
|
||||||
|
assert job.next_retry_at > now
|
||||||
|
|||||||
@@ -227,3 +227,33 @@ def test_install_reject_when_locked(monkeypatch, tmp_path):
|
|||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
install_api._reject_if_locked()
|
install_api._reject_if_locked()
|
||||||
assert exc.value.status_code == 403
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from server.api import install as install_api
|
||||||
|
|
||||||
|
state_file = tmp_path / ".install_state.json"
|
||||||
|
state_file.write_text('{"install_token":"abc123"}', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
install_api._verify_install_token("")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
install_api._verify_install_token("wrong")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
install_api._verify_install_token("abc123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_nexus_push_source_path_rejects_system_prefix(tmp_path, monkeypatch):
|
||||||
|
from server.utils.posix_paths import PosixPathError, resolve_nexus_push_source_path
|
||||||
|
|
||||||
|
allowed = tmp_path / "push_src"
|
||||||
|
allowed.mkdir()
|
||||||
|
assert resolve_nexus_push_source_path(str(allowed)) == str(allowed.resolve())
|
||||||
|
|
||||||
|
with pytest.raises(PosixPathError, match="系统路径"):
|
||||||
|
resolve_nexus_push_source_path("/etc")
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ function installWizard() {
|
|||||||
envChecks: [],
|
envChecks: [],
|
||||||
envAllPass: false,
|
envAllPass: false,
|
||||||
initResult: null,
|
initResult: null,
|
||||||
|
installToken: '',
|
||||||
installDir: '/opt/nexus',
|
installDir: '/opt/nexus',
|
||||||
btPanel: false,
|
btPanel: false,
|
||||||
|
|
||||||
@@ -659,6 +660,7 @@ function installWizard() {
|
|||||||
if (r.ok && d.success) {
|
if (r.ok && d.success) {
|
||||||
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
|
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
|
||||||
this.initResult = d;
|
this.initResult = d;
|
||||||
|
this.installToken = d.install_token || '';
|
||||||
this.btPanel = !!d.bt_panel;
|
this.btPanel = !!d.bt_panel;
|
||||||
this.step = 4;
|
this.step = 4;
|
||||||
} else {
|
} else {
|
||||||
@@ -675,6 +677,7 @@ function installWizard() {
|
|||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
install_token: this.installToken,
|
||||||
...this.adminForm,
|
...this.adminForm,
|
||||||
db_host: this.form.db_host,
|
db_host: this.form.db_host,
|
||||||
db_port: this.form.db_port,
|
db_port: this.form.db_port,
|
||||||
|
|||||||
Reference in New Issue
Block a user