Add ops runtime secret rotation
This commit is contained in:
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -11,19 +11,52 @@ type EditableConfig = {
|
|||||||
description: string;
|
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() {
|
export function ConfigPageClient() {
|
||||||
const [configs, setConfigs] = useState<EditableConfig[]>([]);
|
const [configs, setConfigs] = useState<EditableConfig[]>([]);
|
||||||
|
const [sensitiveConfigs, setSensitiveConfigs] = useState<SensitiveConfig[]>([]);
|
||||||
const [editing, setEditing] = useState<Record<string, string>>({});
|
const [editing, setEditing] = useState<Record<string, string>>({});
|
||||||
|
const [sensitiveEditing, setSensitiveEditing] = useState<Record<string, string>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [sensitiveSaving, setSensitiveSaving] = useState(false);
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
|
const [sensitiveResult, setSensitiveResult] = useState("");
|
||||||
|
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const data = (await res.json()) as { configs?: EditableConfig[] };
|
const data = (await res.json()) as { configs?: EditableConfig[] };
|
||||||
setConfigs(data.configs ?? []);
|
setConfigs(data.configs ?? []);
|
||||||
}
|
}
|
||||||
|
if (sensitiveRes.ok) {
|
||||||
|
const data = (await sensitiveRes.json()) as { configs?: SensitiveConfig[] };
|
||||||
|
setSensitiveConfigs(data.configs ?? []);
|
||||||
|
}
|
||||||
} catch { /* backend not ready yet */ }
|
} catch { /* backend not ready yet */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +84,38 @@ export function ConfigPageClient() {
|
|||||||
setSaving(false);
|
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(); }, []);
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +166,91 @@ export function ConfigPageClient() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-xs text-slate-500">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from urllib.request import Request, urlopen
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.utils.metrics import record_source_call
|
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()
|
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"
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
cookie = os.getenv("POLYWEATHER_AMSC_COOKIE", "").strip()
|
cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||||
session_id = os.getenv("POLYWEATHER_AMSC_SESSION_ID", "").strip()
|
session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||||
if cookie:
|
if cookie:
|
||||||
headers["Cookie"] = cookie
|
headers["Cookie"] = cookie
|
||||||
elif session_id:
|
elif session_id:
|
||||||
|
|||||||
@@ -427,6 +427,14 @@ class DBManager:
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS city_summary_cache (
|
CREATE TABLE IF NOT EXISTS city_summary_cache (
|
||||||
city TEXT PRIMARY KEY,
|
city TEXT PRIMARY KEY,
|
||||||
@@ -831,6 +839,107 @@ class DBManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
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:
|
def append_payment_audit_event(self, event_type: str, payload: Dict[str, Any]) -> None:
|
||||||
kind = str(event_type or "").strip().lower()
|
kind = str(event_type or "").strip().lower()
|
||||||
if not kind:
|
if not kind:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -1,4 +1,103 @@
|
|||||||
from web.services import ops_api
|
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):
|
def test_ops_amsc_health_uses_configured_session_header(monkeypatch):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from web.services.ops_api import (
|
|||||||
extend_ops_subscription,
|
extend_ops_subscription,
|
||||||
get_ops_analytics_funnel,
|
get_ops_analytics_funnel,
|
||||||
get_ops_config,
|
get_ops_config,
|
||||||
|
get_ops_sensitive_config,
|
||||||
get_ops_memberships_growth,
|
get_ops_memberships_growth,
|
||||||
get_ops_memberships_overview,
|
get_ops_memberships_overview,
|
||||||
get_ops_health_check,
|
get_ops_health_check,
|
||||||
@@ -23,6 +24,7 @@ from web.services.ops_api import (
|
|||||||
list_ops_payments,
|
list_ops_payments,
|
||||||
search_ops_users,
|
search_ops_users,
|
||||||
update_ops_config,
|
update_ops_config,
|
||||||
|
update_ops_sensitive_config,
|
||||||
get_ops_training_accuracy,
|
get_ops_training_accuracy,
|
||||||
get_ops_telegram_audit,
|
get_ops_telegram_audit,
|
||||||
)
|
)
|
||||||
@@ -148,6 +150,24 @@ async def ops_update_config(request: Request):
|
|||||||
return update_ops_config(request, key, value)
|
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 ───────────────────────────────────────────────────
|
# ── Subscriptions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/api/ops/subscriptions/grant")
|
@router.post("/api/ops/subscriptions/grant")
|
||||||
|
|||||||
+87
-4
@@ -9,6 +9,7 @@ from fastapi import HTTPException, Request
|
|||||||
import requests as _requests
|
import requests as _requests
|
||||||
|
|
||||||
from src.database.db_manager import DBManager
|
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
|
from web.core import GrantPointsRequest
|
||||||
import web.routes as legacy_routes
|
import web.routes as legacy_routes
|
||||||
|
|
||||||
@@ -481,6 +482,13 @@ _EDITABLE_CONFIG_KEYS: dict[str, str] = {
|
|||||||
"POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS": "手动转账收款钱包地址",
|
"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]:
|
def get_ops_config(request: Request) -> dict[str, Any]:
|
||||||
_require_ops(request)
|
_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}
|
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]:
|
def _build_amsc_awos_headers() -> dict[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json, text/plain, */*",
|
"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"
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
cookie = str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||||
session_id = str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||||
if cookie:
|
if cookie:
|
||||||
headers["Cookie"] = cookie
|
headers["Cookie"] = cookie
|
||||||
elif session_id:
|
elif session_id:
|
||||||
@@ -539,8 +622,8 @@ def _check_amsc_awos_health(timeout: int = 8) -> dict[str, Any]:
|
|||||||
return {"ok": False, "error": "not configured"}
|
return {"ok": False, "error": "not configured"}
|
||||||
|
|
||||||
credential_configured = bool(
|
credential_configured = bool(
|
||||||
str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||||
or str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
or get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
t0 = _time.perf_counter()
|
t0 = _time.perf_counter()
|
||||||
|
|||||||
Reference in New Issue
Block a user