From c5813d68b3d2cc91562e02568ea672c0cb6676b0 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Tue, 16 Jun 2026 03:35:50 +0000 Subject: [PATCH] refactor: update lib modules (common, dnsmasq, logging, network, state, nginx) --- lib/common.py | 27 ++++++++++++++++++++++ lib/dnsmasq.py | 25 ++++++++++++++++----- lib/logging.py | 61 +++++++++++++++++++++++++++++++++++++++++++------- lib/network.py | 58 +++++++++++++++++++++++++++++++++++++++++++---- lib/nginx.py | 14 ++++-------- lib/state.py | 44 +++++++++++++++++++++++++++++++----- 6 files changed, 196 insertions(+), 33 deletions(-) diff --git a/lib/common.py b/lib/common.py index e5ac7dc..b94433e 100644 --- a/lib/common.py +++ b/lib/common.py @@ -6,12 +6,38 @@ deep merging, and directory creation used across all subsystem modules. import json import os +import re import subprocess from copy import deepcopy from pathlib import Path from typing import Any +def validate_interface_name(name: str) -> str: + """Validate a Linux network interface name. + + Args: + name: Interface name to validate. + + Returns: + The validated (stripped) name. + + Raises: + ValueError: When the name is empty, contains path components, + or does not match Linux interface naming rules. + """ + if not name or not isinstance(name, str): + raise ValueError("Interface name must be a non-empty string") + name = name.strip() + if not name: + raise ValueError("Interface name must not be blank") + if "/" in name or ".." in name or " " in name: + raise ValueError(f"Invalid interface name: {name!r}") + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", name): + raise ValueError(f"Invalid interface name: {name!r}") + return name + + def run( cmd: list[str], check: bool = True, @@ -134,4 +160,5 @@ __all__ = [ "run", "run_proc", "save_json", + "validate_interface_name", ] diff --git a/lib/dnsmasq.py b/lib/dnsmasq.py index 817350e..7556eac 100644 --- a/lib/dnsmasq.py +++ b/lib/dnsmasq.py @@ -101,10 +101,25 @@ def generate_conf(cfg: dict[str, Any]) -> str: r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r ] + # Fallback: use network-managed interface addresses for listen-address + listen_addresses = [] + try: + from lib.network import get_config as _get_net_config + + net_cfg = _get_net_config() + for _iface, info in net_cfg.get("interfaces", {}).items(): + for addr_str in info.get("addresses", []): + if "/" in addr_str: + addr_str = addr_str.split("/")[0] + listen_addresses.append(addr_str) + except Exception: + pass + tmpl = ENV.get_template("dnsmasq.conf") return tmpl.render( timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - interfaces=interfaces, + interfaces=interfaces or None, + listen_addresses=listen_addresses if listen_addresses else None, dhcp=dhcp_cfg, dns=dns_cfg, fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None, @@ -182,8 +197,8 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None: for i, lease in enumerate(leases): if lease["mac"].lower() == mac.lower(): - leases[i] = {"mac": mac, "ip": ip} - if hostname: + leases[i].update({"mac": mac, "ip": ip}) + if hostname is not None: leases[i]["hostname"] = hostname save_config(cfg) logger.info("Static DHCP lease updated: %s -> %s", mac, ip) @@ -219,8 +234,8 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None for i, r in enumerate(records): if r["name"] == name: - records[i] = {"name": name, "address": address} - if hostname: + records[i].update({"name": name, "address": address}) + if hostname is not None: records[i]["hostname"] = hostname save_config(cfg) logger.info("DNS record updated: %s -> %s", name, address) diff --git a/lib/logging.py b/lib/logging.py index 66786e9..b3822f6 100644 --- a/lib/logging.py +++ b/lib/logging.py @@ -10,6 +10,7 @@ Output: viewing via the WebUI ``/logs`` page. """ +import contextlib import logging import os import sys @@ -66,15 +67,59 @@ def setup_logging(level: str | None = None) -> None: sh.setFormatter(fmt) root.addHandler(sh) - # rotating file handler + class GroupWriteHandler(RotatingFileHandler): + """RotatingFileHandler that always opens files with group-write mode. + + Ensures the log file is group-writable so both the WebUI process + (vacuum-wall user) and daemon process (vacuum-walld user) can write + to it when they share a group. + """ + + def _open(self): + # Ensure group-write on an existing stale file (e.g. left by the + # other process with a stricter umask at creation time). + with contextlib.suppress(OSError): + os.chmod(self.baseFilename, 0o664) + # Temporarily clear group-write umask bits so os.open's mode is + # not masked away. + old = os.umask(0o002) + try: + fd = os.open( + self.baseFilename, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o664 + ) + finally: + os.umask(old) + return os.fdopen(fd, "a", errors="backslashreplace") + + def doRollover(self): + """Override to enforce group-write on rotated files.""" + super().doRollover() + # Set group-write on all log files (current + backups) + base = Path(self.baseFilename) + for suffix in ("", ".1", ".2", ".3"): + fp = str(base.parent / base.name + suffix) + with contextlib.suppress(OSError): + os.chmod(fp, 0o664) + + # rotating file handler with group-write permissions _LOG_DIR.mkdir(parents=True, exist_ok=True) - fh = RotatingFileHandler( - str(_LOG_FILE), - maxBytes=_MAX_BYTES, - backupCount=_BACKUP_COUNT, - ) - fh.setFormatter(fmt) - root.addHandler(fh) + try: + fh = GroupWriteHandler( + str(_LOG_FILE), + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + ) + fh.setFormatter(fmt) + root.addHandler(fh) + except PermissionError: + # Log file exists but is not writable (e.g. stale file from the other + # process created with a stricter umask). Fall back to stderr-only. + print( + f"WARNING: cannot open log file {_LOG_FILE}, " + "logging to stderr only. Fix with: chmod g+w " + f"{_LOG_FILE}", + file=sys.stderr, + ) # Silence noisy third-party loggers in production for name in ("werkzeug", "urllib3"): diff --git a/lib/network.py b/lib/network.py index 344d878..317add8 100644 --- a/lib/network.py +++ b/lib/network.py @@ -20,7 +20,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd" DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}} +KNOWN_INTERFACE_KEYS: set[str] = { + "addresses", + "ipv6_addresses", + "gateway", + "ipv6_gateway", + "dns", + "ipv6_dns", + "domains", + "ipv6_domains", + "dns_default_route", + "dhcp", + "routes", + "bind_carrier", + "ignore_carrier_loss", + "keep_configuration", + "configure_without_carrier", + "link_local_addressing", + "ipv6_link_local_address_generation_mode", + "ipv6_stable_secret_address", + "ipv4_ll_start_address", + "ipv4_ll_route", + "default_route_on_device", + "ipv6_hop_limit", + "ipv6_retransmission_time_sec", + "ipv4_duplicate_address_detection_timeout_sec", + "ipv4_reverse_path_filter", + "ipv4_accept_local", + "ipv4_route_localnet", + "ipv4_proxy_arp", + "ipv4_proxy_arp_private_vlan", + "ipv6_proxy_ndp", + "ipv6_proxy_ndp_address", + "ipv6_send_ra", + "m_pls_routing", + "keep_master", + "ip_family", + "link", + "dhcp_client", +} + __all__ = [ + "KNOWN_INTERFACE_KEYS", "collect_upstream_dns", "generate_network_files", "get_config", @@ -421,6 +462,7 @@ def parse_networkctl_status(output: str) -> dict[str, Any]: "addresses": [], "gateway": None, "dns": [], + "mac": None, "state": "unknown", "link": parts[1] if len(parts) > 1 else "unknown", } @@ -440,6 +482,8 @@ def parse_networkctl_status(output: str) -> dict[str, Any]: dns_str = stripped.split(":", 1)[1].strip() if dns_str and dns_str.lower() != "n/a": current_iface["dns"] = [d.strip() for d in dns_str.split() if d.strip()] + elif stripped.startswith("Hardware Address:"): + current_iface["mac"] = stripped.split(":", 2)[2].strip() elif stripped.startswith("Addresses:"): addr_str = stripped.split(":", 1)[1].strip() if addr_str and addr_str.lower() != "n/a": @@ -452,7 +496,7 @@ def parse_networkctl_status(output: str) -> dict[str, Any]: def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]: - """Walk config and write all 50-.network files to data/networkd/. + """Walk config and write all 99-.network files to data/networkd/. Also removes stale .network files that no longer match config. @@ -473,7 +517,7 @@ def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]: for iface_name, entry in interfaces_cfg.items(): if not isinstance(entry, dict): continue - fname = f"50-{iface_name}.network" + fname = f"99-{iface_name}.network" expected_names.add(fname) content = render_network_file(iface_name, entry) out_path = DATA_DIR / fname @@ -584,11 +628,17 @@ def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: continue net_addr = net.network_address broadcast = net.broadcast_address + _start = net_addr + 100 + _end = net_addr + 200 + _start = min(_start, broadcast - 1) + _end = min(_end, broadcast - 1) + if _start > _end: + continue result[name] = { "subnet": str(net_addr), "prefix": net.prefixlen, - "start": str(net_addr + 1), - "end": str(broadcast - 1), + "start": str(_start), + "end": str(_end), } break return result diff --git a/lib/nginx.py b/lib/nginx.py index aaf0320..0ff90f8 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -484,23 +484,17 @@ def write_htpasswd(user: str, password: str) -> None: def _hash_password(password: str) -> str: - """Hash *password* using Apache ``apr1`` format via passlib, with crypt fallback. + """Hash *password* using SHA-256 crypt (``$5$`` format) via passlib. Args: password: Plain-text password to hash. Returns: - The hashed password string suitable for ``.htpasswd``. + The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``). """ - try: - from passlib.hash import apache_passwd + from passlib.hash import sha256_crypt - return apache_passwd.using(rounds=12).hash(password) - except Exception: - import crypt as _crypt - - salt = os.urandom(16).hex()[:16] - return _crypt.crypt(password, f"$5${salt}") + return sha256_crypt.hash(password) __all__ = [ diff --git a/lib/state.py b/lib/state.py index f68af99..23c9388 100644 --- a/lib/state.py +++ b/lib/state.py @@ -60,6 +60,43 @@ class State: 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*. @@ -212,7 +249,7 @@ def _collect_firewall() -> dict[str, Any]: parts = line.split() if len(parts) < 4: continue - addr_name = parts[1] + 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: @@ -406,11 +443,6 @@ def _collect_nginx() -> dict[str, Any]: if raw: from lib.common import deep_merge - default_cfg: dict[str, Any] = { - "domains": {}, - "management": None, - "ssl": deepcopy(DEFAULT_SSL), - } cfg = deep_merge(default_cfg, raw) if "ssl" not in cfg: cfg["ssl"] = deepcopy(DEFAULT_SSL)