refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""State collectors for vacuum-walld.
|
||||
|
||||
Importing this package registers every collector with the ``lib.state``
|
||||
store (registration side effect). Import it before the first
|
||||
``populate()``/``poll()`` call.
|
||||
"""
|
||||
|
||||
from daemon.collectors import (
|
||||
acme,
|
||||
dnsmasq,
|
||||
firewall,
|
||||
networkd,
|
||||
nginx,
|
||||
system,
|
||||
wireguard,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"acme",
|
||||
"dnsmasq",
|
||||
"firewall",
|
||||
"networkd",
|
||||
"nginx",
|
||||
"system",
|
||||
"wireguard",
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ACME state collector."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import load_json
|
||||
from lib.state import PROJECT_DIR, _now_iso, register_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_NAME_MAP: dict[str, str] = {
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"zerossl": "ZeroSSL",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_ca_name(ca_server: str) -> str:
|
||||
"""Map a CA server identifier to its human-readable name.
|
||||
|
||||
Uses prefix matching sorted by longest prefix first to avoid
|
||||
shorter prefixes winning (e.g. "letsencrypt" matching before
|
||||
"letsencrypt.org").
|
||||
|
||||
Args:
|
||||
ca_server: Raw CA server string from acme.sh config.
|
||||
|
||||
Returns:
|
||||
Human-readable name, or unchanged string if no match.
|
||||
"""
|
||||
for prefix, name in sorted(
|
||||
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
|
||||
):
|
||||
if ca_server.startswith(prefix):
|
||||
return name
|
||||
return ca_server
|
||||
|
||||
|
||||
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
||||
"""Parse acme.sh account information and return account status dict.
|
||||
|
||||
Checks three sources in order:
|
||||
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
|
||||
2. Declarative ``config/acme/config.json`` (saved by the registration
|
||||
handler with ``email`` and ``ca`` fields)
|
||||
|
||||
Args:
|
||||
acme_home: Optional override for ACME home directory. Falls back
|
||||
to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``.
|
||||
|
||||
Returns:
|
||||
Dict with ``registered``, ``email``, ``ca``, and
|
||||
``key_length`` keys. If no account is found, ``registered`` is
|
||||
``False`` with empty / ``None`` values.
|
||||
"""
|
||||
if acme_home is None:
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
acme_home = Path(acme_home_env)
|
||||
|
||||
default = {
|
||||
"registered": False,
|
||||
"email": "",
|
||||
"ca": "",
|
||||
"key_length": None,
|
||||
}
|
||||
|
||||
# 1. Legacy .account.conf (acme.sh v2.x)
|
||||
account_path = acme_home / ".account.conf"
|
||||
if account_path.is_file():
|
||||
try:
|
||||
text = account_path.read_text()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
email = ""
|
||||
ca_raw = ""
|
||||
key_length = None
|
||||
for line in text.splitlines():
|
||||
if line.startswith("ACME_LEEMAIL="):
|
||||
email = line.split("=", 1)[1].strip().strip("'\"")
|
||||
elif line.startswith("ACME_MCA="):
|
||||
ca_raw = line.split("=", 1)[1].strip().strip("'\"")
|
||||
elif line.startswith("ACME_CERTKEYSIZE="):
|
||||
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
||||
key_length = int(raw_val) if raw_val.isdigit() else None
|
||||
if email and ca_raw:
|
||||
return {
|
||||
"registered": True,
|
||||
"email": email,
|
||||
"ca": _resolve_ca_name(ca_raw),
|
||||
"key_length": key_length,
|
||||
}
|
||||
|
||||
# 2. Declarative config (saved by register_account / set_email handlers)
|
||||
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
|
||||
# (ca/<server>/account.json) — we can't reliably parse those without
|
||||
# walking the directory, so fall back to the declarative config
|
||||
# which the handlers keep in sync.
|
||||
# Derive project root from acme_home (acme_home is at <root>/data/acme).
|
||||
try:
|
||||
project_root = acme_home.parent.parent # data/acme → data → project root
|
||||
acme_cfg = project_root / "config" / "acme" / "config.json"
|
||||
data = load_json(acme_cfg)
|
||||
email = (data.get("email") or "").strip()
|
||||
ca_raw = (data.get("ca") or "").strip()
|
||||
if email and ca_raw:
|
||||
return {
|
||||
"registered": True,
|
||||
"email": email,
|
||||
"ca": _resolve_ca_name(ca_raw),
|
||||
"key_length": None,
|
||||
}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||
|
||||
Falls back to the declarative ACME config (config/acme/config.json)
|
||||
if acme.sh account has not been registered yet.
|
||||
"""
|
||||
from lib.acme import _read_acme_email
|
||||
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _collect_acme() -> schema.AcmeState:
|
||||
"""Collect ACME certificate list and email.
|
||||
|
||||
Returns:
|
||||
Dict containing certificate details and registered email.
|
||||
"""
|
||||
email = _get_acme_email()
|
||||
|
||||
# Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an
|
||||
# ownership flip) must not blank the whole dashboard via a cleared
|
||||
# state store. Collect what we can and surface the failure in
|
||||
# `status.error` so the poll diff still detects recovery.
|
||||
cert_error: str | None = None
|
||||
try:
|
||||
from lib.acme import list_certs
|
||||
|
||||
certs = list_certs()
|
||||
except Exception as exc:
|
||||
logger.warning("ACME state collection failed", exc_info=True)
|
||||
certs = []
|
||||
cert_error = str(exc)
|
||||
|
||||
account = _parse_account_conf()
|
||||
|
||||
return {
|
||||
"certs": certs,
|
||||
"email": email,
|
||||
"account": account,
|
||||
"status": {"error": cert_error},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("acme", _collect_acme)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""DNSMasq state collector."""
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import compute_pending, run_proc, strip_apply_meta
|
||||
from lib.dnsmasq import DEFAULT_CFG, get_config
|
||||
from lib.state import _now_iso, register_collector
|
||||
|
||||
|
||||
def _collect_dnsmasq() -> schema.DnsmasqState:
|
||||
"""Collect dnsmasq status, config, and leases.
|
||||
|
||||
Returns:
|
||||
Dict containing config, service status, leases, and timestamp.
|
||||
"""
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
||||
|
||||
# Load config (lib defaults; fall back to them when the file is broken)
|
||||
try:
|
||||
cfg = get_config()
|
||||
except Exception:
|
||||
cfg = deepcopy(DEFAULT_CFG)
|
||||
|
||||
# Service status
|
||||
service_active = False
|
||||
try:
|
||||
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
|
||||
service_active = proc.stdout.strip() == "active"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Leases
|
||||
leases: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
ts = None
|
||||
leases.append(
|
||||
{
|
||||
"expires": ts.isoformat() if ts else "",
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check config file on disk
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
|
||||
pending_changes, pending_diff = compute_pending(cfg)
|
||||
safe_cfg = strip_apply_meta(cfg)
|
||||
|
||||
return {
|
||||
"config": safe_cfg,
|
||||
"status": {
|
||||
"service_active": service_active,
|
||||
"config_file_exists": conf_exists,
|
||||
"active_leases": len(leases),
|
||||
"pending_changes": pending_changes,
|
||||
"pending_diff": pending_diff,
|
||||
},
|
||||
"leases": leases,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("dnsmasq", _collect_dnsmasq)
|
||||
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Firewall state collector (read-only sudo queries)."""
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import load_json, run, strip_apply_meta
|
||||
from lib.firewall import (
|
||||
_parse_active_zones,
|
||||
_parse_all_zones_output,
|
||||
get_service_descriptions,
|
||||
)
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
)
|
||||
from lib.network import get_config as _network_get_config
|
||||
from lib.state import (
|
||||
PROJECT_DIR,
|
||||
_now_iso,
|
||||
register_collector,
|
||||
register_volatile,
|
||||
)
|
||||
|
||||
|
||||
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
"""Convert a port-forward dict to a compact string representation.
|
||||
|
||||
Args:
|
||||
fp: Port-forward entry containing port and proto keys.
|
||||
|
||||
Returns:
|
||||
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
|
||||
"""
|
||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||
if "toaddr" in fp:
|
||||
parts.append(f"toaddr={fp['toaddr']}")
|
||||
if "toport" in fp:
|
||||
parts.append(f"toport={fp['toport']}")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _collect_firewall() -> schema.FirewallState:
|
||||
"""Return the complete current state of firewalld.
|
||||
|
||||
Returns:
|
||||
Dict containing firewall zones, interfaces, rules, config, and
|
||||
pending changes.
|
||||
"""
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||
|
||||
iface_map: dict[str, dict[str, Any]] = {}
|
||||
for line in link_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
raw_name = parts[1].rstrip(":").split("@")[0]
|
||||
iface_state = "UNKNOWN"
|
||||
mtu = None
|
||||
mac = None
|
||||
for i, p in enumerate(parts):
|
||||
if p == "state" and i + 1 < len(parts):
|
||||
iface_state = parts[i + 1]
|
||||
if p == "mtu" and i + 1 < len(parts):
|
||||
mtu = int(parts[i + 1])
|
||||
if p.startswith("link/ether") and i + 1 < len(parts):
|
||||
mac = parts[i + 1]
|
||||
iface_map[raw_name] = {
|
||||
"name": raw_name,
|
||||
"mac": mac,
|
||||
"state": iface_state,
|
||||
"mtu": mtu,
|
||||
"ips": [],
|
||||
"ipv6": [],
|
||||
"zone": None,
|
||||
}
|
||||
|
||||
for line in addr_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
addr_name = parts[1].split("@")[0]
|
||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||
for entry in iface_map.values():
|
||||
if entry["name"] == addr_name:
|
||||
entry[addr_key].append(parts[3])
|
||||
break
|
||||
|
||||
for zone_name, ifaces in active.items():
|
||||
for raw_if in ifaces:
|
||||
for entry in iface_map.values():
|
||||
if entry["name"] == raw_if:
|
||||
entry["zone"] = zone_name
|
||||
break
|
||||
|
||||
ifaces = list(iface_map.values())
|
||||
|
||||
# Collect all zones in a single call (replaces per-zone loop)
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||
zones = _parse_all_zones_output(all_zones_raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load config (strip apply bookkeeping keys, as the other collectors do)
|
||||
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
||||
config_data = {}
|
||||
if fw_config_path.exists():
|
||||
with contextlib.suppress(Exception):
|
||||
config_data = strip_apply_meta(load_json(fw_config_path))
|
||||
|
||||
# Pending changes
|
||||
full_state = {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
pending = {}
|
||||
with contextlib.suppress(Exception):
|
||||
pending = _config_pending(full_state)
|
||||
|
||||
net_cfg: dict[str, Any] = {}
|
||||
with contextlib.suppress(Exception):
|
||||
net_cfg = _network_get_config()
|
||||
covered: set[str] = set()
|
||||
for zone_ifaces in active.values():
|
||||
covered.update(zone_ifaces)
|
||||
for zone in zones.values():
|
||||
covered.update(zone.get("interfaces", []))
|
||||
uncovered_interfaces = [
|
||||
name
|
||||
for name in net_cfg.get("interfaces", {})
|
||||
if name != "lo" and not name.startswith("wg") and name not in covered
|
||||
]
|
||||
|
||||
return {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
# Parsed from the firewalld service XML definitions; cached per
|
||||
# process so the 30s poll does not re-read the files.
|
||||
"service_descriptions": get_service_descriptions(),
|
||||
"uncovered_interfaces": uncovered_interfaces,
|
||||
"zones": zones,
|
||||
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
|
||||
"config": config_data,
|
||||
"pending": pending,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("firewall", _collect_firewall)
|
||||
register_volatile(
|
||||
"firewall",
|
||||
frozenset(
|
||||
{
|
||||
"interfaces[].ips",
|
||||
"interfaces[].ipv6",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Networkd state collector."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import compute_pending, run, strip_apply_meta
|
||||
from lib.network import get_config, parse_networkctl_status
|
||||
from lib.state import _now_iso, register_collector, register_volatile
|
||||
|
||||
|
||||
def _collect_networkd() -> schema.NetworkdState:
|
||||
"""Collect networkd interface state from networkctl.
|
||||
|
||||
Returns:
|
||||
Dict with interface runtime state parsed from networkctl output,
|
||||
config, and pending changes status.
|
||||
"""
|
||||
# Load config
|
||||
try:
|
||||
net_cfg = get_config()
|
||||
except Exception:
|
||||
net_cfg = {}
|
||||
|
||||
pending_changes, net_pending_diff = compute_pending(net_cfg)
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
safe_net_cfg = strip_apply_meta(net_cfg)
|
||||
net_status: dict[str, Any] = {
|
||||
"pending_changes": pending_changes,
|
||||
"pending_diff": net_pending_diff,
|
||||
}
|
||||
|
||||
try:
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
result = parse_networkctl_status(raw)
|
||||
if not result:
|
||||
return {
|
||||
"interfaces": {},
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"interfaces": {},
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
return {
|
||||
"interfaces": result,
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("networkd", _collect_networkd)
|
||||
register_volatile(
|
||||
"networkd",
|
||||
frozenset(
|
||||
{
|
||||
"interfaces[].addresses",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Nginx state collector."""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import compute_pending, strip_apply_meta
|
||||
from lib.nginx import DEFAULT_CONFIG, SITES_DIR, _resolve_paths, get_config
|
||||
from lib.state import _now_iso, register_collector
|
||||
|
||||
|
||||
def _collect_nginx() -> schema.NginxState:
|
||||
"""Collect nginx config and domains list.
|
||||
|
||||
Returns:
|
||||
Dict containing config, domains, and timestamp.
|
||||
"""
|
||||
# Load config via lib.nginx — a pure read that applies the legacy-format
|
||||
# migration in memory (the one-shot on-disk migration runs at daemon
|
||||
# startup, see lib.bootstrap).
|
||||
try:
|
||||
cfg = get_config()
|
||||
except Exception:
|
||||
cfg = deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
# Build flattened domains list (one entry per path)
|
||||
backends = cfg.get("backends", {})
|
||||
domains: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
if "backend" not in dom:
|
||||
continue
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
paths = _resolve_paths(dom, backends)
|
||||
if not paths:
|
||||
continue
|
||||
for ppath, pcfg in paths.items():
|
||||
entry: dict[str, Any] = {
|
||||
"domain": name,
|
||||
"path": ppath,
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists() if SITES_DIR.exists() else False,
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
"backend_name": dom["backend"],
|
||||
"cert": dom.get("cert"),
|
||||
}
|
||||
if pcfg.get("is_management"):
|
||||
entry["is_management"] = True
|
||||
if pcfg.get("is_websocket"):
|
||||
entry["is_websocket"] = True
|
||||
domains.append(entry)
|
||||
|
||||
pending_changes, nginx_pending_diff = compute_pending(cfg)
|
||||
safe_cfg = strip_apply_meta(cfg)
|
||||
|
||||
return {
|
||||
"config": safe_cfg,
|
||||
"domains": domains,
|
||||
"status": {
|
||||
"pending_changes": pending_changes,
|
||||
"pending_diff": nginx_pending_diff,
|
||||
},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("nginx", _collect_nginx)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""System metrics collector."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.state import _now_iso, register_collector, register_volatile
|
||||
|
||||
|
||||
def _parse_meminfo() -> dict[str, Any]:
|
||||
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
|
||||
info: dict[str, int] = {}
|
||||
try:
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
parts = value.strip().split()
|
||||
val = int(parts[0])
|
||||
# Convert kB to bytes
|
||||
if parts and parts[-1] == "kB":
|
||||
val *= 1024
|
||||
info[key] = val
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return info
|
||||
|
||||
|
||||
def _collect_system() -> schema.SystemState:
|
||||
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||
|
||||
Reads from /proc and /sys — no subprocess needed.
|
||||
|
||||
Returns:
|
||||
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
|
||||
"""
|
||||
# CPU load
|
||||
loads = []
|
||||
try:
|
||||
parts = Path("/proc/loadavg").read_text().split()
|
||||
loads = [float(x) for x in parts[:3]]
|
||||
except (OSError, ValueError):
|
||||
loads = [0.0, 0.0, 0.0]
|
||||
|
||||
# Memory
|
||||
meminfo_raw = _parse_meminfo()
|
||||
mem_total = meminfo_raw.get("MemTotal", 0)
|
||||
mem_free = meminfo_raw.get("MemFree", 0)
|
||||
mem_available = meminfo_raw.get("MemAvailable", mem_free)
|
||||
mem_buffers = meminfo_raw.get("Buffers", 0)
|
||||
mem_cached = meminfo_raw.get("Cached", 0)
|
||||
mem_used = mem_total - mem_free - mem_buffers - mem_cached
|
||||
if mem_used < 0:
|
||||
mem_used = mem_total - mem_available
|
||||
|
||||
# Swap
|
||||
swap_total = meminfo_raw.get("SwapTotal", 0)
|
||||
swap_free = meminfo_raw.get("SwapFree", 0)
|
||||
swap_used = swap_total - swap_free
|
||||
|
||||
# Network traffic from /sys/class/net/<iface>/statistics/
|
||||
traffic: dict[str, dict[str, int]] = {}
|
||||
try:
|
||||
net_root = Path("/sys/class/net")
|
||||
if net_root.is_dir():
|
||||
for iface_dir in net_root.iterdir():
|
||||
stats_dir = iface_dir / "statistics"
|
||||
if not stats_dir.is_dir():
|
||||
continue
|
||||
iface_name = iface_dir.name
|
||||
rx_bytes = 0
|
||||
tx_bytes = 0
|
||||
rx_packets = 0
|
||||
tx_packets = 0
|
||||
try:
|
||||
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
|
||||
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
|
||||
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
|
||||
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
traffic[iface_name] = {
|
||||
"rx_bytes": rx_bytes,
|
||||
"tx_bytes": tx_bytes,
|
||||
"rx_packets": rx_packets,
|
||||
"tx_packets": tx_packets,
|
||||
}
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"load": {
|
||||
"load1": loads[0],
|
||||
"load5": loads[1],
|
||||
"load15": loads[2],
|
||||
},
|
||||
"memory": {
|
||||
"total": mem_total,
|
||||
"available": mem_available,
|
||||
"used": mem_used,
|
||||
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
|
||||
},
|
||||
"swap": {
|
||||
"total": swap_total,
|
||||
"used": swap_used,
|
||||
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
|
||||
},
|
||||
"traffic": traffic,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("system", _collect_system)
|
||||
register_volatile(
|
||||
"system",
|
||||
frozenset(
|
||||
{
|
||||
"load",
|
||||
"memory",
|
||||
"swap",
|
||||
"traffic",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""WireGuard state collector."""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from lib import schema
|
||||
from lib.common import compute_pending, run_proc, strip_apply_meta
|
||||
from lib.state import _now_iso, register_collector, register_volatile
|
||||
from lib.wireguard import DEFAULT_CONFIG, get_config, parse_wg_show_output
|
||||
|
||||
|
||||
def _collect_wireguard() -> schema.WgState:
|
||||
"""Collect WireGuard config, per-class status, and peers.
|
||||
|
||||
Returns:
|
||||
Dict containing interface config, per-class runtime status,
|
||||
combined peers, and overall tunnel status.
|
||||
"""
|
||||
# Load config via lib.wireguard defaults (which include the built-in
|
||||
# full/internet access classes).
|
||||
try:
|
||||
cfg = get_config()
|
||||
except Exception:
|
||||
cfg = deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
pending_changes, pending_diff = compute_pending(cfg)
|
||||
|
||||
# Safe config (strip private keys from interface and access classes)
|
||||
safe = strip_apply_meta(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
if "access_classes" in safe:
|
||||
safe["access_classes"] = {}
|
||||
for ck, cv in cfg.get("access_classes", {}).items():
|
||||
if isinstance(cv, dict):
|
||||
entry = dict(cv)
|
||||
entry.pop("private_key", None)
|
||||
safe["access_classes"][ck] = entry
|
||||
|
||||
# Peers list (safe)
|
||||
peers: list[dict[str, Any]] = []
|
||||
for name, info in cfg.get("peers", {}).items():
|
||||
entry = dict(info)
|
||||
entry["name"] = name
|
||||
entry.pop("private_key", None)
|
||||
peers.append(entry)
|
||||
|
||||
# Runtime status — per-class interfaces
|
||||
status: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
"classes": {},
|
||||
}
|
||||
classes = cfg.get("access_classes", {})
|
||||
any_up = False
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not isinstance(class_cfg, dict):
|
||||
continue
|
||||
ifname = f"wg-{class_key}"
|
||||
try:
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
status["classes"][class_key] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
continue
|
||||
parsed = parse_wg_show_output(res.stdout.strip())
|
||||
status["classes"][class_key] = {
|
||||
"up": parsed["up"],
|
||||
"interface": parsed.get("interface", {}),
|
||||
"peers": parsed.get("peers", []),
|
||||
}
|
||||
if parsed["up"]:
|
||||
any_up = True
|
||||
except Exception:
|
||||
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
# Also collect legacy single-interface status
|
||||
try:
|
||||
ifname = cfg["interface"].get("name", "wg0")
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
parsed = parse_wg_show_output(res.stdout.strip())
|
||||
status["up"] = True
|
||||
status["interface"] = parsed.get("interface", {})
|
||||
status["peers"] = parsed.get("peers", [])
|
||||
any_up = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if any_up:
|
||||
status["up"] = True
|
||||
|
||||
status["pending_changes"] = pending_changes
|
||||
# Drop any private-key paths so the pending summary never exposes
|
||||
# key material.
|
||||
status["pending_diff"] = [d for d in pending_diff if "private_key" not in d["path"]]
|
||||
return {
|
||||
"config": safe,
|
||||
"status": status,
|
||||
"peers": peers,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("wireguard", _collect_wireguard)
|
||||
register_volatile(
|
||||
"wireguard",
|
||||
frozenset(
|
||||
{
|
||||
"status.peers[].transfer_received",
|
||||
"status.peers[].transfer_sent",
|
||||
"status.peers[].latest_handshake",
|
||||
"status.classes[].peers[].transfer_received",
|
||||
"status.classes[].peers[].transfer_sent",
|
||||
"status.classes[].peers[].latest_handshake",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -592,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
from lib.state import _parse_account_conf
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
info = _parse_account_conf(_ACME_HOME)
|
||||
if info.get("registered"):
|
||||
@@ -607,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
|
||||
def _check_account_registered() -> tuple[bool, str]:
|
||||
"""Blocking check: verify an ACME account is registered.
|
||||
|
||||
Delegates to ``lib.state._parse_account_conf()`` which checks both
|
||||
Delegates to ``daemon.collectors.acme._parse_account_conf()`` which checks both
|
||||
the legacy .account.conf and the declarative config/acme/config.json
|
||||
used by modern acme.sh (v3.x).
|
||||
"""
|
||||
from lib.state import _parse_account_conf
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
info = _parse_account_conf(_ACME_HOME)
|
||||
if info.get("registered"):
|
||||
@@ -622,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
|
||||
def _get_account_info() -> dict[str, Any]:
|
||||
"""Read and return the ACME account info dict.
|
||||
|
||||
Delegates to ``lib.state._parse_account_conf()`` for a single
|
||||
Delegates to ``daemon.collectors.acme._parse_account_conf()`` for a single
|
||||
source of truth.
|
||||
"""
|
||||
from lib.state import _parse_account_conf
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
return _parse_account_conf(_ACME_HOME)
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared helpers for daemon handlers."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from daemon.server import refresh_state
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
|
||||
def emit_and_refresh(
|
||||
subsystem: str, payload: dict[str, Any] | None = None
|
||||
) -> list[str]:
|
||||
"""Emit a ``config_saved`` sync event and refresh the affected state.
|
||||
|
||||
All mutation handlers end with the same tail: emit the event, refresh
|
||||
the source subsystem plus every subsystem the sync touched.
|
||||
|
||||
Args:
|
||||
subsystem: Source subsystem name.
|
||||
payload: Event payload (e.g. ``{"action": "zone_created"}``).
|
||||
|
||||
Returns:
|
||||
Subsystems affected by the sync event.
|
||||
"""
|
||||
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
|
||||
refresh_state([subsystem, *sync_result.affected_subsystems])
|
||||
return sync_result.affected_subsystems
|
||||
+16
-75
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.handlers.common import emit_and_refresh
|
||||
from daemon.iface import (
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
@@ -24,7 +25,7 @@ from daemon.iface import (
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
POST_DNSMASQ_UPSTREAMS,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import (
|
||||
deep_merge,
|
||||
ensure_dirs,
|
||||
@@ -35,7 +36,6 @@ from lib.common import (
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "config_saved"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "config_patched"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg_after = _get_config()
|
||||
stamp_applied(cfg_after)
|
||||
_save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"applied": True, "synced": sync_result.affected_subsystems}
|
||||
synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
|
||||
return {"applied": True, "synced": synced}
|
||||
|
||||
|
||||
@registry.register(GET_DNSMASQ_STATUS)
|
||||
@@ -281,12 +272,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
entry["dns"] = body["dns"]
|
||||
ranges.append(entry)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@@ -321,12 +307,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
||||
)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@@ -365,26 +346,14 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
if hostname is not None:
|
||||
leases[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq",
|
||||
"config_saved",
|
||||
{"action": "static_lease_added", "mac": mac},
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@@ -409,12 +378,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if len(cfg["dhcp"]["static_leases"]) == before:
|
||||
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@@ -440,26 +404,14 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
if hostname is not None:
|
||||
records[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq",
|
||||
"config_saved",
|
||||
{"action": "dns_record_added", "name": name},
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@@ -482,12 +434,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
if len(cfg["dns"]["custom_records"]) == before:
|
||||
raise NotFoundError(f"DNS record '{name}' not found")
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@@ -503,10 +450,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["upstreams"] = list(body["servers"])
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@@ -523,8 +467,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("dnsmasq", {"action": "domain_set"})
|
||||
return {"domain": cfg["dns"]["domain"]}
|
||||
|
||||
+105
-134
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.handlers.common import emit_and_refresh
|
||||
from daemon.iface import (
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
@@ -33,7 +34,7 @@ from daemon.iface import (
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from daemon.server import ConflictError, NotFoundError, registry
|
||||
from lib import network
|
||||
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
|
||||
from lib.firewall import (
|
||||
@@ -43,11 +44,11 @@ from lib.firewall import (
|
||||
_parse_all_zones_output,
|
||||
_parse_zone_output,
|
||||
fw_change_summary,
|
||||
validate_coverage,
|
||||
)
|
||||
from lib.firewall import (
|
||||
save_backup as _save_backup,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
|
||||
save_json(CONFIG_FILE, cfg, indent=2)
|
||||
|
||||
|
||||
def _check_coverage(cfg: dict[str, Any]) -> None:
|
||||
"""Reject a config that leaves a managed interface without coverage.
|
||||
|
||||
Runs the pure ``validate_coverage`` invariant against the current
|
||||
network config. ``lo`` and ``wg*`` are exempt, and interfaces declared
|
||||
in the top-level ``unmanaged`` list are exempt.
|
||||
|
||||
Args:
|
||||
cfg: The (merged or full) firewall config dict to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If a network-managed interface is not covered by any
|
||||
zone and is not declared under ``unmanaged``.
|
||||
"""
|
||||
uncovered = validate_coverage(cfg, network.get_config())
|
||||
if uncovered:
|
||||
raise ValueError(
|
||||
"Refusing to save: "
|
||||
f"{', '.join(repr(n) for n in uncovered)} "
|
||||
f"have no firewall zone coverage and are not declared in the "
|
||||
f"'unmanaged' list. Assign each interface to a zone, or add it "
|
||||
f"to the top-level 'unmanaged' list."
|
||||
)
|
||||
|
||||
|
||||
def _reload() -> None:
|
||||
"""Reload firewalld to apply permanent changes."""
|
||||
run(["firewall-cmd", "--reload"], sudo=True)
|
||||
@@ -150,11 +176,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
|
||||
- the config would strip both https and ssh from the default zone
|
||||
(management lockout);
|
||||
- a network-subsystem-managed interface would end up with no firewall
|
||||
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
|
||||
Zones whose config omits the ``interfaces`` key are left hands-off, so
|
||||
their current live interfaces count as coverage, as do the live
|
||||
interfaces of zones that are live but absent from the config.
|
||||
- the config leaves a network-subsystem-managed interface with no
|
||||
firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
|
||||
The config is the source of truth for zone interfaces — an absent
|
||||
``interfaces`` key counts as empty — so coverage is computed from the
|
||||
config alone via ``validate_coverage`` with no live-state fallback.
|
||||
Interfaces listed in the top-level ``unmanaged`` key are exempt. The
|
||||
same invariant is enforced at save time (POST/PATCH /firewall/config),
|
||||
so a conflict here means the network config changed after the firewall
|
||||
config was saved (e.g. a new interface no zone covers).
|
||||
"""
|
||||
from lib.firewall import get_config as _get_lib_config
|
||||
|
||||
@@ -178,37 +208,20 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
f'to the zone\'s services, or pass {{"force": true}}.'
|
||||
)
|
||||
|
||||
# Coverage guard: after apply, every network-managed interface must
|
||||
# belong to a zone or traffic (and DHCP) on that segment is dropped.
|
||||
live_active = _parse_active_zones(
|
||||
run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
)
|
||||
covered: set[str] = set()
|
||||
for zn, zc in cfg_zones.items():
|
||||
if "interfaces" in (zc if isinstance(zc, dict) else {}):
|
||||
covered.update(zc["interfaces"])
|
||||
else:
|
||||
covered.update(live_active.get(zn, []))
|
||||
covered.update(
|
||||
iface
|
||||
for zn, ifaces in live_active.items()
|
||||
if zn not in cfg_zones
|
||||
for iface in ifaces
|
||||
)
|
||||
net_cfg = network.get_config()
|
||||
guarded = [
|
||||
name
|
||||
for name in net_cfg.get("interfaces", {})
|
||||
if name != "lo" and not name.startswith("wg")
|
||||
]
|
||||
uncovered = [name for name in guarded if name not in covered]
|
||||
# Coverage invariant: every network-managed interface must be
|
||||
# covered by a zone in the config (or declared unmanaged), or
|
||||
# traffic (and DHCP) on that segment is dropped. Pure config check
|
||||
# — the config is the source of truth, so no live-state comparison.
|
||||
uncovered = validate_coverage(cfg, network.get_config())
|
||||
if uncovered:
|
||||
raise ConflictError(
|
||||
"Refusing to apply: "
|
||||
f"{', '.join(repr(n) for n in uncovered)} "
|
||||
f"would have no firewall zone coverage after apply, so all "
|
||||
f"traffic (including DHCP) from those segments would be "
|
||||
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
|
||||
f"have no firewall zone coverage in the config and are not "
|
||||
f"declared unmanaged, so all traffic (including DHCP) from "
|
||||
f"those segments would be dropped. Assign each interface to "
|
||||
f"a zone (or list it under the config's top-level 'unmanaged' "
|
||||
f'key), or pass {{"force": true}}.'
|
||||
)
|
||||
|
||||
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
|
||||
@@ -297,38 +310,36 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
||||
# Absent "interfaces" key = hands off (keep the zone's live
|
||||
# interfaces); an explicit empty list = intentional unassign-all.
|
||||
if "interfaces" in zone_cfg:
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
zone_name,
|
||||
run(
|
||||
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
|
||||
),
|
||||
).get("interfaces", [])
|
||||
for iface in current_ifaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in zone_cfg.get("interfaces", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
# The config is the source of truth: an absent "interfaces" key
|
||||
# counts as an empty list (unassign-all), matching the coverage
|
||||
# invariant and the pending diff.
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("interfaces", [])
|
||||
for iface in current_ifaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in zone_cfg.get("interfaces", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
||||
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
||||
@@ -576,19 +587,20 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty, missing ``zones`` key,
|
||||
or ``zones`` is not a dict.
|
||||
ValueError: If body is empty, missing ``zones`` key, ``zones`` is
|
||||
not a dict, ``unmanaged`` is not a list, or the config leaves a
|
||||
network-managed interface without zone coverage.
|
||||
"""
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
if not isinstance(body["zones"], dict):
|
||||
raise ValueError("'zones' must be a dict")
|
||||
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
|
||||
raise ValueError("'unmanaged' must be a list")
|
||||
_check_coverage(body)
|
||||
_save_config(body)
|
||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "config_saved"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -604,20 +616,22 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty.
|
||||
ValueError: If body is empty, ``unmanaged`` is not a list, or the
|
||||
merged config leaves a network-managed interface without zone
|
||||
coverage.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
|
||||
raise ValueError("'unmanaged' must be a list")
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_check_coverage(merged)
|
||||
_save_config(merged)
|
||||
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "config_patched"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
Raises:
|
||||
ConflictError: If the config would strip both https and ssh from the
|
||||
default zone, or would leave a network-managed interface without
|
||||
zone coverage, and ``force`` is not set.
|
||||
default zone, or would remove zone coverage from a
|
||||
network-managed interface that is covered now, and ``force`` is
|
||||
not set.
|
||||
"""
|
||||
force = bool(_body and _body.get("force"))
|
||||
result = _config_apply(force=force)
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
result["synced"] = sync_result.affected_subsystems
|
||||
synced = emit_and_refresh("firewall", {"action": "config_applied"})
|
||||
result["synced"] = synced
|
||||
return result
|
||||
|
||||
|
||||
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@@ -754,10 +761,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
||||
_reload()
|
||||
logger.info("Zone '%s' deleted", zone)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@@ -862,12 +866,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
_save_config(cfg)
|
||||
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@@ -938,10 +937,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
logger.info("Zone '%s' services set to %s", zone, services)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@@ -987,12 +983,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@@ -1044,12 +1035,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
]
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@@ -1130,12 +1116,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
zone_cfg["masquerade"] = bool(enable)
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@@ -1192,12 +1173,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@@ -1258,12 +1234,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
]
|
||||
stamp_applied(cfg)
|
||||
_save_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.handlers.common import emit_and_refresh
|
||||
from daemon.iface import (
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
GET_NETWORK_INFER_ZONES,
|
||||
@@ -20,7 +21,7 @@ from daemon.iface import (
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
POST_NETWORK_SYSCTL_SET,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run, stamp_applied, validate_interface_name
|
||||
from lib.dnsmasq import get_config as _get_dm_cfg
|
||||
from lib.dnsmasq import save_config as _save_dm_cfg
|
||||
@@ -36,7 +37,6 @@ from lib.network import (
|
||||
render_network_file,
|
||||
save_config,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -220,16 +220,13 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
cfg_after = get_config()
|
||||
stamp_applied(cfg_after)
|
||||
save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
|
||||
)
|
||||
synced = emit_and_refresh(
|
||||
"networkd", {"action": "interface_saved", "interface": name}
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
return {
|
||||
"name": name,
|
||||
"applied": deployed,
|
||||
"synced": sync_result.affected_subsystems,
|
||||
"synced": synced,
|
||||
}
|
||||
|
||||
|
||||
@@ -298,10 +295,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg_after = get_config()
|
||||
stamp_applied(cfg_after)
|
||||
save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
synced = emit_and_refresh("networkd", {"action": "config_applied"})
|
||||
|
||||
logger.info(
|
||||
"Network config applied: %d interfaces, %d stale cleaned",
|
||||
@@ -312,7 +306,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"applied": len(generated),
|
||||
"files": [str(p) for p in generated],
|
||||
"cleaned": [str(p) for p in cleaned],
|
||||
"synced": sync_result.affected_subsystems,
|
||||
"synced": synced,
|
||||
}
|
||||
|
||||
|
||||
@@ -366,8 +360,5 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
logger.info("sysctl %s set to %s", name, value)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
|
||||
return {"name": name, "value": value}
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.handlers.common import emit_and_refresh
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_CLASSES,
|
||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||
@@ -27,9 +28,8 @@ from daemon.iface import (
|
||||
POST_WIREGUARD_INITIALIZE,
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
)
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from daemon.server import ConflictError, NotFoundError, registry
|
||||
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
|
||||
from lib.sync import SyncEvent, bus
|
||||
from lib.wireguard import (
|
||||
_class_interface_name,
|
||||
_class_peers,
|
||||
@@ -122,10 +122,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
body["access_classes"] = current.get("access_classes", {})
|
||||
|
||||
_save_wireguard_config(body)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "config_saved"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -153,10 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _get_wireguard_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_wireguard_config(merged)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "config_patched"})
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg_after = _get_wireguard_config()
|
||||
stamp_applied(cfg_after)
|
||||
_save_wireguard_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
synced = emit_and_refresh("wireguard", {"action": "config_applied"})
|
||||
return {
|
||||
"applied": True,
|
||||
"synced": sync_result.affected_subsystems,
|
||||
"synced": synced,
|
||||
"interfaces": affected,
|
||||
}
|
||||
|
||||
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "tunnel_down"})
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@@ -296,12 +284,7 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
|
||||
return {"up": True, "interface": ifname}
|
||||
|
||||
|
||||
@@ -328,14 +311,7 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
|
||||
except Exception:
|
||||
pass
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard",
|
||||
"config_saved",
|
||||
{"action": "class_down", "class_key": class_key},
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
|
||||
return {"down": True, "interface": ifname}
|
||||
|
||||
|
||||
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
_save_wireguard_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "initialized"})
|
||||
|
||||
safe = dict(cfg)
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
@@ -467,12 +440,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
logger.info("WireGuard peer '%s' added", name)
|
||||
_peer_action = "peer_added"
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
|
||||
peer_out = dict(peers[name])
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
del peers[name]
|
||||
_save_wireguard_config(cfg)
|
||||
logger.info("WireGuard peer '%s' removed", name)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"public_key": "",
|
||||
}
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "class_created"})
|
||||
out = dict(classes[key])
|
||||
out.pop("private_key", None)
|
||||
return out
|
||||
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if field in body:
|
||||
class_cfg[field] = body[field]
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "class_updated"})
|
||||
out = dict(classes[key])
|
||||
out.pop("private_key", None)
|
||||
return {"key": key, **out}
|
||||
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
del classes[key]
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
emit_and_refresh("wireguard", {"action": "class_deleted"})
|
||||
return {"key": key}
|
||||
|
||||
+18
-17
@@ -18,6 +18,7 @@ from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import daemon.collectors # noqa: F401 (registers state collectors)
|
||||
from daemon.iface import PathLike
|
||||
from lib.auth import blacklist_expired
|
||||
from lib.state import _DEFAULT_POLL_INTERVALS
|
||||
@@ -145,16 +146,20 @@ class Registry:
|
||||
registry = Registry()
|
||||
|
||||
|
||||
def refresh_state(subsystems: list[str] | None = None) -> None:
|
||||
def refresh_state(subsystems: list[str] | None = None, bump: bool = True) -> None:
|
||||
"""Refresh the pre-computed state for the given subsystems (or all).
|
||||
|
||||
Args:
|
||||
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
||||
bump: Bump the version counter for each refreshed subsystem.
|
||||
``refresh_status`` passes ``False`` — versions advance on
|
||||
structural poll diffs and on mutation-triggered refreshes only.
|
||||
"""
|
||||
state_store.populate(subsystems)
|
||||
targets = subsystems or state_store.SUBSYSTEMS
|
||||
for name in targets:
|
||||
state_store.bump(name)
|
||||
if bump:
|
||||
for name in targets:
|
||||
state_store.bump(name)
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -600,22 +605,11 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body = None
|
||||
subsystems = body.get("subsystems") if body else None
|
||||
state_store.populate(subsystems)
|
||||
targets = subsystems or state_store.SUBSYSTEMS
|
||||
snapshot = {name: state_store.get(name) for name in targets}
|
||||
|
||||
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
|
||||
# Deliberately no version bump — versions advance on structural poll
|
||||
# diffs and on refresh_state() only.
|
||||
async def _broadcast_all():
|
||||
await asyncio.gather(
|
||||
*[broadcast_versions(name) for name in targets],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_broadcast_all())
|
||||
task.add_done_callback(_ws_tasks.discard)
|
||||
_ws_tasks.add(task)
|
||||
refresh_state(subsystems, bump=False)
|
||||
targets = subsystems or state_store.SUBSYSTEMS
|
||||
snapshot = {name: state_store.get(name) for name in targets}
|
||||
return ok(snapshot)
|
||||
|
||||
|
||||
@@ -747,6 +741,13 @@ def main() -> None:
|
||||
if reconciled:
|
||||
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
|
||||
|
||||
# Filesystem bootstrap after the import (which must see absent config
|
||||
# files to adopt live system state on first start): create runtime
|
||||
# directories and persist the one-shot nginx legacy-format migration.
|
||||
from lib.bootstrap import bootstrap
|
||||
|
||||
bootstrap()
|
||||
|
||||
# Reopen group access on the ACME home before the first acme.sh
|
||||
# collection: a tree left owner-only by a prior run (e.g. a manual
|
||||
# run as the WebUI user) would otherwise fail every daemon acme.sh
|
||||
|
||||
Reference in New Issue
Block a user