mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
305 lines
9.6 KiB
Python
305 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
import tomllib
|
|
|
|
from .route_manifest import load_route_manifest
|
|
|
|
DEFAULT_DAEMONS = {
|
|
"clearnet": "nginx",
|
|
"tor": "tor",
|
|
"i2p": "i2pd",
|
|
"gemini": "agate",
|
|
"reticulum": "rnsd",
|
|
"ipfs": "ipfs",
|
|
}
|
|
|
|
DEFAULT_POLICIES = {
|
|
"clearnet": "adopt",
|
|
"tor": "auto",
|
|
"i2p": "auto",
|
|
"gemini": "auto",
|
|
"reticulum": "auto",
|
|
"ipfs": "auto",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceConfig:
|
|
kind: str
|
|
path: Path
|
|
canonical_url: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OutputConfig:
|
|
root: Path
|
|
plan_root: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProtocolConfig:
|
|
name: str
|
|
enabled: bool
|
|
renderer: str
|
|
daemon: str
|
|
daemon_policy: str
|
|
options: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutePolicyConfig:
|
|
match: str
|
|
tier: str
|
|
identity: str = "none"
|
|
payments: str = "none"
|
|
realtime: bool = False
|
|
public_allowed: bool = True
|
|
source: str = "config"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InteractionConfig:
|
|
default_tier: str = "static"
|
|
deny_routes: tuple[str, ...] = ()
|
|
routes: tuple[RoutePolicyConfig, ...] = ()
|
|
route_manifest: Path | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SiteConfig:
|
|
id: str
|
|
domain: str
|
|
source: SourceConfig
|
|
outputs: OutputConfig
|
|
protocols: dict[str, ProtocolConfig]
|
|
interactions: InteractionConfig
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProfileConfig:
|
|
name: str
|
|
protocols: tuple[str, ...] = ()
|
|
platform: str | None = None
|
|
write_artifacts: bool = False
|
|
description: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GatewayPaths:
|
|
state_dir: Path
|
|
cache_dir: Path
|
|
run_dir: Path
|
|
user: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GatewayConfig:
|
|
config_path: Path
|
|
sites: list[SiteConfig]
|
|
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
|
paths: GatewayPaths | None = None
|
|
|
|
|
|
def load_config(path: Path) -> GatewayConfig:
|
|
config_path = path.resolve()
|
|
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
sites = [_parse_site(raw_site, config_path.parent) for raw_site in data.get("site", [])]
|
|
if not sites:
|
|
raise ValueError("config must declare at least one [[site]]")
|
|
profiles = _parse_profiles(data.get("profiles", {}))
|
|
paths = _parse_gateway_paths(data.get("gateway", {}), config_path.parent)
|
|
return GatewayConfig(config_path=config_path, sites=sites, profiles=profiles, paths=paths)
|
|
|
|
|
|
def _parse_gateway_paths(raw_gateway: Any, base_dir: Path) -> GatewayPaths:
|
|
if raw_gateway is None:
|
|
raw_gateway = {}
|
|
if not isinstance(raw_gateway, dict):
|
|
raise ValueError("gateway must be a table")
|
|
user = raw_gateway.get("user")
|
|
if user is not None and not isinstance(user, str):
|
|
raise ValueError("gateway.user must be a string")
|
|
return GatewayPaths(
|
|
state_dir=_resolve_path(base_dir, str(raw_gateway.get("state_dir", "./.ampg/state"))),
|
|
cache_dir=_resolve_path(base_dir, str(raw_gateway.get("cache_dir", "./.ampg/cache"))),
|
|
run_dir=_resolve_path(base_dir, str(raw_gateway.get("run_dir", "./.ampg/run"))),
|
|
user=user,
|
|
)
|
|
|
|
|
|
def _parse_profiles(raw_profiles: Any) -> dict[str, ProfileConfig]:
|
|
if raw_profiles is None:
|
|
return {}
|
|
if not isinstance(raw_profiles, dict):
|
|
raise ValueError("profiles must be a table")
|
|
profiles: dict[str, ProfileConfig] = {}
|
|
for name, raw_profile in raw_profiles.items():
|
|
if not isinstance(raw_profile, dict):
|
|
raise ValueError(f"profile {name!r} must be a table")
|
|
platform = raw_profile.get("platform")
|
|
if platform is not None and not isinstance(platform, str):
|
|
raise ValueError(f"profile {name!r}.platform must be a string")
|
|
description = raw_profile.get("description", "")
|
|
if not isinstance(description, str):
|
|
raise ValueError(f"profile {name!r}.description must be a string")
|
|
profiles[str(name)] = ProfileConfig(
|
|
name=str(name),
|
|
protocols=_string_array(
|
|
raw_profile.get("protocols", ()),
|
|
f"profile {name!r}.protocols",
|
|
),
|
|
platform=platform,
|
|
write_artifacts=bool(raw_profile.get("write_artifacts", False)),
|
|
description=description,
|
|
)
|
|
return profiles
|
|
|
|
|
|
def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig:
|
|
source = raw_site.get("source") or {}
|
|
outputs = raw_site.get("outputs") or {}
|
|
protocols = raw_site.get("protocols") or {}
|
|
interactions = raw_site.get("interactions") or {}
|
|
|
|
site_id = _required_str(raw_site, "id")
|
|
domain = _required_str(raw_site, "domain")
|
|
source_kind = _required_str(source, "kind")
|
|
if source_kind != "static-html":
|
|
raise ValueError(f"{site_id}: unsupported source kind {source_kind!r}")
|
|
|
|
source_path = _resolve_path(base_dir, _required_str(source, "path"))
|
|
output_root = _resolve_path(base_dir, _required_str(outputs, "root"))
|
|
plan_root = _resolve_path(base_dir, str(outputs.get("plan_root", "../dist/ampg-plan")))
|
|
|
|
parsed_protocols = {
|
|
name: _parse_protocol(name, raw_protocol)
|
|
for name, raw_protocol in protocols.items()
|
|
if isinstance(raw_protocol, dict)
|
|
}
|
|
|
|
return SiteConfig(
|
|
id=site_id,
|
|
domain=domain,
|
|
source=SourceConfig(
|
|
kind=source_kind,
|
|
path=source_path,
|
|
canonical_url=source.get("canonical_url"),
|
|
),
|
|
outputs=OutputConfig(root=output_root, plan_root=plan_root),
|
|
protocols=parsed_protocols,
|
|
interactions=_parse_interactions(interactions, base_dir, site_id),
|
|
)
|
|
|
|
|
|
def _parse_protocol(name: str, raw_protocol: dict[str, Any]) -> ProtocolConfig:
|
|
enabled = bool(raw_protocol.get("enabled", False))
|
|
renderer = str(raw_protocol.get("renderer", name))
|
|
daemon = str(raw_protocol.get("daemon", DEFAULT_DAEMONS.get(name, name)))
|
|
policy = str(raw_protocol.get("daemon_policy", DEFAULT_POLICIES.get(name, "auto")))
|
|
options = dict(raw_protocol)
|
|
for key in ("enabled", "renderer", "daemon", "daemon_policy"):
|
|
options.pop(key, None)
|
|
return ProtocolConfig(
|
|
name=name,
|
|
enabled=enabled,
|
|
renderer=renderer,
|
|
daemon=daemon,
|
|
daemon_policy=policy,
|
|
options=options,
|
|
)
|
|
|
|
|
|
def _parse_interactions(
|
|
raw_interactions: dict[str, Any],
|
|
base_dir: Path,
|
|
site_id: str,
|
|
) -> InteractionConfig:
|
|
if not isinstance(raw_interactions, dict):
|
|
raise ValueError("site.interactions must be a table")
|
|
|
|
route_manifest = _route_manifest_path(raw_interactions, base_dir)
|
|
manifest_data = load_route_manifest(route_manifest, site_id=site_id) if route_manifest else {}
|
|
|
|
default_tier = str(
|
|
raw_interactions.get("default_tier", manifest_data.get("default_tier", "static"))
|
|
)
|
|
deny_routes = _string_array(manifest_data.get("deny_routes", ()), "route manifest deny_routes")
|
|
deny_routes += _string_array(raw_interactions.get("deny_routes", ()), "site.interactions.deny_routes")
|
|
routes = tuple(
|
|
_parse_route_policy(raw_route, default_tier, source="route-manifest")
|
|
for raw_route in _route_array(manifest_data.get("routes", ()), "route manifest routes")
|
|
)
|
|
routes += tuple(
|
|
_parse_route_policy(raw_route, default_tier, source="config")
|
|
for raw_route in _route_array(raw_interactions.get("route", ()), "site.interactions.route")
|
|
)
|
|
return InteractionConfig(
|
|
default_tier=default_tier,
|
|
deny_routes=tuple(deny_routes),
|
|
routes=routes,
|
|
route_manifest=route_manifest,
|
|
)
|
|
|
|
|
|
def _parse_route_policy(
|
|
raw_route: dict[str, Any],
|
|
default_tier: str,
|
|
*,
|
|
source: str,
|
|
) -> RoutePolicyConfig:
|
|
if not isinstance(raw_route, dict):
|
|
raise ValueError("route policy entries must be objects")
|
|
return RoutePolicyConfig(
|
|
match=_required_str(raw_route, "match"),
|
|
tier=str(raw_route.get("tier", default_tier)),
|
|
identity=str(raw_route.get("identity", "none")),
|
|
payments=str(raw_route.get("payments", "none")),
|
|
realtime=bool(raw_route.get("realtime", False)),
|
|
public_allowed=bool(raw_route.get("public_allowed", True)),
|
|
source=source,
|
|
)
|
|
|
|
|
|
def _route_manifest_path(raw_interactions: dict[str, Any], base_dir: Path) -> Path | None:
|
|
raw_path = raw_interactions.get("route_manifest")
|
|
if raw_path is None:
|
|
return None
|
|
if not isinstance(raw_path, str) or not raw_path:
|
|
raise ValueError("site.interactions.route_manifest must be a path string")
|
|
return _resolve_path(base_dir, raw_path)
|
|
|
|
|
|
def _route_array(raw_routes: Any, context: str) -> tuple[Any, ...]:
|
|
if isinstance(raw_routes, dict):
|
|
return (raw_routes,)
|
|
if raw_routes is None:
|
|
return ()
|
|
if not isinstance(raw_routes, (list, tuple)):
|
|
raise ValueError(f"{context} must be an array")
|
|
return tuple(raw_routes)
|
|
|
|
|
|
def _string_array(raw_values: Any, context: str) -> tuple[str, ...]:
|
|
if raw_values is None:
|
|
return ()
|
|
if not isinstance(raw_values, (list, tuple)):
|
|
raise ValueError(f"{context} must be an array")
|
|
return tuple(str(value) for value in raw_values)
|
|
|
|
|
|
def _resolve_path(base_dir: Path, raw_path: str) -> Path:
|
|
path = Path(raw_path).expanduser()
|
|
if not path.is_absolute():
|
|
path = base_dir / path
|
|
return path.resolve()
|
|
|
|
|
|
def _required_str(data: dict[str, Any], key: str) -> str:
|
|
value = data.get(key)
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"missing required string field {key!r}")
|
|
return value
|