803258cf18
Add get_interface_ip() helper to resolve an interface's IPv4 address via 'ip -o addr show'. Use it to back-propagate gateway into DHCP ranges so clients receive their default route. - set_dhcp_range() resolves gateway: explicit > existing range > iface IP - DnsToFirewallSync and FirewallToDhcpSync sync ensure gateways are set - Remove automatic masquerade toggle from DnsToFirewallSync - Fix dnsmasq lease file path to /var/lib/misc/dnsmasq.leases - Rename lease state field expires_at -> expires (ISO string) - Add 'ip -o addr show' to sudo whitelist
1060 lines
33 KiB
Python
1060 lines
33 KiB
Python
"""Pre-computed state store for vacuum-walld.
|
|
|
|
Collects system state at startup and on demand. Handlers read from the
|
|
state instead of invoking subprocesses on every request.
|
|
"""
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
from copy import deepcopy
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc
|
|
from lib.firewall import (
|
|
_parse_active_zones,
|
|
_parse_all_zones_output,
|
|
)
|
|
from lib.firewall import (
|
|
config_pending as _config_pending,
|
|
)
|
|
from lib.network import parse_networkctl_status
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
_CA_NAME_MAP: dict[str, str] = {
|
|
"letsencrypt": "Let's Encrypt",
|
|
"zerossl": "ZeroSSL",
|
|
}
|
|
|
|
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
|
"firewall": 30,
|
|
"wireguard": 10,
|
|
"dnsmasq": 10,
|
|
"networkd": 10,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class State:
|
|
"""In-memory state store keyed by subsystem name.
|
|
|
|
Each subsystem's value is a dict collected from the corresponding
|
|
``collect_*`` function. A value of ``None`` means the subsystem has
|
|
not been populated yet or the last collection failed.
|
|
|
|
Attributes:
|
|
SUBSYSTEMS: Ordered list of subsystem names.
|
|
_data: Dict mapping subsystem names to their state data.
|
|
"""
|
|
|
|
SUBSYSTEMS: ClassVar[list[str]] = [
|
|
"firewall",
|
|
"dnsmasq",
|
|
"nginx",
|
|
"acme",
|
|
"wireguard",
|
|
"networkd",
|
|
]
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the state store with empty subsystem slots."""
|
|
self._data: dict[str, dict[str, Any] | None] = {
|
|
name: None for name in self.SUBSYSTEMS
|
|
}
|
|
self._versions: dict[str, int] = {name: 0 for name in self.SUBSYSTEMS}
|
|
self._last_broadcast: dict[str, int] | None = None
|
|
|
|
def bump(self, subsystem: str) -> None:
|
|
"""Increment the version counter for *subsystem*.
|
|
|
|
Args:
|
|
subsystem: Subsystem name.
|
|
"""
|
|
if subsystem in self._versions:
|
|
self._versions[subsystem] += 1
|
|
|
|
def get_versions(self) -> dict[str, int]:
|
|
"""Return a shallow copy of all subsystem versions.
|
|
|
|
Returns:
|
|
Dict mapping subsystem names to their current version integers.
|
|
"""
|
|
return dict(self._versions)
|
|
|
|
def get_updated_versions(self) -> dict[str, int]:
|
|
"""Return versions that changed since the last broadcast.
|
|
|
|
After calling, ``_last_broadcast`` is updated to match current versions.
|
|
|
|
Returns:
|
|
Dict of subsystems whose versions changed, or empty dict.
|
|
"""
|
|
if self._last_broadcast is None:
|
|
self._last_broadcast = dict(self._versions)
|
|
return {}
|
|
updated: dict[str, int] = {}
|
|
for name, v in self._versions.items():
|
|
if v != self._last_broadcast.get(name, 0):
|
|
updated[name] = v
|
|
self._last_broadcast[name] = v
|
|
return updated
|
|
|
|
def get(self, subsystem: str) -> dict[str, Any] | None:
|
|
"""Get state data for *subsystem*.
|
|
|
|
Args:
|
|
subsystem: Subsystem name.
|
|
|
|
Returns:
|
|
State dict, or ``None`` if not populated.
|
|
"""
|
|
return self._data.get(subsystem)
|
|
|
|
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
|
|
"""Set state data for *subsystem*.
|
|
|
|
Args:
|
|
subsystem: Subsystem name.
|
|
data: State data, or ``None`` to clear.
|
|
"""
|
|
self._data[subsystem] = data
|
|
|
|
def populate(self, subsystems: list[str] | None = None) -> None:
|
|
"""Collect state for *subsystems* (all if ``None``).
|
|
|
|
Args:
|
|
subsystems: List of subsystem names to collect. Collects all
|
|
subsystems when ``None``.
|
|
"""
|
|
targets = subsystems or self.SUBSYSTEMS
|
|
for name in targets:
|
|
collector = _COLLECTORS.get(name)
|
|
if collector is None:
|
|
continue
|
|
try:
|
|
self._data[name] = collector()
|
|
except Exception:
|
|
logger.warning(
|
|
"State collection failed for %s, clearing state",
|
|
name,
|
|
exc_info=True,
|
|
)
|
|
self._data[name] = None
|
|
|
|
def is_populated(self) -> bool:
|
|
"""Check whether all subsystem states have been populated.
|
|
|
|
Returns:
|
|
``True`` if every subsystem has non-``None`` state data.
|
|
"""
|
|
return all(v is not None for v in self._data.values())
|
|
|
|
def poll(self, subsystem: str) -> tuple[bool, bool]:
|
|
"""Run the collector for *subsystem* and compare against current state.
|
|
|
|
The polled equivalent of ``populate()`` — same try/except safety,
|
|
but with two-layer diff before storing.
|
|
|
|
Args:
|
|
subsystem: Subsystem name to poll.
|
|
|
|
Returns:
|
|
``(structural_changed, volatile_changed)``. ``(False, False)`` on
|
|
collector failure (no broadcast on failure to avoid noisy ticks).
|
|
"""
|
|
collector = _COLLECTORS.get(subsystem)
|
|
if collector is None:
|
|
return (False, False)
|
|
|
|
vol = _VOLATILE.get(subsystem, frozenset())
|
|
try:
|
|
new_data = collector()
|
|
except Exception:
|
|
logger.warning(
|
|
"Poll collection failed for %s",
|
|
subsystem,
|
|
exc_info=True,
|
|
)
|
|
return (False, False)
|
|
|
|
old_data = self._data.get(subsystem)
|
|
structural, volatile = _diff_layers(old_data, new_data, vol)
|
|
self._data[subsystem] = new_data
|
|
return (structural, volatile)
|
|
|
|
def poll_all(
|
|
self,
|
|
intervals: dict[str, int] | None = None,
|
|
) -> dict[str, tuple[bool, bool]]:
|
|
"""Poll all subsystems that have a polling interval configured.
|
|
|
|
Args:
|
|
intervals: Subsystems to poll keyed by name. Defaults to
|
|
``_DEFAULT_POLL_INTERVALS``.
|
|
|
|
Returns:
|
|
Dict of ``{subsystem: (structural, volatile)}`` for each polled
|
|
subsystem.
|
|
"""
|
|
targets = intervals or _DEFAULT_POLL_INTERVALS
|
|
results: dict[str, tuple[bool, bool]] = {}
|
|
for name in targets:
|
|
results[name] = self.poll(name)
|
|
return results
|
|
|
|
|
|
# Singleton
|
|
state = State()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collector registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COLLECTORS: dict[str, Any] = {}
|
|
_VOLATILE: dict[str, frozenset[str]] = {}
|
|
|
|
|
|
def register_collector(subsystem: str, fn: Any) -> Any:
|
|
"""Register *fn* as the state collector for *subsystem*.
|
|
|
|
Args:
|
|
subsystem: Subsystem name to register for.
|
|
fn: Collector function to register.
|
|
|
|
Returns:
|
|
The *fn* function (for decorator usage).
|
|
"""
|
|
_COLLECTORS[subsystem] = fn
|
|
return fn
|
|
|
|
|
|
def register_volatile(subsystem: str, keys: frozenset[str]) -> None:
|
|
"""Register volatile field paths for *subsystem*.
|
|
|
|
Args:
|
|
subsystem: Subsystem name.
|
|
keys: Frozenset of dot-separated volatile field paths
|
|
(e.g. ``status.peers[].transfer_received``).
|
|
"""
|
|
_VOLATILE[subsystem] = keys
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Two-layer diff
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _strip_volatile(
|
|
data: dict[str, Any],
|
|
volatile: frozenset[str],
|
|
pop_keys: frozenset[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return a copy of *data* with volatile fields zeroed out.
|
|
|
|
For list-of-dicts fields (``key[].subkey``), strips the volatile sub-keys
|
|
from each dict in the list. For scalar/dict fields, sets them to ``None``.
|
|
|
|
Args:
|
|
data: State dict to process.
|
|
volatile: Frozenset of dot-separated volatile field paths.
|
|
pop_keys: Optional keys to remove from the root dict before stripping.
|
|
|
|
Returns:
|
|
A new dict with volatile fields replaced by ``None``.
|
|
"""
|
|
stripped = deepcopy(data)
|
|
if pop_keys:
|
|
for k in pop_keys:
|
|
stripped.pop(k, None)
|
|
for vpath in volatile:
|
|
# Determine if this is a list-of-dicts pattern
|
|
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
|
if list_marker != -1:
|
|
# Split into prefix (before []), item keys (after [])
|
|
prefix = vpath[:list_marker].split(".")
|
|
item_keys = (
|
|
vpath[list_marker + 3 :].split(".")
|
|
if list_marker + 3 < len(vpath)
|
|
else []
|
|
)
|
|
parent = stripped
|
|
for seg in prefix:
|
|
if isinstance(parent, dict) and seg in parent:
|
|
parent = parent[seg]
|
|
else:
|
|
break
|
|
if isinstance(parent, list):
|
|
items = parent
|
|
elif isinstance(parent, dict):
|
|
logger.debug(
|
|
"_strip_volatile: %s resolved to dict, falling back to .values()",
|
|
vpath,
|
|
)
|
|
items = parent.values()
|
|
else:
|
|
continue
|
|
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
curr = item
|
|
for i, ik in enumerate(item_keys):
|
|
if i == len(item_keys) - 1:
|
|
curr[ik] = None
|
|
else:
|
|
if isinstance(curr, dict) and ik in curr:
|
|
curr = curr[ik]
|
|
else:
|
|
break
|
|
else:
|
|
# Scalar/dict path
|
|
segments = vpath.split(".")
|
|
parent = stripped
|
|
for i, seg in enumerate(segments):
|
|
if i == len(segments) - 1:
|
|
if isinstance(parent, dict) and seg in parent:
|
|
parent[seg] = None
|
|
else:
|
|
if isinstance(parent, dict) and seg in parent:
|
|
parent = parent[seg]
|
|
else:
|
|
break
|
|
return stripped
|
|
|
|
|
|
def _diff_layers(
|
|
old: dict[str, Any] | None,
|
|
new: dict[str, Any],
|
|
volatile: frozenset[str],
|
|
) -> tuple[bool, bool]:
|
|
"""Compare *old* and *new* state using two-layer diff.
|
|
|
|
Strips ``timestamp`` from both before comparing.
|
|
|
|
Returns:
|
|
``(structural_changed, volatile_changed)`` —
|
|
``True`` means that layer differs between old and new.
|
|
|
|
If structural data changed, volatile is always ``False``
|
|
(the structural change already triggers a full re-fetch, so
|
|
the volatile signal is suppressed).
|
|
"""
|
|
if old is None:
|
|
return (True, True)
|
|
|
|
# Structural diff: compare with volatile/timestamp fields zeroed
|
|
pop_keys = frozenset(("timestamp",))
|
|
old_struct = _strip_volatile(old, volatile, pop_keys)
|
|
new_struct = _strip_volatile(new, volatile, pop_keys)
|
|
structural = old_struct != new_struct
|
|
|
|
# Volatile diff: compare without timestamp
|
|
volatile_changed = False
|
|
if not structural:
|
|
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
|
|
new_no_ts = {k: v for k, v in new.items() if k != "timestamp"}
|
|
volatile_changed = old_no_ts != new_no_ts
|
|
|
|
return (structural, volatile_changed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Firewall collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _now_iso() -> str:
|
|
"""Return the current UTC time as an ISO 8601 string."""
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
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() -> dict[str, Any]:
|
|
"""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)
|
|
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
|
|
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
|
config_data = {}
|
|
if fw_config_path.exists():
|
|
with contextlib.suppress(Exception):
|
|
config_data = load_json(fw_config_path)
|
|
|
|
# Pending changes
|
|
full_state = {
|
|
"active_zones": active,
|
|
"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)
|
|
|
|
return {
|
|
"active_zones": active,
|
|
"interfaces": ifaces,
|
|
"available_services": services,
|
|
"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",
|
|
}
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DNSMasq collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_dnsmasq() -> dict[str, Any]:
|
|
"""Collect dnsmasq status, config, and leases.
|
|
|
|
Returns:
|
|
Dict containing config, service status, leases, and timestamp.
|
|
"""
|
|
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
|
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
|
|
|
DEFAULT_CFG: dict[str, Any] = {
|
|
"dhcp": {"ranges": [], "static_leases": []},
|
|
"dns": {
|
|
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
|
"domain": None,
|
|
"custom_records": [],
|
|
},
|
|
}
|
|
|
|
# Load config
|
|
cfg: dict[str, Any] = {}
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
raw = load_json(CONFIG_PATH)
|
|
if raw:
|
|
from lib.common import deep_merge
|
|
|
|
cfg = deep_merge(deepcopy(DEFAULT_CFG), raw)
|
|
else:
|
|
cfg = deepcopy(DEFAULT_CFG)
|
|
except Exception:
|
|
cfg = deepcopy(DEFAULT_CFG)
|
|
else:
|
|
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 = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
|
cfg
|
|
)
|
|
|
|
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
|
return {
|
|
"config": safe_cfg,
|
|
"status": {
|
|
"service_active": service_active,
|
|
"config_file_exists": conf_exists,
|
|
"active_leases": len(leases),
|
|
"pending_changes": pending_changes,
|
|
},
|
|
"leases": leases,
|
|
"timestamp": _now_iso(),
|
|
}
|
|
|
|
|
|
register_collector("dnsmasq", _collect_dnsmasq)
|
|
# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nginx collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_nginx() -> dict[str, Any]:
|
|
"""Collect nginx config and domains list.
|
|
|
|
Returns:
|
|
Dict containing config, domains, and timestamp.
|
|
"""
|
|
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
|
|
|
|
DEFAULT_SSL: dict[str, Any] = {
|
|
"protocols": "TLSv1.2 TLSv1.3",
|
|
"ciphers": (
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
|
"ECDHE-RSA-AES128-GCM-SHA256:"
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
|
"ECDHE-RSA-AES256-GCM-SHA384:"
|
|
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
|
"ECDHE-RSA-CHACHA20-POLY1305"
|
|
),
|
|
"prefer_server_ciphers": False,
|
|
}
|
|
|
|
default_cfg: dict[str, Any] = {
|
|
"domains": {},
|
|
"ssl": deepcopy(DEFAULT_SSL),
|
|
}
|
|
cfg = deepcopy(default_cfg)
|
|
if CONFIG_FILE.exists():
|
|
try:
|
|
raw = load_json(CONFIG_FILE)
|
|
if raw:
|
|
from lib.common import deep_merge
|
|
|
|
cfg = deep_merge(default_cfg, raw)
|
|
if "ssl" not in cfg:
|
|
cfg["ssl"] = deepcopy(DEFAULT_SSL)
|
|
except Exception:
|
|
pass
|
|
|
|
# Build flattened domains list (one entry per path)
|
|
domains: list[dict[str, Any]] = []
|
|
for name, dom in cfg.get("domains", {}).items():
|
|
site = SITES_DIR / f"{name}.conf"
|
|
paths = dom.get("paths", {})
|
|
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),
|
|
"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 = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
|
cfg
|
|
)
|
|
|
|
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
|
return {
|
|
"config": safe_cfg,
|
|
"domains": domains,
|
|
"status": {"pending_changes": pending_changes},
|
|
"timestamp": _now_iso(),
|
|
}
|
|
|
|
|
|
register_collector("nginx", _collect_nginx)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ACME collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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() -> dict[str, Any]:
|
|
"""Collect ACME certificate list and email.
|
|
|
|
Returns:
|
|
Dict containing certificate details and registered email.
|
|
"""
|
|
email = _get_acme_email()
|
|
|
|
try:
|
|
from lib.acme import list_certs
|
|
|
|
certs = list_certs()
|
|
except Exception:
|
|
logger.warning(
|
|
"ACME state collection failed, returning empty cert list",
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
|
|
account = _parse_account_conf()
|
|
|
|
return {
|
|
"certs": certs,
|
|
"email": email,
|
|
"account": account,
|
|
"timestamp": _now_iso(),
|
|
}
|
|
|
|
|
|
register_collector("acme", _collect_acme)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WireGuard collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_wireguard() -> dict[str, Any]:
|
|
"""Collect WireGuard config, status, and peers.
|
|
|
|
Returns:
|
|
Dict containing interface config, runtime status, and peers.
|
|
"""
|
|
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
|
|
|
DEFAULT_CONFIG: dict[str, Any] = {
|
|
"interface": {
|
|
"name": "wg0",
|
|
"listen_port": 51820,
|
|
"private_key": "",
|
|
"public_key": "",
|
|
"addresses": ["10.137.0.1/24"],
|
|
"post_up": None,
|
|
"post_down": None,
|
|
},
|
|
"peers": {},
|
|
}
|
|
|
|
from lib.common import deep_merge
|
|
|
|
cfg: dict[str, Any] = deepcopy(DEFAULT_CONFIG)
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
raw = load_json(CONFIG_PATH)
|
|
if raw:
|
|
cfg = deep_merge(deepcopy(DEFAULT_CONFIG), raw)
|
|
except Exception:
|
|
pass
|
|
|
|
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
|
cfg
|
|
)
|
|
|
|
# Safe config (strip private key and internal hash)
|
|
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
|
if "interface" in safe:
|
|
safe["interface"] = dict(safe["interface"])
|
|
safe["interface"].pop("private_key", None)
|
|
|
|
# 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
|
|
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
|
|
name = cfg["interface"]["name"]
|
|
peer_name = name if isinstance(name, str) else "wg0"
|
|
try:
|
|
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
|
|
if res.returncode == 0:
|
|
raw = res.stdout.strip()
|
|
current_peer: dict[str, Any] | None = None
|
|
status_peers: list[dict[str, Any]] = []
|
|
for line in raw.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("interface:"):
|
|
status["up"] = True
|
|
status["interface"] = {}
|
|
current_peer = None
|
|
continue
|
|
if line.startswith("public key:"):
|
|
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
|
continue
|
|
if line.startswith("listening port:"):
|
|
status["interface"]["listen_port"] = int(
|
|
line.split(":", 1)[1].strip()
|
|
)
|
|
continue
|
|
if line.startswith("fwmark:"):
|
|
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
|
continue
|
|
if line.startswith("peer:"):
|
|
cur_key = line.split(":", 1)[1].strip()
|
|
current_peer = {
|
|
"public_key": cur_key,
|
|
"endpoint": None,
|
|
"allowed_ips": [],
|
|
"latest_handshake": None,
|
|
"transfer_received": "0",
|
|
"transfer_sent": "0",
|
|
"persistent_keepalive": None,
|
|
}
|
|
status_peers.append(current_peer)
|
|
continue
|
|
if current_peer is None:
|
|
continue
|
|
if line.startswith("endpoint:"):
|
|
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
|
elif line.startswith("allowed ips:"):
|
|
current_peer["allowed_ips"] = (
|
|
line.split(":", 1)[1].strip().split(", ")
|
|
)
|
|
elif line.startswith("latest handshake:"):
|
|
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
|
elif line.startswith("transfer:"):
|
|
rest = line.split(":", 1)[1].strip().split(", ")
|
|
if rest:
|
|
current_peer["transfer_received"] = rest[0].strip()
|
|
if len(rest) > 1:
|
|
current_peer["transfer_sent"] = rest[1].strip()
|
|
elif line.startswith("persistent-keepalive:"):
|
|
with contextlib.suppress(ValueError):
|
|
current_peer["persistent_keepalive"] = int(
|
|
line.split(":", 1)[1].strip()
|
|
)
|
|
status["peers"] = status_peers
|
|
except Exception:
|
|
pass
|
|
|
|
status["pending_changes"] = pending_changes
|
|
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",
|
|
}
|
|
),
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Networkd collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_networkd() -> dict[str, Any]:
|
|
"""Collect networkd interface state from networkctl.
|
|
|
|
Returns:
|
|
Dict with interface runtime state parsed from networkctl output,
|
|
config, and pending changes status.
|
|
"""
|
|
CONFIG_PATH = PROJECT_DIR / "config" / "network" / "config.json"
|
|
|
|
# Load config
|
|
net_cfg: dict[str, Any] = {}
|
|
if CONFIG_PATH.exists():
|
|
with contextlib.suppress(Exception):
|
|
net_cfg = load_json(CONFIG_PATH)
|
|
|
|
pending_changes = _APPLY_HASH_KEY not in net_cfg or net_cfg[
|
|
_APPLY_HASH_KEY
|
|
] != config_hash(net_cfg)
|
|
|
|
result: dict[str, dict[str, Any]] = {}
|
|
safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY}
|
|
|
|
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": {"pending_changes": pending_changes},
|
|
"timestamp": _now_iso(),
|
|
}
|
|
except Exception:
|
|
return {
|
|
"interfaces": {},
|
|
"config": safe_net_cfg,
|
|
"status": {"pending_changes": pending_changes},
|
|
"timestamp": _now_iso(),
|
|
}
|
|
|
|
return {
|
|
"interfaces": result,
|
|
"config": safe_net_cfg,
|
|
"status": {"pending_changes": pending_changes},
|
|
"timestamp": _now_iso(),
|
|
}
|
|
|
|
|
|
register_collector("networkd", _collect_networkd)
|
|
register_volatile(
|
|
"networkd",
|
|
frozenset(
|
|
{
|
|
"interfaces[].addresses",
|
|
}
|
|
),
|
|
)
|
|
|
|
__all__ = [
|
|
"_DEFAULT_POLL_INTERVALS",
|
|
"State",
|
|
"_diff_layers",
|
|
"_strip_volatile",
|
|
"register_volatile",
|
|
"state",
|
|
]
|