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 json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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(
|
def run(
|
||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
@@ -134,4 +160,5 @@ __all__ = [
|
|||||||
"run",
|
"run",
|
||||||
"run_proc",
|
"run_proc",
|
||||||
"save_json",
|
"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
|
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")
|
tmpl = ENV.get_template("dnsmasq.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
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,
|
dhcp=dhcp_cfg,
|
||||||
dns=dns_cfg,
|
dns=dns_cfg,
|
||||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
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):
|
for i, lease in enumerate(leases):
|
||||||
if lease["mac"].lower() == mac.lower():
|
if lease["mac"].lower() == mac.lower():
|
||||||
leases[i] = {"mac": mac, "ip": ip}
|
leases[i].update({"mac": mac, "ip": ip})
|
||||||
if hostname:
|
if hostname is not None:
|
||||||
leases[i]["hostname"] = hostname
|
leases[i]["hostname"] = hostname
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
|
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):
|
for i, r in enumerate(records):
|
||||||
if r["name"] == name:
|
if r["name"] == name:
|
||||||
records[i] = {"name": name, "address": address}
|
records[i].update({"name": name, "address": address})
|
||||||
if hostname:
|
if hostname is not None:
|
||||||
records[i]["hostname"] = hostname
|
records[i]["hostname"] = hostname
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
logger.info("DNS record updated: %s -> %s", name, address)
|
logger.info("DNS record updated: %s -> %s", name, address)
|
||||||
|
|||||||
+47
-2
@@ -10,6 +10,7 @@ Output:
|
|||||||
viewing via the WebUI ``/logs`` page.
|
viewing via the WebUI ``/logs`` page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -66,15 +67,59 @@ def setup_logging(level: str | None = None) -> None:
|
|||||||
sh.setFormatter(fmt)
|
sh.setFormatter(fmt)
|
||||||
root.addHandler(sh)
|
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)
|
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
fh = RotatingFileHandler(
|
try:
|
||||||
|
fh = GroupWriteHandler(
|
||||||
str(_LOG_FILE),
|
str(_LOG_FILE),
|
||||||
maxBytes=_MAX_BYTES,
|
maxBytes=_MAX_BYTES,
|
||||||
backupCount=_BACKUP_COUNT,
|
backupCount=_BACKUP_COUNT,
|
||||||
)
|
)
|
||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
root.addHandler(fh)
|
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
|
# Silence noisy third-party loggers in production
|
||||||
for name in ("werkzeug", "urllib3"):
|
for name in ("werkzeug", "urllib3"):
|
||||||
|
|||||||
+54
-4
@@ -20,7 +20,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
|||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
|
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__ = [
|
__all__ = [
|
||||||
|
"KNOWN_INTERFACE_KEYS",
|
||||||
"collect_upstream_dns",
|
"collect_upstream_dns",
|
||||||
"generate_network_files",
|
"generate_network_files",
|
||||||
"get_config",
|
"get_config",
|
||||||
@@ -421,6 +462,7 @@ def parse_networkctl_status(output: str) -> dict[str, Any]:
|
|||||||
"addresses": [],
|
"addresses": [],
|
||||||
"gateway": None,
|
"gateway": None,
|
||||||
"dns": [],
|
"dns": [],
|
||||||
|
"mac": None,
|
||||||
"state": "unknown",
|
"state": "unknown",
|
||||||
"link": parts[1] if len(parts) > 1 else "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()
|
dns_str = stripped.split(":", 1)[1].strip()
|
||||||
if dns_str and dns_str.lower() != "n/a":
|
if dns_str and dns_str.lower() != "n/a":
|
||||||
current_iface["dns"] = [d.strip() for d in dns_str.split() if d.strip()]
|
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:"):
|
elif stripped.startswith("Addresses:"):
|
||||||
addr_str = stripped.split(":", 1)[1].strip()
|
addr_str = stripped.split(":", 1)[1].strip()
|
||||||
if addr_str and addr_str.lower() != "n/a":
|
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]]:
|
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.
|
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():
|
for iface_name, entry in interfaces_cfg.items():
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
fname = f"50-{iface_name}.network"
|
fname = f"99-{iface_name}.network"
|
||||||
expected_names.add(fname)
|
expected_names.add(fname)
|
||||||
content = render_network_file(iface_name, entry)
|
content = render_network_file(iface_name, entry)
|
||||||
out_path = DATA_DIR / fname
|
out_path = DATA_DIR / fname
|
||||||
@@ -584,11 +628,17 @@ def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
net_addr = net.network_address
|
net_addr = net.network_address
|
||||||
broadcast = net.broadcast_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] = {
|
result[name] = {
|
||||||
"subnet": str(net_addr),
|
"subnet": str(net_addr),
|
||||||
"prefix": net.prefixlen,
|
"prefix": net.prefixlen,
|
||||||
"start": str(net_addr + 1),
|
"start": str(_start),
|
||||||
"end": str(broadcast - 1),
|
"end": str(_end),
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
return result
|
return result
|
||||||
|
|||||||
+4
-10
@@ -484,23 +484,17 @@ def write_htpasswd(user: str, password: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
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:
|
Args:
|
||||||
password: Plain-text password to hash.
|
password: Plain-text password to hash.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The hashed password string suitable for ``.htpasswd``.
|
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``).
|
||||||
"""
|
"""
|
||||||
try:
|
from passlib.hash import sha256_crypt
|
||||||
from passlib.hash import apache_passwd
|
|
||||||
|
|
||||||
return apache_passwd.using(rounds=12).hash(password)
|
return sha256_crypt.hash(password)
|
||||||
except Exception:
|
|
||||||
import crypt as _crypt
|
|
||||||
|
|
||||||
salt = os.urandom(16).hex()[:16]
|
|
||||||
return _crypt.crypt(password, f"$5${salt}")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
+38
-6
@@ -60,6 +60,43 @@ class State:
|
|||||||
self._data: dict[str, dict[str, Any] | None] = {
|
self._data: dict[str, dict[str, Any] | None] = {
|
||||||
name: None for name in self.SUBSYSTEMS
|
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:
|
def get(self, subsystem: str) -> dict[str, Any] | None:
|
||||||
"""Get state data for *subsystem*.
|
"""Get state data for *subsystem*.
|
||||||
@@ -212,7 +249,7 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) < 4:
|
if len(parts) < 4:
|
||||||
continue
|
continue
|
||||||
addr_name = parts[1]
|
addr_name = parts[1].split("@")[0]
|
||||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||||
for entry in iface_map.values():
|
for entry in iface_map.values():
|
||||||
if entry["name"] == addr_name:
|
if entry["name"] == addr_name:
|
||||||
@@ -406,11 +443,6 @@ def _collect_nginx() -> dict[str, Any]:
|
|||||||
if raw:
|
if raw:
|
||||||
from lib.common import deep_merge
|
from lib.common import deep_merge
|
||||||
|
|
||||||
default_cfg: dict[str, Any] = {
|
|
||||||
"domains": {},
|
|
||||||
"management": None,
|
|
||||||
"ssl": deepcopy(DEFAULT_SSL),
|
|
||||||
}
|
|
||||||
cfg = deep_merge(default_cfg, raw)
|
cfg = deep_merge(default_cfg, raw)
|
||||||
if "ssl" not in cfg:
|
if "ssl" not in cfg:
|
||||||
cfg["ssl"] = deepcopy(DEFAULT_SSL)
|
cfg["ssl"] = deepcopy(DEFAULT_SSL)
|
||||||
|
|||||||
Reference in New Issue
Block a user