From aa583e744074a382a81c38dcbce7b185a812ca54 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 20:33:30 +0800 Subject: [PATCH] Add ops runtime secret rotation --- .../app/api/ops/sensitive-config/route.ts | 36 ++++ .../ops/config/ConfigPageClient.tsx | 155 +++++++++++++++++- src/data_collection/amsc_awos_sources.py | 5 +- src/database/db_manager.py | 109 ++++++++++++ src/utils/runtime_secrets.py | 65 ++++++++ tests/test_ops_amsc_health.py | 99 +++++++++++ web/routers/ops.py | 20 +++ web/services/ops_api.py | 91 +++++++++- 8 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 frontend/app/api/ops/sensitive-config/route.ts create mode 100644 src/utils/runtime_secrets.py diff --git a/frontend/app/api/ops/sensitive-config/route.ts b/frontend/app/api/ops/sensitive-config/route.ts new file mode 100644 index 00000000..a11a9f89 --- /dev/null +++ b/frontend/app/api/ops/sensitive-config/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : ""; + +export async function GET(req: NextRequest) { + if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" }); + const raw = await res.text(); + const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); + return applyAuthResponseCookies(response, auth.response); + } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); } +} + +export async function PUT(req: NextRequest) { + if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const body = await req.text(); + const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); + const raw = await res.text(); + const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); + return applyAuthResponseCookies(response, auth.response); + } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); } +} diff --git a/frontend/components/ops/config/ConfigPageClient.tsx b/frontend/components/ops/config/ConfigPageClient.tsx index c4dfcee0..d13e9533 100644 --- a/frontend/components/ops/config/ConfigPageClient.tsx +++ b/frontend/components/ops/config/ConfigPageClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { RefreshCcw, Save } from "lucide-react"; +import { KeyRound, RefreshCcw, Save } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -11,19 +11,52 @@ type EditableConfig = { description: string; }; +type SensitiveConfig = { + key: string; + label: string; + description: string; + configured: boolean; + masked: string; + length: number; + updated_at: string; + updated_by: string; + source: string; +}; + +type SensitiveHealth = { + ok?: boolean; + status?: number; + latency_ms?: number; + points?: number; + observation_time_local?: string; + error?: string; +}; + export function ConfigPageClient() { const [configs, setConfigs] = useState([]); + const [sensitiveConfigs, setSensitiveConfigs] = useState([]); const [editing, setEditing] = useState>({}); + const [sensitiveEditing, setSensitiveEditing] = useState>({}); const [saving, setSaving] = useState(false); + const [sensitiveSaving, setSensitiveSaving] = useState(false); const [result, setResult] = useState(""); + const [sensitiveResult, setSensitiveResult] = useState(""); + const [sensitiveHealth, setSensitiveHealth] = useState(null); const load = async () => { try { - const res = await fetch("/api/ops/config"); + const [res, sensitiveRes] = await Promise.all([ + fetch("/api/ops/config"), + fetch("/api/ops/sensitive-config"), + ]); if (res.ok) { const data = (await res.json()) as { configs?: EditableConfig[] }; setConfigs(data.configs ?? []); } + if (sensitiveRes.ok) { + const data = (await sensitiveRes.json()) as { configs?: SensitiveConfig[] }; + setSensitiveConfigs(data.configs ?? []); + } } catch { /* backend not ready yet */ } }; @@ -51,6 +84,38 @@ export function ConfigPageClient() { setSaving(false); }; + const handleSensitiveSave = async (key: string) => { + const newVal = sensitiveEditing[key]?.trim(); + if (!newVal) return; + setSensitiveSaving(true); + setSensitiveResult(""); + setSensitiveHealth(null); + try { + const res = await fetch("/api/ops/sensitive-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value: newVal }), + }); + if (res.ok) { + const data = (await res.json()) as { + config?: SensitiveConfig; + health?: SensitiveHealth | null; + }; + if (data.config) { + setSensitiveConfigs((prev) => prev.map((cfg) => (cfg.key === key ? data.config as SensitiveConfig : cfg))); + } + setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; }); + setSensitiveHealth(data.health ?? null); + setSensitiveResult(`${key} 已轮换`); + } else { + setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`); + } + } catch { + setSensitiveResult("轮换失败"); + } + setSensitiveSaving(false); + }; + useEffect(() => { void load(); }, []); return ( @@ -101,7 +166,91 @@ export function ConfigPageClient() {

)}

- 仅显示非敏感配置项。API Key 等密钥不在此处暴露。修改后立即生效,建议重启服务以确保持久化。 + 仅显示非敏感配置项。修改后立即影响当前后端进程;需要跨重启持久化的密钥请使用下方凭证轮换模块。 +

+ + + + + + + + 敏感凭证轮换 + + + + {sensitiveConfigs.length === 0 ? ( +

敏感配置 API 尚未就绪。

+ ) : ( +
+ {sensitiveConfigs.map((cfg) => ( +
+
+
+
+
{cfg.label}
+ + {cfg.configured ? "已配置" : "未配置"} + + + {cfg.source === "environment" ? "环境变量兜底" : "DB 持久化"} + +
+
{cfg.description}
+
+
+ 当前值 + {cfg.masked || "未设置"} +
+
+ 更新人 + {cfg.updated_by || "-"} +
+
+ 更新时间 + {cfg.updated_at || "-"} +
+
+
+
+ setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))} + className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white font-mono outline-none focus:border-cyan-400/50" + /> + +
+
+
+ ))} +
+ )} + {sensitiveResult && ( +

+ {sensitiveResult} +

+ )} + {sensitiveHealth && ( +
+ AMSC 健康检查:{sensitiveHealth.ok ? "通过" : "失败"} + {typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""} + {sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""} + {sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""} +
+ )} +

+ 这里不会返回或展示明文。轮换值写入共享运行时数据库,后端和 Bot 会优先读取该值,环境变量仅作为兜底。

diff --git a/src/data_collection/amsc_awos_sources.py b/src/data_collection/amsc_awos_sources.py index f8edf85d..3d03892a 100644 --- a/src/data_collection/amsc_awos_sources.py +++ b/src/data_collection/amsc_awos_sources.py @@ -20,6 +20,7 @@ from urllib.request import Request, urlopen from loguru import logger from src.utils.metrics import record_source_call +from src.utils.runtime_secrets import get_runtime_secret AMSC_AWOS_BASE_URL = os.getenv("AMSC_AWOS_BASE_URL", "").strip() @@ -316,8 +317,8 @@ class AmscAwosSourceMixin: "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36" ), } - cookie = os.getenv("POLYWEATHER_AMSC_COOKIE", "").strip() - session_id = os.getenv("POLYWEATHER_AMSC_SESSION_ID", "").strip() + cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE") + session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") if cookie: headers["Cookie"] = cookie elif session_id: diff --git a/src/database/db_manager.py b/src/database/db_manager.py index f3d98c36..56892fdf 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -427,6 +427,14 @@ class DBManager: updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS runtime_secrets ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS city_summary_cache ( city TEXT PRIMARY KEY, @@ -831,6 +839,107 @@ class DBManager: ) conn.commit() + @staticmethod + def _mask_secret_value(value: str) -> str: + text = str(value or "") + if not text: + return "" + if len(text) <= 8: + return "***" + return f"{text[:4]}...{text[-4:]}" + + def get_runtime_secret(self, key: str) -> Optional[str]: + normalized_key = str(key or "").strip() + if not normalized_key: + return None + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT value + FROM runtime_secrets + WHERE key = ? + LIMIT 1 + """, + (normalized_key,), + ).fetchone() + if not row: + return None + value = str(row["value"] or "") + return value if value else None + + def get_runtime_secret_metadata(self, key: str) -> Dict[str, Any]: + normalized_key = str(key or "").strip() + if not normalized_key: + return { + "key": "", + "configured": False, + "masked": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_store", + } + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT key, value, updated_at, updated_by + FROM runtime_secrets + WHERE key = ? + LIMIT 1 + """, + (normalized_key,), + ).fetchone() + if not row: + return { + "key": normalized_key, + "configured": False, + "masked": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_store", + } + value = str(row["value"] or "") + return { + "key": normalized_key, + "configured": bool(value), + "masked": self._mask_secret_value(value), + "length": len(value), + "updated_at": str(row["updated_at"] or ""), + "updated_by": str(row["updated_by"] or ""), + "source": "runtime_store", + } + + def set_runtime_secret( + self, + key: str, + value: str, + *, + updated_by: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_key = str(key or "").strip() + secret_value = str(value or "").strip() + if not normalized_key: + raise ValueError("runtime secret key is required") + if not secret_value: + raise ValueError("runtime secret value is required") + now = datetime.now().isoformat() + operator = str(updated_by or "").strip() + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO runtime_secrets (key, value, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (normalized_key, secret_value, now, operator), + ) + conn.commit() + return self.get_runtime_secret_metadata(normalized_key) + def append_payment_audit_event(self, event_type: str, payload: Dict[str, Any]) -> None: kind = str(event_type or "").strip().lower() if not kind: diff --git a/src/utils/runtime_secrets.py b/src/utils/runtime_secrets.py new file mode 100644 index 00000000..0b96b5b9 --- /dev/null +++ b/src/utils/runtime_secrets.py @@ -0,0 +1,65 @@ +"""Runtime secret lookup helpers. + +Secrets rotated from the ops UI live in the shared SQLite database so backend +processes can pick them up without host-level Docker access. Environment +variables remain the fallback source for bootstrapping and local development. +""" + +from __future__ import annotations + +import os +from typing import Any + +from src.database.db_manager import DBManager + + +def get_runtime_secret(key: str) -> str: + normalized_key = str(key or "").strip() + if not normalized_key: + return "" + try: + stored = DBManager().get_runtime_secret(normalized_key) + except Exception: + stored = None + if stored: + return str(stored).strip() + return str(os.getenv(normalized_key) or "").strip() + + +def get_runtime_secret_status(key: str) -> dict[str, Any]: + normalized_key = str(key or "").strip() + if not normalized_key: + return { + "key": "", + "configured": False, + "masked": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_store", + } + try: + metadata = DBManager().get_runtime_secret_metadata(normalized_key) + except Exception: + metadata = {} + if isinstance(metadata, dict) and metadata.get("configured"): + return metadata + + env_value = str(os.getenv(normalized_key) or "").strip() + if not env_value: + return { + "key": normalized_key, + "configured": False, + "masked": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_store", + } + return { + "key": normalized_key, + "configured": True, + "masked": DBManager._mask_secret_value(env_value), + "length": len(env_value), + "updated_at": "", + "updated_by": "", + "source": "environment", + } diff --git a/tests/test_ops_amsc_health.py b/tests/test_ops_amsc_health.py index 03f66257..ff043d1b 100644 --- a/tests/test_ops_amsc_health.py +++ b/tests/test_ops_amsc_health.py @@ -1,4 +1,103 @@ from web.services import ops_api +from src.database.db_manager import DBManager +from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin + + +def test_runtime_secret_metadata_masks_value(tmp_path): + db = DBManager(str(tmp_path / "polyweather.db")) + secret = "9153$$example-session" + + saved = db.set_runtime_secret( + "POLYWEATHER_AMSC_SESSION_ID", + secret, + updated_by="ops@example.com", + ) + + assert saved["configured"] is True + assert saved["masked"] == "9153...sion" + assert "value" not in saved + assert db.get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") == secret + + metadata = db.get_runtime_secret_metadata("POLYWEATHER_AMSC_SESSION_ID") + + assert metadata["configured"] is True + assert metadata["masked"] == "9153...sion" + assert metadata["updated_by"] == "ops@example.com" + assert "value" not in metadata + + +def test_amsc_headers_prefer_runtime_secret_over_env(monkeypatch, tmp_path): + monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db")) + monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", "env-session") + monkeypatch.delenv("POLYWEATHER_AMSC_COOKIE", raising=False) + DBManager().set_runtime_secret( + "POLYWEATHER_AMSC_SESSION_ID", + "db-session-1234", + updated_by="ops@example.com", + ) + + class FakeSource(AmscAwosSourceMixin): + timeout = 1.0 + + headers = FakeSource()._amsc_headers() + + assert headers["sessionId"] == "db-session-1234" + assert headers["app"] == "AMS" + + +def test_ops_sensitive_config_update_rotates_session_without_echoing_secret( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db")) + monkeypatch.setattr( + ops_api, + "_require_ops", + lambda request: {"email": "ops@example.com"}, + ) + monkeypatch.setattr( + ops_api, + "_check_amsc_awos_health", + lambda timeout=8: {"ok": True, "credential_configured": True, "points": 4}, + ) + secret = "9153$$rotated-session" + + result = ops_api.update_ops_sensitive_config( + object(), + "POLYWEATHER_AMSC_SESSION_ID", + secret, + ) + + assert result["ok"] is True + assert result["config"]["configured"] is True + assert result["config"]["masked"] == "9153...sion" + assert result["health"]["ok"] is True + assert DBManager().get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") == secret + assert "value" not in result["config"] + assert secret not in str(result) + + +def test_ops_sensitive_config_status_uses_metadata_not_plaintext(monkeypatch, tmp_path): + monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db")) + monkeypatch.setattr( + ops_api, + "_require_ops", + lambda request: {"email": "ops@example.com"}, + ) + secret = "9153$$stored-session" + DBManager().set_runtime_secret( + "POLYWEATHER_AMSC_SESSION_ID", + secret, + updated_by="ops@example.com", + ) + + result = ops_api.get_ops_sensitive_config(object()) + + assert result["configs"][0]["key"] == "POLYWEATHER_AMSC_SESSION_ID" + assert result["configs"][0]["configured"] is True + assert result["configs"][0]["masked"] == "9153...sion" + assert "value" not in result["configs"][0] + assert secret not in str(result) def test_ops_amsc_health_uses_configured_session_header(monkeypatch): diff --git a/web/routers/ops.py b/web/routers/ops.py index cf9ee520..adbdcf84 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -7,6 +7,7 @@ from web.services.ops_api import ( extend_ops_subscription, get_ops_analytics_funnel, get_ops_config, + get_ops_sensitive_config, get_ops_memberships_growth, get_ops_memberships_overview, get_ops_health_check, @@ -23,6 +24,7 @@ from web.services.ops_api import ( list_ops_payments, search_ops_users, update_ops_config, + update_ops_sensitive_config, get_ops_training_accuracy, get_ops_telegram_audit, ) @@ -148,6 +150,24 @@ async def ops_update_config(request: Request): return update_ops_config(request, key, value) +@router.get("/api/ops/sensitive-config") +async def ops_sensitive_config(request: Request): + return get_ops_sensitive_config(request) + + +@router.put("/api/ops/sensitive-config") +async def ops_update_sensitive_config(request: Request): + import json as _json + body_bytes = await request.body() + body = _json.loads(body_bytes.decode("utf-8")) + key = str(body.get("key") or "").strip() + value = str(body.get("value") or "") + if not key: + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="key is required") + return update_ops_sensitive_config(request, key, value) + + # ── Subscriptions ─────────────────────────────────────────────────── @router.post("/api/ops/subscriptions/grant") diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 704d2f7c..58a088b3 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -9,6 +9,7 @@ from fastapi import HTTPException, Request import requests as _requests from src.database.db_manager import DBManager +from src.utils.runtime_secrets import get_runtime_secret, get_runtime_secret_status from web.core import GrantPointsRequest import web.routes as legacy_routes @@ -481,6 +482,13 @@ _EDITABLE_CONFIG_KEYS: dict[str, str] = { "POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS": "手动转账收款钱包地址", } +_SENSITIVE_CONFIG_KEYS: dict[str, dict[str, str]] = { + "POLYWEATHER_AMSC_SESSION_ID": { + "label": "AMSC AWOS sessionId", + "description": "中国跑道观测接口 sessionId,用于上海/北京/广州等 AMSC AWOS 数据源。", + }, +} + def get_ops_config(request: Request) -> dict[str, Any]: _require_ops(request) @@ -510,6 +518,81 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: return {"key": key, "value": value, "ok": True} +def _sensitive_config_payload(key: str) -> dict[str, Any]: + definition = _SENSITIVE_CONFIG_KEYS.get(key) or {} + metadata = get_runtime_secret_status(key) + return { + "key": key, + "label": definition.get("label") or key, + "description": definition.get("description") or "", + "configured": bool(metadata.get("configured")), + "masked": str(metadata.get("masked") or ""), + "length": int(metadata.get("length") or 0), + "updated_at": str(metadata.get("updated_at") or ""), + "updated_by": str(metadata.get("updated_by") or ""), + "source": str(metadata.get("source") or "runtime_store"), + } + + +def get_ops_sensitive_config(request: Request) -> dict[str, Any]: + _require_ops(request) + return { + "configs": [ + _sensitive_config_payload(key) + for key in _SENSITIVE_CONFIG_KEYS + ] + } + + +def update_ops_sensitive_config( + request: Request, + key: str, + value: str, +) -> dict[str, Any]: + admin = _require_ops(request) or {} + normalized_key = str(key or "").strip() + if normalized_key not in _SENSITIVE_CONFIG_KEYS: + raise HTTPException( + status_code=400, + detail=f"sensitive config key '{normalized_key}' is not editable", + ) + secret_value = str(value or "").strip() + if not 12 <= len(secret_value) <= 256 or any(ch.isspace() for ch in secret_value): + raise HTTPException( + status_code=400, + detail="sessionId must be 12-256 non-whitespace characters", + ) + + db = DBManager() + try: + config = db.set_runtime_secret( + normalized_key, + secret_value, + updated_by=str(admin.get("email") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + os.environ[normalized_key] = secret_value + response_config = _sensitive_config_payload(normalized_key) + response_config.update( + { + "configured": bool(config.get("configured")), + "masked": str(config.get("masked") or ""), + "length": int(config.get("length") or 0), + "updated_at": str(config.get("updated_at") or ""), + "updated_by": str(config.get("updated_by") or ""), + "source": str(config.get("source") or "runtime_store"), + } + ) + health = ( + _check_amsc_awos_health(timeout=8) + if normalized_key == "POLYWEATHER_AMSC_SESSION_ID" + else None + ) + return {"ok": True, "config": response_config, "health": health} + + def _build_amsc_awos_headers() -> dict[str, str]: headers = { "Accept": "application/json, text/plain, */*", @@ -519,8 +602,8 @@ def _build_amsc_awos_headers() -> dict[str, str]: "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36" ), } - cookie = str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip() - session_id = str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip() + cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE") + session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") if cookie: headers["Cookie"] = cookie elif session_id: @@ -539,8 +622,8 @@ def _check_amsc_awos_health(timeout: int = 8) -> dict[str, Any]: return {"ok": False, "error": "not configured"} credential_configured = bool( - str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip() - or str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip() + get_runtime_secret("POLYWEATHER_AMSC_COOKIE") + or get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") ) try: t0 = _time.perf_counter()