feat: add declarative sing-box egress control plane

This commit is contained in:
2026-08-27 00:58:52 +05:00
commit b8d19b2c3e
54 changed files with 3337 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import ipaddress
import json
import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlsplit
from .errors import ValidationError
@dataclass(frozen=True, slots=True)
class SingBoxPolicy:
binary: str
config_path: str
service: str
required_version: str
@dataclass(frozen=True, slots=True)
class RuntimePolicy:
uri_path: str
state_dir: str
lock_path: str
backup_keep: int
@dataclass(frozen=True, slots=True)
class NetworkPolicy:
upstream_interface: str
vpn_lan_interface: str
tun_name: str
tun_address: str
mtu: int
route_exclude_address: tuple[str, ...]
iproute2_table_index: int
iproute2_rule_index: int
auto_redirect_input_mark: str
auto_redirect_output_mark: str
auto_redirect_reset_mark: str
auto_redirect_nfqueue: int
auto_redirect_fallback_rule_index: int
@dataclass(frozen=True, slots=True)
class DnsPolicy:
bootstrap_server: str
bootstrap_port: int
remote_server: str
remote_port: int
remote_path: str
remote_tls_server_name: str
strategy: str
@dataclass(frozen=True, slots=True)
class BandwidthPolicy:
up_mbps: int
down_mbps: int
@dataclass(frozen=True, slots=True)
class HealthcheckPolicy:
url: str | None
timeout_seconds: float
settle_seconds: float
expected_status: int
body_contains: str | None
@dataclass(frozen=True, slots=True)
class Policy:
schema_version: int
sing_box: SingBoxPolicy
runtime: RuntimePolicy
network: NetworkPolicy
dns: DnsPolicy
bandwidth: BandwidthPolicy
healthcheck: HealthcheckPolicy
_EXPECTED: dict[str, set[str]] = {
"root": {"schema_version", "sing_box", "runtime", "network", "dns", "bandwidth", "healthcheck"},
"sing_box": {"binary", "config_path", "service", "required_version"},
"runtime": {"uri_path", "state_dir", "lock_path", "backup_keep"},
"network": {
"upstream_interface", "vpn_lan_interface", "tun_name", "tun_address", "mtu",
"route_exclude_address", "iproute2_table_index", "iproute2_rule_index",
"auto_redirect_input_mark", "auto_redirect_output_mark", "auto_redirect_reset_mark",
"auto_redirect_nfqueue", "auto_redirect_fallback_rule_index",
},
"dns": {"bootstrap_server", "bootstrap_port", "remote_server", "remote_port", "remote_path", "remote_tls_server_name", "strategy"},
"bandwidth": {"up_mbps", "down_mbps"},
"healthcheck": {"url", "timeout_seconds", "settle_seconds", "expected_status", "body_contains"},
}
def _mapping(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValidationError(f"Policy section {label} must be an object")
unknown = set(value) - _EXPECTED[label]
missing = _EXPECTED[label] - set(value)
if unknown:
raise ValidationError(f"Unknown policy keys in {label}: {', '.join(sorted(unknown))}")
if missing:
raise ValidationError(f"Missing policy keys in {label}: {', '.join(sorted(missing))}")
return value
def _string(mapping: dict[str, Any], key: str, *, nonempty: bool = True) -> str:
value = mapping[key]
if not isinstance(value, str) or (nonempty and not value):
raise ValidationError(f"Policy value {key} must be a non-empty string")
return value
def _integer(mapping: dict[str, Any], key: str, minimum: int, maximum: int) -> int:
value = mapping[key]
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise ValidationError(f"Policy value {key} must be in {minimum}..{maximum}")
return value
def _absolute(value: str, label: str) -> str:
path = PurePosixPath(value)
if not path.is_absolute() or ".." in path.parts:
raise ValidationError(f"Policy path {label} must be an absolute normalised POSIX path")
return str(path)
def _interface(value: str, label: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,15}", value):
raise ValidationError(f"Invalid interface name in {label}")
return value
def _mark(value: str, label: str) -> str:
if not re.fullmatch(r"0x[0-9a-fA-F]{1,8}", value):
raise ValidationError(f"Invalid hexadecimal mark in {label}")
return "0x" + value[2:].lower()
def load_policy(path: str | Path) -> Policy:
try:
with Path(path).open("r", encoding="utf-8") as stream:
raw = json.load(stream)
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(f"Cannot read policy file: {path}") from exc
root = _mapping(raw, "root")
if root["schema_version"] != 1:
raise ValidationError("Only policy schema_version=1 is supported")
sb = _mapping(root["sing_box"], "sing_box")
runtime = _mapping(root["runtime"], "runtime")
network = _mapping(root["network"], "network")
dns = _mapping(root["dns"], "dns")
bandwidth = _mapping(root["bandwidth"], "bandwidth")
health = _mapping(root["healthcheck"], "healthcheck")
required_version = _string(sb, "required_version")
if required_version != "1.13.19":
raise ValidationError("This release requires sing-box version exactly 1.13.19")
service = _string(sb, "service")
if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service):
raise ValidationError("Invalid sing-box systemd service name")
exclusions_raw = network["route_exclude_address"]
if not isinstance(exclusions_raw, list) or not exclusions_raw:
raise ValidationError("route_exclude_address must be a non-empty array")
exclusions: list[str] = []
for value in exclusions_raw:
if not isinstance(value, str):
raise ValidationError("route_exclude_address entries must be strings")
try:
parsed = ipaddress.ip_network(value, strict=True)
except ValueError as exc:
raise ValidationError(f"Invalid route exclusion: {value}") from exc
exclusions.append(str(parsed))
tun_address = _string(network, "tun_address")
try:
ipaddress.ip_interface(tun_address)
except ValueError as exc:
raise ValidationError("Invalid tun_address") from exc
bootstrap = _string(dns, "bootstrap_server")
remote = _string(dns, "remote_server")
try:
ipaddress.ip_address(bootstrap)
ipaddress.ip_address(remote)
except ValueError as exc:
raise ValidationError("DNS bootstrap and remote servers must be IP addresses") from exc
if dns["strategy"] != "ipv4_only":
raise ValidationError("Only DNS strategy ipv4_only is supported")
url = health["url"]
body_contains = health["body_contains"]
if url is not None and (not isinstance(url, str) or not url.startswith("https://")):
raise ValidationError("healthcheck.url must be null or an https:// URL")
if url is not None:
parsed_url = urlsplit(url)
if not parsed_url.hostname or parsed_url.username is not None or parsed_url.password is not None or parsed_url.fragment:
raise ValidationError("healthcheck.url must not contain credentials or a fragment")
if not _string(dns, "remote_path").startswith("/"):
raise ValidationError("dns.remote_path must start with /")
if body_contains is not None and not isinstance(body_contains, str):
raise ValidationError("healthcheck.body_contains must be null or a string")
for float_key in ("timeout_seconds", "settle_seconds"):
value = health[float_key]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 120:
raise ValidationError(f"healthcheck.{float_key} must be in 0..120")
return Policy(
schema_version=1,
sing_box=SingBoxPolicy(
binary=_absolute(_string(sb, "binary"), "sing_box.binary"),
config_path=_absolute(_string(sb, "config_path"), "sing_box.config_path"),
service=service,
required_version=required_version,
),
runtime=RuntimePolicy(
uri_path=_absolute(_string(runtime, "uri_path"), "runtime.uri_path"),
state_dir=_absolute(_string(runtime, "state_dir"), "runtime.state_dir"),
lock_path=_absolute(_string(runtime, "lock_path"), "runtime.lock_path"),
backup_keep=_integer(runtime, "backup_keep", 1, 100),
),
network=NetworkPolicy(
upstream_interface=_interface(_string(network, "upstream_interface"), "upstream_interface"),
vpn_lan_interface=_interface(_string(network, "vpn_lan_interface"), "vpn_lan_interface"),
tun_name=_interface(_string(network, "tun_name"), "tun_name"),
tun_address=tun_address,
mtu=_integer(network, "mtu", 576, 9000),
route_exclude_address=tuple(exclusions),
iproute2_table_index=_integer(network, "iproute2_table_index", 1, 2**31 - 1),
iproute2_rule_index=_integer(network, "iproute2_rule_index", 1, 32765),
auto_redirect_input_mark=_mark(_string(network, "auto_redirect_input_mark"), "input mark"),
auto_redirect_output_mark=_mark(_string(network, "auto_redirect_output_mark"), "output mark"),
auto_redirect_reset_mark=_mark(_string(network, "auto_redirect_reset_mark"), "reset mark"),
auto_redirect_nfqueue=_integer(network, "auto_redirect_nfqueue", 0, 65535),
auto_redirect_fallback_rule_index=_integer(network, "auto_redirect_fallback_rule_index", 32766, 2**31 - 1),
),
dns=DnsPolicy(
bootstrap_server=bootstrap,
bootstrap_port=_integer(dns, "bootstrap_port", 1, 65535),
remote_server=remote,
remote_port=_integer(dns, "remote_port", 1, 65535),
remote_path=_string(dns, "remote_path"),
remote_tls_server_name=_string(dns, "remote_tls_server_name"),
strategy=_string(dns, "strategy"),
),
bandwidth=BandwidthPolicy(
up_mbps=_integer(bandwidth, "up_mbps", 0, 1_000_000),
down_mbps=_integer(bandwidth, "down_mbps", 0, 1_000_000),
),
healthcheck=HealthcheckPolicy(
url=url,
timeout_seconds=float(health["timeout_seconds"]),
settle_seconds=float(health["settle_seconds"]),
expected_status=_integer(health, "expected_status", 100, 599),
body_contains=body_contains,
),
)