Files
cmshoppe/app/client_policy.py
T
chengma df9da63841
Tests / Python 3.11 / Windows (push) Has been cancelled
feat: 接入远程订阅策略门禁 T-705
2026-07-28 15:42:19 +08:00

206 lines
6.5 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.
"""Remote client-policy parsing and non-sensitive local caching."""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from . import appconfig
POLICY_VERSION = 1
CACHE_VERSION = 1
CACHE_FILENAME = "client_policy.json"
class ClientPolicyError(ValueError):
"""Raised when a remote or cached client policy is malformed."""
@dataclass(frozen=True)
class ClientPolicy:
policy_version: int
subscription_check_enabled: bool
subscription_enforcement_enabled: bool
updated_at: str
source: str = "remote"
warning: str = ""
@property
def mode(self) -> str:
if not self.subscription_check_enabled:
return "off"
if self.subscription_enforcement_enabled:
return "enforce"
return "observe"
def to_contract_dict(self) -> dict:
return {
"policy_version": self.policy_version,
"subscription_check_enabled": self.subscription_check_enabled,
"subscription_enforcement_enabled": (
self.subscription_enforcement_enabled
),
"updated_at": self.updated_at,
}
def observation_policy(*, source="fallback", warning="") -> ClientPolicy:
"""Return the fail-open client fallback that still observes entitlement."""
return ClientPolicy(
policy_version=POLICY_VERSION,
subscription_check_enabled=True,
subscription_enforcement_enabled=False,
updated_at="",
source=source,
warning=warning,
)
def parse_client_policy(value, *, source="remote") -> ClientPolicy:
if not isinstance(value, dict):
raise ClientPolicyError("客户端策略不是 JSON 对象")
policy_version = value.get("policy_version")
if type(policy_version) is not int or policy_version != POLICY_VERSION:
raise ClientPolicyError("客户端策略版本不受支持")
check_enabled = value.get("subscription_check_enabled")
enforcement_enabled = value.get("subscription_enforcement_enabled")
if type(check_enabled) is not bool or type(enforcement_enabled) is not bool:
raise ClientPolicyError("客户端订阅策略开关必须是布尔值")
updated_at = _parse_timezone_timestamp(
value.get("updated_at"),
field_name="客户端策略更新时间",
)
warning = ""
if not check_enabled and enforcement_enabled:
enforcement_enabled = False
warning = "客户端订阅策略组合非法,已按关闭模式处理"
return ClientPolicy(
policy_version=policy_version,
subscription_check_enabled=check_enabled,
subscription_enforcement_enabled=enforcement_enabled,
updated_at=updated_at,
source=source,
warning=warning,
)
def policy_cache_path(config=None) -> str:
return appconfig.data_path("config", CACHE_FILENAME, config=config)
def save_cached_policy(
policy: ClientPolicy,
*,
config=None,
path=None,
cached_at=None,
) -> str:
destination = os.path.abspath(path or policy_cache_path(config))
os.makedirs(os.path.dirname(destination), exist_ok=True)
acquired_at = _parse_timezone_timestamp(
cached_at or _now_iso(),
field_name="客户端策略缓存时间",
)
payload = {
"cache_version": CACHE_VERSION,
"cached_at": acquired_at,
"client_policy": policy.to_contract_dict(),
}
temporary_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=os.path.dirname(destination),
prefix="client-policy-",
suffix=".tmp",
delete=False,
) as handle:
temporary_path = handle.name
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, destination)
finally:
if temporary_path and os.path.exists(temporary_path):
try:
os.remove(temporary_path)
except OSError:
pass
return destination
def load_cached_policy(*, config=None, path=None) -> ClientPolicy:
source_path = os.path.abspath(path or policy_cache_path(config))
try:
with open(source_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, ValueError) as exc:
raise ClientPolicyError("客户端策略缓存不可用") from exc
cache_version = payload.get("cache_version") if isinstance(payload, dict) else None
if type(cache_version) is not int or cache_version != CACHE_VERSION:
raise ClientPolicyError("客户端策略缓存版本不受支持")
_parse_timezone_timestamp(
payload.get("cached_at"),
field_name="客户端策略缓存时间",
)
return parse_client_policy(payload.get("client_policy"), source="cache")
def resolve_client_policy(
remote_policy,
*,
config=None,
path=None,
) -> ClientPolicy:
"""Prefer a valid live policy, then cache, then observation mode."""
if isinstance(remote_policy, ClientPolicy):
try:
save_cached_policy(remote_policy, config=config, path=path)
except (OSError, ClientPolicyError) as exc:
warning = _join_warning(
remote_policy.warning,
"客户端策略缓存写入失败:%s" % exc,
)
return replace(remote_policy, source="remote", warning=warning)
return replace(remote_policy, source="remote")
try:
return load_cached_policy(config=config, path=path)
except ClientPolicyError as exc:
return observation_policy(
warning="未取得有效远程策略或本地缓存,已使用观察模式:%s" % exc
)
def _parse_timezone_timestamp(value, *, field_name) -> str:
text = str(value or "").strip()
if not text:
raise ClientPolicyError("%s不能为空" % field_name)
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError as exc:
raise ClientPolicyError("%s格式不正确" % field_name) from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ClientPolicyError("%s必须包含时区" % field_name)
return text
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _join_warning(*messages) -> str:
return ";".join(str(message).strip() for message in messages if str(message).strip())