chore: 部署门控 — 今日 changelog/audit、bandit nosec、shell CRLF
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user