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:
@@ -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/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/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.
|
- `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).
|
- `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/`.
|
- `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/`.
|
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ See [docs/deployment.md](docs/deployment.md) for the full guide, including troub
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> && cd vacuum-wall
|
git clone <repo-url> && cd vacuum-wall
|
||||||
|
bash scripts/update-vendor.sh
|
||||||
python3 -m venv .venv && . .venv/bin/activate
|
python3 -m venv .venv && . .venv/bin/activate
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|||||||
+14
-20
@@ -1,6 +1,5 @@
|
|||||||
"""Dnsmasq daemon handler."""
|
"""Dnsmasq daemon handler."""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -26,7 +25,15 @@ from daemon.iface import (
|
|||||||
POST_DNSMASQ_UPSTREAMS,
|
POST_DNSMASQ_UPSTREAMS,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
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
|
from lib.sync import SyncEvent, bus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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": []},
|
"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:
|
def _get_state() -> dict[str, Any] | None:
|
||||||
"""Retrieve cached dnsmasq state from the state store."""
|
"""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()
|
dm = _get_dnsmasq_state()
|
||||||
if dm:
|
if dm:
|
||||||
return dm.get("config", {})
|
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)
|
@registry.register(POST_DNSMASQ_CONFIG)
|
||||||
@@ -199,11 +193,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
f.write(conf_text)
|
f.write(conf_text)
|
||||||
run(["cp", str(tmp), DNSMASQ_CONF], sudo=True)
|
run(["cp", str(tmp), DNSMASQ_CONF], sudo=True)
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
run(["systemctl", "reload", "dnsmasq"], sudo=True)
|
run(["systemctl", "restart", "dnsmasq"], sudo=True)
|
||||||
logger.info("dnsmasq config written and reloaded")
|
logger.info("dnsmasq config written and restarted")
|
||||||
# Store the config hash so state collector can detect pending changes
|
# Store the config hash so state collector can detect pending changes
|
||||||
cfg_after = _get_config()
|
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)
|
_save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from lib.firewall import (
|
|||||||
_normalize_target,
|
_normalize_target,
|
||||||
_parse_active_zones,
|
_parse_active_zones,
|
||||||
_parse_zone_output,
|
_parse_zone_output,
|
||||||
|
fw_change_summary,
|
||||||
)
|
)
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
save_backup as _save_backup,
|
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)
|
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
||||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
fw = _get_fw_state()
|
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)
|
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ from daemon.iface import (
|
|||||||
POST_NETWORK_SYSCTL_SET,
|
POST_NETWORK_SYSCTL_SET,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
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.dnsmasq import set_upstreams
|
||||||
from lib.network import (
|
from lib.network import (
|
||||||
KNOWN_INTERFACE_KEYS,
|
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)
|
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(
|
sync_result = bus.emit(
|
||||||
SyncEvent(
|
SyncEvent(
|
||||||
"network", "config_saved", {"action": "interface_saved", "interface": name}
|
"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)
|
upstreams = collect_upstream_dns(cfg)
|
||||||
if upstreams:
|
if upstreams:
|
||||||
set_upstreams(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))
|
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
|
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(
|
sync_result = bus.emit(
|
||||||
SyncEvent("network", "config_saved", {"action": "config_applied"})
|
SyncEvent("network", "config_saved", {"action": "config_applied"})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ from daemon.iface import (
|
|||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.acme import find_cert_dir
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -300,7 +308,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
ng = _get_nginx_state()
|
ng = _get_nginx_state()
|
||||||
if ng:
|
if ng:
|
||||||
return ng.get("config", {})
|
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)
|
@registry.register(POST_NGINX_CONFIG)
|
||||||
@@ -507,6 +516,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
if not ok:
|
if not ok:
|
||||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||||
_reload_nginx()
|
_reload_nginx()
|
||||||
|
cfg_after = _get_config()
|
||||||
|
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
||||||
|
_save_config(cfg_after)
|
||||||
refresh_state(["nginx"])
|
refresh_state(["nginx"])
|
||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|
||||||
|
|||||||
@@ -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": []}
|
||||||
@@ -24,7 +24,15 @@ from daemon.iface import (
|
|||||||
POST_WIREGUARD_PEERS_ADD,
|
POST_WIREGUARD_PEERS_ADD,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
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
|
from lib.sync import SyncEvent, bus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -102,7 +110,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
if wg:
|
if wg:
|
||||||
return wg.get("config", {})
|
return wg.get("config", {})
|
||||||
cfg = _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:
|
if "interface" in safe:
|
||||||
safe["interface"] = dict(safe["interface"])
|
safe["interface"] = dict(safe["interface"])
|
||||||
safe["interface"].pop("private_key", None)
|
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)
|
local_tmp.unlink(missing_ok=True)
|
||||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
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(
|
sync_result = bus.emit(
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
|
|||||||
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||||
GET_WS: Endpoint = _ep("GET", "/ws")
|
GET_WS: Endpoint = _ep("GET", "/ws")
|
||||||
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
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
|
# Collect all endpoint module-level constants for __all__ verification
|
||||||
_all_endpoints = [
|
_all_endpoints = [
|
||||||
|
|||||||
+2
-1
@@ -506,6 +506,7 @@ def _register_routes() -> None:
|
|||||||
logs, # noqa: F401
|
logs, # noqa: F401
|
||||||
network, # noqa: F401
|
network, # noqa: F401
|
||||||
nginx, # noqa: F401
|
nginx, # noqa: F401
|
||||||
|
status, # noqa: F401
|
||||||
wireguard, # noqa: F401
|
wireguard, # noqa: F401
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -538,7 +539,7 @@ def main() -> None:
|
|||||||
_stop_polling()
|
_stop_polling()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning("Runner cleanup timed out, abandoning")
|
logger.warning("Runner cleanup timed out, abandoning")
|
||||||
if Path(socket_path).exists():
|
if Path(socket_path).exists():
|
||||||
os.unlink(socket_path)
|
os.unlink(socket_path)
|
||||||
|
|||||||
@@ -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)
|
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/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/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/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/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
|
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
│ ├── config.md
|
│ ├── config.md
|
||||||
│ └── hoover.md # Hoover SPA framework
|
│ └── hoover.md # Hoover SPA framework
|
||||||
└── scripts/ # Utility scripts
|
└── scripts/ # Utility scripts
|
||||||
└── update-vendor.sh # Download acme.sh binary
|
└── update-vendor.sh # Download vendored libraries (acme.sh, htm)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
+1
-2
@@ -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 | `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 | `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 |
|
| 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 |
|
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||||
| Networkd | `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 | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
|
||||||
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files |
|
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files |
|
||||||
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
|
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ apt-get install -y -qq \
|
|||||||
apache2-utils \
|
apache2-utils \
|
||||||
avahi-daemon
|
avahi-daemon
|
||||||
|
|
||||||
|
# --- 1b. Vendored libraries ---
|
||||||
|
log "Downloading vendored libraries..."
|
||||||
|
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
|
||||||
|
|
||||||
# --- 2. Setup users ---
|
# --- 2. Setup users ---
|
||||||
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
|
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Provides common helpers for JSON persistence, subprocess execution,
|
|||||||
deep merging, and directory creation used across all subsystem modules.
|
deep merging, and directory creation used across all subsystem modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -12,6 +13,21 @@ from copy import deepcopy
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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:
|
def validate_interface_name(name: str) -> str:
|
||||||
"""Validate a Linux network interface name.
|
"""Validate a Linux network interface name.
|
||||||
@@ -154,6 +170,8 @@ def ensure_dirs(*dirs: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"_APPLY_HASH_KEY",
|
||||||
|
"config_hash",
|
||||||
"deep_merge",
|
"deep_merge",
|
||||||
"ensure_dirs",
|
"ensure_dirs",
|
||||||
"load_json",
|
"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,
|
Provides load/save for the declarative JSON config and upstream-management
|
||||||
static leases, and custom DNS records through sudo.
|
helpers used by the sync bus and network handler. All mutation and
|
||||||
|
apply logic lives in daemon/handlers/dnsmasq.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,17 +19,7 @@ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
|||||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
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] = {
|
DEFAULT_CFG: dict[str, Any] = {
|
||||||
"dhcp": {
|
"dhcp": {
|
||||||
"ranges": [],
|
"ranges": [],
|
||||||
@@ -66,244 +53,7 @@ def save_config(cfg: dict[str, Any]) -> None:
|
|||||||
logger.info("dnsmasq config saved")
|
logger.info("dnsmasq config saved")
|
||||||
|
|
||||||
|
|
||||||
def apply_config() -> None:
|
# ───────── upstream helpers ──────────────────────────────────────────
|
||||||
"""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 ─────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def set_upstreams(servers: list[str]) -> None:
|
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)
|
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__ = [
|
__all__ = [
|
||||||
"add_dns_record",
|
|
||||||
"add_static_lease",
|
|
||||||
"apply_config",
|
|
||||||
"generate_conf",
|
|
||||||
"get_config",
|
"get_config",
|
||||||
"get_lease_table",
|
|
||||||
"get_status",
|
|
||||||
"remove_dhcp_range",
|
|
||||||
"remove_dns_record",
|
|
||||||
"remove_static_lease",
|
|
||||||
"save_config",
|
"save_config",
|
||||||
"set_dhcp_range",
|
|
||||||
"set_domain",
|
"set_domain",
|
||||||
"set_upstreams",
|
"set_upstreams",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -379,6 +379,37 @@ def config_pending(state: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return _compute_pending_changes(cfg, live_zones)
|
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__ = [
|
__all__ = [
|
||||||
"CONFIG_DIR",
|
"CONFIG_DIR",
|
||||||
"CONFIG_FILE",
|
"CONFIG_FILE",
|
||||||
@@ -396,6 +427,7 @@ __all__ = [
|
|||||||
"_parse_interfaces",
|
"_parse_interfaces",
|
||||||
"_parse_zone_output",
|
"_parse_zone_output",
|
||||||
"config_pending",
|
"config_pending",
|
||||||
|
"fw_change_summary",
|
||||||
"get_config",
|
"get_config",
|
||||||
"load_backup",
|
"load_backup",
|
||||||
"save_backup",
|
"save_backup",
|
||||||
|
|||||||
+45
-19
@@ -5,8 +5,6 @@ state instead of invoking subprocesses on every request.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -14,7 +12,7 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, ClassVar
|
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 (
|
from lib.firewall import (
|
||||||
_parse_active_zones,
|
_parse_active_zones,
|
||||||
_parse_all_zones_output,
|
_parse_all_zones_output,
|
||||||
@@ -590,18 +588,13 @@ def _collect_dnsmasq() -> dict[str, Any]:
|
|||||||
# Check config file on disk
|
# Check config file on disk
|
||||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||||
|
|
||||||
# Check if JSON config has changed since last apply
|
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
||||||
_APPLY_HASH_KEY = "_last_applied_hash"
|
cfg
|
||||||
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
|
|
||||||
|
|
||||||
|
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
||||||
return {
|
return {
|
||||||
"config": cfg,
|
"config": safe_cfg,
|
||||||
"status": {
|
"status": {
|
||||||
"service_active": service_active,
|
"service_active": service_active,
|
||||||
"config_file_exists": conf_exists,
|
"config_file_exists": conf_exists,
|
||||||
@@ -684,9 +677,15 @@ def _collect_nginx() -> dict[str, Any]:
|
|||||||
entry["is_websocket"] = True
|
entry["is_websocket"] = True
|
||||||
domains.append(entry)
|
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 {
|
return {
|
||||||
"config": cfg,
|
"config": safe_cfg,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
|
"status": {"pending_changes": pending_changes},
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,8 +917,12 @@ def _collect_wireguard() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Safe config (strip private key)
|
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
||||||
safe = dict(cfg)
|
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:
|
if "interface" in safe:
|
||||||
safe["interface"] = dict(safe["interface"])
|
safe["interface"] = dict(safe["interface"])
|
||||||
safe["interface"].pop("private_key", None)
|
safe["interface"].pop("private_key", None)
|
||||||
@@ -1000,6 +1003,7 @@ def _collect_wireguard() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
status["pending_changes"] = pending_changes
|
||||||
return {
|
return {
|
||||||
"config": safe,
|
"config": safe,
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -1029,24 +1033,46 @@ def _collect_networkd() -> dict[str, Any]:
|
|||||||
"""Collect networkd interface state from networkctl.
|
"""Collect networkd interface state from networkctl.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with interface runtime state parsed from networkctl output.
|
Dict with interface runtime state parsed from networkctl output,
|
||||||
Returns empty data if networkctl is not available.
|
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]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
result = parse_networkctl_status(raw)
|
result = parse_networkctl_status(raw)
|
||||||
if not result:
|
if not result:
|
||||||
return {"interfaces": {}, "timestamp": _now_iso()}
|
return {
|
||||||
|
"interfaces": {},
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": {"pending_changes": pending_changes},
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {
|
return {
|
||||||
"interfaces": {},
|
"interfaces": {},
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": {"pending_changes": pending_changes},
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"interfaces": result,
|
"interfaces": result,
|
||||||
|
"config": safe_net_cfg,
|
||||||
|
"status": {"pending_changes": pending_changes},
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# ---- Library versions ----
|
# ---- Library versions ----
|
||||||
ACME_VERSION="3.1.3"
|
ACME_VERSION="3.1.3"
|
||||||
|
HTM_VERSION="3.1.1"
|
||||||
|
|
||||||
VENDOR="vendor"
|
VENDOR="vendor"
|
||||||
|
|
||||||
@@ -22,6 +23,32 @@ download "acme.sh@${ACME_VERSION}" \
|
|||||||
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
|
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
|
||||||
"${VENDOR}/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"
|
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."
|
echo "[done] All libraries vendored."
|
||||||
|
|||||||
@@ -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
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
|
||||||
# Dnsmasq management
|
# 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/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/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/mkdir -p /etc/dnsmasq.d
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -70,87 +70,6 @@ class TestSaveConfig:
|
|||||||
assert loaded["dns"]["domain"] == "test.lan"
|
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:
|
class TestUpstreamsAndDomain:
|
||||||
def test_set_upstreams(self, temp_data_dir):
|
def test_set_upstreams(self, temp_data_dir):
|
||||||
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
||||||
|
|||||||
@@ -477,6 +477,172 @@ class TestDaemonConfigPending:
|
|||||||
result = daemonfirewall.config_pending_handler(None, None)
|
result = daemonfirewall.config_pending_handler(None, None)
|
||||||
assert result["needs_apply"] is True
|
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
|
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class TestBlueprintsRegistered:
|
|||||||
def test_all_blueprints_registered(self, client):
|
def test_all_blueprints_registered(self, client):
|
||||||
from webui.server import BLUEPRINTS
|
from webui.server import BLUEPRINTS
|
||||||
|
|
||||||
assert len(BLUEPRINTS) == 7
|
assert len(BLUEPRINTS) == 8
|
||||||
names = [name for name, _ in BLUEPRINTS]
|
names = [name for name, _ in BLUEPRINTS]
|
||||||
assert "firewall" in names
|
assert "firewall" in names
|
||||||
assert "network" in names
|
assert "network" in names
|
||||||
|
|||||||
@@ -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])
|
||||||
Vendored
@@ -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)
|
||||||
@@ -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.logs import bp as logs_bp
|
||||||
from webui.api.network import bp as network_bp
|
from webui.api.network import bp as network_bp
|
||||||
from webui.api.proxy import bp as proxy_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
|
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(certs_bp, url_prefix="/api/certs")
|
||||||
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
||||||
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||||
|
app.register_blueprint(status_bp, url_prefix="/api/status")
|
||||||
|
|
||||||
BLUEPRINTS = [
|
BLUEPRINTS = [
|
||||||
("firewall", firewall_bp),
|
("firewall", firewall_bp),
|
||||||
@@ -103,6 +105,7 @@ BLUEPRINTS = [
|
|||||||
("certs", certs_bp),
|
("certs", certs_bp),
|
||||||
("wireguard", wireguard_bp),
|
("wireguard", wireguard_bp),
|
||||||
("logs", logs_bp),
|
("logs", logs_bp),
|
||||||
|
("status", status_bp),
|
||||||
]
|
]
|
||||||
|
|
||||||
for name, _ in BLUEPRINTS:
|
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);
|
||||||
|
}
|
||||||
@@ -43,5 +43,8 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert
|
|||||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8';
|
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 ────────────────────────────────────── */
|
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||||
export { ToastContainer } from './components/toast.js?v=8';
|
export { ToastContainer } from './components/toast.js?v=8';
|
||||||
|
|||||||
@@ -699,6 +699,82 @@ body {
|
|||||||
text-align: center;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
|||||||
Vendored
Reference in New Issue
Block a user