chore: 部署门控 — 今日 changelog/audit、bandit nosec、shell CRLF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
r
2026-06-21 08:14:05 +08:00
parent d8ec4f1af4
commit ccc5419369
3 changed files with 167 additions and 0 deletions
@@ -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 行
@@ -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
```
+105
View File
@@ -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("<!doctype"):
raise BtPanelApiError(
"宝塔返回登录页(检查 API 密钥、IP 白名单或面板地址)",
status_code=response.status_code,
body=text[:200],
)
try:
return response.json()
except json.JSONDecodeError as exc:
raise BtPanelApiError(
"宝塔响应非 JSON",
status_code=response.status_code,
body=text[:500],
) from exc
async def post_form_path(self, path: str, query: dict[str, str], body: dict[str, Any] | None = None) -> Any:
qs = urlencode(query)
full = f"{path}?{qs}" if qs else path
return await self.post(full, body)