dadabd7954
- Add system metrics endpoint (CPU load, memory, swap, network traffic) - Collect metrics from /proc and /sys (no subprocess required) - Overhaul dashboard to pull from per-subsystem models - Remove deprecated /status/all monolithic endpoint - Improve networkd import to handle optional priority prefix - Fix CSS duplicate .grid-4 rule and unused dashboard imports
1196 lines
38 KiB
Python
1196 lines
38 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,
|
|
"system": 30,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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",
|
|
"system",
|
|
]
|
|
|
|
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 path uses list-of-dicts pattern (e.g. "peers[].transfer").
|
|
# The [] marker signals that the parent key holds a list of dicts, and we
|
|
# must strip the volatile sub-key from each dict in the list.
|
|
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
|
if list_marker != -1:
|
|
# Split into prefix (path before []), item keys (path after []).
|
|
# e.g. "status.peers[].transfer_received" → prefix=["status","peers"],
|
|
# item_keys=["transfer_received"]
|
|
prefix = vpath[:list_marker].split(".")
|
|
item_keys = (
|
|
vpath[list_marker + 3 :].split(".")
|
|
if list_marker + 3 < len(vpath)
|
|
else []
|
|
)
|
|
# Navigate to the list container via the prefix path
|
|
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:
|
|
# parent should now be a list; iterate each dict and strip sub-keys
|
|
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: navigate via segments and set final key to None
|
|
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.
|
|
|
|
The two-layer strategy distinguishes between:
|
|
1. Structural changes (config, topology) → triggers full client re-fetch
|
|
2. Volatile changes (byte counters, timestamps) → triggers lightweight tick
|
|
|
|
If structural data changed, volatile is suppressed (False) because the
|
|
structural change already triggers a full re-fetch, making the volatile
|
|
signal redundant.
|
|
|
|
Args:
|
|
old: Previous state data, or ``None`` if not yet populated.
|
|
new: New state data from collector.
|
|
volatile: Frozenset of volatile field paths.
|
|
|
|
Returns:
|
|
``(structural_changed, volatile_changed)``.
|
|
"""
|
|
if old is None:
|
|
return (True, True)
|
|
|
|
# Structural diff: compare with volatile fields zeroed out, plus timestamp
|
|
# removed. If these differ, the configuration or topology has changed.
|
|
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 diff: only relevant if structural is unchanged. Compare full
|
|
# data (minus timestamp). If this differs, only volatile fields changed
|
|
# (e.g. WireGuard transfer counters), and a lightweight tick suffices.
|
|
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)
|
|
from lib.nginx import _resolve_paths as _ngx_resolve_paths
|
|
|
|
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 = _ngx_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 = _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",
|
|
}
|
|
),
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# System metrics collector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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() -> dict[str, Any]:
|
|
"""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)
|
|
|
|
|
|
__all__ = [
|
|
"_DEFAULT_POLL_INTERVALS",
|
|
"State",
|
|
"_diff_layers",
|
|
"_strip_volatile",
|
|
"register_volatile",
|
|
"state",
|
|
]
|