refactor: update lib modules (common, dnsmasq, logging, network, state, nginx)
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
+53
-8
@@ -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"):
|
||||
|
||||
+54
-4
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user