This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user