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()