Add ops runtime secret rotation

This commit is contained in:
2569718930@qq.com
2026-05-30 20:33:30 +08:00
parent e00405df8a
commit aa583e7440
8 changed files with 571 additions and 9 deletions
@@ -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" }); }
}
@@ -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<EditableConfig[]>([]);
const [sensitiveConfigs, setSensitiveConfigs] = useState<SensitiveConfig[]>([]);
const [editing, setEditing] = useState<Record<string, string>>({});
const [sensitiveEditing, setSensitiveEditing] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [sensitiveSaving, setSensitiveSaving] = useState(false);
const [result, setResult] = useState("");
const [sensitiveResult, setSensitiveResult] = useState("");
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(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() {
</p>
)}
<p className="mt-4 text-xs text-slate-500">
API Key
使
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-cyan-300" />
</CardTitle>
</CardHeader>
<CardContent>
{sensitiveConfigs.length === 0 ? (
<p className="text-slate-500 text-sm"> API </p>
) : (
<div className="space-y-3">
{sensitiveConfigs.map((cfg) => (
<div key={cfg.key} className="rounded-lg border border-white/5 bg-white/5 px-4 py-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-white text-sm font-semibold">{cfg.label}</div>
<span className={`rounded-full px-2 py-0.5 text-[11px] ${cfg.configured ? "bg-emerald-400/10 text-emerald-300" : "bg-amber-400/10 text-amber-300"}`}>
{cfg.configured ? "已配置" : "未配置"}
</span>
<span className="rounded-full bg-slate-500/10 px-2 py-0.5 text-[11px] text-slate-400">
{cfg.source === "environment" ? "环境变量兜底" : "DB 持久化"}
</span>
</div>
<div className="mt-1 text-xs text-slate-500">{cfg.description}</div>
<div className="mt-3 grid gap-2 text-xs text-slate-400 sm:grid-cols-3">
<div>
<span className="text-slate-500"> </span>
<span className="font-mono text-slate-200">{cfg.masked || "未设置"}</span>
</div>
<div>
<span className="text-slate-500"> </span>
<span className="font-mono text-slate-200">{cfg.updated_by || "-"}</span>
</div>
<div>
<span className="text-slate-500"> </span>
<span className="font-mono text-slate-200">{cfg.updated_at || "-"}</span>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]">
<input
type="password"
autoComplete="off"
placeholder="输入新的 sessionId,不会回显"
value={sensitiveEditing[cfg.key] ?? ""}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="outline"
disabled={sensitiveSaving || !sensitiveEditing[cfg.key]?.trim()}
onClick={() => handleSensitiveSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
{sensitiveResult && (
<p className={`mt-3 text-sm ${sensitiveResult.includes("失败") ? "text-amber-400" : "text-emerald-400"}`}>
{sensitiveResult}
</p>
)}
{sensitiveHealth && (
<div className={`mt-3 rounded-lg border px-3 py-2 text-xs ${sensitiveHealth.ok ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-200" : "border-amber-400/20 bg-amber-400/10 text-amber-200"}`}>
AMSC {sensitiveHealth.ok ? "通过" : "失败"}
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}
</div>
)}
<p className="mt-4 text-xs text-slate-500">
Bot
</p>
</CardContent>
</Card>
+3 -2
View File
@@ -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:
+109
View File
@@ -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:
+65
View File
@@ -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",
}
+99
View File
@@ -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):
+20
View File
@@ -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")
+87 -4
View File
@@ -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()