206 lines
6.5 KiB
Python
206 lines
6.5 KiB
Python
"""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())
|