ForcePilot/backend/package/yuxi/channel/config/snapshot.py
Kris 4962d1f6f8 feat(channel/config): 新增完整的配置模块实现
新增了channel目录下的配置相关模块,包含默认配置、配置迁移、配置差异计算、文件加载器、重载计划、重载器以及配置校验功能,完善了配置管理体系
2026-05-21 10:24:03 +08:00

552 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hashlib
import json
import logging
import re
import threading
from dataclasses import dataclass, field
from datetime import UTC, datetime
from itertools import count
from typing import Any
logger = logging.getLogger(__name__)
_SENSITIVE_KEY_PATTERNS = [
re.compile(r"token$", re.IGNORECASE),
re.compile(r"password", re.IGNORECASE),
re.compile(r"secret", re.IGNORECASE),
re.compile(r"api[\W_]?key", re.IGNORECASE),
re.compile(r"encrypt[\W_]?key", re.IGNORECASE),
re.compile(r"private[\W_]?key", re.IGNORECASE),
re.compile(r"serviceaccount(?:ref)?$", re.IGNORECASE),
]
_SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"tokenlimit",
"tokenbudget",
"passwordfile",
]
REDACTED_SENTINEL = "__FORCEPILOT_REDACTED__"
_ENV_VAR_PLACEHOLDER_PATTERN = re.compile(r"^\$\{[^}]*\}$")
# ---------------------------------------------------------------------------
# AccountConfigEntry 渠道账户配置与运行时状态
# ---------------------------------------------------------------------------
@dataclass
class AccountConfigEntry:
channel_type: str
account_id: str
enabled: bool
configured: bool
config_hash: str
name: str | None = None
connected: bool | None = None
running: bool | None = None
linked: bool | None = None
restart_pending: bool | None = None
status_state: str | None = None
health_state: str | None = None
mode: str | None = None
dm_policy: str | None = None
allow_from: list[str] | None = None
reconnect_attempts: int | None = None
last_connected_at: float | None = None
last_message_at: float | None = None
last_inbound_at: float | None = None
last_outbound_at: float | None = None
last_event_at: float | None = None
last_transport_activity_at: float | None = None
last_error: str | None = None
last_start_at: float | None = None
last_stop_at: float | None = None
busy: bool | None = None
active_runs: int | None = None
last_run_activity_at: float | None = None
token_status: str | None = None
bot_token_status: str | None = None
app_token_status: str | None = None
signing_secret_status: str | None = None
user_token_status: str | None = None
base_url: str | None = None
port: int | None = None
allow_unmentioned_groups: bool | None = None
cli_path: str | None = None
db_path: str | None = None
_SENSITIVE_ACCOUNT_FIELDS: set[str] = {
"webhook_url",
"webhook_path",
"audience",
"public_key",
"channel_access_token",
"channel_secret",
"token",
"bot_token",
"app_token",
"signing_secret",
"user_token",
}
# ---------------------------------------------------------------------------
# 安全投影 剥离敏感字段
# ---------------------------------------------------------------------------
def project_safe_account_fields(account: dict[str, Any] | Any) -> dict[str, Any]:
"""从账户对象中提取对外安全的快照字段,剥离敏感信息"""
if not isinstance(account, dict):
return {}
record: dict[str, Any] = account
result: dict[str, Any] = {}
for key in ("name", "status_state", "health_state", "mode", "dm_policy"):
val = record.get(key)
if isinstance(val, str) and val:
result[key] = val
for key in ("linked", "running", "connected", "restart_pending", "busy"):
val = record.get(key)
if isinstance(val, bool):
result[key] = val
for key in (
"reconnect_attempts",
"active_runs",
"last_connected_at",
"last_message_at",
"last_inbound_at",
"last_outbound_at",
"last_event_at",
"last_transport_activity_at",
"last_start_at",
"last_stop_at",
"last_run_activity_at",
):
val = record.get(key)
if isinstance(val, (int, float)):
result[key] = val
elif val is None and key in record:
result[key] = None
for key in ("token_status", "bot_token_status", "app_token_status", "signing_secret_status", "user_token_status"):
val = record.get(key)
if val in ("available", "configured_unavailable", "missing"):
result[key] = val
base_url = record.get("base_url")
if isinstance(base_url, str) and base_url:
result["base_url"] = _strip_url_userinfo(base_url)
allow_from = record.get("allow_from")
if isinstance(allow_from, list):
normalized = [str(e).strip() for e in allow_from if isinstance(e, (str, int, float)) and str(e).strip()]
if normalized:
result["allow_from"] = normalized
cli_path = record.get("cli_path")
if isinstance(cli_path, str) and cli_path:
result["cli_path"] = cli_path
db_path = record.get("db_path")
if isinstance(db_path, str) and db_path:
result["db_path"] = db_path
port = record.get("port")
if isinstance(port, (int, float)):
result["port"] = int(port)
elif port is None and "port" in record:
result["port"] = None
allow_unmentioned = record.get("allow_unmentioned_groups")
if isinstance(allow_unmentioned, bool):
result["allow_unmentioned_groups"] = allow_unmentioned
account_id = record.get("account_id")
if isinstance(account_id, str):
result["account_id"] = account_id
enabled = record.get("enabled")
if isinstance(enabled, bool):
result["enabled"] = enabled
configured = record.get("configured")
if isinstance(configured, bool):
result["configured"] = configured
return result
def _strip_url_userinfo(url: str) -> str:
"""剥离 URL 中的用户信息部分 (https://user:pass@host -> https://host)"""
import re as _re
return _re.sub(r"://[^@/]+@", "://", url)
# ---------------------------------------------------------------------------
# 敏感路径检测
# ---------------------------------------------------------------------------
def _is_whitelisted_sensitive_path(path: str) -> bool:
lower = path.lower()
return any(lower.endswith(suffix) for suffix in _SENSITIVE_KEY_WHITELIST_SUFFIXES)
def is_sensitive_config_path(path: str) -> bool:
"""检测配置路径是否指向敏感字段 (token/password/secret/api_key 等)"""
if _is_whitelisted_sensitive_path(path):
return False
return any(pattern.search(path) for pattern in _SENSITIVE_KEY_PATTERNS)
def _is_env_var_placeholder(value: str) -> bool:
return bool(_ENV_VAR_PLACEHOLDER_PATTERN.match(value.strip()))
# ---------------------------------------------------------------------------
# 脱敏 & 还原 (Redact / Restore)
# ---------------------------------------------------------------------------
def _is_object_record(value: Any) -> bool:
return isinstance(value, dict)
def _collect_sensitive_strings(value: Any, values: list[str]) -> None:
if isinstance(value, str):
if not _is_env_var_placeholder(value):
values.append(value)
return
if isinstance(value, list):
for item in value:
_collect_sensitive_strings(item, values)
return
if _is_object_record(value):
for item in value.values():
_collect_sensitive_strings(item, values)
def redact_config_object(obj: Any, ui_hints: dict[str, Any] | None = None) -> Any:
"""深度遍历对象,将敏感路径上的字符串值替换为 REDACTED_SENTINEL"""
if ui_hints:
lookup = _build_redaction_lookup(ui_hints)
if "" in lookup:
return _redact_with_lookup(obj, lookup, "")
return _redact_guessing(obj, "", ui_hints)
return _redact_guessing(obj, "")
def restore_redacted_values(incoming: Any, original: Any, ui_hints: dict[str, Any] | None = None) -> Any:
"""深度遍历 incoming将 REDACTED_SENTINEL 替换回 original 中的真实值"""
if incoming is None:
return incoming
if not isinstance(incoming, dict):
return incoming
if ui_hints:
lookup = _build_redaction_lookup(ui_hints)
if "" in lookup:
return _restore_with_lookup(incoming, original, lookup, "", ui_hints)
return _restore_guessing(incoming, original, "", ui_hints)
return _restore_guessing(incoming, original, "")
def _build_redaction_lookup(hints: dict[str, Any]) -> set[str]:
result: set[str] = set()
for path, hint in hints.items():
if not hint.get("sensitive"):
continue
parts = path.split(".")
joined = parts[0] if parts else ""
result.add(joined)
if joined.endswith("[]"):
result.add(joined[:-2])
for part in parts[1:]:
if part.endswith("[]"):
result.add(f"{joined}.{part[:-2]}")
joined = f"{joined}.{part}"
result.add(joined)
if result:
result.add("")
return result
def _redact_with_lookup(obj: Any, lookup: set[str], prefix: str) -> Any:
if obj is None:
return obj
if isinstance(obj, list):
path = f"{prefix}[]"
if path not in lookup:
return [_redact_guessing(item, prefix) for item in obj]
return [
REDACTED_SENTINEL
if isinstance(item, str) and not _is_env_var_placeholder(item)
else _redact_with_lookup(item, lookup, path)
for item in obj
]
if _is_object_record(obj):
result: dict[str, Any] = {}
for key, value in obj.items():
path = f"{prefix}.{key}" if prefix else key
wildcard_path = f"{prefix}.*" if prefix else "*"
matched = False
for candidate in (path, wildcard_path):
if candidate in lookup:
matched = True
if isinstance(value, str) and not _is_env_var_placeholder(value):
result[key] = REDACTED_SENTINEL
elif isinstance(value, dict):
result[key] = _redact_with_lookup(value, lookup, candidate)
elif isinstance(value, list):
result[key] = _redact_with_lookup(value, lookup, candidate)
else:
result[key] = value
break
if not matched:
result[key] = _redact_guessing(value, path)
return result
return obj
def _redact_guessing(obj: Any, prefix: str, hints: dict[str, Any] | None = None) -> Any:
if obj is None:
return obj
if isinstance(obj, list):
return [_redact_guessing(item, f"{prefix}[]", hints) for item in obj]
if _is_object_record(obj):
result: dict[str, Any] = {}
for key, value in obj.items():
dot_path = f"{prefix}.{key}" if prefix else key
if isinstance(value, str) and not _is_env_var_placeholder(value) and is_sensitive_config_path(dot_path):
result[key] = REDACTED_SENTINEL
elif isinstance(value, dict):
result[key] = _redact_guessing(value, dot_path, hints)
elif isinstance(value, list):
result[key] = _redact_guessing(value, dot_path, hints)
else:
result[key] = value
return result
return obj
def _restore_with_lookup(incoming: Any, original: Any, lookup: set[str], prefix: str, hints: dict[str, Any]) -> Any:
if incoming is None or not isinstance(incoming, (dict, list)):
return incoming
if isinstance(incoming, list):
path = f"{prefix}[]"
if path not in lookup:
return [_restore_guessing(item, original, prefix, hints) for item in incoming]
orig_list = original if isinstance(original, list) else []
return [
orig_list[i]
if item == REDACTED_SENTINEL and i < len(orig_list)
else _restore_with_lookup(item, orig_list[i] if i < len(orig_list) else None, lookup, path, hints)
for i, item in enumerate(incoming)
]
if _is_object_record(incoming):
orig = original if _is_object_record(original) else {}
result: dict[str, Any] = {}
for key, value in incoming.items():
path = f"{prefix}.{key}" if prefix else key
wildcard_path = f"{prefix}.*" if prefix else "*"
matched = False
for candidate in (path, wildcard_path):
if candidate in lookup:
matched = True
if value == REDACTED_SENTINEL:
result[key] = orig.get(key, value)
elif isinstance(value, (dict, list)):
result[key] = _restore_with_lookup(value, orig.get(key), lookup, candidate, hints)
else:
result[key] = value
break
if not matched:
result[key] = _restore_guessing(value, orig, prefix, hints)
return result
return incoming
def _restore_guessing(incoming: Any, original: Any, prefix: str, hints: dict[str, Any] | None = None) -> Any:
if incoming is None or not isinstance(incoming, (dict, list)):
return incoming
if isinstance(incoming, list):
orig_list = original if isinstance(original, list) else []
path = f"{prefix}[]"
return [
orig_list[i]
if item == REDACTED_SENTINEL and is_sensitive_config_path(path) and i < len(orig_list)
else _restore_guessing(item, orig_list[i] if i < len(orig_list) else None, path, hints)
for i, item in enumerate(incoming)
]
if _is_object_record(incoming):
orig = original if _is_object_record(original) else {}
result: dict[str, Any] = {}
for key, value in incoming.items():
dot_path = f"{prefix}.{key}" if prefix else key
if value == REDACTED_SENTINEL and is_sensitive_config_path(dot_path):
result[key] = orig.get(key, value)
elif isinstance(value, (dict, list)):
result[key] = _restore_guessing(value, orig.get(key), dot_path, hints)
else:
result[key] = value
return result
return incoming
# ---------------------------------------------------------------------------
# ConfigFileSnapshot 配置文件全量快照
# ---------------------------------------------------------------------------
@dataclass
class ConfigFileSnapshot:
path: str
exists: bool
raw: str | None
parsed: Any
source_config: dict[str, Any] = field(default_factory=dict)
resolved: dict[str, Any] = field(default_factory=dict)
runtime_config: dict[str, Any] = field(default_factory=dict)
valid: bool = False
hash: str = ""
issues: list[dict[str, Any]] = field(default_factory=list)
warnings: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self):
if not self.hash and self.runtime_config:
self.hash = hash_config_value(self.runtime_config)
# ---------------------------------------------------------------------------
# ConfigSnapshot 渠道配置运行时快照
# ---------------------------------------------------------------------------
@dataclass
class ConfigSnapshot:
version: str
accounts: list[AccountConfigEntry]
route_bindings_hash: str
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
revision: int = 0
source_fingerprint: str = ""
runtime_fingerprint: str = ""
@property
def stable_hash(self) -> str:
if self.source_fingerprint:
return self.source_fingerprint
return _compute_snapshot_stable_hash(self.accounts, self.route_bindings_hash)
def _compute_snapshot_stable_hash(accounts: list[AccountConfigEntry], routes_hash: str) -> str:
sorted_accounts = sorted(
[
{
"t": a.channel_type,
"id": a.account_id,
"e": a.enabled,
"c": a.configured,
"h": a.config_hash,
}
for a in accounts
],
key=lambda x: (x["t"], x["id"]),
)
payload = {"accounts": sorted_accounts, "routes_hash": routes_hash}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode()).hexdigest()[:16]
# ---------------------------------------------------------------------------
# 通用哈希 & 快照比较
# ---------------------------------------------------------------------------
def _stable_config_stringify(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return json.dumps(value)
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, (list, tuple)):
return f"[{','.join(_stable_config_stringify(v) for v in value)}]"
if isinstance(value, dict):
keys = sorted(value.keys())
return (
"{"
+ ",".join(f"{json.dumps(k, ensure_ascii=False)}:{_stable_config_stringify(value[k])}" for k in keys)
+ "}"
)
return json.dumps(value, default=str, ensure_ascii=False)
def hash_config_value(value: dict[str, Any]) -> str:
return hashlib.sha256(_stable_config_stringify(value).encode()).hexdigest()[:16]
def compute_source_fingerprint(config: dict) -> str:
raw = json.dumps(config, sort_keys=True, default=str, ensure_ascii=False)
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def config_snapshots_match(left: dict[str, Any], right: dict[str, Any]) -> bool:
if left is right:
return True
try:
return _stable_config_stringify(left) == _stable_config_stringify(right)
except Exception as e:
logger.warning("Config snapshot comparison failed: %s", e)
return False
# ---------------------------------------------------------------------------
# RevisionCounter 全局递增计数器
# ---------------------------------------------------------------------------
class RevisionCounter:
_revision = count(1)
_current: int = 0
_lock = threading.Lock()
@classmethod
def next(cls) -> int:
with cls._lock:
cls._current = next(cls._revision)
return cls._current
@classmethod
def current(cls) -> int:
with cls._lock:
return cls._current
@classmethod
def reset(cls) -> None:
with cls._lock:
cls._revision = count(1)
cls._current = 0