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:
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user