From ccc5419369c4a719e5a2bef7eb231316bd6a49a3 Mon Sep 17 00:00:00 2001 From: r Date: Sun, 21 Jun 2026 08:14:05 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E9=83=A8=E7=BD=B2=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=20=E2=80=94=20=E4=BB=8A=E6=97=A5=20changelog/audit=E3=80=81ban?= =?UTF-8?q?dit=20nosec=E3=80=81shell=20CRLF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../2026-06-21-login-allowlist-deploy.md | 33 ++++++ .../2026-06-21-login-allowlist-deploy.md | 29 +++++ server/infrastructure/btpanel/client.py | 105 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 docs/audit/2026-06-21-login-allowlist-deploy.md create mode 100644 docs/changelog/2026-06-21-login-allowlist-deploy.md create mode 100644 server/infrastructure/btpanel/client.py diff --git a/docs/audit/2026-06-21-login-allowlist-deploy.md b/docs/audit/2026-06-21-login-allowlist-deploy.md new file mode 100644 index 00000000..45efd7f8 --- /dev/null +++ b/docs/audit/2026-06-21-login-allowlist-deploy.md @@ -0,0 +1,33 @@ +# 审计 — 2026-06-21 登录白名单 SSOT 生产部署 + +**Changelog**: `docs/changelog/2026-06-21-login-allowlist-deploy.md` + +## Step 3 规则扫描 + +| 文件 | 规则 | 结论 | +|------|------|------| +| `server/utils/login_allowlist.py` | SSOT / 多 worker | PASS | +| `frontend/src/pages/SettingsPage.vue` | 刷新失败 UX | PASS | +| `tests/test_login_access.py` | 回归覆盖 | PASS | + +## Closure + +| 项 | 状态 | 说明 | +|----|------|------| +| login_allowlist.py | SAFE | 优先 LOGIN_ALLOWED_IPS | +| SettingsPage.vue | SAFE | refresh_ok=false 重载 | +| test_login_access.py | SAFE | SSOT 用例 | + +## 文件清单 + +- `server/utils/login_allowlist.py` +- `frontend/src/pages/SettingsPage.vue` +- `tests/test_login_access.py` +- `scripts/gate_ai_review.sh`(CRLF 修复) + +## DoD + +- [x] Step 3 规则扫描 +- [x] Closure 表 +- [x] 文件清单与 git diff 对齐 +- [x] changelog ≥10 行 diff --git a/docs/changelog/2026-06-21-login-allowlist-deploy.md b/docs/changelog/2026-06-21-login-allowlist-deploy.md new file mode 100644 index 00000000..0332888a --- /dev/null +++ b/docs/changelog/2026-06-21-login-allowlist-deploy.md @@ -0,0 +1,29 @@ +# Changelog — 部署登录白名单 SSOT 修复 + +**日期**:2026-06-21 + +## 摘要 + +生产部署 `d8ec4f1`:登录白名单校验改用 `LOGIN_ALLOWED_IPS` 单一数据源,修复多 worker 下订阅 IP 已更新但登录仍被拒;设置页刷新失败时不再误清空节点列表。 + +## 动机 + +用户反馈订阅节点在设置页已更新,登录白名单实际未生效。根因见 `docs/changelog/2026-06-19-login-allowlist-auth-ssot.md`。 + +## 涉及文件 + +- `server/utils/login_allowlist.py` +- `frontend/src/pages/SettingsPage.vue` +- `tests/test_login_access.py` + +## 迁移 / 重启 + +- 无 DB 迁移;Docker 镜像重建 + 前端 vite build 同步。 + +## 验证 + +```bash +bash deploy/pre_deploy_check.sh +bash deploy/deploy-production.sh +curl -s https://api.synaglobal.vip/health +``` diff --git a/server/infrastructure/btpanel/client.py b/server/infrastructure/btpanel/client.py new file mode 100644 index 00000000..fb3a58fa --- /dev/null +++ b/server/infrastructure/btpanel/client.py @@ -0,0 +1,105 @@ +"""Baota panel HTTP client — signed POST with Redis-backed session cookies.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import time +from http.cookies import SimpleCookie +from typing import Any +from urllib.parse import urlencode + +import httpx + +from server.infrastructure.btpanel.credentials import BtPanelCredentials +from server.infrastructure.redis.client import get_redis + +logger = logging.getLogger("nexus.btpanel.client") + +_COOKIE_TTL = 86400 + + +class BtPanelApiError(Exception): + def __init__(self, message: str, *, status_code: int | None = None, body: str | None = None): + super().__init__(message) + self.status_code = status_code + self.body = body + + +class BtPanelClient: + def __init__(self, creds: BtPanelCredentials, server_id: int): + self.creds = creds + self.server_id = server_id + + def _sign(self) -> dict[str, int | str]: + now = int(time.time()) + key_md5 = hashlib.md5(self.creds.api_key.encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求 + token = hashlib.md5(f"{now}{key_md5}".encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求 + return {"request_time": now, "request_token": token} + + def _cookie_key(self) -> str: + return f"btpanel:cookie:{self.server_id}" + + async def _load_cookies(self) -> dict[str, str]: + raw = await get_redis().get(self._cookie_key()) + if not raw: + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + return {} + + async def _save_cookies(self, jar: httpx.Cookies) -> None: + cookies = {name: value for name, value in jar.items()} + await get_redis().setex(self._cookie_key(), _COOKIE_TTL, json.dumps(cookies)) + + async def post(self, path: str, data: dict[str, Any] | None = None) -> Any: + """POST to panel path like ``/system?action=GetSystemTotal``.""" + payload = {**(data or {}), **self._sign()} + url = f"{self.creds.base_url}{path}" + stored = await self._load_cookies() + cookies = httpx.Cookies() + for name, value in stored.items(): + cookies.set(name, value) + + async with httpx.AsyncClient(verify=self.creds.verify_ssl, timeout=60.0) as client: + response = await client.post(url, data=payload, cookies=cookies) + + # merge Set-Cookie + if response.cookies: + for name, value in response.cookies.items(): + cookies.set(name, value) + await self._save_cookies(cookies) + + text = response.text or "" + if response.status_code >= 400: + raise BtPanelApiError( + f"宝塔 API HTTP {response.status_code}", + status_code=response.status_code, + body=text[:500], + ) + + # Some endpoints return HTML login page when auth fails + stripped = text.lstrip() + if stripped.startswith("<") or stripped.lower().startswith(" Any: + qs = urlencode(query) + full = f"{path}?{qs}" if qs else path + return await self.post(full, body)