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
+1 -1
View File
@@ -35,7 +35,7 @@ through the daemon client over a Unix socket.
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers.
- `lib/logging.py` — Logging setup used by both webui and daemon.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htmx`, `json-enc`).
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
+1
View File
@@ -73,6 +73,7 @@ See [docs/deployment.md](docs/deployment.md) for the full guide, including troub
```bash
git clone <repo-url> && cd vacuum-wall
bash scripts/update-vendor.sh
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
```
+14 -20
View File
@@ -1,6 +1,5 @@
"""Dnsmasq daemon handler."""
import hashlib
import logging
from copy import deepcopy
from datetime import UTC, datetime
@@ -26,7 +25,15 @@ from daemon.iface import (
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, save_json
from lib.common import (
_APPLY_HASH_KEY,
config_hash,
deep_merge,
ensure_dirs,
load_json,
run,
save_json,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -51,20 +58,6 @@ DEFAULT_CFG: dict[str, Any] = {
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
}
# Internal field for tracking applied config version
_APPLY_HASH_KEY = "_last_applied_hash"
def _config_hash(cfg: dict[str, Any]) -> str:
"""Compute a hash of the config, excluding the _last_applied_hash field.
Used to detect whether the JSON config has changed since the last apply.
"""
import 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 _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store."""
@@ -144,7 +137,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
return _get_config()
cfg = _get_config()
return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
@registry.register(POST_DNSMASQ_CONFIG)
@@ -199,11 +193,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
f.write(conf_text)
run(["cp", str(tmp), DNSMASQ_CONF], sudo=True)
tmp.unlink(missing_ok=True)
run(["systemctl", "reload", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and reloaded")
run(["systemctl", "restart", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and restarted")
# Store the config hash so state collector can detect pending changes
cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after)
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
+10 -1
View File
@@ -39,6 +39,7 @@ from lib.firewall import (
_normalize_target,
_parse_active_zones,
_parse_zone_output,
fw_change_summary,
)
from lib.firewall import (
save_backup as _save_backup,
@@ -378,7 +379,15 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(GET_FIREWALL_CONFIG_PENDING)
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
return fw.get("pending", {})
pending = fw.get("pending", {})
changes = pending.get("pending", [])
summaries = [
fw_change_summary(c.get("zone", "unknown"), c.get("type", "unknown"), c)
for c in changes
]
return {**pending, "pending_summary": summaries}
@registry.register(POST_FIREWALL_CONFIG_APPLY)
+14 -1
View File
@@ -21,7 +21,9 @@ from daemon.iface import (
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import run, validate_interface_name
from lib.common import _APPLY_HASH_KEY, config_hash, run, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
from lib.dnsmasq import set_upstreams
from lib.network import (
KNOWN_INTERFACE_KEYS,
@@ -195,6 +197,10 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
)
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
if deployed:
cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"network", "config_saved", {"action": "interface_saved", "interface": name}
@@ -258,10 +264,17 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
upstreams = collect_upstream_dns(cfg)
if upstreams:
set_upstreams(upstreams)
# Update dnsmasq apply hash so pending-changes detection stays correct
dm_cfg = _get_dm_cfg()
dm_cfg[_APPLY_HASH_KEY] = config_hash(dm_cfg)
_save_dm_cfg(dm_cfg)
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("network", "config_saved", {"action": "config_applied"})
)
+14 -2
View File
@@ -23,7 +23,15 @@ from daemon.iface import (
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
from lib.common import (
_APPLY_HASH_KEY,
config_hash,
ensure_dirs,
load_json,
run,
run_proc,
save_json,
)
logger = logging.getLogger(__name__)
@@ -300,7 +308,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
return _get_config()
cfg = _get_config()
return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
@registry.register(POST_NGINX_CONFIG)
@@ -507,6 +516,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_reload_nginx()
cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after)
refresh_state(["nginx"])
return {"applied": True}
+146
View File
@@ -0,0 +1,146 @@
"""Aggregate status handler.
Exposes pending changes across all subsystems and a single apply-all
endpoint that invokes each subsystem's apply in the correct order.
"""
from __future__ import annotations
import logging
from typing import Any
from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config
from daemon.handlers.firewall import config_apply as firewall_config_apply
from daemon.handlers.network import apply_all as network_apply_all
from daemon.handlers.nginx import apply as nginx_apply
from daemon.handlers.wireguard import apply as wireguard_apply
from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL
from daemon.server import refresh_state, registry
from lib.firewall import fw_change_summary
from lib.state import state as state_store
logger = logging.getLogger(__name__)
SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"]
SYS_LABELS = {
"networkd": "Network",
"firewall": "Firewall",
"wireguard": "WireGuard",
"dnsmasq": "DHCP/DNS",
"nginx": "Nginx",
}
SYS_APPLY = {
"networkd": network_apply_all,
"firewall": firewall_config_apply,
"wireguard": wireguard_apply,
"dnsmasq": dnsmasq_apply_config,
"nginx": nginx_apply,
}
@registry.register(GET_STATUS_PENDING)
def status_pending(_request: Any, _body: Any) -> dict[str, Any]:
"""Aggregate pending changes across all subsystems.
Returns:
Dict with per-subsystem pending status and total change count.
"""
fw = state_store.get("firewall") or {}
pending_fw = fw.get("pending", {})
fw_needs_apply = pending_fw.get("needs_apply", False)
fw_pending_list = pending_fw.get("pending", [])
fw_changes = []
for c in fw_pending_list:
zone = c.get("zone", "unknown")
ctype = c.get("type", "unknown")
summary = fw_change_summary(zone, ctype, c)
fw_changes.append({"summary": summary, "detail": ""})
fw_result = {
"needs_apply": fw_needs_apply,
"change_count": len(fw_changes),
"changes": fw_changes,
}
hash_subsystems = {
"dnsmasq": _hash_subsystem("dnsmasq", state_store.get("dnsmasq")),
"nginx": _hash_subsystem("nginx", state_store.get("nginx")),
"wireguard": _hash_subsystem("wireguard", state_store.get("wireguard")),
"networkd": _hash_subsystem("networkd", state_store.get("networkd")),
}
total = len(fw_changes)
for _name, result in hash_subsystems.items():
total += len(result["changes"])
return {
"firewall": fw_result,
"dnsmasq": hash_subsystems["dnsmasq"],
"nginx": hash_subsystems["nginx"],
"wireguard": hash_subsystems["wireguard"],
"networkd": hash_subsystems["networkd"],
"total_changes": total,
}
@registry.register(POST_STATUS_APPLY_ALL)
def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""Apply pending changes for all subsystems in dependency order.
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
Returns:
Dict with applied subsystems and any errors encountered.
"""
applied = []
errors = {}
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
"nginx": pending_data["nginx"]["pending_changes"],
"wireguard": pending_data["wireguard"]["pending_changes"],
"networkd": pending_data["networkd"]["pending_changes"],
}
for name in SYS_ORDER:
if name == "firewall":
if not fw_pending:
continue
else:
if not hash_pending.get(name, False):
continue
handler = SYS_APPLY[name]
try:
handler(None, None)
applied.append(name)
except Exception as exc:
label = SYS_LABELS.get(name, name)
errors[label] = str(exc)
logger.error("Apply-all failed for %s: %s", name, exc)
refresh_state(SYS_ORDER)
return {"applied": applied, "errors": errors}
def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]:
"""Build pending result for a hash-based subsystem."""
if state is None:
return {"pending_changes": False, "summary": "Up to date", "changes": []}
status = state.get("status", {})
pending = status.get("pending_changes", False)
label = SYS_LABELS.get(name, name)
if pending:
summary = f"{label} configuration has unapplied changes"
return {
"pending_changes": True,
"summary": summary,
"changes": [{"summary": summary, "detail": ""}],
}
return {"pending_changes": False, "summary": "Up to date", "changes": []}
+13 -2
View File
@@ -24,7 +24,15 @@ from daemon.iface import (
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json
from lib.common import (
_APPLY_HASH_KEY,
config_hash,
deep_merge,
load_json,
run,
run_proc,
save_json,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -102,7 +110,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
if wg:
return wg.get("config", {})
cfg = _get_config()
safe = dict(cfg)
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)
@@ -174,6 +182,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
+2
View File
@@ -146,6 +146,8 @@ GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
GET_WS: Endpoint = _ep("GET", "/ws")
POST_BATCH: Endpoint = _ep("POST", "/batch")
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
# Collect all endpoint module-level constants for __all__ verification
_all_endpoints = [
+2 -1
View File
@@ -506,6 +506,7 @@ def _register_routes() -> None:
logs, # noqa: F401
network, # noqa: F401
nginx, # noqa: F401
status, # noqa: F401
wireguard, # noqa: F401
)
@@ -538,7 +539,7 @@ def main() -> None:
_stop_polling()
try:
await asyncio.wait_for(runner.cleanup(), timeout=5)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning("Runner cleanup timed out, abandoning")
if Path(socket_path).exists():
os.unlink(socket_path)
+1 -1
View File
@@ -37,7 +37,7 @@ External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
+1 -1
View File
@@ -136,7 +136,7 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── config.md
│ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts
└── update-vendor.sh # Download acme.sh binary
└── update-vendor.sh # Download vendored libraries (acme.sh, htm)
```
## Documentation
+1 -2
View File
@@ -29,10 +29,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
| Nginx file ops | `cp -- * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Networkd | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files |
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
+4
View File
@@ -182,6 +182,10 @@ apt-get install -y -qq \
apache2-utils \
avahi-daemon
# --- 1b. Vendored libraries ---
log "Downloading vendored libraries..."
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
# --- 2. Setup users ---
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
+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(),
}
+27
View File
@@ -5,6 +5,7 @@ set -euo pipefail
# ---- Library versions ----
ACME_VERSION="3.1.3"
HTM_VERSION="3.1.1"
VENDOR="vendor"
@@ -22,6 +23,32 @@ download "acme.sh@${ACME_VERSION}" \
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
"${VENDOR}/acme.sh"
download "htm@${HTM_VERSION}" \
"https://raw.githubusercontent.com/developit/htm/${HTM_VERSION}/mini/index.module.js" \
"${VENDOR}/htm.js"
chmod +x "${VENDOR}/acme.sh"
# --- Symlinks for webui ---
WEBUI_VENDOR="webui/static/vendor"
mkdir -p "$WEBUI_VENDOR"
WEBUI_LINKS=(
"htm.js:../../../vendor/htm.js"
)
for entry in "${WEBUI_LINKS[@]}"; do
IFS=':' read -r name target <<< "$entry"
if [[ -L "${WEBUI_VENDOR}/${name}" ]]; then
cur=$(readlink "${WEBUI_VENDOR}/${name}")
if [[ "$cur" != "$target" ]]; then
echo "[symlink] ${WEBUI_VENDOR}/${name}${target} (updated)"
ln -sf "$target" "${WEBUI_VENDOR}/${name}"
fi
else
echo "[symlink] ${WEBUI_VENDOR}/${name}${target}"
ln -sf "$target" "${WEBUI_VENDOR}/${name}"
fi
done
echo "[done] All libraries vendored."
+1 -2
View File
@@ -18,10 +18,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
+239
View File
@@ -0,0 +1,239 @@
/**
* Tests for hoover/components/applyconfirm.js
*
* Component-level tests: VNode structure, buildRows logic,
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
*/
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
}
function assertIncludes(str, substr, msg) {
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
}
console.log('Testing ApplyConfirm component\n');
// === isPending ===
test('isPending returns true for needs_apply', () => {
assertEq(isPending({ needs_apply: true }), true);
});
test('isPending returns true for pending_changes', () => {
assertEq(isPending({ pending_changes: true }), true);
});
test('isPending returns false when neither flag set', () => {
assertEq(isPending({}), false);
});
test('isPending returns false for explicit false', () => {
assertEq(isPending({ needs_apply: false, pending_changes: false }), false);
});
// === SUBSYSTEM_LIST ===
test('SUBSYSTEM_LIST contains 5 subsystems', () => {
assertEq(SUBSYSTEM_LIST.length, 5);
});
test('SUBSYSTEM_LIST uses networkd key (not network)', () => {
assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd');
assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network');
});
test('SUBSYSTEM_LIST keys match daemon response keys', () => {
const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'];
for (const key of expectedKeys) {
assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`);
}
});
// === buildRows ===
test('buildRows returns 5 rows for empty subsystems', () => {
const rows = buildRows({}, {});
assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state');
});
test('buildRows marks pending firewall subsystem correctly', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
const fwRow = rows[0];
assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class');
});
test('buildRows changes are VNodes with proper structure', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
const fwRow = rows[0];
// Status span should contain "1 pending changes"
const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
assert(statusSpan, 'should have status span');
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count');
});
test('buildRows marks pending dnsmasq subsystem correctly', () => {
const data = {
dnsmasq: {
pending_changes: true,
changes: [
{ summary: 'DHCP/DNS configuration has unapplied changes', detail: '' },
],
},
};
const rows = buildRows(data, {});
const dnsmasqRow = rows[1];
assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class');
});
test('buildRows shows correct change count text', () => {
const data = {
dnsmasq: {
pending_changes: true,
changes: [
{ summary: 'Range 1', detail: '' },
{ summary: 'Range 2', detail: '' },
],
},
};
const rows = buildRows(data, {});
const dnsmasqRow = rows[1];
const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assertEq(textChild.text, '2 pending changes');
});
test('buildRows shows up-to-date for non-pending', () => {
const data = {
nginx: { pending_changes: false, changes: [] },
wireguard: { pending_changes: false, changes: [] },
};
const rows = buildRows(data, {});
const nginxRow = rows[2];
assert(
!nginxRow.props.class.includes('pending'),
'nginx row should not have pending class',
);
const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assertEq(textChild.text, 'Up to date');
});
test('buildRows shows expand icon and details when expanded', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
{ summary: 'Zone dmz: services changed', detail: '' },
],
},
};
const rows = buildRows(data, { firewall: true });
assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)');
});
test('buildRows hides expand icon when not expanded', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
assertEq(rows.length, 5, 'should only have 5 rows, no detail section');
});
test('buildRows pending flag but no changes treated as up-to-date', () => {
const data = {
firewall: { needs_apply: true, changes: [] },
};
const rows = buildRows(data, {});
const fwRow = rows[0];
assert(
!fwRow.props.class.includes('pending'),
'no changes = up to date',
);
});
test('buildRows detail section contains item VNodes', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, { firewall: true });
const detailSection = rows[1];
assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section');
assert(detailSection.ch.length > 0, 'detail section should have children');
});
test('buildRows handles networkd key correctly', () => {
const data = {
networkd: {
pending_changes: true,
changes: [
{ summary: 'Network configuration has unapplied changes', detail: '' },
],
},
};
const rows = buildRows(data, {});
const networkdRow = rows[4]; // networkd is 5th in list
assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class');
});
test('buildRows row VNodes have correct tag', () => {
const data = {
firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] },
};
const rows = buildRows(data, {});
for (const row of rows.slice(0, 5)) {
assertEq(row.tag, 'div', 'row should be a div');
assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class');
}
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
-81
View File
@@ -70,87 +70,6 @@ class TestSaveConfig:
assert loaded["dns"]["domain"] == "test.lan"
class TestSetDhcpRange:
def test_add_new_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
def test_replace_existing_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
class TestStaticLeases:
def test_add_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
def test_update_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
def test_remove_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
dnsmasq.remove_static_lease("aa:bb:cc")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
class TestDnsRecords:
def test_add_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
def test_remove_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
dnsmasq.add_dns_record("other", "10.0.0.101")
dnsmasq.remove_dns_record("host")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0]["name"] == "other"
class TestParseLeaseLine:
def test_valid_line(self):
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
assert result["ip"] == "10.0.0.50"
assert result["hostname"] == "printer"
def test_empty_line(self):
assert dnsmasq._parse_lease_line("") is None
def test_comment_line(self):
assert dnsmasq._parse_lease_line("# comment") is None
def test_short_line(self):
assert dnsmasq._parse_lease_line("incomplete") is None
def test_minimal_fields(self):
line = "1700000000 AA:BB:CC 10.0.0.50"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["hostname"] == ""
assert result["interface"] == ""
class TestUpstreamsAndDomain:
def test_set_upstreams(self, temp_data_dir):
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
+166
View File
@@ -477,6 +477,172 @@ class TestDaemonConfigPending:
result = daemonfirewall.config_pending_handler(None, None)
assert result["needs_apply"] is True
@patch("lib.state.state")
def test_no_state_mutation(self, mock_st):
pending = {
"needs_apply": True,
"pending": [{"zone": "public", "type": "services"}],
}
mock_st.get.return_value = {**_mock_state(), "pending": pending}
original_keys = set(pending.keys())
result = daemonfirewall.config_pending_handler(None, None)
assert "pending_summary" in result
assert set(pending.keys()) == original_keys, (
"config_pending_handler must not mutate state store pending dict"
)
@patch("lib.state.state")
def test_detail_text_interfaces(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1", "eth2"],
"live": ["eth1"],
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
assert "eth2" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_services(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "dmz",
"type": "services",
"config": ["ssh", "dns"],
"live": ["ssh"],
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone dmz: services changed" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_rich_rules(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "public",
"type": "rich_rules",
"config_count": 3,
"live_count": 1,
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone public: rich rules differ" in result["pending_summary"][0]
assert "config: 3" in result["pending_summary"][0]
assert "live: 1" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_masquerade(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "wan",
"type": "masquerade",
"config": True,
"live": False,
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone wan: masquerade changed" in result["pending_summary"][0]
assert "config: True" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_target(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "trusted",
"type": "target",
"config": "ACCEPT",
"live": "default",
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone trusted: target changed" in result["pending_summary"][0]
assert "config: ACCEPT" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_unknown_type(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [{"zone": "public", "type": "foobarLayout"}],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone public: foobarLayout changed" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_mixed_types(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1"],
"live": [],
},
{
"zone": "dmz",
"type": "services",
"config": ["ssh", "dns"],
"live": ["ssh"],
},
{
"zone": "public",
"type": "rich_rules",
"config_count": 2,
"live_count": 1,
},
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 3
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
assert "Zone dmz: services changed" in result["pending_summary"][1]
assert "Zone public: rich rules differ" in result["pending_summary"][2]
# ---------------------------------------------------------------------------
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
+1 -1
View File
@@ -66,7 +66,7 @@ class TestBlueprintsRegistered:
def test_all_blueprints_registered(self, client):
from webui.server import BLUEPRINTS
assert len(BLUEPRINTS) == 7
assert len(BLUEPRINTS) == 8
names = [name for name, _ in BLUEPRINTS]
assert "firewall" in names
assert "network" in names
+342
View File
@@ -0,0 +1,342 @@
"""Tests for daemon/handlers/status.py — aggregate pending + apply-all."""
from typing import Any, ClassVar
from unittest.mock import MagicMock, patch
from daemon.handlers import status
from lib.state import State
def _make_state(**kwargs):
"""Create a minimal in-memory state for pending checks."""
st = State()
for name, data in kwargs.items():
st.set(name, data)
return st
def _mock_state_store(state_dict):
"""Return a mock that looks like state_store.get()."""
mock = MagicMock()
mock.get.side_effect = lambda name: state_dict.get(name)
return mock
class TestFwChangeSummary:
"""Test fw_change_summary helper from status module."""
def test_interfaces_summary(self):
s = status.fw_change_summary(
"internal", "interfaces", {"config": ["eth1"], "live": []}
)
assert "Zone internal: interfaces changed" in s
assert "eth1" in s
def test_services_summary(self):
s = status.fw_change_summary(
"dmz", "services", {"config": ["ssh", "dns"], "live": ["ssh"]}
)
assert "Zone dmz: services changed" in s
def test_rich_rules_summary(self):
s = status.fw_change_summary(
"public", "rich_rules", {"config_count": 2, "live_count": 1}
)
assert "config: 2" in s
assert "live: 1" in s
def test_forward_ports_summary(self):
s = status.fw_change_summary(
"wan", "forward_ports", {"config_count": 3, "live_count": 0}
)
assert "Zone wan: port forwards differ" in s
def test_masquerade_summary(self):
s = status.fw_change_summary(
"lan", "masquerade", {"config": True, "live": False}
)
assert "Zone lan: masquerade changed" in s
def test_target_summary(self):
s = status.fw_change_summary(
"vpn", "target", {"config": "ACCEPT", "live": "default"}
)
assert "Zone vpn: target changed" in s
def test_unknown_type_summary(self):
s = status.fw_change_summary("public", "weird", {})
assert "Zone public: weird changed" in s
class TestHashSubsystem:
"""Test _hash_subsystem helper from status module."""
def test_no_state(self):
result = status._hash_subsystem("nginx", None)
assert result["pending_changes"] is False
assert result["summary"] == "Up to date"
def test_pending_true(self):
st = {"status": {"pending_changes": True}}
result = status._hash_subsystem("wireguard", st)
assert result["pending_changes"] is True
assert "unapplied changes" in result["summary"]
assert len(result["changes"]) == 1
def test_pending_false(self):
st = {"status": {"pending_changes": False}}
result = status._hash_subsystem("networkd", st)
assert result["pending_changes"] is False
def test_empty_status(self):
st = {}
result = status._hash_subsystem("dnsmasq", st)
assert result["pending_changes"] is False
class TestStatusPending:
"""Test the aggregate pending endpoint."""
@patch("daemon.handlers.status.state_store")
def test_all_synced(self, mock_store):
mock_store.get.return_value = {
"firewall": {"pending": {"needs_apply": False, "pending": []}},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
assert not result["dnsmasq"]["pending_changes"]
def _patch_store(self, data):
mock = MagicMock()
mock.get.side_effect = lambda name: data.get(name)
return patch("daemon.handlers.status.state_store", mock)
def test_firewall_pending_only(self):
with self._patch_store(
{
"firewall": {
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1"],
"live": [],
}
],
}
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 1
assert result["firewall"]["change_count"] == 1
def test_multiple_subsystems_pending(self):
with self._patch_store(
{
"firewall": {
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "lan",
"type": "services",
"config": ["ssh"],
"live": [],
},
{
"zone": "wan",
"type": "masquerade",
"config": True,
"live": False,
},
],
}
},
"dnsmasq": {"status": {"pending_changes": True}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": True}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 4 # 2 FW + 1 DHCP + 1 WG
assert result["firewall"]["change_count"] == 2
assert result["firewall"]["needs_apply"] is True
assert result["dnsmasq"]["pending_changes"] is True
assert result["wireguard"]["pending_changes"] is True
def test_empty_state(self):
with self._patch_store({}):
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
def test_firewall_no_pending_key(self):
with self._patch_store(
{
"firewall": {},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": None,
"wireguard": None,
"networkd": None,
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
class TestStatusApplyAll:
"""Test the apply-all endpoint.
Patches SYS_APPLY dict entries directly since they hold function
references at import time.
"""
_fake_pending_all: ClassVar[dict[str, Any]] = {
"firewall": {"needs_apply": False, "change_count": 0, "changes": []},
"dnsmasq": {"pending_changes": False, "summary": "Up to date", "changes": []},
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
"wireguard": {"pending_changes": False, "summary": "Up to date", "changes": []},
"networkd": {"pending_changes": False, "summary": "Up to date", "changes": []},
}
@patch("daemon.handlers.status.status_pending")
@patch("daemon.handlers.status.refresh_state")
def test_nothing_to_apply(self, mock_refresh, mock_pending):
mock_pending.return_value = self._fake_pending_all
result = status.status_apply_all(None, None)
assert result["applied"] == []
assert result["errors"] == {}
mock_refresh.assert_called_once()
def test_applies_pending_subsystems(self):
mock_net = MagicMock()
mock_fw = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["networkd"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"networkd": mock_net,
"firewall": mock_fw,
},
),
):
result = status.status_apply_all(None, None)
assert "networkd" in result["applied"]
assert "firewall" in result["applied"]
mock_net.assert_called_once()
mock_fw.assert_called_once()
def test_error_in_subsystem(self):
mock_fw = MagicMock(side_effect=RuntimeError("firewalld not running"))
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
):
result = status.status_apply_all(None, None)
assert "firewall" not in result["applied"]
assert "Firewall" in result["errors"]
assert "firewalld not running" in result["errors"]["Firewall"]
def test_order_is_respected(self):
call_order = []
def track(name):
def wrapper(*args):
call_order.append(name)
return wrapper
mock_net = MagicMock(side_effect=track("networkd"))
mock_wg = MagicMock(side_effect=track("wireguard"))
pending_data = {**self._fake_pending_all}
pending_data["wireguard"]["pending_changes"] = True
pending_data["networkd"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"networkd": mock_net,
"wireguard": mock_wg,
},
),
):
status.status_apply_all(None, None)
assert call_order == ["networkd", "wireguard"]
def test_partial_failure_still_applies_others(self):
mock_fw = MagicMock(side_effect=RuntimeError("fail"))
mock_nginx = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["nginx"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"firewall": mock_fw,
"nginx": mock_nginx,
},
),
):
result = status.status_apply_all(None, None)
assert "firewall" not in result["applied"]
assert "nginx" in result["applied"]
assert "Firewall" in result["errors"]
mock_nginx.assert_called_once()
class TestSysOrder:
"""Verify SYS_ORDER and SYS_LABELS constants."""
def test_order_network_first(self):
assert status.SYS_ORDER[0] == "networkd"
def test_all_subsystems_present(self):
expected = {"networkd", "firewall", "wireguard", "dnsmasq", "nginx"}
assert set(status.SYS_ORDER) == expected
def test_labels_match(self):
for name in status.SYS_ORDER:
assert name in status.SYS_LABELS
assert name in status.SYS_APPLY
def test_apply_functions_callable(self):
for name in status.SYS_ORDER:
assert callable(status.SYS_APPLY[name])
View File
+51
View File
@@ -0,0 +1,51 @@
"""Aggregate status API blueprint.
Exposed at /api/status/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
import logging
from flask import Blueprint
from daemon.client import get, post
from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"])
def pending():
"""Retrieve aggregate pending changes across all subsystems.
Endpoint:
GET /api/status/pending
Returns:
JSON response with per-subsystem pending status and total change count.
"""
try:
return _ok(get(GET_STATUS_PENDING))
except RuntimeError as exc:
logger.error("Failed to get pending status: %s", exc)
return _error(str(exc), 500)
@bp.route("/apply-all", methods=["POST"])
def apply_all():
"""Apply pending changes for all subsystems in dependency order.
Endpoint:
POST /api/status/apply-all
Returns:
JSON response with applied subsystems list and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_APPLY_ALL))
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500)
+3
View File
@@ -26,6 +26,7 @@ from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.status import bp as status_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
@@ -94,6 +95,7 @@ app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
app.register_blueprint(status_bp, url_prefix="/api/status")
BLUEPRINTS = [
("firewall", firewall_bp),
@@ -103,6 +105,7 @@ BLUEPRINTS = [
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
("status", status_bp),
]
for name, _ in BLUEPRINTS:
@@ -0,0 +1,138 @@
/**
* Hoover components/applyconfirm.js
*
* Apply button with cross-subsystem confirmation modal.
* Fetches pending changes from /api/status/pending, shows them in an
* expandable modal, then applies all via /api/status/apply-all.
*/
import { h } from '../vdom.js?v=8';
import { html } from '../html.js?v=8';
import { reactive } from '../reactivity.js?v=8';
import { apiFetch, toast } from '../api.js?v=8';
import { modelFetch } from '../model.js?v=8';
import { openModal, closeModal, modalVNodes } from './modal.js?v=8';
export const SUBSYSTEM_LIST = [
{ key: 'firewall', label: 'Firewall' },
{ key: 'dnsmasq', label: 'DHCP/DNS' },
{ key: 'nginx', label: 'Nginx' },
{ key: 'wireguard', label: 'WireGuard' },
{ key: 'networkd', label: 'Network' },
];
/**
* Extract pending state from a subsystem result.
* Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`.
*/
export function isPending(ss) {
return (ss.needs_apply || ss.pending_changes || false);
}
/**
* Build the VNode array for modal rows given pending data and expanded state.
*/
export function buildRows(pendingData, expanded) {
const vnodeList = [];
for (const sub of SUBSYSTEM_LIST) {
const ss = pendingData[sub.key] || {};
const changes = ss.changes || [];
const hasPending = isPending(ss) && changes.length > 0;
const isExpanded = !!expanded[sub.key];
vnodeList.push(html`<div class="apply-subsystem-row${hasPending ? ' pending' : ''}">
<span class="apply-subsystem-name">${sub.label}</span>
<span class="apply-subsystem-status${hasPending ? ' pending' : ''}">${hasPending ? changes.length + ' pending changes' : 'Up to date'}</span>
${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : ''}">\u25B6</span>` : ''}
</div>`);
if (hasPending && isExpanded) {
vnodeList.push(html`<div class="apply-detail-section">${changes.map(c => html`<div class="apply-detail-item">${c.summary || c.detail || c}</div>`)}</div>`);
}
}
return vnodeList;
}
/**
* POST apply-all, toast result, close modal, refresh models.
*/
async function doApply(successMsg, refreshTargets) {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Apply failed', 'error');
}
}
/**
* Fetch pending state, then open the confirmation modal.
*/
async function openApplyModal(successMsg, refreshTargets) {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
return;
}
const pendingData = pendingResp.data || {};
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
if (totalChanges === 0) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="apply-no-changes">No pending changes to apply.</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button></div>
</div>`);
return;
}
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, refreshTargets)}">Apply All</button></div>
</div>`);
});
}
/**
* Apply button with cross-subsystem confirmation modal.
*
* @param {object} props
* @param {boolean} props.pending - Whether any subsystem has pending changes
* @param {string} [props.label] - Apply button text (default: 'Apply')
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply
*/
export function ApplyConfirm(props = {}) {
const label = props.label || 'Apply';
const syncedLabel = props.syncedLabel || 'Synced';
const successMsg = props.successMsg || 'All changes applied';
return h('button', {
class: props.cls !== undefined
? props.cls
: (props.pending ? 'btn btn-primary' : 'btn btn-outline'),
'on:click': () => {
if (!props.pending) {
toast(successMsg || 'All synced', 'info');
return;
}
openApplyModal(successMsg, props.refresh);
},
}, props.pending ? label : syncedLabel);
}
+3
View File
@@ -43,5 +43,8 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8';
/* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js?v=8';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=8';
+76
View File
@@ -699,6 +699,82 @@ body {
text-align: center;
}
/* ApplyConfirm modal */
.apply-subsystem-row {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
cursor: default;
}
.apply-subsystem-row.pending {
cursor: pointer;
}
.apply-subsystem-row.pending:hover {
background: rgba(0, 180, 216, 0.08);
}
.apply-subsystem-name {
flex: 1;
font-weight: 600;
font-size: 14px;
}
.apply-subsystem-status {
margin-left: 12px;
color: var(--text-muted);
font-size: 13px;
}
.apply-subsystem-status.pending {
color: var(--warning);
font-weight: 500;
}
.apply-expand-icon {
margin-left: 8px;
transition: transform 0.2s;
font-size: 12px;
}
.apply-expand-icon.expanded {
transform: rotate(90deg);
}
.apply-detail-section {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.15);
margin: 4px 0 4px 12px;
border-radius: 4px;
font-size: 13px;
}
.apply-detail-item {
padding: 4px 0;
border-bottom: 1px solid var(--border);
}
.apply-detail-item:last-child {
border-bottom: none;
}
.apply-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid var(--border);
}
.apply-no-changes {
padding: 16px;
text-align: center;
color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
View File