Add update-vendor.sh symlink support, unify install.sh vendor flow

- update-vendor.sh now creates webui/vendor symlinks (htm.js)
- install.sh calls update-vendor.sh after package install
- Add vendor/.empty and webui/vendor/.empty as directory placeholders in git
This commit is contained in:
2026-07-01 00:44:08 +00:00
parent 575cf06a4b
commit 8c13ad55ce
32 changed files with 1371 additions and 445 deletions
+18
View File
@@ -4,6 +4,7 @@ Provides common helpers for JSON persistence, subprocess execution,
deep merging, and directory creation used across all subsystem modules.
"""
import hashlib
import json
import os
import re
@@ -12,6 +13,21 @@ from copy import deepcopy
from pathlib import Path
from typing import Any
_APPLY_HASH_KEY = "_last_applied_hash"
def config_hash(cfg: dict[str, Any]) -> str:
"""Compute a SHA-256 hash of *cfg* excluding the ``_last_applied_hash`` key.
Args:
cfg: Config dict, possibly containing ``_last_applied_hash``.
Returns:
Hex digest of the stripped config JSON.
"""
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -154,6 +170,8 @@ def ensure_dirs(*dirs: Path) -> None:
__all__ = [
"_APPLY_HASH_KEY",
"config_hash",
"deep_merge",
"ensure_dirs",
"load_json",
+5 -310
View File
@@ -1,18 +1,15 @@
"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
"""Dnsmasq config persistence for Vacuum Wall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
Provides load/save for the declarative JSON config and upstream-management
helpers used by the sync bus and network handler. All mutation and
apply logic lives in daemon/handlers/dnsmasq.py.
"""
import logging
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
@@ -22,17 +19,7 @@ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
# --- defaults ---
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {
"ranges": [],
@@ -66,244 +53,7 @@ def save_config(cfg: dict[str, Any]) -> None:
logger.info("dnsmasq config saved")
def apply_config() -> None:
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
cfg = get_config()
conf_text = generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
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 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,
)
# ───────── dhcp management ───────────────────────────────────────────
def set_dhcp_range(
iface: str,
start: str,
end: str,
lease_time: str = "12h",
gateway: str | None = None,
dns: str | None = None,
) -> None:
"""Add or replace the DHCP range for a given interface."""
cfg = get_config()
ranges = cfg["dhcp"]["ranges"]
found = False
for i, r in enumerate(ranges):
if r.get("interface") == iface:
ranges[i] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
ranges[i]["gateway"] = gateway
if dns:
ranges[i]["dns"] = dns
found = True
break
if not found:
entry: dict[str, Any] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
entry["gateway"] = gateway
if dns:
entry["dns"] = dns
ranges.append(entry)
save_config(cfg)
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
"""Remove a DHCP range by interface + IP range."""
cfg = get_config()
cfg["dhcp"]["ranges"] = [
r
for r in cfg["dhcp"]["ranges"]
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
save_config(cfg)
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
"""Add (or update) a static DHCP lease by MAC address."""
cfg = get_config()
leases = cfg["dhcp"]["static_leases"]
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
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)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
def remove_static_lease(mac: str) -> None:
"""Remove a static DHCP lease by MAC address."""
cfg = get_config()
cfg["dhcp"]["static_leases"] = [
lease
for lease in cfg["dhcp"]["static_leases"]
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
logger.info("Static DHCP lease removed for MAC %s", mac)
# ───────── dns record management ─────────────────────────────────────
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
"""Add or update a custom DNS A record."""
cfg = get_config()
records = cfg["dns"]["custom_records"]
for i, r in enumerate(records):
if r["name"] == name:
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)
return
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
logger.info("DNS record added: %s -> %s", name, address)
def remove_dns_record(name: str) -> None:
"""Remove a custom DNS record by name."""
cfg = get_config()
cfg["dns"]["custom_records"] = [
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
logger.info("DNS record removed: %s", name)
# ───────── lease table ───────────────────────────────────────────────
def _parse_lease_line(line: str) -> dict[str, Any] | None:
"""Parse one line from dnsmasq.leases into a dict."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split()
if len(parts) < 3:
return None
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
return {
"expires_at": ts,
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
def get_lease_table() -> list[dict[str, Any]]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict[str, Any]] = []
try:
result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
except subprocess.CalledProcessError:
pass
return leases
# ───────── upstream / domain helpers ─────────────────────────────────
# ───────── upstream helpers ──────────────────────────────────────────
def set_upstreams(servers: list[str]) -> None:
@@ -322,64 +72,9 @@ def set_domain(domain: str | None) -> None:
logger.info("DNS domain set to '%s'", domain)
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
capture_output=True,
text=True,
)
active = proc.stdout.strip() == "active"
except Exception:
active = False
conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
conf_on_disk = f.read()
except PermissionError:
conf_on_disk = ""
else:
conf_on_disk = ""
expected = generate_conf(cfg)
leases = get_lease_table()
return {
"service_active": active,
"config_file_exists": conf_exists,
"config_in_sync": conf_on_disk == expected,
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
"static_leases": len(cfg["dhcp"]["static_leases"]),
"custom_dns_records": len(cfg["dns"]["custom_records"]),
"upstreams": cfg["dns"]["upstreams"],
"domain": cfg["dns"].get("domain"),
"active_leases": len(leases),
"leases": leases,
}
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]
+32
View File
@@ -379,6 +379,37 @@ def config_pending(state: dict[str, Any]) -> dict[str, Any]:
return _compute_pending_changes(cfg, live_zones)
def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str:
"""Build a human-readable summary string for a firewall change."""
if ctype == "interfaces":
config_if = change.get("config", [])
live_if = change.get("live", [])
return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})"
if ctype == "services":
config_sv = change.get("config", [])
live_sv = change.get("live", [])
return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})"
if ctype == "rich_rules":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return (
f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})"
)
if ctype == "forward_ports":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})"
if ctype == "masquerade":
cfg_val = change.get("config", False)
live_val = change.get("live", False)
return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})"
if ctype == "target":
cfg_val = change.get("config", "default")
live_val = change.get("live", "default")
return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})"
return f"Zone {zone}: {ctype} changed"
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
@@ -396,6 +427,7 @@ __all__ = [
"_parse_interfaces",
"_parse_zone_output",
"config_pending",
"fw_change_summary",
"get_config",
"load_backup",
"save_backup",
+45 -19
View File
@@ -5,8 +5,6 @@ state instead of invoking subprocesses on every request.
"""
import contextlib
import hashlib
import json
import logging
import os
from copy import deepcopy
@@ -14,7 +12,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
from lib.common import load_json, run, run_proc
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,
@@ -590,18 +588,13 @@ def _collect_dnsmasq() -> dict[str, Any]:
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
# Check if JSON config has changed since last apply
_APPLY_HASH_KEY = "_last_applied_hash"
pending_changes = True
if _APPLY_HASH_KEY in cfg:
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
current_hash = hashlib.sha256(
json.dumps(clean, sort_keys=True).encode()
).hexdigest()
pending_changes = cfg[_APPLY_HASH_KEY] != current_hash
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": cfg,
"config": safe_cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
@@ -684,9 +677,15 @@ def _collect_nginx() -> dict[str, Any]:
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": cfg,
"config": safe_cfg,
"domains": domains,
"status": {"pending_changes": pending_changes},
"timestamp": _now_iso(),
}
@@ -918,8 +917,12 @@ def _collect_wireguard() -> dict[str, Any]:
except Exception:
pass
# Safe config (strip private key)
safe = dict(cfg)
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)
@@ -1000,6 +1003,7 @@ def _collect_wireguard() -> dict[str, Any]:
except Exception:
pass
status["pending_changes"] = pending_changes
return {
"config": safe,
"status": status,
@@ -1029,24 +1033,46 @@ def _collect_networkd() -> dict[str, Any]:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
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": {}, "timestamp": _now_iso()}
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(),
}