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:
Nexus Agent
2026-06-07 12:12:49 +08:00
parent eab35a35be
commit 72d82d737b
11 changed files with 324 additions and 17 deletions
+1 -1
View File
@@ -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(仅安装模式可用) |
+60
View File
@@ -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 锁定 404router 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 归档先于 lockOSError → 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-07WS 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-0105` |
| 远程 | `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-0105` |
| 容器 | `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 不满足 PydanticFastAPI 在 handler 前返回 **422**(非 403 指纹);带合法 body 的锁定请求为 404。可接受,后续可用 router 级 Depends 提前 404。
## WebSocketBL-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
```
+2 -1
View File
@@ -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
View File
@@ -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}
+37
View File
@@ -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"
+2 -1
View File
@@ -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):
+8
View File
@@ -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}")