refactor: update lib modules (common, dnsmasq, logging, network, state, nginx)

This commit is contained in:
2026-06-16 03:35:50 +00:00
parent 593dece92b
commit c5813d68b3
6 changed files with 196 additions and 33 deletions
+27
View File
@@ -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",
]
+20 -5
View File
@@ -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)
+47 -2
View File
@@ -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(
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"):
+54 -4
View File
@@ -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-<name>.network files to data/networkd/.
"""Walk config and write all 99-<name>.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
+4 -10
View File
@@ -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__ = [
+38 -6
View File
@@ -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)