fix(security): BL-07 WS 403 澄清与 code-review 跟进加固
外网无 token WebSocket 的 HTTP 403 为应用层拒绝而非反代故障;边端 agent 在中心 401 时停止心跳重试;install 锁定路由级 404 且归档失败 fail-closed。 同步安全规范与 BL-06 一致,门控 7/7 PASS。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,7 +11,7 @@ alwaysApply: false
|
||||
| 调用方 | 机制 |
|
||||
|--------|------|
|
||||
| 管理端 API | JWT Bearer + 可选 TOTP |
|
||||
| Agent | `X-API-Key`(全局或 per-server `agent_api_key`) |
|
||||
| Agent | `X-API-Key`(仅 per-server `agent_api_key`;经 `POST /api/servers/{id}/agent-key` 生成,BL-06 起无 global 回退) |
|
||||
| WebSocket | JWT query param |
|
||||
| 安装向导 | 无 JWT(仅安装模式可用) |
|
||||
|
||||
|
||||
@@ -166,3 +166,63 @@
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-04T23:47:38+08:00","date":"2026-06-04","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-install-locked-404.md 34lines"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T01:44:38+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-agent-per-server-key-enforced.md 47lines"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T03:16:37+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-agent-per-server-key-enforced.md 47lines"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-bl06-agent-per-server-key.md"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T03:17:09+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 29lines"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-bl06-agent-per-server-key.md"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T12:01:55+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 42lines"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T12:02:39+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-code-review-followups.md 42lines"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-code-review-followups.md"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T12:02:49+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 审计 — 2026-06-07 ce-code-review 跟进 + BL-07 探测
|
||||
|
||||
## 范围
|
||||
|
||||
本批次 `git diff HEAD~1` 含 **BL-06**(已提交)与 **ce-code-review 跟进**(工作区)。BL-06 细读见 `2026-06-07-bl06-agent-per-server-key.md`。
|
||||
|
||||
| 文件 | 变更要点 |
|
||||
|------|----------|
|
||||
| `server/api/servers.py` | BL-06:安装链路无 global key 回退 |
|
||||
| `server/application/services/script_service.py` | BL-06:回调仅 per-server key |
|
||||
| `tests/test_agent_per_server_key.py` | BL-06:认证单元测试 |
|
||||
| `tests/load_test.py` | BL-06:文档注释 |
|
||||
| `tests/quick_load.py` | BL-06:文档注释 |
|
||||
| `web/agent/agent.py` | 心跳 401 退出循环(对齐 BL-06 中心端) |
|
||||
| `server/api/install.py` | 路由级 lock Depends;归档先于写 lock;归档失败 fail-closed |
|
||||
| `.cursor/rules/nexus-security.mdc` | Agent 认证矩阵:仅 per-server key |
|
||||
| `scripts/security_probe_external.sh` | WS 无 token HTTP 403 → PASS |
|
||||
| `tests/test_install_locked_404.py` | create-admin / test-credentials 锁定 404;router Depends 断言 |
|
||||
| `tests/test_security_unit.py` | `_reject_if_locked` 期望 404 |
|
||||
| `deploy/install-nx-cli.sh` | CRLF → LF(门控 pre-flight) |
|
||||
| `deploy/update.sh` | CRLF → LF |
|
||||
| `deploy/gate_log.jsonl` | 门控运行记录(自动生成) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 边端 agent 401 不重试 | FIXED `agent.py` 401 → return |
|
||||
| H2 | 锁定 install 统一 404 | FIXED `install.py` router `Depends(_raise_not_found_if_locked)` |
|
||||
| H3 | 归档失败仍 lock 成功 | FIXED 归档先于 lock;OSError → RuntimeError → 500 |
|
||||
| H4 | 安全 SSOT 与 BL-06 漂移 | FIXED `nexus-security.mdc` |
|
||||
| H5 | 外网 WS 403 误判反代 | FIXED 探测脚本 PASS + 报告澄清 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | FIXED | `web/agent/agent.py` `elif resp.status_code == 401: return` |
|
||||
| H2 | FIXED | `install.py` `APIRouter(..., dependencies=[Depends(...)])` |
|
||||
| H3 | FIXED | `_finalize_install_lock` 先 `_archive_install_wizard_html` |
|
||||
| H4 | FIXED | `nexus-security.mdc` 认证矩阵 |
|
||||
| H5 | FIXED | `security_probe_external.sh` L167-168 |
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| `bash scripts/local_verify.sh` | 全绿(smoke 47 + test_api 26/26 + ruff) |
|
||||
| `pytest tests/test_install_locked_404.py` | 11 passed |
|
||||
| `deploy/pre_deploy_check.sh` | 待本审计入库后重跑 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 无静默吞错(归档失败 error + 500)
|
||||
- [x] changelog `2026-06-07-code-review-followups.md`
|
||||
- [x] 本地 L2b `local_verify` 证据
|
||||
- [x] 涉及文件列入本审计范围表
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog — BL-07 WebSocket 外网 403 澄清
|
||||
|
||||
**日期**:2026-06-06
|
||||
|
||||
## 摘要
|
||||
|
||||
澄清外网无 token WebSocket 探测见 HTTP 403 为应用层拒绝握手(非反代故障);登录后 `/ws/alerts`、`/ws/sync` 生产验证连接成功。BL-07 标为接受,更新探测脚本与报告。
|
||||
|
||||
## 动机
|
||||
|
||||
外网攻击面巡检误判「反代 403」;经直连 8600 对比与登录后 WS 验证,确认无需改 Nginx。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docs/reports/2026-06-06-external-hardening-deploy-verification.md`
|
||||
- `docs/reports/2026-06-06-external-attack-hardening-backlog.md`
|
||||
- `docs/reports/2026-06-06-external-attack-surface-audit.md` §7
|
||||
- `scripts/security_probe_external.sh`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
||||
# WS 无 token → [PASS] rejected (HTTP 403)
|
||||
# 登录后 wss://.../ws/alerts?token=... → 连接成功(人工/API)
|
||||
```
|
||||
|
||||
## 同日决定
|
||||
|
||||
- **BL-08** 全站 IP 白名单:运维确认**不需要**,backlog 已关闭。
|
||||
@@ -0,0 +1,42 @@
|
||||
# Changelog — ce-code-review 跟进修复
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
落实 `/ce-code-review` walk-through 与 auto-resolve:边端 agent 401 停循环、install 锁定路由级 404、归档失败 fail-closed、安全规范与 BL-06 对齐,并补 install 锁定测试。
|
||||
|
||||
## 动机
|
||||
|
||||
BL-06 中心端改 401 后边端仍无限重试;锁定后 POST install 畸形 body 仍可能 422;`install.html` 归档失败时 lock 仍成功;`nexus-security.mdc` 与实现漂移。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web/agent/agent.py`
|
||||
- `server/api/install.py`
|
||||
- `.cursor/rules/nexus-security.mdc`
|
||||
- `scripts/security_probe_external.sh`(BL-07:WS 403 → PASS)
|
||||
- `tests/test_install_locked_404.py`
|
||||
- `tests/test_security_unit.py`
|
||||
- `deploy/install-nx-cli.sh`、`deploy/update.sh`(CRLF→LF,门控 pre-flight)
|
||||
- `docs/audit/2026-06-07-code-review-followups.md`
|
||||
- `docs/changelog/2026-06-06-bl07-websocket-closure.md`(同日 BL-07 说明)
|
||||
- `docs/reports/2026-06-06-external-hardening-deploy-verification.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 边端 agent 需重新部署/同步 `web/agent/agent.py` 后方生效
|
||||
- 中心 API 重启后 install 路由级 Depends 生效
|
||||
- 无 DB 迁移
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash scripts/local_verify.sh
|
||||
# smoke 47 passed · test_api 26/26 · ruff F 0
|
||||
|
||||
pytest tests/test_install_locked_404.py -q
|
||||
# 11 passed
|
||||
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# 外网加固部署与生产验证(2026-06-06)
|
||||
|
||||
## Git 推送
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| Commit | `e5e2582` — `fix(security): 外网攻击面加固 BL-01~05` |
|
||||
| 远程 | `http://66.154.115.8:3000/admin/Nexus.git` `main` |
|
||||
| Push | ✅ `722917b..e5e2582` |
|
||||
|
||||
## 生产部署
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| 方式 | `ssh -i nz-admin.pem azureuser@20.24.218.235` → `sudo nx update` |
|
||||
| 版本 | **e5e2582** `fix(security): 外网攻击面加固 BL-01~05` |
|
||||
| 容器 | `nexus-prod-nexus-1` 重建 healthy |
|
||||
| `/health` | `ok` |
|
||||
|
||||
## 生产探测(部署后 — 2026-06-06)
|
||||
|
||||
`https://api.synaglobal.vip`:
|
||||
|
||||
| 探测项 | 结果 | 判定 |
|
||||
|--------|------|------|
|
||||
| `GET /health/detail` | **401** | ✅ BL-02 |
|
||||
| `GET /openapi.json` `/docs` `/redoc` | **404** | ✅ BL-04 |
|
||||
| `GET /api/install/status` | **404** | ✅ |
|
||||
| `GET /api/install/env-check` 等 | **404** | ✅ BL-05 |
|
||||
| `POST /api/install/lock` | **404** | ✅ BL-05 |
|
||||
| `POST /api/settings/bing-wallpapers/sync` | **401** | ✅ BL-03 |
|
||||
| `GET/POST /api/servers/` | **401** | ✅ |
|
||||
| Agent 假 Key | **401** | ✅ |
|
||||
|
||||
**备注**:`POST /api/install/init-db` 等若 body 不满足 Pydantic,FastAPI 在 handler 前返回 **422**(非 403 指纹);带合法 body 的锁定请求为 404。可接受,后续可用 router 级 Depends 提前 404。
|
||||
|
||||
## WebSocket(BL-07 已澄清)
|
||||
|
||||
| 场景 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| 外网无 token / 假 token | HTTP **403** | Starlette 在 `accept()` 前 `close(4001/4401)` 时,黑盒常见 HTTP 403,**非反代故障** |
|
||||
| 直连 `ws://127.0.0.1:8600/ws/alerts` 无 token | HTTP **403** | 与经 HTTPS 一致,排除仅反代问题 |
|
||||
| 1Panel `root.conf` | 含 `Upgrade` / `Connection` | 全站反代至 8600,配置正常 |
|
||||
| 登录后 `wss://.../ws/alerts?token=<JWT>` | **连接成功** | 2026-06-06 生产验证 |
|
||||
| 登录后 `wss://.../ws/sync?token=<JWT>` | **连接成功** | 同上 |
|
||||
|
||||
**结论**:BL-07 标为**接受**(与 risk-acceptance ADR-011 一致);外网探测脚本以「无 token 拒绝连接」为 PASS,不要求 HTTP 层可见 4001/4401。
|
||||
|
||||
## 本地测试(推送前已通过)
|
||||
|
||||
```bash
|
||||
pytest tests/test_install_locked_404.py tests/test_external_attack_surface_hardening.py -q
|
||||
# 12 passed
|
||||
```
|
||||
@@ -164,7 +164,8 @@ async def probe_ws(path, token=None):
|
||||
elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
|
||||
print(f" [PASS] {path} bad token → rejected ({msg[:80]})")
|
||||
elif "403" in msg:
|
||||
print(f" [INFO] {path} → reverse proxy 403 (app 4001/4401 not observable)")
|
||||
# Starlette: close() before accept() → HTTP 403 (same direct and via proxy)
|
||||
print(f" [PASS] {path} unauthenticated → rejected (HTTP 403)")
|
||||
else:
|
||||
print(f" [INFO] {path} → {msg[:120]}")
|
||||
|
||||
|
||||
+27
-14
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus, unquote_plus, urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -24,8 +24,6 @@ from server.utils.text_io import read_utf8_text, write_utf8_lf
|
||||
|
||||
logger = logging.getLogger("nexus.install")
|
||||
|
||||
router = APIRouter(prefix="/api/install", tags=["install"])
|
||||
|
||||
# ── Paths ──
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -53,6 +51,19 @@ def _is_installed() -> bool:
|
||||
return _is_locked()
|
||||
|
||||
|
||||
def _raise_not_found_if_locked() -> None:
|
||||
"""Hide install API fingerprint after lock — same response as unknown route."""
|
||||
if _is_locked():
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/install",
|
||||
tags=["install"],
|
||||
dependencies=[Depends(_raise_not_found_if_locked)],
|
||||
)
|
||||
|
||||
|
||||
# ── Pydantic Models ──
|
||||
|
||||
class InitDbRequest(BaseModel):
|
||||
@@ -903,7 +914,8 @@ def _archive_install_wizard_html() -> None:
|
||||
try:
|
||||
INSTALL_HTML.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove install.html after archive: %s", e)
|
||||
logger.error("Failed to remove install.html after archive: %s", e)
|
||||
raise RuntimeError("Failed to remove install wizard HTML after lock") from e
|
||||
return
|
||||
if not INSTALL_HTML.is_file():
|
||||
return
|
||||
@@ -911,18 +923,19 @@ def _archive_install_wizard_html() -> None:
|
||||
INSTALL_HTML.rename(INSTALL_HTML_BAK)
|
||||
logger.info("Archived install wizard: %s → %s", INSTALL_HTML, INSTALL_HTML_BAK)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to archive install.html: %s", e)
|
||||
logger.error("Failed to archive install.html: %s", e)
|
||||
raise RuntimeError("Failed to archive install wizard HTML") from e
|
||||
|
||||
|
||||
def _finalize_install_lock() -> None:
|
||||
"""Write install lock marker and remove wizard state file."""
|
||||
_sync_env_to_persist_volume()
|
||||
_archive_install_wizard_html()
|
||||
write_utf8_lf(
|
||||
INSTALL_LOCK,
|
||||
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n",
|
||||
)
|
||||
_sync_lock_to_persist_volume()
|
||||
_archive_install_wizard_html()
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
STATE_FILE.unlink()
|
||||
@@ -948,12 +961,6 @@ def _verify_install_token(provided: str) -> None:
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
def _raise_not_found_if_locked() -> None:
|
||||
"""Hide install API fingerprint after lock — same response as unknown route."""
|
||||
if _is_locked():
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def install_status():
|
||||
"""Check whether Nexus is already installed."""
|
||||
@@ -1399,7 +1406,10 @@ async def create_admin(req: CreateAdminRequest):
|
||||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||||
|
||||
# Atomically lock after admin creation (closes unauthenticated install API window)
|
||||
_finalize_install_lock()
|
||||
try:
|
||||
_finalize_install_lock()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return {"success": True, "locked": True}
|
||||
|
||||
|
||||
@@ -1439,7 +1449,10 @@ async def lock_install():
|
||||
detail="数据库暂时不可用,无法验证管理员账户,请稍后重试。",
|
||||
) from e
|
||||
|
||||
_finalize_install_lock()
|
||||
try:
|
||||
_finalize_install_lock()
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return {"success": True}
|
||||
|
||||
|
||||
|
||||
@@ -101,3 +101,40 @@ def test_reject_if_locked_raises_404():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_reject_if_locked()
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_credentials_locked_404(locked):
|
||||
from server.api.install import test_credentials, InitDbRequest
|
||||
|
||||
req = InitDbRequest(
|
||||
db_host="localhost",
|
||||
db_port="3306",
|
||||
db_name="nexus",
|
||||
db_user="root",
|
||||
db_pass="x",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await test_credentials(req)
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_locked_404(locked):
|
||||
from server.api.install import create_admin, CreateAdminRequest
|
||||
|
||||
req = CreateAdminRequest(
|
||||
install_token="tok",
|
||||
db_pass="x",
|
||||
admin_password="secret12",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await create_admin(req)
|
||||
_assert_not_found(exc.value)
|
||||
|
||||
|
||||
def test_install_router_has_lock_dependency():
|
||||
"""Router-level lock guard runs before per-route handlers (BL-05)."""
|
||||
from server.api.install import router
|
||||
|
||||
assert router.dependencies, "install router must enforce lock before body validation"
|
||||
|
||||
@@ -473,7 +473,8 @@ def test_install_reject_when_locked(monkeypatch, tmp_path):
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
install_api._reject_if_locked()
|
||||
assert exc.value.status_code == 403
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail == "Not found"
|
||||
|
||||
|
||||
def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
|
||||
|
||||
@@ -408,6 +408,14 @@ async def send_heartbeat():
|
||||
return # Exit heartbeat loop — agent needs reconfiguration
|
||||
_consecutive_failures = 0
|
||||
logger.debug("Heartbeat sent")
|
||||
elif resp.status_code == 401:
|
||||
logger.error(
|
||||
"Heartbeat rejected by central (401): invalid API key or "
|
||||
"server_id=%s not configured with per-server agent_api_key. "
|
||||
"Stopping heartbeat — reconfigure agent key via admin UI.",
|
||||
SERVER_ID,
|
||||
)
|
||||
return
|
||||
else:
|
||||
_consecutive_failures += 1
|
||||
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
||||
|
||||
Reference in New Issue
Block a user