ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
This commit is contained in:
@@ -8,7 +8,7 @@ Deploys on Debian 13 (trixie). Serves from repo root by default.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
|
Client ──→ nginx (TLS; basic auth on basic-authed proxy domains only) ──→ Flask (127.0.0.1:9090)
|
||||||
Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock)
|
Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock)
|
||||||
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ system service
|
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ system service
|
||||||
```
|
```
|
||||||
@@ -16,6 +16,15 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ syste
|
|||||||
Blueprints are thin proxies — they never call `lib/` directly. All operations flow
|
Blueprints are thin proxies — they never call `lib/` directly. All operations flow
|
||||||
through the daemon client over a Unix socket.
|
through the daemon client over a Unix socket.
|
||||||
|
|
||||||
|
**Authentication:** the management UI gets **no** nginx-level `auth_basic` — the mgmt
|
||||||
|
server block's `location /` is a bare proxy and `/ws` is `auth_basic off`. Management
|
||||||
|
auth is the Flask-layer JWT middleware (`POST /api/auth/login` →
|
||||||
|
`Authorization: Bearer <token>`; public paths: static files, `/vendor/`, auth endpoints)
|
||||||
|
plus the daemon WS handshake (raw JWT as the `Sec-WebSocket-Protocol` subprotocol).
|
||||||
|
Basic auth (`.htpasswd`) renders **only** for proxy domains whose
|
||||||
|
`config/nginx/config.json` has an `auth` block — never for the management domain
|
||||||
|
(see `docs/security.md`, "Management Interface").
|
||||||
|
|
||||||
### Two-User Model with Shared Group
|
### Two-User Model with Shared Group
|
||||||
|
|
||||||
- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`.
|
- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`.
|
||||||
@@ -31,7 +40,7 @@ through the daemon client over a Unix socket.
|
|||||||
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
|
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
|
||||||
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
|
- `daemon/iface.py` — **Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
|
||||||
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
|
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
|
||||||
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
|
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
|
||||||
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
|
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
|
||||||
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
|
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
|
||||||
- `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.
|
||||||
@@ -59,7 +68,7 @@ Conventions:
|
|||||||
### Daemon Endpoints
|
### Daemon Endpoints
|
||||||
|
|
||||||
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`)
|
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`)
|
||||||
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state notifications
|
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state streaming: full `snapshot` on connect, then per-subsystem data-carrying `versions` (structural) / `tick` (volatile-only) deltas. The client patches reactive models in place via `modelSet()` — no HTTP round-trip for auto-refresh.
|
||||||
- Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`)
|
- Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`)
|
||||||
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
|
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
|
||||||
|
|
||||||
@@ -119,7 +128,9 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
`scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
|
`scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
|
||||||
`MGMT_PASS` is strictly required; `MGMT_DOMAIN` auto-detected from hostname.
|
`MGMT_PASS` is strictly required — it is the **SQLite DB** password for the initial `admin`
|
||||||
|
user (full `rw` on all subsystems; default username `admin`), **not** an nginx htpasswd.
|
||||||
|
`MGMT_DOMAIN` auto-detected from hostname.
|
||||||
|
|
||||||
### Service Start Order
|
### Service Start Order
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from daemon.iface import (
|
|||||||
POST_FIREWALL_ZONES_INTERFACES,
|
POST_FIREWALL_ZONES_INTERFACES,
|
||||||
POST_FIREWALL_ZONES_SERVICES,
|
POST_FIREWALL_ZONES_SERVICES,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||||
from lib.common import load_json, run, save_json
|
from lib.common import load_json, run, save_json
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
_normalize_target,
|
_normalize_target,
|
||||||
@@ -85,6 +85,35 @@ def _reload() -> None:
|
|||||||
run(["firewall-cmd", "--reload"], sudo=True)
|
run(["firewall-cmd", "--reload"], sudo=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_zone() -> str:
|
||||||
|
"""Return the firewalld default zone name.
|
||||||
|
|
||||||
|
The default zone is the catch-all for any interface without an explicit
|
||||||
|
zone assignment (including VPN interfaces), so it normally fronts the WAN.
|
||||||
|
"""
|
||||||
|
return run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _would_remove_mgmt(zone: str, services: list[str]) -> bool:
|
||||||
|
"""Return True if *services* lacks both https and ssh on the default zone.
|
||||||
|
|
||||||
|
The default zone fronts unassigned (WAN/VPN) interfaces, so removing both
|
||||||
|
management access (https via nginx) and remote recovery (ssh) from it
|
||||||
|
would leave no path back except a physical console.
|
||||||
|
"""
|
||||||
|
if "https" in services or "ssh" in services:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
default = _default_zone()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Could not determine the firewalld default zone; failing closed for %s",
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return zone == default
|
||||||
|
|
||||||
|
|
||||||
def _fp_to_str(fp: dict[str, Any]) -> str:
|
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||||
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
|
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
|
||||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||||
@@ -106,18 +135,39 @@ def _get_forward_ports(zone_name: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _config_apply() -> dict[str, Any]:
|
def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||||
"""Apply saved declarative config to live firewalld.
|
"""Apply saved declarative config to live firewalld.
|
||||||
|
|
||||||
For each zone in the config, reconciles interfaces, services, target,
|
For each zone in the config, reconciles interfaces, services, target,
|
||||||
masquerade, rich rules, and forward ports by removing old values first,
|
masquerade, rich rules, and forward ports by removing old values first,
|
||||||
then adding desired values. Reloads firewalld at the end.
|
then adding desired values. Reloads firewalld at the end.
|
||||||
|
|
||||||
|
With *force* False (default), a ``ConflictError`` is raised before any
|
||||||
|
mutation if the config would strip both https and ssh from the default
|
||||||
|
zone; pass ``force=True`` to override.
|
||||||
"""
|
"""
|
||||||
from lib.firewall import get_config as _get_lib_config
|
from lib.firewall import get_config as _get_lib_config
|
||||||
|
|
||||||
cfg = _get_lib_config()
|
cfg = _get_lib_config()
|
||||||
cfg_zones = cfg.get("zones", {})
|
cfg_zones = cfg.get("zones", {})
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
default_zone = _default_zone()
|
||||||
|
lockout_zones = [
|
||||||
|
zn
|
||||||
|
for zn, zc in cfg_zones.items()
|
||||||
|
if zn == default_zone
|
||||||
|
and "https" not in zc.get("services", [])
|
||||||
|
and "ssh" not in zc.get("services", [])
|
||||||
|
]
|
||||||
|
if lockout_zones:
|
||||||
|
raise ConflictError(
|
||||||
|
f"Refusing to remove both https and ssh from default zone(s) "
|
||||||
|
f"{', '.join(repr(z) for z in lockout_zones)}: management access "
|
||||||
|
f"and remote recovery would be lost. Add at least one of them "
|
||||||
|
f'to the zone\'s services, or pass {{"force": true}}.'
|
||||||
|
)
|
||||||
|
|
||||||
full_state: dict[str, Any] = {
|
full_state: dict[str, Any] = {
|
||||||
"active_zones": {},
|
"active_zones": {},
|
||||||
"interfaces": [],
|
"interfaces": [],
|
||||||
@@ -556,13 +606,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
_request: The incoming HTTP request (unused).
|
_request: The incoming HTTP request (unused).
|
||||||
_body: The request body (unused).
|
_body: Optional JSON body; ``{"force": true}`` overrides the
|
||||||
|
management-lockout guard for the default zone.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
||||||
and ``synced`` (affected subsystems).
|
and ``synced`` (affected subsystems).
|
||||||
"""
|
"""
|
||||||
result = _config_apply()
|
force = bool(_body and _body.get("force"))
|
||||||
|
result = _config_apply(force=force)
|
||||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
||||||
@@ -736,18 +788,21 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
|
|
||||||
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
||||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Replace zone services, emitting sync event and refreshing state.
|
"""Replace zone services, persist to declarative config, and refresh state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
_request: The incoming HTTP request (unused).
|
_request: The incoming HTTP request (unused).
|
||||||
body: JSON body with ``zone`` and ``services`` list.
|
body: JSON body with ``zone`` and ``services`` list; optional
|
||||||
|
``force`` (bool) overrides the management-lockout guard.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``zone`` and ``services`` keys.
|
Dict with ``zone`` and ``services`` keys.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If body is missing ``zone``.
|
ValueError: If body is missing ``zone``.
|
||||||
NotFoundError: If the zone does not exist.
|
NotFoundError: If the specified zone does not exist.
|
||||||
|
ConflictError: If the change would strip both https and ssh from the
|
||||||
|
firewalld default zone and ``force`` is not set.
|
||||||
"""
|
"""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
@@ -757,6 +812,12 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
raise ValueError("'zone' is required")
|
raise ValueError("'zone' is required")
|
||||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||||
|
if not body.get("force") and _would_remove_mgmt(zone, list(services)):
|
||||||
|
raise ConflictError(
|
||||||
|
f"Refusing to remove both https and ssh from default zone '{zone}': "
|
||||||
|
f"management access and remote recovery would be lost. Add at least "
|
||||||
|
f'one of them back, or send "force": true to override.'
|
||||||
|
)
|
||||||
current = _parse_zone_output(
|
current = _parse_zone_output(
|
||||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||||
).get("services", [])
|
).get("services", [])
|
||||||
@@ -782,6 +843,13 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
|||||||
sudo=True,
|
sudo=True,
|
||||||
)
|
)
|
||||||
_reload()
|
_reload()
|
||||||
|
|
||||||
|
# Keep the declarative config in sync so the next apply does not
|
||||||
|
# reconcile the live services back to the stale config value.
|
||||||
|
cfg = _get_config()
|
||||||
|
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
|
||||||
|
_save_config(cfg)
|
||||||
|
logger.info("Zone '%s' services set to %s", zone, services)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-37
@@ -160,7 +160,14 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
task = asyncio.create_task(broadcast_versions())
|
# Broadcast each refreshed subsystem individually (gather for parallelism)
|
||||||
|
async def _broadcast_all():
|
||||||
|
await asyncio.gather(
|
||||||
|
*[broadcast_versions(name) for name in targets],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = asyncio.create_task(_broadcast_all())
|
||||||
task.add_done_callback(_ws_tasks.discard)
|
task.add_done_callback(_ws_tasks.discard)
|
||||||
_ws_tasks.add(task)
|
_ws_tasks.add(task)
|
||||||
|
|
||||||
@@ -401,10 +408,12 @@ _last_blacklist_cleanup_lock: asyncio.Lock | None = None
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_ws(request: web.Request) -> web.Response:
|
async def _handle_ws(request: web.Request) -> web.Response:
|
||||||
"""WebSocket endpoint for real-time state change notifications.
|
"""WebSocket endpoint for real-time state streaming.
|
||||||
|
|
||||||
On connect: sends current versions. On state change: broadcasts
|
On connect: sends a full state snapshot of every subsystem
|
||||||
updated subsystem versions. Clients disconnect to unsubscribe.
|
({type: snapshot, data: {subsystem: state, …}}). On state change:
|
||||||
|
broadcasts a data-carrying per-subsystem delta (versions or tick).
|
||||||
|
Clients disconnect to unsubscribe.
|
||||||
|
|
||||||
Authentication: JWT access token passed via:
|
Authentication: JWT access token passed via:
|
||||||
1. WebSocket subprotocol header — the bundled client sends the raw JWT
|
1. WebSocket subprotocol header — the bundled client sends the raw JWT
|
||||||
@@ -452,7 +461,8 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
await ws.prepare(request)
|
await ws.prepare(request)
|
||||||
_ws_subscribers.add(ws)
|
_ws_subscribers.add(ws)
|
||||||
|
|
||||||
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
|
snapshot = state_store.get_snapshot()
|
||||||
|
await ws.send_json({"type": "snapshot", "data": snapshot})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
@@ -466,35 +476,59 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_versions() -> None:
|
async def _send_all(message: str) -> None:
|
||||||
"""Broadcast updated subsystem versions to all WebSocket clients."""
|
"""Send a message string to all WS subscribers, removing dead ones."""
|
||||||
updated = state_store.get_updated_versions()
|
dead: set[web.WebSocketResponse] = set()
|
||||||
if not updated or not _ws_subscribers:
|
for ws in _ws_subscribers:
|
||||||
|
try:
|
||||||
|
await ws.send_str(message)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
_ws_subscribers.difference_update(dead)
|
||||||
|
if dead:
|
||||||
|
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_versions(subsystem: str) -> None:
|
||||||
|
"""Send {type: versions} + full subsystem data to WS clients.
|
||||||
|
|
||||||
|
NOTE: Does NOT call state_store.bump(). Callers who need version bumps
|
||||||
|
(e.g. refresh_state) call bump themselves. poll_loop bumps before calling.
|
||||||
|
No legacy `updated` field is emitted (no backward compat) — version
|
||||||
|
counters still advance but are not sent over the wire.
|
||||||
|
"""
|
||||||
|
data = state_store.get(subsystem)
|
||||||
|
if data is None:
|
||||||
|
# Collector failed during the triggering populate/refresh — populate()
|
||||||
|
# cleared this subsystem to None (state.py). Skip the broadcast:
|
||||||
|
# a null payload would overwrite good client data. The next successful
|
||||||
|
# poll or mutation broadcasts the real value.
|
||||||
return
|
return
|
||||||
data = json.dumps({"type": "versions", "updated": updated})
|
message = json.dumps(
|
||||||
dead: set[web.WebSocketResponse] = set()
|
{
|
||||||
for ws in _ws_subscribers:
|
"type": "versions",
|
||||||
try:
|
"subsystem": subsystem,
|
||||||
await ws.send_str(data)
|
"data": data,
|
||||||
except Exception:
|
}
|
||||||
dead.add(ws)
|
)
|
||||||
_ws_subscribers.difference_update(dead)
|
await _send_all(message)
|
||||||
if dead:
|
|
||||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_tick(subsystems: list[str]) -> None:
|
async def broadcast_tick(subsystem: str) -> None:
|
||||||
"""Broadcast a lightweight tick to WS clients without version payload."""
|
"""Send {type: tick} with the changed subsystem data.
|
||||||
data = json.dumps({"type": "tick", "subsystems": subsystems})
|
|
||||||
dead: set[web.WebSocketResponse] = set()
|
No bump — tick is volatile-only; the version counter only bumps on
|
||||||
for ws in _ws_subscribers:
|
structural changes.
|
||||||
try:
|
"""
|
||||||
await ws.send_str(data)
|
data = state_store.get(subsystem)
|
||||||
except Exception:
|
message = json.dumps(
|
||||||
dead.add(ws)
|
{
|
||||||
_ws_subscribers.difference_update(dead)
|
"type": "tick",
|
||||||
if dead:
|
"subsystem": subsystem,
|
||||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
"data": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await _send_all(message)
|
||||||
|
|
||||||
|
|
||||||
async def _poll_loop(subsystem: str, interval: int) -> None:
|
async def _poll_loop(subsystem: str, interval: int) -> None:
|
||||||
@@ -512,9 +546,9 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
|
|||||||
structural, volatile = state_store.poll(subsystem)
|
structural, volatile = state_store.poll(subsystem)
|
||||||
if structural:
|
if structural:
|
||||||
state_store.bump(subsystem)
|
state_store.bump(subsystem)
|
||||||
await broadcast_versions()
|
await broadcast_versions(subsystem) # now per-subsystem, carries data
|
||||||
elif volatile:
|
elif volatile:
|
||||||
await broadcast_tick([subsystem])
|
await broadcast_tick(subsystem) # now per-subsystem, carries data
|
||||||
# Periodic blacklist cleanup — coordinated across all poll loops
|
# Periodic blacklist cleanup — coordinated across all poll loops
|
||||||
async with _last_blacklist_cleanup_lock:
|
async with _last_blacklist_cleanup_lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -565,11 +599,24 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
|||||||
body = await _request.json()
|
body = await _request.json()
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
body = None
|
body = None
|
||||||
subsystems = None
|
subsystems = body.get("subsystems") if body else None
|
||||||
if body and "subsystems" in body:
|
|
||||||
subsystems = body["subsystems"]
|
|
||||||
state_store.populate(subsystems)
|
state_store.populate(subsystems)
|
||||||
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
|
targets = subsystems or state_store.SUBSYSTEMS
|
||||||
|
snapshot = {name: state_store.get(name) for name in targets}
|
||||||
|
|
||||||
|
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
|
||||||
|
# Deliberately no version bump — versions advance on structural poll
|
||||||
|
# diffs and on refresh_state() only.
|
||||||
|
async def _broadcast_all():
|
||||||
|
await asyncio.gather(
|
||||||
|
*[broadcast_versions(name) for name in targets],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = asyncio.create_task(_broadcast_all())
|
||||||
|
task.add_done_callback(_ws_tasks.discard)
|
||||||
|
_ws_tasks.add(task)
|
||||||
|
return ok(snapshot)
|
||||||
|
|
||||||
|
|
||||||
async def _catch_all(request: web.Request) -> web.Response:
|
async def _catch_all(request: web.Request) -> web.Response:
|
||||||
|
|||||||
+49
-7
@@ -1947,6 +1947,40 @@ Apply pending changes for all subsystems in dependency order.
|
|||||||
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
||||||
| `errors` | `[object, ...]` | Any errors encountered during apply |
|
| `errors` | `[object, ...]` | Any errors encountered during apply |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Refresh State
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/status/refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's `POST /status/refresh`, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a `versions` WS delta for each so all connected viewers stay in sync.
|
||||||
|
|
||||||
|
**Request Body** (optional — `{}` or omitted refreshes all subsystems):
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `subsystems` | `[string, ...]` | No | Subsystem names to refresh (e.g., `["firewall"]`) |
|
||||||
|
|
||||||
|
**Response (`data`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| — | `object` | Map of the requested subsystem name(s) to its full state dict (`null` = collector not populated / failed) |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{"subsystems": ["firewall"]}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns HTTP `500` if the daemon is unreachable.
|
||||||
|
|
||||||
### Sysctl
|
### Sysctl
|
||||||
|
|
||||||
#### Set Kernel Parameter
|
#### Set Kernel Parameter
|
||||||
@@ -2069,14 +2103,22 @@ Returns HTTP `404` if the log file does not exist.
|
|||||||
|
|
||||||
## WebSocket Protocol
|
## WebSocket Protocol
|
||||||
|
|
||||||
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends:
|
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (`modelSet`) with no HTTP round-trip.
|
||||||
|
|
||||||
```json
|
### Handshake Authentication
|
||||||
{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
|
|
||||||
```
|
The JWT **access** token travels as the **raw `Sec-WebSocket-Protocol` subprotocol name** (the bundled client sends the bare token, no `Bearer ` prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy `Bearer <token>` subprotocol (non-browser clients) and an `X-Auth-Token` header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP `401` and no socket is opened.
|
||||||
|
|
||||||
### Message Types
|
### Message Types
|
||||||
|
|
||||||
- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
|
| Type | Sent | Fields | Meaning |
|
||||||
- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch.
|
|------|------|--------|---------|
|
||||||
- **`notify`** — Single-topic notification. `topic` is the subsystem name.
|
| `snapshot` | On connect (after auth) | `data: {subsystem: state\|null, …}` | Full state for every subsystem. `null` = collector not populated / failed — clients skip those entries. |
|
||||||
|
| `versions` | Structural change | `subsystem`, `data` | The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. |
|
||||||
|
| `tick` | Volatile-only change | `subsystem`, `data` | The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. |
|
||||||
|
|
||||||
|
There is no legacy `updated` dict or `subsystems` array — each data-carrying message names a single `subsystem` and carries its full `data`.
|
||||||
|
|
||||||
|
### Manual Refresh
|
||||||
|
|
||||||
|
`POST /api/status/refresh` re-collects state (optionally filtered by a `subsystems` array) and broadcasts a `versions` delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the [Status API — Refresh State](#refresh-state) section for the full request/response contract.
|
||||||
+12
-9
@@ -148,19 +148,22 @@ The daemon runs background polling tasks for subsystems with external runtime st
|
|||||||
| wireguard | 10s | Peer connections/handshakes change frequently |
|
| wireguard | 10s | Peer connections/handshakes change frequently |
|
||||||
| dnsmasq | 10s | Lease file + service status |
|
| dnsmasq | 10s | Lease file + service status |
|
||||||
| networkd | 10s | Interface up/down, DHCP address changes |
|
| networkd | 10s | Interface up/down, DHCP address changes |
|
||||||
|
| system | 1s | Real-time metrics (load/memory/swap/traffic) |
|
||||||
|
| nginx | 60s | Config-file drift self-heal (lazy in-place migration) |
|
||||||
|
| acme | 300s | Config-file drift self-heal (lazy in-place migration) |
|
||||||
|
|
||||||
nginx, acme, and auth are not polled — they have no external runtime state.
|
Only `auth` is not polled — it has no external runtime state.
|
||||||
|
|
||||||
**Two-layer diff:** Each poll cycle classifies changes as:
|
**Two-layer diff:** Each poll cycle classifies changes as:
|
||||||
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load
|
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", "subsystem": ..., "data": ...}` → daemon pushes the full subsystem data over WS; the client patches the model in place via `modelSet`
|
||||||
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch
|
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump
|
||||||
- **No change**: silence
|
- **No change**: silence
|
||||||
|
|
||||||
Volatile fields per subsystem: `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
|
Volatile fields per subsystem: `system` (load/memory/swap/traffic), `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
|
||||||
|
|
||||||
Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
|
Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
|
||||||
|
|
||||||
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
|
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`, and `broadcast_versions` additionally skips a `None` payload defensively (a null payload would clobber good client data — the next successful poll or mutation broadcasts the real value).
|
||||||
|
|
||||||
## System Config Import
|
## System Config Import
|
||||||
|
|
||||||
@@ -338,11 +341,11 @@ Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 wi
|
|||||||
Authenticated ──→ mounts #sidebar and #main render roots
|
Authenticated ──→ mounts #sidebar and #main render roots
|
||||||
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
||||||
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
||||||
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091?token=<access_token>)
|
Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer <token>` subprotocol + X-Auth-Token header fallbacks accepted)
|
||||||
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
|
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
|
||||||
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
|
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
|
||||||
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
|
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
|
||||||
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
|
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
|
||||||
```
|
```
|
||||||
|
|
||||||
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
|
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
|
||||||
@@ -355,9 +358,9 @@ Each route is a `definePage()` component with reactive state, async data loading
|
|||||||
|
|
||||||
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
|
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
|
||||||
|
|
||||||
### WebSocket Broadcast
|
### WebSocket Data Streaming
|
||||||
|
|
||||||
The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
|
The daemon pushes state over the WebSocket — no HTTP round-trip for auto-refresh. On connect, after the JWT handshake, it sends a full snapshot (`{"type": "snapshot", "data": {subsystem: state|null, …}}`). On every structural change it broadcasts a per-subsystem delta (`{"type": "versions", "subsystem": …, "data": …}`); on volatile-only changes it sends `{"type": "tick", "subsystem": …, "data": …}`. The client's `handleMessage` patches the matching reactive model in place via `modelSet()`, and the VDOM diff touches only the changed nodes. HTTP remains the fallback for the initial load (3s timer) and for reconnect recovery.
|
||||||
|
|
||||||
## Zone Model
|
## Zone Model
|
||||||
|
|
||||||
|
|||||||
@@ -534,6 +534,10 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
|||||||
|
|
||||||
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
|
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
|
||||||
|
|
||||||
|
Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
|
||||||
|
|
||||||
|
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
|
||||||
|
|
||||||
## Networkd (IP Configuration)
|
## Networkd (IP Configuration)
|
||||||
|
|
||||||
**File**: `config/network/config.json`
|
**File**: `config/network/config.json`
|
||||||
|
|||||||
+2
-2
@@ -176,8 +176,8 @@ Log in with the username and password you provided during installation.
|
|||||||
|
|
||||||
1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration
|
1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration
|
||||||
2. Confirm `data/auth.db` exists with admin user present
|
2. Confirm `data/auth.db` exists with admin user present
|
||||||
3. Nginx config no longer has `auth_basic` for management domain
|
3. Confirm the management server block has no server-level `auth_basic` directive — the management UI is authenticated by the Flask-layer JWT middleware, not nginx
|
||||||
4. WebSocket location no longer has `auth_basic off`
|
4. Confirm `location /ws` has `auth_basic off` — the WebSocket is authenticated by the daemon via the raw-JWT `Sec-WebSocket-Protocol` subprotocol, never by nginx
|
||||||
5. Access the WebUI at `https://<management-domain>` — should show a login page
|
5. Access the WebUI at `https://<management-domain>` — should show a login page
|
||||||
|
|
||||||
### Certificate Note
|
### Certificate Note
|
||||||
|
|||||||
+154
-91
@@ -11,9 +11,9 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
|
|||||||
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
|
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
|
||||||
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
||||||
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
||||||
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
|
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
|
||||||
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
|
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
|
||||||
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh, `disconnect()` (terminal-auth socket teardown) |
|
| WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) |
|
||||||
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
|
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
|
||||||
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
|
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
|
||||||
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
|
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
|
||||||
@@ -26,12 +26,12 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp
|
|||||||
```
|
```
|
||||||
index.html — static shell with #sidebar, #main, #modal-root
|
index.html — static shell with #sidebar, #main, #modal-root
|
||||||
└── app.js — SPA bootstrap
|
└── app.js — SPA bootstrap
|
||||||
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
||||||
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
||||||
├── modelFetch('firewall') / modelFetch('dnsmasq') / ...
|
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
|
||||||
├── render(sidebarEl, Sidebar) — sidebar render root
|
├── render(sidebarEl, Sidebar) — sidebar render root
|
||||||
├── render(mainEl, MainContent) — main content render root
|
├── render(mainEl, MainContent) — main content render root
|
||||||
└── connect() — WebSocket lifecycle
|
└── connect() — WebSocket lifecycle (snapshot → modelSet)
|
||||||
```
|
```
|
||||||
|
|
||||||
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots.
|
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots.
|
||||||
@@ -41,14 +41,16 @@ Each render root registers a render function via `render(container, fn)`. When r
|
|||||||
### Data Flow
|
### Data Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
|
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
|
||||||
→ reactivity proxy triggers render
|
(snapshot on connect, versions/tick deltas per subsystem)
|
||||||
→ page.render(state) reads model data
|
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()
|
||||||
```
|
```
|
||||||
|
|
||||||
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
|
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
|
||||||
|
|
||||||
Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh.
|
State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`).
|
||||||
|
|
||||||
|
Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`).
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
|
|
||||||
@@ -58,21 +60,52 @@ The app starts from `webui/static/app.js`:
|
|||||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
||||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
|
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
|
||||||
|
|
||||||
// 1. Register subsystem models
|
// 1. Register subsystem models. All state-backed models share the same
|
||||||
modelRegister('firewall', {
|
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
|
||||||
subsystem: 'firewall',
|
// primary data path is the WS snapshot + deltas (modelSet).
|
||||||
fetch: async () => {
|
const STATE_MODELS = [
|
||||||
const r = await apiFetch('/api/firewall/config');
|
{ name: 'firewall', subsystem: 'firewall' },
|
||||||
if (!r.ok) throw new Error(r.error);
|
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||||
return r.data;
|
{ name: 'nginx', subsystem: 'nginx' },
|
||||||
},
|
{ name: 'acme', subsystem: 'acme' },
|
||||||
});
|
{ name: 'wireguard', subsystem: 'wireguard' },
|
||||||
|
{ name: 'network', subsystem: 'networkd' },
|
||||||
|
{ name: 'system', subsystem: 'system' },
|
||||||
|
];
|
||||||
|
for (const { name, subsystem } of STATE_MODELS) {
|
||||||
|
modelRegister(name, {
|
||||||
|
subsystem,
|
||||||
|
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||||
|
fetch: async () => {
|
||||||
|
const r = await apiFetch('/api/status/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { subsystems: [subsystem] },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
const payload = r.data?.[subsystem];
|
||||||
|
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
|
||||||
|
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } });
|
||||||
|
|
||||||
|
```javascript
|
||||||
// ... more modelRegister calls ...
|
// ... more modelRegister calls ...
|
||||||
|
|
||||||
// 2. Initial fetch for all models
|
// 2. Initial data. State-backed models receive their first data via the WS
|
||||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
|
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
|
||||||
modelFetch(name);
|
// Non-state models fetch immediately.
|
||||||
|
function fetchInitialData() {
|
||||||
|
for (const { name } of STATE_MODELS) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const model = getModel(name);
|
||||||
|
if (model.loading) modelFetch(name); // snapshot not yet delivered
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
modelFetch('backends');
|
||||||
|
modelFetch('logs', 'journal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create reactive router state
|
// 3. Create reactive router state
|
||||||
@@ -130,33 +163,39 @@ Manually schedule a re-render. Only one microtask is queued regardless of how ma
|
|||||||
|
|
||||||
## Model
|
## Model
|
||||||
|
|
||||||
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles fetching, WS invalidation, loading states, and in-flight dedup.
|
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup.
|
||||||
|
|
||||||
### `modelRegister(name, definition)`
|
### `modelRegister(name, definition)`
|
||||||
|
|
||||||
Register a subsystem model at app bootstrap.
|
Register a subsystem model at app bootstrap.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
// State-backed model — the fetch below is the HTTP *fallback* (POST
|
||||||
|
// /api/status/refresh with a subsystem filter); the primary path is the WS
|
||||||
|
// snapshot + per-subsystem deltas applied via modelSet().
|
||||||
modelRegister('firewall', {
|
modelRegister('firewall', {
|
||||||
subsystem: 'firewall', // WS topic to listen for ('*' = all)
|
subsystem: 'firewall', // daemon subsystem ('*' = all)
|
||||||
fetch: async (signal) => { // async fetch function
|
defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data
|
||||||
const r = await apiFetch('/api/firewall/config', { signal });
|
fetch: async (signal) => { // HTTP fallback
|
||||||
|
const r = await apiFetch('/api/status/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { subsystems: ['firewall'] },
|
||||||
|
});
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
return r.data;
|
return r.data?.firewall; // null → throw so stale data is kept
|
||||||
},
|
},
|
||||||
defaultData: null, // optional, initial data value
|
|
||||||
// onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null)
|
// onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null)
|
||||||
// onFailure: (name, error) => { }, // optional — after model.error is set (real throws only)
|
// onFailure: (name, error) => { }, // optional — after model.error is set (real throws only)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parameterized example — tab-aware fetch:
|
// Parameterized example — tab-aware fetch (non-state model):
|
||||||
modelRegister('logs', {
|
modelRegister('logs', {
|
||||||
subsystem: '*',
|
subsystem: '*',
|
||||||
fetch: async (signal, tab) => {
|
fetch: async (signal, tab) => {
|
||||||
const url = LOG_TABS[tab || 'journal'];
|
const url = LOG_TABS[tab || 'journal'];
|
||||||
const r = await apiFetch(url, { signal });
|
const r = await apiFetch(url, { signal });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
return (r.data || '').split('\n').filter(l => l.length > 0);
|
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -164,7 +203,7 @@ modelRegister('logs', {
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
|
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
|
||||||
| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. |
|
| `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) |
|
||||||
| `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). |
|
| `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). |
|
||||||
| `definition.defaultData` | Optional initial data value (default: `null`) |
|
| `definition.defaultData` | Optional initial data value (default: `null`) |
|
||||||
| `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. |
|
| `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. |
|
||||||
@@ -172,7 +211,7 @@ modelRegister('logs', {
|
|||||||
|
|
||||||
### `getModel(name)`
|
### `getModel(name)`
|
||||||
|
|
||||||
Get a reactive model by name. Returns the model object with `{ data, loading, refresh, error }` properties. Call in `init()` to access model state in `render()`.
|
Get a reactive model by name. Throws if not registered. Returns the model object with `{ data, loading, refreshing, error }` properties. Call in `init()` to access model state in `render()`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// In page init
|
// In page init
|
||||||
@@ -197,18 +236,24 @@ render(state) {
|
|||||||
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
|
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Initial load
|
// HTTP fallback for a state-backed model (WS snapshot is the primary path;
|
||||||
|
// app.js kicks in with modelFetch(name) if no snapshot arrives within 3s)
|
||||||
modelFetch('firewall');
|
modelFetch('firewall');
|
||||||
|
|
||||||
// Post-mutation refresh
|
// Non-state models fetch directly (not backed by the daemon state store)
|
||||||
const r = await apiFetch('/api/firewall/zones', { method: 'POST', body });
|
modelFetch('backends');
|
||||||
if (r.ok) modelFetch('firewall');
|
|
||||||
|
|
||||||
// Parameterized fetch (e.g., tab-aware logs)
|
|
||||||
modelFetch('logs', 'journal');
|
modelFetch('logs', 'journal');
|
||||||
modelFetch('logs', 'nginx-access');
|
modelFetch('logs', 'nginx-access');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`,
|
||||||
|
> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem
|
||||||
|
> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the
|
||||||
|
> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and
|
||||||
|
> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is
|
||||||
|
> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a
|
||||||
|
> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`.
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
|
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
|
||||||
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
|
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
|
||||||
@@ -219,9 +264,32 @@ modelFetch('logs', 'nginx-access');
|
|||||||
- Does not abort in-progress fetches — other consumers may still need the data.
|
- Does not abort in-progress fetches — other consumers may still need the data.
|
||||||
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key.
|
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key.
|
||||||
|
|
||||||
|
### `modelSet(name, data)`
|
||||||
|
|
||||||
|
Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no
|
||||||
|
`fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a
|
||||||
|
re-render. Clears `model.loading` unconditionally on arrival of real data and resets
|
||||||
|
`model.error` to `null`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Called by websocket.js for every WS snapshot / delta — usually you will not call this
|
||||||
|
modelSet('firewall', payload); // payload: the subsystem state object
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. |
|
||||||
|
| `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. |
|
||||||
|
|
||||||
|
`websocket.js` maps subsystem → model name (`networkd` → `network`), and never applies a
|
||||||
|
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
|
||||||
|
**WS Data Streaming Flow** below.
|
||||||
|
|
||||||
### `refreshByTopic(topic)`
|
### `refreshByTopic(topic)`
|
||||||
|
|
||||||
Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives.
|
Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for
|
||||||
|
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via
|
||||||
|
`modelSet` instead).
|
||||||
|
|
||||||
| Model `subsystem` | Topic | Match? |
|
| Model `subsystem` | Topic | Match? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -363,7 +431,7 @@ html`<div class="card">
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
html`<${Badge} text=${val} variant="info" />`
|
html`<${Badge} text=${val} variant="info" />`
|
||||||
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
|
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />`
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
|
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
|
||||||
@@ -489,7 +557,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
|
|||||||
|
|
||||||
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
|
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
|
||||||
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
|
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
|
||||||
3. **WS auto-refresh**: Topic message arrives → `refreshByTopic()` → `modelFetch()` for matching models → `model.data` update → reactivity triggers `render()`.
|
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
|
||||||
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
||||||
|
|
||||||
### `hComp(renderer, key)`
|
### `hComp(renderer, key)`
|
||||||
@@ -562,37 +630,31 @@ socket must be closed explicitly on a terminal transition; `app.js` listens for
|
|||||||
|
|
||||||
### WS Message Types
|
### WS Message Types
|
||||||
|
|
||||||
| Type | Fields | Effect |
|
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
|
||||||
|---|---|---|
|
|
||||||
| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics |
|
|
||||||
| `refresh` | `topics: [topic, …]` | Same as `versions` |
|
|
||||||
| `notify` | `topic` | Refresh all models matching the topic |
|
|
||||||
| `status` | `topic` | Refresh all models matching the topic |
|
|
||||||
|
|
||||||
Model `subsystem: '*'` matches all topics.
|
| Type | Fields | When sent | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `snapshot` | `data: {subsystem: state \| null, …}` | Once on connect (after JWT handshake) | `modelSet()` for every subsystem; `null` payloads (failed collectors) are skipped |
|
||||||
|
| `versions` | `subsystem`, `data` | Structural change (config mutated, bump detected) | `modelSet()` for the matching model |
|
||||||
|
| `tick` | `subsystem`, `data` | Volatile-only change (e.g., `system` metrics at 1s cadence) | `modelSet()` for the matching model |
|
||||||
|
|
||||||
### WS Auto-Refresh Flow
|
Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`,
|
||||||
|
`status`) are ignored — no backward compat.
|
||||||
|
|
||||||
When a WS message arrives for a topic:
|
System name → model name mapping is handled internally (`networkd` → `network`); unknown
|
||||||
1. `refreshByTopic(topic)` iterates registered models.
|
subsystem names fall through to the raw name.
|
||||||
2. Matching models call `modelFetch(name)`.
|
|
||||||
3. Model fetch updates `model.data`, triggering reactivity and page re-renders.
|
|
||||||
4. In-flight dedup prevents duplicate fetches.
|
|
||||||
|
|
||||||
Pages have no awareness of WS events. The model layer handles all WS-driven refresh.
|
### WS Data Streaming Flow
|
||||||
|
|
||||||
### `onMessage(topics, handler)`
|
When a data-carrying WS message arrives:
|
||||||
|
1. `handleMessage()` maps the subsystem to its model name.
|
||||||
|
2. `modelSet(name, data)` replaces `model.data` in place — no fetch, no `loading`/`refreshing` churn.
|
||||||
|
3. Reactivity detects the change and re-renders the pages reading that model.
|
||||||
|
4. A `null` payload is never applied — it means the collector failed and stale good data is kept.
|
||||||
|
|
||||||
Direct one-off subscription for code outside `definePage`:
|
Pages have no awareness of WS events. Initial load uses `modelFetch` over HTTP (a 3-second timer
|
||||||
|
in `app.js` kicks in if no snapshot has arrived yet); afterwards the WS stream is the sole
|
||||||
```javascript
|
auto-refresh path for state-backed models.
|
||||||
const unsub = onMessage(['firewall'], (msg) => {
|
|
||||||
// handle raw message
|
|
||||||
});
|
|
||||||
// Later: unsub();
|
|
||||||
```
|
|
||||||
|
|
||||||
Handler receives the parsed WS message object.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -633,7 +695,7 @@ function MainContent() {
|
|||||||
|
|
||||||
### `apiSubmit(config)`
|
### `apiSubmit(config)`
|
||||||
|
|
||||||
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success. After success, refreshes the named model(s).
|
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts (appending an auto-synced note when the response includes a `synced` array), and closes the modal on success. Affected state-backed models update from the daemon's WS delta — no explicit `modelFetch`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
apiSubmit({
|
apiSubmit({
|
||||||
@@ -642,7 +704,6 @@ apiSubmit({
|
|||||||
body: () => ({ name: $val('zone-name') }),
|
body: () => ({ name: $val('zone-name') }),
|
||||||
validate: (b) => !b.name ? 'Name required' : null,
|
validate: (b) => !b.name ? 'Name required' : null,
|
||||||
successMsg: 'Zone created',
|
successMsg: 'Zone created',
|
||||||
refresh: 'firewall', // model name(s) to refresh after success
|
|
||||||
closeModal: () => closeModal(), // optional, called after success toast
|
closeModal: () => closeModal(), // optional, called after success toast
|
||||||
}),
|
}),
|
||||||
```
|
```
|
||||||
@@ -658,10 +719,13 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
|
|||||||
| `body` | `() => body` function, or `undefined` for no body |
|
| `body` | `() => body` function, or `undefined` for no body |
|
||||||
| `validate` | `(body) => string | null` — validation function |
|
| `validate` | `(body) => string | null` — validation function |
|
||||||
| `successMsg` | Success toast message |
|
| `successMsg` | Success toast message |
|
||||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
|
||||||
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
|
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
|
||||||
| `submitText` | Submit button text (default: `'Submit'`) |
|
| `submitText` | Submit button text (default: `'Submit'`) |
|
||||||
|
|
||||||
|
> The legacy `refresh` option is no longer supported — state-backed models are
|
||||||
|
> updated by the WS delta after the mutation. To refresh a non-state model after
|
||||||
|
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
|
||||||
|
|
||||||
### `checkAbort(ac)`
|
### `checkAbort(ac)`
|
||||||
|
|
||||||
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||||||
@@ -725,7 +789,7 @@ poll({
|
|||||||
onErrorKey: (d) => d.status === 'failed',
|
onErrorKey: (d) => d.status === 'failed',
|
||||||
onComplete: (d) => {
|
onComplete: (d) => {
|
||||||
toast('Certificate issued', 'success');
|
toast('Certificate issued', 'success');
|
||||||
modelFetch('acme');
|
// No modelFetch — the WS delta updates the acme model (state-backed).
|
||||||
},
|
},
|
||||||
onError: (d) => {
|
onError: (d) => {
|
||||||
toast('Issuance failed', 'error');
|
toast('Issuance failed', 'error');
|
||||||
@@ -824,7 +888,7 @@ Flex button container with 8px gap. Accepts VNode children directly.
|
|||||||
```javascript
|
```javascript
|
||||||
ActionGroup(
|
ActionGroup(
|
||||||
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
||||||
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
|
ActionButton({ url: '/api/apply', label: 'Apply' }),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -889,15 +953,16 @@ Card container with optional header.
|
|||||||
|
|
||||||
#### `ConfirmDelete(props)`
|
#### `ConfirmDelete(props)`
|
||||||
|
|
||||||
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. Shows a spinner animation during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion until the model refresh removes it from the DOM.
|
Delete button with native `confirm()` dialog, then API `DELETE` call and a success toast (appending an auto-synced note when the response includes a `synced` array). Shows a spinner during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion. State-backed models update from the daemon's WS delta — no `modelFetch`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ConfirmDelete({
|
ConfirmDelete({
|
||||||
url: '/api/firewall/zones/myzone',
|
url: '/api/firewall/zones/myzone',
|
||||||
message: 'Delete zone myzone?',
|
message: 'Delete zone myzone?',
|
||||||
success: 'Zone deleted',
|
success: 'Zone deleted',
|
||||||
refresh: 'firewall',
|
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
deleteKey: 'myzone',
|
||||||
|
onComplete: () => { /* optional, runs after successful delete */ },
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -908,14 +973,15 @@ ConfirmDelete({
|
|||||||
| `url` | API DELETE URL |
|
| `url` | API DELETE URL |
|
||||||
| `message` | Confirmation prompt text |
|
| `message` | Confirmation prompt text |
|
||||||
| `success` | Success toast message (default: `'Removed'`) |
|
| `success` | Success toast message (default: `'Removed'`) |
|
||||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
|
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||||||
| `label` | Button text (default: `'Remove'`) |
|
| `label` | Button text (default: `'Remove'`) |
|
||||||
| `body` | Optional JSON body to send with DELETE |
|
| `body` | Optional JSON body to send with DELETE |
|
||||||
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success until model refresh removes it from the DOM. Requires `_deleting.has(key)` class binding on the parent element. |
|
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success; the mark is auto-purged after 2s (the WS delta normally removes the row sooner). Requires `_deleting.has(key)` class binding on the parent element. |
|
||||||
|
| `onComplete` | Callback after a successful deletion. Wire it to `modelFetch()` for non-state models. |
|
||||||
|
|
||||||
#### `ActionButton(props)`
|
#### `ActionButton(props)`
|
||||||
|
|
||||||
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. Shows a spinner animation during API calls and auto-disables the button to prevent double-submit.
|
Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ActionButton({
|
ActionButton({
|
||||||
@@ -925,7 +991,7 @@ ActionButton({
|
|||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
successMsg: 'Applied',
|
successMsg: 'Applied',
|
||||||
errorType: 'error', // optional, defaults to 'error'
|
errorType: 'error', // optional, defaults to 'error'
|
||||||
refresh: 'dnsmasq', // model name(s) to refresh
|
onSuccess: () => { /* optional, runs after the success toast */ },
|
||||||
cls: 'btn btn-outline', // optional
|
cls: 'btn btn-outline', // optional
|
||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
})
|
||||||
@@ -937,7 +1003,6 @@ ActionButton({
|
|||||||
labelOn: 'Disable',
|
labelOn: 'Disable',
|
||||||
labelOff: 'Enable',
|
labelOff: 'Enable',
|
||||||
condition: z.masquerade,
|
condition: z.masquerade,
|
||||||
refresh: 'firewall',
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -953,7 +1018,8 @@ ActionButton({
|
|||||||
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
||||||
| `successMsg` | Success toast message |
|
| `successMsg` | Success toast message |
|
||||||
| `errorType` | Toast type for errors (default: `'error'`) |
|
| `errorType` | Toast type for errors (default: `'error'`) |
|
||||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||||||
|
| `onSuccess` | Callback after the success toast. Wire it to `modelFetch()` for non-state models (e.g., `backends`). |
|
||||||
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
||||||
| `disabled` | Disabled state |
|
| `disabled` | Disabled state |
|
||||||
|
|
||||||
@@ -968,8 +1034,8 @@ ActionCell({
|
|||||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||||
removeSuccess: 'Domain removed',
|
removeSuccess: 'Domain removed',
|
||||||
removeRefresh: 'proxy',
|
|
||||||
removeLabel: 'Delete',
|
removeLabel: 'Delete',
|
||||||
|
deleteKey: d.domain,
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -982,7 +1048,7 @@ ActionCell({
|
|||||||
| `removeUrl` | API DELETE URL |
|
| `removeUrl` | API DELETE URL |
|
||||||
| `removeMessage` | Confirmation prompt text |
|
| `removeMessage` | Confirmation prompt text |
|
||||||
| `removeSuccess` | Success toast message |
|
| `removeSuccess` | Success toast message |
|
||||||
| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete |
|
| `removeRefresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||||||
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
||||||
| `removeBody` | Optional JSON body to send with DELETE |
|
| `removeBody` | Optional JSON body to send with DELETE |
|
||||||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
||||||
@@ -1098,7 +1164,6 @@ Table({
|
|||||||
url: '/api/item/' + enc(i.id),
|
url: '/api/item/' + enc(i.id),
|
||||||
message: 'Delete ' + esc(i.name) + '?',
|
message: 'Delete ' + esc(i.name) + '?',
|
||||||
success: 'Item removed',
|
success: 'Item removed',
|
||||||
refresh: 'firewall',
|
|
||||||
})),
|
})),
|
||||||
)),
|
)),
|
||||||
emptyText: 'No items',
|
emptyText: 'No items',
|
||||||
@@ -1167,7 +1232,6 @@ const addZone = QuickModal({
|
|||||||
validate: (b) => !b.name ? 'Name required' : null,
|
validate: (b) => !b.name ? 'Name required' : null,
|
||||||
successMsg: 'Zone created', // or (data) => string
|
successMsg: 'Zone created', // or (data) => string
|
||||||
},
|
},
|
||||||
refresh: 'firewall', // model name(s) to refresh after success
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usage in render:
|
// Usage in render:
|
||||||
@@ -1183,9 +1247,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
|||||||
| `submit.url` | API URL or `(data) => string` |
|
| `submit.url` | API URL or `(data) => string` |
|
||||||
| `submit.method` | HTTP method (default: `'POST'`) |
|
| `submit.method` | HTTP method (default: `'POST'`) |
|
||||||
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
|
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
|
||||||
| `submit.validate` | `(body) => string | null`, validation function |
|
| `submit.validate` | `(body) => string \| null`, validation function |
|
||||||
| `submit.successMsg` | Success toast message or `(data) => string` |
|
| `submit.successMsg` | Success toast message or `(data) => string` |
|
||||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
|
||||||
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
||||||
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
||||||
|
|
||||||
@@ -1201,7 +1265,6 @@ const editIface = MultiSelectModal({
|
|||||||
selected: zone.interfaces,
|
selected: zone.interfaces,
|
||||||
fieldKey: 'interfaces',
|
fieldKey: 'interfaces',
|
||||||
successMsg: 'Interfaces updated',
|
successMsg: 'Interfaces updated',
|
||||||
refresh: 'firewall',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usage:
|
// Usage:
|
||||||
@@ -1218,7 +1281,7 @@ h('button', { 'on:click': editIface }, 'Edit')
|
|||||||
| `selected` | Currently selected values (`string[]`) |
|
| `selected` | Currently selected values (`string[]`) |
|
||||||
| `fieldKey` | JSON key for the submitted field |
|
| `fieldKey` | JSON key for the submitted field |
|
||||||
| `successMsg` | Success toast message (default: `'Updated'`) |
|
| `successMsg` | Success toast message (default: `'Updated'`) |
|
||||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
|
||||||
|
|
||||||
### Toast
|
### Toast
|
||||||
|
|
||||||
@@ -1247,10 +1310,10 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
||||||
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. The model layer handles fetching, loading states, error handling, and WS-driven refresh. Pages never call `apiFetch` in `load()`.
|
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
|
||||||
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
||||||
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
|
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
|
||||||
- **Mutation refresh**: UI components use `refresh: 'model_name'` to trigger `modelFetch()` after API mutations. Accepts single name or array.
|
- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`).
|
||||||
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
|
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
|
||||||
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
|
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
|
||||||
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
|
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
|
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx (TLS termination only — management authentication is a Flask-layer JWT, not nginx basic auth; individual proxy domains may optionally configure their own basic auth).
|
||||||
|
|
||||||
## Subsystems
|
## Subsystems
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ ACME certificate operations via `acme.sh` run as the daemon user — not as root
|
|||||||
|
|
||||||
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
|
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
|
||||||
|
|
||||||
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token as a query parameter for validation before upgrade.
|
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades.
|
||||||
|
|
||||||
## Communication Between WebUI and Daemon
|
## Communication Between WebUI and Daemon
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# State Model Reference
|
||||||
|
|
||||||
|
Authoritative reference for the shapes returned by the daemon's
|
||||||
|
pre-computed state store (`lib/state.py`), collected per subsystem and
|
||||||
|
pushed over the WebSocket (snapshot on connect, per-subsystem deltas
|
||||||
|
after every change).
|
||||||
|
|
||||||
|
Python schemas live in `lib/schema.py` (TypedDicts); each collector's
|
||||||
|
return annotation references them.
|
||||||
|
|
||||||
|
## Shared notes
|
||||||
|
|
||||||
|
- Every collector return carries a top-level `timestamp` (ISO-8601).
|
||||||
|
- Subsystems with a declarative config expose pending state as a status
|
||||||
|
dict: `status: {"pending_changes": bool}`, **except firewall**, which
|
||||||
|
uses `pending: {config_pending() result}`.
|
||||||
|
- A subsystem whose collection failed holds `null`/`None` in the state
|
||||||
|
store — WS snapshots and deltas skip `null` payloads so a failed
|
||||||
|
collector never overwrites good client data.
|
||||||
|
|
||||||
|
## State shape summary
|
||||||
|
|
||||||
|
`state_store.get(<subsystem>)` returns:
|
||||||
|
|
||||||
|
| Subsystem | Poll | Volatile fields | Top-level keys |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `zones`, `rich_rules`, `pending`, `timestamp` |
|
||||||
|
| `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` |
|
||||||
|
| `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` |
|
||||||
|
| `acme` | 300s | *(none)* | `certs`, `email`, `account`, `timestamp` |
|
||||||
|
| `wireguard` | 10s | `status.peers[].transfer_received`/`.transfer_sent`/`.latest_handshake` and the same three under `status.classes[].peers[]` | `config`, `status`, `peers`, `timestamp` |
|
||||||
|
| `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` |
|
||||||
|
| `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` |
|
||||||
|
|
||||||
|
Poll intervals are overridable via `VACUUM_WALL_POLL_INTERVALS`
|
||||||
|
(`subsystem:seconds,subsystem:seconds`).
|
||||||
|
|
||||||
|
## Firewall
|
||||||
|
|
||||||
|
Top-level `FirewallState`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
config: {}, // config/firewall/config.json
|
||||||
|
active_zones: {zone: [iface]}, // zones with assigned interfaces
|
||||||
|
interfaces: [ // ip link/addr parsing
|
||||||
|
{name, mac, state, mtu, ips, ipv6, zone}
|
||||||
|
],
|
||||||
|
available_services: [str], // firewall-cmd --get-services
|
||||||
|
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
|
||||||
|
// may carry "sources", "ports",
|
||||||
|
// "protocols", "forward-ports", "ics",
|
||||||
|
// "icmp-blocks", "module", "rich-rules"
|
||||||
|
rich_rules: {zone: [str]}, // raw firewalld rich-rule strings,
|
||||||
|
// re-derived from zones (NO ids —
|
||||||
|
// deletion-by-id uses config.zones[].rich_rules)
|
||||||
|
pending: { // config_pending() (lib/firewall.py)
|
||||||
|
pending: [...], needs_apply: bool,
|
||||||
|
unmanaged_zones: {zone: {interfaces: [...]}}
|
||||||
|
},
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `interfaces[].ips` / `interfaces[].ipv6` hold `"ip/prefix"` strings
|
||||||
|
(IPv6 list is separate).
|
||||||
|
- The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`);
|
||||||
|
`state.rich_rules` is the snake_case top-level re-derivation.
|
||||||
|
|
||||||
|
## Dnsmasq
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
config: {}, // config/dnsmasq/config.json, deep-merged
|
||||||
|
status: {
|
||||||
|
service_active: bool, config_file_exists: bool,
|
||||||
|
active_leases: int, pending_changes: bool
|
||||||
|
},
|
||||||
|
leases: [
|
||||||
|
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
|
||||||
|
],
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
config: {}, // config/nginx/config.json
|
||||||
|
domains: [ // flattened: one entry per domain+path
|
||||||
|
{domain, path, backend, online, force_ssl, backend_name, cert,
|
||||||
|
[is_management], [is_websocket]}
|
||||||
|
],
|
||||||
|
status: {pending_changes: bool},
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ACME
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
certs: [ // list_certs(); extra keys possible
|
||||||
|
{domain, expiry, renewed, status, days_remaining, ...}
|
||||||
|
],
|
||||||
|
email: str,
|
||||||
|
account: {registered, email, ca, key_length},
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WireGuard
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
config: {}, // private_key stripped from interface
|
||||||
|
// AND every access class
|
||||||
|
status: {
|
||||||
|
up: bool, // true when ANY managed iface is up
|
||||||
|
interface: {}, peers: [], // legacy single interface (wg0)
|
||||||
|
classes: {class: {up, interface, peers}}, // per wg-<class>
|
||||||
|
pending_changes: bool
|
||||||
|
},
|
||||||
|
peers: [ // config peers, private keys stripped
|
||||||
|
{name, public_key, endpoint, allowed_ips,
|
||||||
|
persistent_keepalive, preshared_key, ...}
|
||||||
|
],
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime peers (`status.peers[]`, `status.classes[].peers[]`) carry:
|
||||||
|
`public_key`, `endpoint`, `allowed_ips`, `latest_handshake`,
|
||||||
|
`transfer_received`, `transfer_sent`, `persistent_keepalive`.
|
||||||
|
|
||||||
|
## Networkd
|
||||||
|
|
||||||
|
Matches `parse_networkctl_status()` output (lib/network.py) exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
config: {}, // config/network/config.json
|
||||||
|
interfaces: {iface: { // flat runtime entry per interface;
|
||||||
|
addresses: ["ip/prefix"], // a single combined addresses list
|
||||||
|
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
|
||||||
|
state, link}
|
||||||
|
},
|
||||||
|
status: {pending_changes: bool},
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The parser does not filter `lo`; clients that don't want it filter
|
||||||
|
client-side.
|
||||||
|
|
||||||
|
## System
|
||||||
|
|
||||||
|
Metrics only — no config, no pending state.
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
load: {load1, load5, load15},
|
||||||
|
memory: {total, available, used, used_pct}, // bytes; 0-100
|
||||||
|
swap: {total, used, used_pct}, // bytes; 0-100
|
||||||
|
traffic: {iface: {rx_bytes, tx_bytes,
|
||||||
|
rx_packets, tx_packets}},
|
||||||
|
timestamp: str,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All four metric fields are volatile (1s tick cadence); structural diffs
|
||||||
|
only fire on interface-set changes.
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
"""TypedDict schemas for every state collector's return value.
|
||||||
|
|
||||||
|
Single source of truth for the state-store data shapes. The Markdown
|
||||||
|
reference is ``docs/state-model.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AcmeAccount",
|
||||||
|
"AcmeCert",
|
||||||
|
"AcmeState",
|
||||||
|
"CpuLoad",
|
||||||
|
"DnsmasqDhcpLease",
|
||||||
|
"DnsmasqState",
|
||||||
|
"DnsmasqStatus",
|
||||||
|
"FirewallInterface",
|
||||||
|
"FirewallState",
|
||||||
|
"FirewallZone",
|
||||||
|
"MemoryStats",
|
||||||
|
"NetworkdInterface",
|
||||||
|
"NetworkdState",
|
||||||
|
"NginxDomain",
|
||||||
|
"NginxState",
|
||||||
|
"SwapStats",
|
||||||
|
"SystemState",
|
||||||
|
"TrafficStats",
|
||||||
|
"WgClassStatus",
|
||||||
|
"WgPeer",
|
||||||
|
"WgState",
|
||||||
|
"WgStatus",
|
||||||
|
"WgStatusPeer",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shared notes ───────────────────────────────────────────────
|
||||||
|
# Every collector return carries a top-level `timestamp` (ISO-8601).
|
||||||
|
# Subsystems with a declarative config expose pending state as a
|
||||||
|
# status dict: `status: {"pending_changes": bool}`, except firewall,
|
||||||
|
# which uses `pending: {config_pending() result}`.
|
||||||
|
|
||||||
|
# ── Firewall ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallInterface(TypedDict):
|
||||||
|
"""A network interface as parsed from `ip link` / `ip addr`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Interface name (e.g. "eth0").
|
||||||
|
mac: MAC address, or ``None`` if unavailable.
|
||||||
|
state: Link state from `ip link` ("UP", "DOWN", "UNKNOWN", ...).
|
||||||
|
mtu: MTU value, or ``None`` if unavailable.
|
||||||
|
ips: IPv4 addresses as "ip/prefix" strings.
|
||||||
|
ipv6: IPv6 addresses as "ip/prefix" strings.
|
||||||
|
zone: Assigned firewalld zone name, or ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
mac: str | None
|
||||||
|
state: str
|
||||||
|
mtu: int | None
|
||||||
|
ips: list[str]
|
||||||
|
ipv6: list[str]
|
||||||
|
zone: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallZone(TypedDict, total=False):
|
||||||
|
"""A firewalld zone as parsed from `--list-all-zones`.
|
||||||
|
|
||||||
|
The zone dict carries the HYPHENATED key "rich-rules" (see
|
||||||
|
``lib.firewall._parse_all_zones_output``), which TypedDict fields
|
||||||
|
cannot express. Additional firewalld keys may also appear:
|
||||||
|
"sources", "ports", "protocols", "forward-ports", "ics",
|
||||||
|
"icmp-blocks", "module", "rich-rules".
|
||||||
|
"""
|
||||||
|
|
||||||
|
target: str
|
||||||
|
interfaces: list[str]
|
||||||
|
services: list[str]
|
||||||
|
masquerade: bool
|
||||||
|
# snake_case `rich_rules` exists only at the top-level FirewallState
|
||||||
|
# (collector re-derivation, lib/state.py); the zone dict itself uses
|
||||||
|
# the hyphenated "rich-rules" key.
|
||||||
|
rich_rules: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallState(TypedDict):
|
||||||
|
"""Complete firewalld state (collector: `_collect_firewall`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: Contents of config/firewall/config.json.
|
||||||
|
active_zones: Zone name → assigned interfaces.
|
||||||
|
default_zone: firewalld default zone name ("--get-default-zone");
|
||||||
|
catch-all zone for interfaces with no explicit assignment.
|
||||||
|
interfaces: All system interfaces (see FirewallInterface).
|
||||||
|
available_services: firewalld service catalog ("--get-services").
|
||||||
|
zones: All zones as runtime dicts (see FirewallZone).
|
||||||
|
rich_rules: Zone name → raw firewalld rich-rule strings.
|
||||||
|
pending: config_pending() result:
|
||||||
|
``{pending: [...], needs_apply: bool,
|
||||||
|
unmanaged_zones: {zone: {interfaces: [...]}}}``.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: dict[str, Any]
|
||||||
|
active_zones: dict[str, list[str]]
|
||||||
|
default_zone: str
|
||||||
|
interfaces: list[FirewallInterface]
|
||||||
|
available_services: list[str]
|
||||||
|
zones: dict[str, FirewallZone]
|
||||||
|
rich_rules: dict[str, list[str]]
|
||||||
|
pending: dict[str, Any]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dnsmasq ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DnsmasqDhcpLease(TypedDict):
|
||||||
|
"""A single dnsmasq DHCP lease from the lease file.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
expires: ISO-8601 expiry timestamp, or "" if unparseable.
|
||||||
|
mac: Client MAC address.
|
||||||
|
ip: Leased IP address.
|
||||||
|
hostname: Client hostname (may be "").
|
||||||
|
interface: Interface the lease was granted on (may be "").
|
||||||
|
"""
|
||||||
|
|
||||||
|
expires: str
|
||||||
|
mac: str
|
||||||
|
ip: str
|
||||||
|
hostname: str
|
||||||
|
interface: str
|
||||||
|
|
||||||
|
|
||||||
|
class DnsmasqStatus(TypedDict):
|
||||||
|
"""Dnsmasq service status snapshot.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
service_active: Whether the dnsmasq systemd unit is active.
|
||||||
|
config_file_exists: Whether the rendered .conf is on disk.
|
||||||
|
active_leases: Count of currently active leases.
|
||||||
|
pending_changes: Whether the config is dirty vs the applied state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_active: bool
|
||||||
|
config_file_exists: bool
|
||||||
|
active_leases: int
|
||||||
|
pending_changes: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DnsmasqState(TypedDict):
|
||||||
|
"""Dnsmasq state (collector: `_collect_dnsmasq`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: config/dnsmasq/config.json, deep-merged with defaults.
|
||||||
|
status: Service/config status (see DnsmasqStatus).
|
||||||
|
leases: Active DHCP leases.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: dict[str, Any]
|
||||||
|
status: DnsmasqStatus
|
||||||
|
leases: list[DnsmasqDhcpLease]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nginx ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NginxDomain(TypedDict, total=False):
|
||||||
|
"""One flattened domain+path entry (see `_resolve_paths`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
domain: Domain name (config key).
|
||||||
|
path: Path prefix for this entry.
|
||||||
|
backend: Resolved backend config dict.
|
||||||
|
online: Whether a site .conf exists on disk.
|
||||||
|
force_ssl: Redirect-to-HTTPS flag.
|
||||||
|
backend_name: Backend config key this domain points at.
|
||||||
|
cert: Certificate reference (e.g. "acme", "selfsigned", ...).
|
||||||
|
is_management: Management UI path marker.
|
||||||
|
is_websocket: WebSocket-capable path marker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
path: str
|
||||||
|
backend: dict[str, Any]
|
||||||
|
online: bool
|
||||||
|
force_ssl: bool
|
||||||
|
backend_name: str
|
||||||
|
cert: str | None
|
||||||
|
is_management: bool
|
||||||
|
is_websocket: bool
|
||||||
|
|
||||||
|
|
||||||
|
class NginxState(TypedDict):
|
||||||
|
"""Nginx state (collector: `_collect_nginx`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: config/nginx/config.json.
|
||||||
|
domains: Flattened domain entries (one per domain+path).
|
||||||
|
status: ``{"pending_changes": bool}``.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: dict[str, Any]
|
||||||
|
domains: list[NginxDomain]
|
||||||
|
status: dict[str, Any]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── ACME ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeAccount(TypedDict):
|
||||||
|
"""ACME account status (see `_parse_account_conf`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
registered: Whether an account exists.
|
||||||
|
email: Registered email ("" when not registered).
|
||||||
|
ca: Human-readable CA name ("" when not registered).
|
||||||
|
key_length: Account key size, or ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
registered: bool
|
||||||
|
email: str
|
||||||
|
ca: str
|
||||||
|
key_length: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeCert(TypedDict, total=False):
|
||||||
|
"""One certificate entry from `list_certs()` output.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
domain: Certificate domain name.
|
||||||
|
expiry: Expiry date string.
|
||||||
|
renewed: Last renewal date string.
|
||||||
|
status: "valid" | "expired" | "active" | ...
|
||||||
|
days_remaining: Days until expiry.
|
||||||
|
|
||||||
|
Additional keys from `lib.acme.list_certs()` output may appear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
expiry: str
|
||||||
|
renewed: str
|
||||||
|
status: str
|
||||||
|
days_remaining: int
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeState(TypedDict):
|
||||||
|
"""ACME state (collector: `_collect_acme`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
certs: Certificate list.
|
||||||
|
email: Registered ACME email.
|
||||||
|
account: Account status (see AcmeAccount).
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
certs: list[AcmeCert]
|
||||||
|
email: str
|
||||||
|
account: AcmeAccount
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── WireGuard ─────────────────────────────────────────────────
|
||||||
|
# The collector tracks BOTH a legacy single interface (wg0) and one
|
||||||
|
# wg-<class> interface per access class.
|
||||||
|
|
||||||
|
|
||||||
|
class WgStatusPeer(TypedDict):
|
||||||
|
"""A runtime peer parsed from `wg show` output.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
public_key: Peer public key.
|
||||||
|
endpoint: Last-seen endpoint, or ``None``.
|
||||||
|
allowed_ips: Allowed IP/CIDR list.
|
||||||
|
latest_handshake: Human-readable last handshake time, or ``None``.
|
||||||
|
transfer_received: Received-bytes string from the transfer line.
|
||||||
|
transfer_sent: Sent-bytes string from the transfer line.
|
||||||
|
persistent_keepalive: Keepalive seconds, or ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
public_key: str
|
||||||
|
endpoint: str | None
|
||||||
|
allowed_ips: list[str]
|
||||||
|
latest_handshake: str | None
|
||||||
|
transfer_received: str
|
||||||
|
transfer_sent: str
|
||||||
|
persistent_keepalive: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class WgClassStatus(TypedDict):
|
||||||
|
"""Runtime status for one wg-<class> interface.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
up: Whether the class interface is up.
|
||||||
|
interface: ``{public_key, listen_port}`` (empty when down).
|
||||||
|
peers: Runtime peers of this class interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
up: bool
|
||||||
|
interface: dict[str, Any]
|
||||||
|
peers: list[WgStatusPeer]
|
||||||
|
|
||||||
|
|
||||||
|
class WgStatus(TypedDict):
|
||||||
|
"""Aggregated WireGuard runtime status.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
up: True when any managed interface is up.
|
||||||
|
interface: Legacy single-interface info (public_key, listen_port).
|
||||||
|
peers: Legacy single-interface runtime peers.
|
||||||
|
classes: Per-access-class runtime status (keyed by class name).
|
||||||
|
pending_changes: Whether the config is dirty vs the applied state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
up: bool
|
||||||
|
interface: dict[str, Any]
|
||||||
|
peers: list[WgStatusPeer]
|
||||||
|
classes: dict[str, WgClassStatus]
|
||||||
|
pending_changes: bool
|
||||||
|
|
||||||
|
|
||||||
|
class WgPeer(TypedDict, total=False):
|
||||||
|
"""A config-file peer (``private_key`` stripped).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Peer config key.
|
||||||
|
public_key: Peer public key.
|
||||||
|
endpoint: Configured endpoint, or ``None``.
|
||||||
|
allowed_ips: Allowed IP/CIDR list.
|
||||||
|
persistent_keepalive: Keepalive seconds, or ``None``.
|
||||||
|
preshared_key: Preshared key, or ``None``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
endpoint: str | None
|
||||||
|
allowed_ips: list[str]
|
||||||
|
persistent_keepalive: int | None
|
||||||
|
preshared_key: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class WgState(TypedDict):
|
||||||
|
"""WireGuard state (collector: `_collect_wireguard`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: config/wireguard/config.json; ``private_key`` stripped
|
||||||
|
from the interface AND from every access class.
|
||||||
|
status: Runtime status (see WgStatus).
|
||||||
|
peers: Config peers, private keys stripped.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: dict[str, Any]
|
||||||
|
status: WgStatus
|
||||||
|
peers: list[WgPeer]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Networkd ──────────────────────────────────────────────────
|
||||||
|
# Matches parse_networkctl_status() output exactly (lib/network.py).
|
||||||
|
# There is a single combined `addresses` list — no separate
|
||||||
|
# `ipv6_addresses` or `routes` keys.
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkdInterface(TypedDict):
|
||||||
|
"""One networkd interface as parsed by `parse_networkctl_status`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
addresses: "ip/prefix" entries, IPv4+IPv6 combined.
|
||||||
|
gateway: Default-route gateway, or ``None``.
|
||||||
|
dns: Configured DNS server list.
|
||||||
|
mac: MAC address, or ``None``.
|
||||||
|
state: OperationalState (e.g. "routable", "degraded", "off").
|
||||||
|
link: Link type (e.g. "ether", "loopback", ...).
|
||||||
|
"""
|
||||||
|
|
||||||
|
addresses: list[str]
|
||||||
|
gateway: str | None
|
||||||
|
dns: list[str]
|
||||||
|
mac: str | None
|
||||||
|
state: str
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkdState(TypedDict):
|
||||||
|
"""Networkd state (collector: `_collect_networkd`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
config: config/network/config.json.
|
||||||
|
interfaces: Runtime state keyed by interface name.
|
||||||
|
status: ``{"pending_changes": bool}``.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config: dict[str, Any]
|
||||||
|
interfaces: dict[str, NetworkdInterface]
|
||||||
|
status: dict[str, Any]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── System ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CpuLoad(TypedDict):
|
||||||
|
"""CPU load averages (see /proc/loadavg).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
load1: 1-minute load average.
|
||||||
|
load5: 5-minute load average.
|
||||||
|
load15: 15-minute load average.
|
||||||
|
"""
|
||||||
|
|
||||||
|
load1: float
|
||||||
|
load5: float
|
||||||
|
load15: float
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryStats(TypedDict):
|
||||||
|
"""Memory usage (see /proc/meminfo).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
total: Total memory in bytes.
|
||||||
|
available: Available memory in bytes.
|
||||||
|
used: Used memory in bytes.
|
||||||
|
used_pct: Used percentage (0-100), rounded to 0.1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
available: int
|
||||||
|
used: int
|
||||||
|
used_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class SwapStats(TypedDict):
|
||||||
|
"""Swap usage (see /proc/meminfo).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
total: Total swap in bytes.
|
||||||
|
used: Used swap in bytes.
|
||||||
|
used_pct: Used percentage (0-100), rounded to 0.1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
used: int
|
||||||
|
used_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class TrafficStats(TypedDict):
|
||||||
|
"""Per-interface traffic counters (see /sys/class/net/<iface>/statistics).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
rx_bytes: Total bytes received.
|
||||||
|
tx_bytes: Total bytes transmitted.
|
||||||
|
rx_packets: Total packets received.
|
||||||
|
tx_packets: Total packets transmitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rx_bytes: int
|
||||||
|
tx_bytes: int
|
||||||
|
rx_packets: int
|
||||||
|
tx_packets: int
|
||||||
|
|
||||||
|
|
||||||
|
class SystemState(TypedDict):
|
||||||
|
"""System-wide metrics (collector: `_collect_system`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
load: CPU load averages (see CpuLoad).
|
||||||
|
memory: Memory usage (see MemoryStats).
|
||||||
|
swap: Swap usage (see SwapStats).
|
||||||
|
traffic: Per-interface counters keyed by interface name.
|
||||||
|
timestamp: ISO-8601 collection time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
load: CpuLoad
|
||||||
|
memory: MemoryStats
|
||||||
|
swap: SwapStats
|
||||||
|
traffic: dict[str, TrafficStats]
|
||||||
|
timestamp: str
|
||||||
+34
-8
@@ -12,6 +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 import schema
|
||||||
from lib.common import _APPLY_HASH_KEY, config_hash, 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,
|
||||||
@@ -36,7 +37,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
|||||||
"wireguard": 10,
|
"wireguard": 10,
|
||||||
"dnsmasq": 10,
|
"dnsmasq": 10,
|
||||||
"networkd": 10,
|
"networkd": 10,
|
||||||
"system": 30,
|
"system": 1,
|
||||||
# nginx/acme state derives from config files (and lazy in-place migration
|
# nginx/acme state derives from config files (and lazy in-place migration
|
||||||
# can rewrite them without a mutation); poll so drift self-heals.
|
# can rewrite them without a mutation); poll so drift self-heals.
|
||||||
"nginx": 60,
|
"nginx": 60,
|
||||||
@@ -125,6 +126,17 @@ class State:
|
|||||||
"""
|
"""
|
||||||
return self._data.get(subsystem)
|
return self._data.get(subsystem)
|
||||||
|
|
||||||
|
def get_snapshot(self) -> dict[str, dict[str, Any] | None]:
|
||||||
|
"""Return all subsystem state dicts.
|
||||||
|
|
||||||
|
Used for the initial WS snapshot on connect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping every subsystem name to its state data
|
||||||
|
(``None`` when not populated or the last collection failed).
|
||||||
|
"""
|
||||||
|
return {name: self._data.get(name) for name in self.SUBSYSTEMS}
|
||||||
|
|
||||||
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
|
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
|
||||||
"""Set state data for *subsystem*.
|
"""Set state data for *subsystem*.
|
||||||
|
|
||||||
@@ -424,7 +436,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
|
|||||||
return "/".join(parts)
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _collect_firewall() -> dict[str, Any]:
|
def _collect_firewall() -> schema.FirewallState:
|
||||||
"""Return the complete current state of firewalld.
|
"""Return the complete current state of firewalld.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -433,6 +445,7 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||||
active = _parse_active_zones(active_raw)
|
active = _parse_active_zones(active_raw)
|
||||||
|
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||||
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
||||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||||
@@ -505,6 +518,7 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
# Pending changes
|
# Pending changes
|
||||||
full_state = {
|
full_state = {
|
||||||
"active_zones": active,
|
"active_zones": active,
|
||||||
|
"default_zone": default_zone,
|
||||||
"interfaces": ifaces,
|
"interfaces": ifaces,
|
||||||
"available_services": services,
|
"available_services": services,
|
||||||
"zones": zones,
|
"zones": zones,
|
||||||
@@ -517,6 +531,7 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"active_zones": active,
|
"active_zones": active,
|
||||||
|
"default_zone": default_zone,
|
||||||
"interfaces": ifaces,
|
"interfaces": ifaces,
|
||||||
"available_services": services,
|
"available_services": services,
|
||||||
"zones": zones,
|
"zones": zones,
|
||||||
@@ -544,7 +559,7 @@ register_volatile(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _collect_dnsmasq() -> dict[str, Any]:
|
def _collect_dnsmasq() -> schema.DnsmasqState:
|
||||||
"""Collect dnsmasq status, config, and leases.
|
"""Collect dnsmasq status, config, and leases.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -645,7 +660,7 @@ register_collector("dnsmasq", _collect_dnsmasq)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _collect_nginx() -> dict[str, Any]:
|
def _collect_nginx() -> schema.NginxState:
|
||||||
"""Collect nginx config and domains list.
|
"""Collect nginx config and domains list.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -846,7 +861,7 @@ def _get_acme_email() -> str:
|
|||||||
return _read_acme_email()
|
return _read_acme_email()
|
||||||
|
|
||||||
|
|
||||||
def _collect_acme() -> dict[str, Any]:
|
def _collect_acme() -> schema.AcmeState:
|
||||||
"""Collect ACME certificate list and email.
|
"""Collect ACME certificate list and email.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -883,7 +898,7 @@ register_collector("acme", _collect_acme)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _collect_wireguard() -> dict[str, Any]:
|
def _collect_wireguard() -> schema.WgState:
|
||||||
"""Collect WireGuard config, per-class status, and peers.
|
"""Collect WireGuard config, per-class status, and peers.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1129,7 +1144,7 @@ register_volatile(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _collect_networkd() -> dict[str, Any]:
|
def _collect_networkd() -> schema.NetworkdState:
|
||||||
"""Collect networkd interface state from networkctl.
|
"""Collect networkd interface state from networkctl.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1212,7 +1227,7 @@ def _parse_meminfo() -> dict[str, Any]:
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
def _collect_system() -> dict[str, Any]:
|
def _collect_system() -> schema.SystemState:
|
||||||
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||||
|
|
||||||
Reads from /proc and /sys — no subprocess needed.
|
Reads from /proc and /sys — no subprocess needed.
|
||||||
@@ -1297,6 +1312,17 @@ def _collect_system() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
register_collector("system", _collect_system)
|
register_collector("system", _collect_system)
|
||||||
|
register_volatile(
|
||||||
|
"system",
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"load",
|
||||||
|
"memory",
|
||||||
|
"swap",
|
||||||
|
"traffic",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Tests for hoover/model.js modelSet() — the WS data-streaming entry point.
|
||||||
|
*
|
||||||
|
* modelSet() bypasses the fetch cycle: it assigns directly to the reactive
|
||||||
|
* model, clears loading unconditionally (schema defaults mean model.data is
|
||||||
|
* never null), clears error, and never sets refreshing.
|
||||||
|
*
|
||||||
|
* model.js imports only reactivity.js — DOM-free at import, so the tests
|
||||||
|
* run under plain node (same pattern as test-auth-model.js).
|
||||||
|
*
|
||||||
|
* Run with `node tests/test-model-set.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js';
|
||||||
|
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const tests = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEq(a, b, msg) {
|
||||||
|
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep equality for objects/arrays (assertEq is reference-based). */
|
||||||
|
function assertDeep(a, b, msg) {
|
||||||
|
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('modelSet assigns data and clears loading unconditionally', () => {
|
||||||
|
modelRegister('firewall', {
|
||||||
|
subsystem: 'firewall',
|
||||||
|
defaultData: SUBSYSTEMS.firewall.defaults,
|
||||||
|
fetch: async () => ({}),
|
||||||
|
});
|
||||||
|
const m = getModel('firewall');
|
||||||
|
assertEq(m.loading, true, 'registering model is loading');
|
||||||
|
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'schema defaults pre-populated');
|
||||||
|
assertEq(m.refreshing, false, 'not refreshing at rest');
|
||||||
|
|
||||||
|
modelSet('firewall', { zones: { public: {} }, pending: { pending: [] } });
|
||||||
|
|
||||||
|
assertEq(m.loading, false, 'real data ends the initial load');
|
||||||
|
assertEq(m.refreshing, false, 'modelSet never sets refreshing');
|
||||||
|
assertEq(m.error, null, 'modelSet clears error');
|
||||||
|
assertDeep(m.data.zones?.public, {}, 'data assigned to the reactive model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelSet is a no-op for an unregistered name', () => {
|
||||||
|
assertEq(typeof modelSet('no-such-model', { x: 1 }), 'undefined', 'no throw, no return');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelSet clears a fetch-set error and replaces error-state data', async () => {
|
||||||
|
modelRegister('dnsmasq', {
|
||||||
|
subsystem: 'dnsmasq',
|
||||||
|
defaultData: SUBSYSTEMS.dnsmasq.defaults,
|
||||||
|
fetch: async () => { throw new Error('boom'); },
|
||||||
|
});
|
||||||
|
const m = getModel('dnsmasq');
|
||||||
|
await modelFetch('dnsmasq');
|
||||||
|
assertEq(m.error, 'boom', 'fetch failure sets error');
|
||||||
|
assertEq(m.loading, false, 'fetch failure clears loading');
|
||||||
|
assertEq(m.data, SUBSYSTEMS.dnsmasq.defaults, 'failed fetch keeps schema defaults');
|
||||||
|
|
||||||
|
const delta = { leases: [{ mac: 'aa:bb', ip: '10.0.0.9' }] };
|
||||||
|
modelSet('dnsmasq', delta);
|
||||||
|
assertEq(m.error, null, 'subsequent real data clears the error');
|
||||||
|
assertEq(m.data.leases?.[0]?.mac, 'aa:bb', 'delta replaces default data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelSet works repeatedly without flag corruption', () => {
|
||||||
|
modelRegister('system', {
|
||||||
|
subsystem: 'system',
|
||||||
|
defaultData: SUBSYSTEMS.system.defaults,
|
||||||
|
fetch: async () => ({}),
|
||||||
|
});
|
||||||
|
const m = getModel('system');
|
||||||
|
modelSet('system', { load: { load1: 0.1 } });
|
||||||
|
modelSet('system', { load: { load1: 0.2 } });
|
||||||
|
assertEq(m.loading, false, 'still not loading');
|
||||||
|
assertEq(m.refreshing, false, 'still not refreshing');
|
||||||
|
assertEq(m.error, null, 'no error');
|
||||||
|
assertEq(m.data.load?.load1, 0.2, 'latest delta wins');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Runner ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` \u2717 ${name}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the 3-second HTTP-fallback contract (Phase 3e).
|
||||||
|
*
|
||||||
|
* app.js cannot be imported under node (it imports every page and touches
|
||||||
|
* the DOM), so this covers the model-layer contract the deferred fetch
|
||||||
|
* decides on:
|
||||||
|
*
|
||||||
|
* - a model registered with schema defaults keeps `loading: true`
|
||||||
|
* (data is never null — that's why the guard is `if (model.loading)`)
|
||||||
|
* - `modelSet()` or a completed `modelFetch()` clears `loading`
|
||||||
|
* - the `if (model.loading) modelFetch(name)` decision fires ONLY for
|
||||||
|
* still-loading models — a WS-delivered snapshot suppresses the HTTP
|
||||||
|
* fallback for that model
|
||||||
|
*
|
||||||
|
* Uses a fake setTimeout queue and recording fetch stubs (no timers, no
|
||||||
|
* network) — same pattern as test-auth-model.js.
|
||||||
|
*
|
||||||
|
* Run with `node tests/test-reconnect-fallback.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js';
|
||||||
|
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const tests = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEq(a, b, msg) {
|
||||||
|
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep equality for objects/arrays (assertEq is reference-based). */
|
||||||
|
function assertDeep(a, b, msg) {
|
||||||
|
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stubs ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Fake setTimeout queue — captures scheduled fallbacks, never runs them. */
|
||||||
|
const _timers = [];
|
||||||
|
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
|
||||||
|
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
|
||||||
|
|
||||||
|
/** Drain pending timers in order, like a real 3s elapse. */
|
||||||
|
async function runTimers() {
|
||||||
|
while (_timers.length) {
|
||||||
|
const t = _timers.shift();
|
||||||
|
if (t) await t.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register one state-backed model with recording fetch. */
|
||||||
|
function registerModel(name) {
|
||||||
|
const calls = { count: 0 };
|
||||||
|
modelRegister(name, {
|
||||||
|
subsystem: name,
|
||||||
|
defaultData: SUBSYSTEMS[name].defaults,
|
||||||
|
fetch: async () => {
|
||||||
|
calls.count++;
|
||||||
|
return { fetched: true, name };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mirrors app.js fetchInitialData() decision logic ───────── */
|
||||||
|
|
||||||
|
/** Schedule the per-model 3s fallback timers (as app.js does). */
|
||||||
|
function scheduleFallbacks(names) {
|
||||||
|
for (const name of names) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const model = getModel(name);
|
||||||
|
if (model.loading) {
|
||||||
|
modelFetch(name);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('schema-defaulted model: loading stays true until modelSet or fetch completes', () => {
|
||||||
|
registerModel('firewall');
|
||||||
|
const m = getModel('firewall');
|
||||||
|
assertEq(m.loading, true, 'fresh model is loading');
|
||||||
|
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'data is schema defaults (never null)');
|
||||||
|
assertEq(m.error, null, 'no error yet');
|
||||||
|
|
||||||
|
modelSet('firewall', { zones: { public: {} } });
|
||||||
|
assertEq(m.loading, false, 'modelSet clears loading');
|
||||||
|
|
||||||
|
registerModel('dnsmasq');
|
||||||
|
const d = getModel('dnsmasq');
|
||||||
|
assertEq(d.loading, true, 'a different fresh model is still loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3s fallback fetches only models still loading (snapshot suppresses HTTP)', async () => {
|
||||||
|
const firewallCalls = registerModel('firewall');
|
||||||
|
const dnsmasqCalls = registerModel('dnsmasq');
|
||||||
|
|
||||||
|
scheduleFallbacks(['firewall', 'dnsmasq']);
|
||||||
|
// The WS snapshot arrived first for firewall only.
|
||||||
|
modelSet('firewall', { zones: { internal: {} } });
|
||||||
|
|
||||||
|
await runTimers();
|
||||||
|
|
||||||
|
assertEq(firewallCalls.count, 0, 'snapshot-delivered model: no HTTP fallback');
|
||||||
|
assertEq(dnsmasqCalls.count, 1, 'still-loading model: HTTP fallback fired');
|
||||||
|
|
||||||
|
const fw = getModel('firewall');
|
||||||
|
assertEq(fw.loading, false, 'firewall not loading');
|
||||||
|
assertDeep(fw.data.zones?.internal, {}, 'firewall keeps the snapshot data, not fetch output');
|
||||||
|
|
||||||
|
const dm = getModel('dnsmasq');
|
||||||
|
assertEq(dm.loading, false, 'completed fetch clears loading');
|
||||||
|
assertEq(dm.data.fetched, true, 'dnsmasq got the fallback data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a re-fired decision never double-fetches a settled model', async () => {
|
||||||
|
const calls = registerModel('acme');
|
||||||
|
scheduleFallbacks(['acme']);
|
||||||
|
await runTimers();
|
||||||
|
assertEq(calls.count, 1, 'first fallback fetch');
|
||||||
|
|
||||||
|
// A later decision pass (e.g. reconnect path) must not re-fetch.
|
||||||
|
const model = getModel('acme');
|
||||||
|
if (model.loading) modelFetch('acme');
|
||||||
|
await runTimers();
|
||||||
|
assertEq(calls.count, 1, 'no double fetch once settled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback fetch failure lands in model.error, self-heals on next data', async () => {
|
||||||
|
modelRegister('wireguard', {
|
||||||
|
subsystem: 'wireguard',
|
||||||
|
defaultData: SUBSYSTEMS.wireguard.defaults,
|
||||||
|
fetch: async () => { throw new Error('state not populated yet'); },
|
||||||
|
});
|
||||||
|
const m = getModel('wireguard');
|
||||||
|
|
||||||
|
setTimeout(() => { if (m.loading) modelFetch('wireguard'); }, 3000);
|
||||||
|
await runTimers();
|
||||||
|
|
||||||
|
assertEq(m.error, 'state not populated yet', 'failure sets model.error');
|
||||||
|
assertEq(m.loading, false, 'failure still clears loading (finally)');
|
||||||
|
assertEq(m.data, SUBSYSTEMS.wireguard.defaults, 'schema defaults preserved on failure');
|
||||||
|
|
||||||
|
modelSet('wireguard', { up: false });
|
||||||
|
assertEq(m.error, null, 'next real data clears the error');
|
||||||
|
assertEq(m.data.up, false, 'real data lands');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Runner ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` \u2717 ${name}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Tests for hoover/websocket.js handleMessage() — WS data streaming.
|
||||||
|
*
|
||||||
|
* handleMessage is driven through a real connect() against a stubbed
|
||||||
|
* globalThis.WebSocket: we record the constructed instance and call its
|
||||||
|
* onmessage handler with serialized daemon→client messages, then assert the
|
||||||
|
* reactive model state. Covers the snapshot fast path, per-subsystem deltas
|
||||||
|
* (including the networkd→network mapping), null-payload guards, and that
|
||||||
|
* retired/legacy message types are ignored (no model mutation, no throw).
|
||||||
|
*
|
||||||
|
* websocket.js pulls in model.js → reactivity.js and auth_model.js
|
||||||
|
* (DOM-free at import), so it runs under plain node with stubbed globals.
|
||||||
|
*
|
||||||
|
* Run with `node tests/test-ws-handler.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { connect } from '../webui/static/hoover/websocket.js';
|
||||||
|
import { modelRegister, getModel, modelSet } from '../webui/static/hoover/model.js';
|
||||||
|
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const tests = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEq(a, b, msg) {
|
||||||
|
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep equality for objects/arrays (assertEq is reference-based). */
|
||||||
|
function assertDeep(a, b, msg) {
|
||||||
|
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stubs ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function makeStorage(initial = {}) {
|
||||||
|
const m = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||||
|
setItem: (k, v) => m.set(k, String(v)),
|
||||||
|
removeItem: (k) => m.delete(k),
|
||||||
|
keys: () => [...m.keys()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub the TTL refresh timer so the node process never waits on it.
|
||||||
|
const _timers = [];
|
||||||
|
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
|
||||||
|
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
|
||||||
|
|
||||||
|
globalThis.location = { protocol: 'http:', host: '127.0.0.1:9090' };
|
||||||
|
globalThis.sessionStorage = makeStorage({ 'vw:access': 'tok-abc.def.ghi' });
|
||||||
|
globalThis.document = { location: { hash: '#/dashboard' } };
|
||||||
|
globalThis.window = { dispatchEvent: () => {}, addEventListener: () => {} };
|
||||||
|
|
||||||
|
/** Records each constructed WebSocket so tests can drive onmessage. */
|
||||||
|
class FakeWebSocket {
|
||||||
|
static instances = [];
|
||||||
|
constructor(url, protocols) {
|
||||||
|
this.url = url;
|
||||||
|
this.protocols = protocols;
|
||||||
|
this.readyState = 1;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onclose = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
close() { this.readyState = 3; }
|
||||||
|
send() {}
|
||||||
|
}
|
||||||
|
globalThis.WebSocket = FakeWebSocket;
|
||||||
|
|
||||||
|
/** Register the auth + state models the WS handler depends on. */
|
||||||
|
function setupModels() {
|
||||||
|
modelRegister('auth', { subsystem: 'auth', fetch: async () => ({}) });
|
||||||
|
modelSet('auth', { token: 'tok-abc.def.ghi', user: { username: 'admin' } });
|
||||||
|
for (const [name, subsystem] of [
|
||||||
|
['firewall', 'firewall'], ['dnsmasq', 'dnsmasq'], ['nginx', 'nginx'],
|
||||||
|
['acme', 'acme'], ['wireguard', 'wireguard'], ['network', 'networkd'], ['system', 'system'],
|
||||||
|
]) {
|
||||||
|
modelRegister(name, {
|
||||||
|
subsystem,
|
||||||
|
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||||
|
fetch: async () => ({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Establish a fresh WS connection and return the recorded instance. */
|
||||||
|
function freshConnect() {
|
||||||
|
const prev = FakeWebSocket.instances.at(-1);
|
||||||
|
if (prev && prev.readyState <= 1) { prev.onclose = null; prev.close(); }
|
||||||
|
connect();
|
||||||
|
return FakeWebSocket.instances.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed one daemon→client message through the recorded instance. */
|
||||||
|
function emit(inst, msg) {
|
||||||
|
inst.onmessage({ data: JSON.stringify(msg) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT = {
|
||||||
|
type: 'snapshot',
|
||||||
|
data: {
|
||||||
|
firewall: { zones: { public: {} } },
|
||||||
|
dnsmasq: { leases: [] },
|
||||||
|
nginx: null, // collector failed → must be skipped
|
||||||
|
acme: { certs: [] },
|
||||||
|
wireguard: { up: true },
|
||||||
|
networkd: { interfaces: { eth0: {} } },
|
||||||
|
system: { load: { load1: 0.5 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Tests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('connect() sends the raw JWT as the Sec-WebSocket-Protocol subprotocol', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
assert(inst, 'a WS instance was constructed');
|
||||||
|
assertEq(inst.url, 'ws://127.0.0.1:9090/ws', 'WS URL from origin');
|
||||||
|
assert(Array.isArray(inst.protocols), 'subprotocols passed');
|
||||||
|
assertEq(inst.protocols[0], 'tok-abc.def.ghi', 'bare JWT (no Bearer prefix)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot fast path sets every non-null model; null entries are skipped', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
emit(inst, SNAPSHOT);
|
||||||
|
|
||||||
|
assertDeep(getModel('firewall').data.zones?.public, {}, 'firewall patched');
|
||||||
|
assertDeep(getModel('dnsmasq').data.leases, [], 'dnsmasq patched');
|
||||||
|
assertDeep(getModel('acme').data.certs, [], 'acme patched');
|
||||||
|
assertEq(getModel('wireguard').data.up, true, 'wireguard patched');
|
||||||
|
assertDeep(getModel('network').data.interfaces?.eth0, {}, 'networkd mapped → network');
|
||||||
|
assertEq(getModel('system').data.load?.load1, 0.5, 'system patched');
|
||||||
|
|
||||||
|
// nginx data was null — modelSet was skipped entirely.
|
||||||
|
const nginx = getModel('nginx');
|
||||||
|
assertDeep(nginx.data, SUBSYSTEMS.nginx.defaults, 'null entry keeps schema defaults');
|
||||||
|
assertEq(nginx.loading, true, 'null entry never clears loading');
|
||||||
|
// Non-null models had loading cleared by modelSet.
|
||||||
|
assertEq(getModel('firewall').loading, false, 'loading cleared on real data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versions delta patches the mapped subsystem model', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
const before = JSON.stringify(getModel('firewall').data);
|
||||||
|
emit(inst, { type: 'versions', subsystem: 'firewall', data: { zones: { dmz: {} } } });
|
||||||
|
assert(getModel('firewall').data.zones?.dmz !== undefined, 'firewall delta applied');
|
||||||
|
assertEq(JSON.stringify(getModel('dnsmasq').data), JSON.stringify(SUBSYSTEMS.dnsmasq.defaults), 'other models untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('networkd delta maps to the network model', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
emit(inst, { type: 'versions', subsystem: 'networkd', data: { interfaces: { lo: {} } } });
|
||||||
|
assertDeep(getModel('network').data.interfaces?.lo, {}, 'networkd → network');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null payload delta never overwrites good data (defense in depth)', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
emit(inst, SNAPSHOT); // networkd has data
|
||||||
|
const kept = getModel('network').data;
|
||||||
|
emit(inst, { type: 'tick', subsystem: 'networkd', data: null });
|
||||||
|
assertDeep(getModel('network').data, kept, 'null data left model untouched');
|
||||||
|
emit(inst, { type: 'versions', subsystem: 'firewall', data: null });
|
||||||
|
assertDeep(getModel('firewall').data.zones?.public, {}, 'null firewall data left model untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retired/legacy message types are ignored (no mutation, no throw)', () => {
|
||||||
|
setupModels();
|
||||||
|
const inst = freshConnect();
|
||||||
|
emit(inst, SNAPSHOT);
|
||||||
|
const before = {};
|
||||||
|
for (const n of ['firewall', 'dnsmasq', 'nginx', 'acme', 'wireguard', 'network', 'system']) {
|
||||||
|
before[n] = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading;
|
||||||
|
}
|
||||||
|
const legacy = [
|
||||||
|
{ type: 'versions', updated: { firewall: 1 } }, // legacy dict form
|
||||||
|
{ type: 'tick', subsystems: ['firewall', 'wireguard'] }, // legacy array form
|
||||||
|
{ type: 'refresh', topic: 'firewall' },
|
||||||
|
{ type: 'notify', topic: 'firewall' },
|
||||||
|
{ type: 'status', topic: 'firewall' },
|
||||||
|
{ type: 'unknown' },
|
||||||
|
];
|
||||||
|
for (const msg of legacy) emit(inst, msg);
|
||||||
|
for (const n of Object.keys(before)) {
|
||||||
|
const now = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading;
|
||||||
|
assertEq(now, before[n], `model ${n} unchanged by legacy message`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Runner ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` \u2717 ${name}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
})();
|
||||||
@@ -40,6 +40,11 @@ def _ne(func, **kw):
|
|||||||
return _patch(f"webui.api.network.{func}", **kw)
|
return _patch(f"webui.api.network.{func}", **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _st(func, **kw):
|
||||||
|
"""Patch daemon.client.{func} in the status blueprint namespace."""
|
||||||
|
return _patch(f"webui.api.status.{func}", **kw)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -883,3 +888,55 @@ class TestNetworkApplyAll:
|
|||||||
mock_post.side_effect = RuntimeError("apply failed")
|
mock_post.side_effect = RuntimeError("apply failed")
|
||||||
resp = client.post("/api/network/apply")
|
resp = client.post("/api/network/apply")
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Status
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def status_client():
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from webui.api.status import bp as status_bp
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(status_bp, url_prefix="/api/status")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusRefresh:
|
||||||
|
def test_filtered_subsystems_passed_through(self, status_client):
|
||||||
|
"""The subsystem body is forwarded to the daemon POST endpoint."""
|
||||||
|
from daemon.iface import POST_STATUS_REFRESH
|
||||||
|
|
||||||
|
with _st("post") as mock_post:
|
||||||
|
mock_post.return_value = {"firewall": {"zones": {}}}
|
||||||
|
resp = status_client.post(
|
||||||
|
"/api/status/refresh", json={"subsystems": ["firewall"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["data"] == {"firewall": {"zones": {}}}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
POST_STATUS_REFRESH, {"subsystems": ["firewall"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_body_forwards_empty_dict(self, status_client):
|
||||||
|
"""An empty body becomes {} (daemon-side 'all subsystems' default)."""
|
||||||
|
from daemon.iface import POST_STATUS_REFRESH
|
||||||
|
|
||||||
|
with _st("post") as mock_post:
|
||||||
|
mock_post.return_value = {}
|
||||||
|
resp = status_client.post("/api/status/refresh")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_post.assert_called_once_with(POST_STATUS_REFRESH, {})
|
||||||
|
|
||||||
|
@_st("post")
|
||||||
|
def test_runtime_error(self, mock_post, status_client):
|
||||||
|
mock_post.side_effect = RuntimeError("no daemon")
|
||||||
|
resp = status_client.post("/api/status/refresh", json={})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert resp.get_json()["ok"] is False
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Tests for broadcast_versions per-subsystem contract (daemon.server).
|
||||||
|
|
||||||
|
broadcast_versions(subsystem) sends exactly one data-carrying message for
|
||||||
|
its subsystem — no legacy `updated` field — and skips the broadcast
|
||||||
|
entirely when the subsystem's state is None (collector failed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
|
||||||
|
|
||||||
|
class TestBroadcastVersionsPerSubsystem:
|
||||||
|
def _ws(self):
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_str = AsyncMock()
|
||||||
|
server._ws_subscribers.add(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def test_only_target_subsystem_sent(self):
|
||||||
|
"""Each subscriber gets one versions message carrying that subsystem."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"v": name}
|
||||||
|
ws = self._ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("firewall"))
|
||||||
|
ws.send_str.assert_awaited_once()
|
||||||
|
msg = json.loads(ws.send_str.call_args[0][0])
|
||||||
|
assert msg == {
|
||||||
|
"type": "versions",
|
||||||
|
"subsystem": "firewall",
|
||||||
|
"data": {"v": "firewall"},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_data_per_subsystem_not_shared(self):
|
||||||
|
"""The data payload is that subsystem's state, not another's."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"name": name}
|
||||||
|
ws = self._ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("dnsmasq"))
|
||||||
|
asyncio.run(server.broadcast_versions("acme"))
|
||||||
|
msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
|
||||||
|
assert [(m["subsystem"], m["data"]) for m in msgs] == [
|
||||||
|
("dnsmasq", {"name": "dnsmasq"}),
|
||||||
|
("acme", {"name": "acme"}),
|
||||||
|
]
|
||||||
|
# No legacy diff field in any message.
|
||||||
|
for m in msgs:
|
||||||
|
assert "updated" not in m
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_none_state_produces_no_message(self):
|
||||||
|
"""A None payload (failed collection) is skipped — no clobber."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.return_value = None
|
||||||
|
ws = self._ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("firewall"))
|
||||||
|
ws.send_str.assert_not_awaited()
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_no_bump_called(self):
|
||||||
|
"""broadcast_versions never bumps — callers own the version counter."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.return_value = {"a": 1}
|
||||||
|
store.bump = MagicMock()
|
||||||
|
ws = self._ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("firewall"))
|
||||||
|
store.bump.assert_not_called()
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_dead_subscriber_removed(self):
|
||||||
|
"""A failing subscriber is pruned and healthy ones still receive data."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.return_value = {"a": 1}
|
||||||
|
dead = AsyncMock()
|
||||||
|
dead.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||||
|
healthy = self._ws()
|
||||||
|
server._ws_subscribers.add(dead)
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("firewall"))
|
||||||
|
assert dead not in server._ws_subscribers
|
||||||
|
healthy.send_str.assert_awaited_once()
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(dead)
|
||||||
|
server._ws_subscribers.discard(healthy)
|
||||||
+146
-2
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from daemon.handlers import firewall as daemonfirewall
|
from daemon.handlers import firewall as daemonfirewall
|
||||||
from daemon.server import NotFoundError
|
from daemon.server import ConflictError, NotFoundError
|
||||||
from lib import firewall
|
from lib import firewall
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -467,6 +467,150 @@ class TestDaemonConfigApply:
|
|||||||
assert result["applied_zones"] == ["public"]
|
assert result["applied_zones"] == ["public"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Management-lockout guard: default zone must keep https or ssh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDaemonMgmtLockoutGuard:
|
||||||
|
ZONES_OUT = "public\ninternal"
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||||
|
def test_set_zone_services_blocks_default_zone(self, mock_dz):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall.run", return_value=self.ZONES_OUT
|
||||||
|
) as mock_run,
|
||||||
|
pytest.raises(ConflictError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall.set_zone_services(
|
||||||
|
None, {"zone": "public", "services": ["http"]}
|
||||||
|
)
|
||||||
|
assert "https and ssh" in str(exc.value)
|
||||||
|
# Guard fires before any mutation: only the zone-existence check ran.
|
||||||
|
assert mock_run.call_args_list == [
|
||||||
|
call(["firewall-cmd", "--get-zones"], sudo=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||||
|
def test_set_zone_services_force_bypasses_guard(self, mock_dz):
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
|
||||||
|
patch.object(
|
||||||
|
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||||
|
),
|
||||||
|
patch.object(daemonfirewall, "_reload"),
|
||||||
|
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||||
|
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||||
|
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||||
|
patch("daemon.handlers.firewall.refresh_state"),
|
||||||
|
):
|
||||||
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
|
result = daemonfirewall.set_zone_services(
|
||||||
|
None, {"zone": "public", "services": ["http"], "force": True}
|
||||||
|
)
|
||||||
|
assert result == {"zone": "public", "services": ["http"]}
|
||||||
|
cfg = mock_save.call_args[0][0]
|
||||||
|
assert cfg["zones"]["public"]["services"] == ["http"]
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="internal")
|
||||||
|
def test_set_zone_services_non_default_zone_allowed(self, mock_dz):
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
|
||||||
|
patch.object(
|
||||||
|
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||||
|
),
|
||||||
|
patch.object(daemonfirewall, "_reload"),
|
||||||
|
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||||
|
patch.object(daemonfirewall, "_save_config"),
|
||||||
|
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||||
|
patch("daemon.handlers.firewall.refresh_state"),
|
||||||
|
):
|
||||||
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
|
result = daemonfirewall.set_zone_services(
|
||||||
|
None, {"zone": "public", "services": []}
|
||||||
|
)
|
||||||
|
assert result == {"zone": "public", "services": []}
|
||||||
|
|
||||||
|
def test_would_remove_mgmt_keeps_https(self):
|
||||||
|
assert daemonfirewall._would_remove_mgmt("public", ["http", "https"]) is False
|
||||||
|
assert daemonfirewall._would_remove_mgmt("public", ["ssh"]) is False
|
||||||
|
|
||||||
|
def test_would_remove_mgmt_fails_closed_on_error(self):
|
||||||
|
with patch(
|
||||||
|
"daemon.handlers.firewall._default_zone", side_effect=RuntimeError("boom")
|
||||||
|
):
|
||||||
|
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is True
|
||||||
|
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="default-zone")
|
||||||
|
def test_would_remove_mgmt_other_zone(self, mock_dz):
|
||||||
|
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is False
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"lib.firewall.get_config",
|
||||||
|
return_value={
|
||||||
|
"zones": {"public": {"services": ["http"], "interfaces": ["eth0"]}}
|
||||||
|
},
|
||||||
|
create=True,
|
||||||
|
)
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||||
|
def test_config_apply_blocks_lockout_before_backup(self, mock_dz, mock_cfg):
|
||||||
|
with (
|
||||||
|
patch("daemon.handlers.firewall._save_backup") as mock_backup,
|
||||||
|
pytest.raises(ConflictError) as exc,
|
||||||
|
):
|
||||||
|
daemonfirewall._config_apply()
|
||||||
|
assert "https and ssh" in str(exc.value)
|
||||||
|
mock_backup.assert_not_called()
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"lib.firewall.get_config",
|
||||||
|
return_value={
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"target": "DEFAULT",
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create=True,
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"daemon.handlers.firewall.run",
|
||||||
|
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
|
||||||
|
)
|
||||||
|
@patch("daemon.handlers.firewall._default_zone", return_value="public")
|
||||||
|
def test_config_apply_force_bypasses_guard(self, mock_dz, mock_run, mock_cfg):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.firewall._get_state",
|
||||||
|
return_value={"zones": {"public": {}}},
|
||||||
|
),
|
||||||
|
patch("daemon.handlers.firewall.refresh_state"),
|
||||||
|
):
|
||||||
|
result = daemonfirewall._config_apply(force=True)
|
||||||
|
assert result["applied_zones"] == ["public"]
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"daemon.handlers.firewall._config_apply",
|
||||||
|
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||||
|
)
|
||||||
|
@patch("daemon.handlers.firewall.bus")
|
||||||
|
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
|
||||||
|
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||||
|
with patch("daemon.handlers.firewall.refresh_state"):
|
||||||
|
daemonfirewall.config_apply(None, None)
|
||||||
|
mock_apply.assert_called_once_with(force=False)
|
||||||
|
mock_apply.reset_mock()
|
||||||
|
daemonfirewall.config_apply(None, {"force": True})
|
||||||
|
mock_apply.assert_called_once_with(force=True)
|
||||||
|
|
||||||
|
|
||||||
class TestDaemonConfigPending:
|
class TestDaemonConfigPending:
|
||||||
@patch("lib.state.state")
|
@patch("lib.state.state")
|
||||||
def test_returns_pending(self, mock_st):
|
def test_returns_pending(self, mock_st):
|
||||||
|
|||||||
+17
-9
@@ -14,6 +14,11 @@ class TestPollIntervals:
|
|||||||
assert _POLL_INTERVALS["wireguard"] == 10
|
assert _POLL_INTERVALS["wireguard"] == 10
|
||||||
assert _POLL_INTERVALS["dnsmasq"] == 10
|
assert _POLL_INTERVALS["dnsmasq"] == 10
|
||||||
assert _POLL_INTERVALS["networkd"] == 10
|
assert _POLL_INTERVALS["networkd"] == 10
|
||||||
|
# Phase 5: real-time system metrics poll at 1s.
|
||||||
|
assert _POLL_INTERVALS["system"] == 1
|
||||||
|
# nginx/acme derive from config files; poll for drift self-heal.
|
||||||
|
assert _POLL_INTERVALS["nginx"] == 60
|
||||||
|
assert _POLL_INTERVALS["acme"] == 300
|
||||||
|
|
||||||
def test_env_override(self):
|
def test_env_override(self):
|
||||||
"""VACUUM_WALL_POLL_INTERVALS env var can override values."""
|
"""VACUUM_WALL_POLL_INTERVALS env var can override values."""
|
||||||
@@ -33,28 +38,31 @@ class TestPollIntervals:
|
|||||||
|
|
||||||
class TestBroadcastTick:
|
class TestBroadcastTick:
|
||||||
def test_sends_tick_message(self):
|
def test_sends_tick_message(self):
|
||||||
from daemon.server import _ws_subscribers, broadcast_tick
|
import daemon.server as server
|
||||||
|
|
||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
mock_ws.send_str = AsyncMock()
|
mock_ws.send_str = AsyncMock()
|
||||||
_ws_subscribers.add(mock_ws)
|
server._ws_subscribers.add(mock_ws)
|
||||||
try:
|
try:
|
||||||
asyncio.run(broadcast_tick(["firewall", "wireguard"]))
|
with patch.object(server.state_store, "get", return_value={"up": True}):
|
||||||
|
asyncio.run(server.broadcast_tick("firewall"))
|
||||||
mock_ws.send_str.assert_called_once()
|
mock_ws.send_str.assert_called_once()
|
||||||
sent = json.loads(mock_ws.send_str.call_args[0][0])
|
sent = json.loads(mock_ws.send_str.call_args[0][0])
|
||||||
assert sent["type"] == "tick"
|
assert sent["type"] == "tick"
|
||||||
assert sent["subsystems"] == ["firewall", "wireguard"]
|
assert sent["subsystem"] == "firewall"
|
||||||
|
assert sent["data"] == {"up": True}
|
||||||
finally:
|
finally:
|
||||||
_ws_subscribers.discard(mock_ws)
|
server._ws_subscribers.discard(mock_ws)
|
||||||
|
|
||||||
def test_prunes_dead_subscribers(self):
|
def test_prunes_dead_subscribers(self):
|
||||||
from daemon.server import _ws_subscribers, broadcast_tick
|
import daemon.server as server
|
||||||
|
|
||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
|
mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||||
_ws_subscribers.add(mock_ws)
|
server._ws_subscribers.add(mock_ws)
|
||||||
asyncio.run(broadcast_tick(["firewall"]))
|
with patch.object(server.state_store, "get", return_value={"up": True}):
|
||||||
assert mock_ws not in _ws_subscribers
|
asyncio.run(server.broadcast_tick("firewall"))
|
||||||
|
assert mock_ws not in server._ws_subscribers
|
||||||
|
|
||||||
|
|
||||||
class TestPollTasks:
|
class TestPollTasks:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Tests for refresh_state / refresh_status WS broadcasting (daemon.server).
|
||||||
|
|
||||||
|
refresh_state() and refresh_status() re-collect state and broadcast a
|
||||||
|
data-carrying versions message for every (requested) subsystem so all
|
||||||
|
viewers stay in sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
|
||||||
|
|
||||||
|
def _run_and_drain(fn):
|
||||||
|
"""Run *fn* inside a running event loop (required for broadcast tasks),
|
||||||
|
then drain the fire-and-forget broadcast tasks."""
|
||||||
|
|
||||||
|
async def drive():
|
||||||
|
fn()
|
||||||
|
for _ in range(20):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if not server._ws_tasks:
|
||||||
|
break
|
||||||
|
for task in list(server._ws_tasks):
|
||||||
|
with suppress(Exception):
|
||||||
|
await task
|
||||||
|
|
||||||
|
asyncio.run(drive())
|
||||||
|
|
||||||
|
|
||||||
|
def _new_ws():
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_str = AsyncMock()
|
||||||
|
server._ws_subscribers.add(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def _messages(ws):
|
||||||
|
return [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshStateBroadcast:
|
||||||
|
def test_broadcasts_each_requested_subsystem(self):
|
||||||
|
"""refresh_state(["firewall","dnsmasq"]) broadcasts both, and bumps."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"s": name}
|
||||||
|
ws = _new_ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
_run_and_drain(lambda: server.refresh_state(["firewall", "dnsmasq"]))
|
||||||
|
store.populate.assert_called_once_with(["firewall", "dnsmasq"])
|
||||||
|
store.bump.assert_any_call("firewall")
|
||||||
|
store.bump.assert_any_call("dnsmasq")
|
||||||
|
msgs = _messages(ws)
|
||||||
|
assert sorted(m["subsystem"] for m in msgs) == ["dnsmasq", "firewall"]
|
||||||
|
assert all(m["type"] == "versions" for m in msgs)
|
||||||
|
for m in msgs:
|
||||||
|
assert "updated" not in m
|
||||||
|
assert m["data"] == {"s": m["subsystem"]}
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_failed_subsystem_skipped_others_buzz(self):
|
||||||
|
"""A subsystem whose collection failed (None) is not broadcast."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"s": name} if name != "acme" else None
|
||||||
|
ws = _new_ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
_run_and_drain(lambda: server.refresh_state(["firewall", "acme"]))
|
||||||
|
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||||
|
assert subs == ["firewall"]
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_no_subsystems_arg_broadcasts_all(self):
|
||||||
|
"""refresh_state() with no filter targets every subsystem."""
|
||||||
|
from lib.state import State
|
||||||
|
|
||||||
|
store = State()
|
||||||
|
for name in State.SUBSYSTEMS:
|
||||||
|
store.set(name, {"k": name})
|
||||||
|
ws = _new_ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
_run_and_drain(lambda: server.refresh_state())
|
||||||
|
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||||
|
assert subs == sorted(State.SUBSYSTEMS)
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshStatusBroadcast:
|
||||||
|
def test_filtered_response_and_broadcast(self):
|
||||||
|
"""POST /status/refresh replies only with the requested subsystems
|
||||||
|
and broadcasts each of them."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"s": name}
|
||||||
|
ws = _new_ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
|
||||||
|
async def drive():
|
||||||
|
request = MagicMock()
|
||||||
|
request.json = AsyncMock(return_value={"subsystems": ["firewall"]})
|
||||||
|
response = await server.refresh_status(request)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response = asyncio.run(drive())
|
||||||
|
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert set(body["data"]) == {"firewall"}
|
||||||
|
store.bump.assert_not_called()
|
||||||
|
subs = sorted(m["subsystem"] for m in _messages(ws))
|
||||||
|
assert subs == ["firewall"]
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_no_body_returns_all_subsystems(self):
|
||||||
|
store = MagicMock()
|
||||||
|
store.get.side_effect = lambda name: {"s": name}
|
||||||
|
store.SUBSYSTEMS = ["firewall", "dnsmasq"]
|
||||||
|
ws = _new_ws()
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
|
||||||
|
async def drive():
|
||||||
|
request = MagicMock()
|
||||||
|
request.json = AsyncMock(return_value=None)
|
||||||
|
response = await server.refresh_status(request)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response = asyncio.run(drive())
|
||||||
|
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert set(body["data"]) == {"firewall", "dnsmasq"}
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests that collector outputs match the lib.schema TypedDict shapes.
|
||||||
|
|
||||||
|
Each collector's return value is asserted against its TypedDict's required
|
||||||
|
keys at runtime (subprocess/shell calls mocked — no system services). The
|
||||||
|
TypedDicts in lib/schema.py are the authoritative state-store contract;
|
||||||
|
these tests catch drift between the schemas and the collectors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import lib.state
|
||||||
|
from lib import schema
|
||||||
|
|
||||||
|
|
||||||
|
def _missing(required_keys: frozenset, data: dict) -> set[str]:
|
||||||
|
return set(required_keys) - set(data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectorShapesMatchSchema:
|
||||||
|
def test_firewall_state(self):
|
||||||
|
with patch.object(lib.state, "run") as mock_run:
|
||||||
|
|
||||||
|
def run_side(args, **kwargs):
|
||||||
|
if "--get-active-zones" in args:
|
||||||
|
return "public\n eth0"
|
||||||
|
if "--get-services" in args:
|
||||||
|
return "ssh http"
|
||||||
|
if "--list-all-zones" in args:
|
||||||
|
return (
|
||||||
|
"public\n"
|
||||||
|
" target: default\n"
|
||||||
|
" interfaces: eth0\n"
|
||||||
|
" services: \n"
|
||||||
|
" ports: \n"
|
||||||
|
" protocols: \n"
|
||||||
|
" forward-ports: \n"
|
||||||
|
" masquerade: no\n"
|
||||||
|
" rich rules: \n"
|
||||||
|
)
|
||||||
|
if "ip" in args[0]:
|
||||||
|
if "link" in args:
|
||||||
|
return (
|
||||||
|
"1: lo: <LOOPBACK,UP> mtu 65536\n"
|
||||||
|
"2: eth0: <BROADCAST,UP> mtu 1500 link/ether aa:bb\n"
|
||||||
|
)
|
||||||
|
if "addr" in args:
|
||||||
|
return "2: eth0 inet 192.168.1.1/24\n"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
mock_run.side_effect = run_side
|
||||||
|
result = lib.state._collect_firewall()
|
||||||
|
|
||||||
|
assert not _missing(schema.FirewallState.__required_keys__, result)
|
||||||
|
for iface in result["interfaces"]:
|
||||||
|
for k in schema.FirewallInterface.__required_keys__:
|
||||||
|
assert k in iface, f"FirewallInterface missing {k}"
|
||||||
|
|
||||||
|
def test_dnsmasq_state(self):
|
||||||
|
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||||
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||||
|
result = lib.state._collect_dnsmasq()
|
||||||
|
|
||||||
|
assert not _missing(schema.DnsmasqState.__required_keys__, result)
|
||||||
|
for k in schema.DnsmasqStatus.__required_keys__:
|
||||||
|
assert k in result["status"], f"DnsmasqStatus missing {k}"
|
||||||
|
|
||||||
|
def test_nginx_state(self):
|
||||||
|
result = lib.state._collect_nginx()
|
||||||
|
assert not _missing(schema.NginxState.__required_keys__, result)
|
||||||
|
assert "pending_changes" in result["status"]
|
||||||
|
|
||||||
|
def test_acme_state(self):
|
||||||
|
with (
|
||||||
|
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
||||||
|
patch("lib.acme.list_certs", return_value=[]),
|
||||||
|
patch.object(
|
||||||
|
lib.state,
|
||||||
|
"_parse_account_conf",
|
||||||
|
return_value={"registered": False, "email": "", "ca": ""},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = lib.state._collect_acme()
|
||||||
|
|
||||||
|
assert not _missing(schema.AcmeState.__required_keys__, result)
|
||||||
|
|
||||||
|
def test_wireguard_state(self):
|
||||||
|
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||||
|
mock_proc.return_value = Mock(stdout="", returncode=1)
|
||||||
|
result = lib.state._collect_wireguard()
|
||||||
|
|
||||||
|
assert not _missing(schema.WgState.__required_keys__, result)
|
||||||
|
for k in schema.WgStatus.__required_keys__:
|
||||||
|
assert k in result["status"], f"WgStatus missing {k}"
|
||||||
|
assert "classes" in result["status"]
|
||||||
|
|
||||||
|
def test_networkd_state(self):
|
||||||
|
networkctl = {
|
||||||
|
"Interfaces": [
|
||||||
|
{
|
||||||
|
"Name": "eth0",
|
||||||
|
"Type": "ether",
|
||||||
|
"OperationalState": "routable",
|
||||||
|
"HardwareAddress": [1, 2, 3, 4, 5, 6],
|
||||||
|
"Addresses": [
|
||||||
|
{"Address": [192, 168, 30, 50], "Family": 2, "PrefixLength": 24}
|
||||||
|
],
|
||||||
|
"Routes": [
|
||||||
|
{
|
||||||
|
"Family": 2,
|
||||||
|
"Destination": [0, 0, 0, 0],
|
||||||
|
"DestinationPrefixLength": 0,
|
||||||
|
"Gateway": [192, 168, 30, 1],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DNS": [{"Address": [1, 1, 1, 1], "Family": 2}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
|
||||||
|
result = lib.state._collect_networkd()
|
||||||
|
|
||||||
|
assert not _missing(schema.NetworkdState.__required_keys__, result)
|
||||||
|
assert "eth0" in result["interfaces"]
|
||||||
|
entry = result["interfaces"]["eth0"]
|
||||||
|
for k in schema.NetworkdInterface.__required_keys__:
|
||||||
|
assert k in entry, f"NetworkdInterface missing {k}"
|
||||||
|
assert entry["gateway"] == "192.168.30.1"
|
||||||
|
assert entry["addresses"] == ["192.168.30.50/24"]
|
||||||
|
|
||||||
|
def test_system_state(self):
|
||||||
|
"""Reads /proc and /sys directly — no mocking needed on Linux."""
|
||||||
|
result = lib.state._collect_system()
|
||||||
|
assert not _missing(schema.SystemState.__required_keys__, result)
|
||||||
|
for k in schema.CpuLoad.__required_keys__:
|
||||||
|
assert k in result["load"], f"CpuLoad missing {k}"
|
||||||
|
for k in schema.MemoryStats.__required_keys__:
|
||||||
|
assert k in result["memory"], f"MemoryStats missing {k}"
|
||||||
|
for k in schema.SwapStats.__required_keys__:
|
||||||
|
assert k in result["swap"], f"SwapStats missing {k}"
|
||||||
|
|
||||||
|
def test_volatile_system_registered(self):
|
||||||
|
"""Phase 5: system metrics are volatile (tick, not version bumps)."""
|
||||||
|
from lib.state import _VOLATILE
|
||||||
|
|
||||||
|
expected = frozenset({"load", "memory", "swap", "traffic"})
|
||||||
|
assert _VOLATILE.get("system") == expected
|
||||||
@@ -28,6 +28,25 @@ class TestState:
|
|||||||
assert state is not None
|
assert state is not None
|
||||||
assert isinstance(state, State)
|
assert isinstance(state, State)
|
||||||
|
|
||||||
|
def test_get_snapshot_empty(self):
|
||||||
|
"""Fresh store: snapshot lists every subsystem, all None."""
|
||||||
|
s = State()
|
||||||
|
snap = s.get_snapshot()
|
||||||
|
assert set(snap) == set(s.SUBSYSTEMS)
|
||||||
|
assert all(v is None for v in snap.values())
|
||||||
|
|
||||||
|
def test_get_snapshot_reflects_set_and_none(self):
|
||||||
|
"""Snapshot carries set data; failed collections stay None."""
|
||||||
|
s = State()
|
||||||
|
s.set("firewall", {"zones": {}})
|
||||||
|
s.set("system", {"load": {"load1": 0.0}})
|
||||||
|
s.set("dnsmasq", None)
|
||||||
|
snap = s.get_snapshot()
|
||||||
|
assert snap["firewall"] == {"zones": {}}
|
||||||
|
assert snap["system"] == {"load": {"load1": 0.0}}
|
||||||
|
assert snap["dnsmasq"] is None
|
||||||
|
assert snap["acme"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestCollectAll:
|
class TestCollectAll:
|
||||||
@patch("lib.state.run")
|
@patch("lib.state.run")
|
||||||
@@ -37,6 +56,8 @@ class TestCollectAll:
|
|||||||
def run_side(args, **kwargs):
|
def run_side(args, **kwargs):
|
||||||
if "--get-active-zones" in args:
|
if "--get-active-zones" in args:
|
||||||
return "public\n eth0"
|
return "public\n eth0"
|
||||||
|
if "--get-default-zone" in args:
|
||||||
|
return "public\n"
|
||||||
if "--get-services" in args:
|
if "--get-services" in args:
|
||||||
return "ssh http"
|
return "ssh http"
|
||||||
if "ip" in args[0]:
|
if "ip" in args[0]:
|
||||||
@@ -60,6 +81,8 @@ class TestCollectAll:
|
|||||||
result = _collect_firewall()
|
result = _collect_firewall()
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert "active_zones" in result
|
assert "active_zones" in result
|
||||||
|
assert "default_zone" in result
|
||||||
|
assert result["default_zone"] == "public"
|
||||||
assert "interfaces" in result
|
assert "interfaces" in result
|
||||||
assert "timestamp" in result
|
assert "timestamp" in result
|
||||||
|
|
||||||
@@ -71,6 +94,8 @@ class TestCollectAll:
|
|||||||
def run_side(args, **kwargs):
|
def run_side(args, **kwargs):
|
||||||
if "--get-active-zones" in args:
|
if "--get-active-zones" in args:
|
||||||
return "public\n eth0\ninternal\n eth0.100"
|
return "public\n eth0\ninternal\n eth0.100"
|
||||||
|
if "--get-default-zone" in args:
|
||||||
|
return "public\n"
|
||||||
if "--get-services" in args:
|
if "--get-services" in args:
|
||||||
return "ssh http"
|
return "ssh http"
|
||||||
if "ip" in args[0]:
|
if "ip" in args[0]:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests for WS delta structure (daemon.server._poll_loop + broadcasts).
|
||||||
|
|
||||||
|
After the push-stream migration the poll loop drives per-subsystem deltas:
|
||||||
|
a structural diff bumps the version and broadcasts {type: versions,
|
||||||
|
subsystem, data}; a volatile-only diff broadcasts {type: tick, subsystem,
|
||||||
|
data}. No legacy `updated` dict / `subsystems` array is emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
from lib.state import State
|
||||||
|
|
||||||
|
|
||||||
|
def _zero_offset_subsystem(interval: int) -> str:
|
||||||
|
"""Find a subsystem name whose md5 offset is 0 so the loop starts at once."""
|
||||||
|
for i in range(100_000):
|
||||||
|
name = f"sub{i}"
|
||||||
|
offset = int(hashlib.md5(name.encode()).hexdigest(), 16) % interval
|
||||||
|
if offset == 0:
|
||||||
|
return name
|
||||||
|
raise AssertionError("could not find zero-offset name")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_one_poll_iteration(poll_result):
|
||||||
|
"""Run _poll_loop for a single iteration and return the broadcast mocks."""
|
||||||
|
namespaced = _zero_offset_subsystem(60)
|
||||||
|
|
||||||
|
async def drive():
|
||||||
|
store = MagicMock()
|
||||||
|
store.poll.return_value = poll_result
|
||||||
|
store.bump = MagicMock()
|
||||||
|
store.get.return_value = {"value": 1}
|
||||||
|
bv = AsyncMock()
|
||||||
|
bt = AsyncMock()
|
||||||
|
task = None
|
||||||
|
with (
|
||||||
|
patch.object(server, "state_store", store),
|
||||||
|
patch.object(server, "broadcast_versions", bv),
|
||||||
|
patch.object(server, "broadcast_tick", bt),
|
||||||
|
patch.object(server, "blacklist_expired"),
|
||||||
|
):
|
||||||
|
task = asyncio.create_task(server._poll_loop(namespaced, 60))
|
||||||
|
await asyncio.sleep(0.02) # let one full iteration run
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
return store, bv, bt
|
||||||
|
|
||||||
|
store, bv, bt = asyncio.run(drive())
|
||||||
|
return store, bv, bt
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollLoopDeltas:
|
||||||
|
def test_structural_change_broadcasts_versions(self):
|
||||||
|
store, bv, bt = _run_one_poll_iteration((True, False))
|
||||||
|
store.bump.assert_called_once_with(_zero_offset_subsystem(60))
|
||||||
|
bv.assert_awaited_once()
|
||||||
|
bt.assert_not_awaited()
|
||||||
|
|
||||||
|
def test_volatile_change_broadcasts_tick(self):
|
||||||
|
store, bv, bt = _run_one_poll_iteration((False, True))
|
||||||
|
store.bump.assert_not_called()
|
||||||
|
bv.assert_not_awaited()
|
||||||
|
bt.assert_awaited_once()
|
||||||
|
|
||||||
|
def test_no_change_no_broadcast(self):
|
||||||
|
store, bv, bt = _run_one_poll_iteration((False, False))
|
||||||
|
store.bump.assert_not_called()
|
||||||
|
bv.assert_not_awaited()
|
||||||
|
bt.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeltaMessageShape:
|
||||||
|
def test_versions_message_carries_subsystem_and_data(self):
|
||||||
|
"""broadcast_versions emits {type, subsystem, data} — no `updated`."""
|
||||||
|
store = State()
|
||||||
|
store.set("firewall", {"zones": {"public": {}}})
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_str = AsyncMock()
|
||||||
|
server._ws_subscribers.add(ws)
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_versions("firewall"))
|
||||||
|
ws.send_str.assert_awaited_once()
|
||||||
|
msg = json.loads(ws.send_str.call_args[0][0])
|
||||||
|
assert msg["type"] == "versions"
|
||||||
|
assert msg["subsystem"] == "firewall"
|
||||||
|
assert msg["data"] == {"zones": {"public": {}}}
|
||||||
|
assert "updated" not in msg
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
def test_tick_message_carries_subsystem_and_data(self):
|
||||||
|
store = State()
|
||||||
|
store.set("system", {"load": {"load1": 1.0}})
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_str = AsyncMock()
|
||||||
|
server._ws_subscribers.add(ws)
|
||||||
|
try:
|
||||||
|
with patch.object(server, "state_store", store):
|
||||||
|
asyncio.run(server.broadcast_tick("system"))
|
||||||
|
ws.send_str.assert_awaited_once()
|
||||||
|
msg = json.loads(ws.send_str.call_args[0][0])
|
||||||
|
assert msg["type"] == "tick"
|
||||||
|
assert msg["subsystem"] == "system"
|
||||||
|
assert msg["data"] == {"load": {"load1": 1.0}}
|
||||||
|
assert "subsystems" not in msg
|
||||||
|
finally:
|
||||||
|
server._ws_subscribers.discard(ws)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Tests for the WS connect snapshot (daemon.server._handle_ws).
|
||||||
|
|
||||||
|
After the push-stream migration, a successful WS handshake sends a full
|
||||||
|
state snapshot ({type: snapshot, data: {subsystem: state|null, ...}})
|
||||||
|
instead of the retired {type: init, versions: ...} message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lib.state import State
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=False)
|
||||||
|
def db_reset():
|
||||||
|
"""Isolated in-memory DB so a builtin admin exists for token minting.
|
||||||
|
|
||||||
|
Mirrors the autouse _db_reset fixture in tests/test_auth.py (the DB
|
||||||
|
singleton must be reset and pointed at SQLite :memory: before each test).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from lib.db import get_db, reset_db_for_test
|
||||||
|
|
||||||
|
reset_db_for_test()
|
||||||
|
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
|
||||||
|
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
|
||||||
|
old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None)
|
||||||
|
|
||||||
|
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||||
|
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
|
||||||
|
|
||||||
|
get_db() # triggers builtin-admin seed on the empty :memory: DB
|
||||||
|
yield
|
||||||
|
reset_db_for_test()
|
||||||
|
if old_backend is not None:
|
||||||
|
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
|
||||||
|
if old_path is not None:
|
||||||
|
os.environ["VACUUM_WALL_DB_PATH"] = old_path
|
||||||
|
if old_seed is not None:
|
||||||
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def access_token(db_reset):
|
||||||
|
"""Mint a real access token for the seeded builtin admin."""
|
||||||
|
from lib.auth import generate_tokens
|
||||||
|
|
||||||
|
tokens = generate_tokens("admin", {"firewall": "rw"})
|
||||||
|
return tokens["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWsSnapshot:
|
||||||
|
def test_snapshot_sent_on_auth_connect(self, access_token):
|
||||||
|
"""A valid JWT subprotocol yields a full snapshot after auth."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
|
||||||
|
store = State()
|
||||||
|
store.set("firewall", {"zones": {"public": {}}})
|
||||||
|
store.set("system", {"load": {"load1": 0.1}})
|
||||||
|
# Remaining subsystems stay None (not populated).
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.prepare = AsyncMock()
|
||||||
|
ws.send_json = AsyncMock()
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {"Sec-WebSocket-Protocol": access_token}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aiohttp.web.WebSocketResponse", return_value=ws),
|
||||||
|
patch.object(server, "state_store", store),
|
||||||
|
):
|
||||||
|
asyncio.run(server._handle_ws(request))
|
||||||
|
|
||||||
|
ws.send_json.assert_awaited_once()
|
||||||
|
payload = ws.send_json.call_args[0][0]
|
||||||
|
assert payload["type"] == "snapshot"
|
||||||
|
data = payload["data"]
|
||||||
|
# Every subsystem key is present (push-stream: no `init`/`versions` shape).
|
||||||
|
for name in State.SUBSYSTEMS:
|
||||||
|
assert name in data
|
||||||
|
assert data["firewall"] == {"zones": {"public": {}}}
|
||||||
|
assert data["system"] == {"load": {"load1": 0.1}}
|
||||||
|
# Unpopulated subsystems are present but None (partial snapshot).
|
||||||
|
assert data["dnsmasq"] is None
|
||||||
|
assert data["wireguard"] is None
|
||||||
|
|
||||||
|
def test_no_snapshot_without_token(self):
|
||||||
|
"""Missing token -> 401 JSON, no WS is opened, no snapshot sent."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
with patch("aiohttp.web.WebSocketResponse") as mock_ctor:
|
||||||
|
result = asyncio.run(server._handle_ws(request))
|
||||||
|
|
||||||
|
assert result.status == 401
|
||||||
|
mock_ctor.assert_not_called()
|
||||||
|
ws.send_json.assert_not_awaited()
|
||||||
|
|
||||||
|
def test_no_snapshot_on_invalid_token(self, access_token):
|
||||||
|
"""A token that fails validation -> 401, no snapshot sent."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import daemon.server as server
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {"Sec-WebSocket-Protocol": access_token}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aiohttp.web.WebSocketResponse") as mock_ctor,
|
||||||
|
patch("lib.auth.validate_token", return_value=None),
|
||||||
|
):
|
||||||
|
result = asyncio.run(server._handle_ws(request))
|
||||||
|
|
||||||
|
assert result.status == 401
|
||||||
|
mock_ctor.assert_not_called()
|
||||||
|
ws.send_json.assert_not_awaited()
|
||||||
+24
-2
@@ -7,10 +7,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from daemon.client import get, post
|
from daemon.client import get, post
|
||||||
from daemon.iface import GET_STATUS_PENDING, GET_SYSTEM_METRICS, POST_STATUS_APPLY_ALL
|
from daemon.iface import (
|
||||||
|
GET_STATUS_PENDING,
|
||||||
|
GET_SYSTEM_METRICS,
|
||||||
|
POST_STATUS_APPLY_ALL,
|
||||||
|
POST_STATUS_REFRESH,
|
||||||
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,6 +56,23 @@ def apply_all():
|
|||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/refresh", methods=["POST"])
|
||||||
|
def refresh():
|
||||||
|
"""Re-collect state from the daemon, optionally filtered by subsystem.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
POST /api/status/refresh
|
||||||
|
Body:
|
||||||
|
{"subsystems": ["firewall"]} or {} for all.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
return _ok(post(POST_STATUS_REFRESH, body))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to refresh state: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/system-metrics", methods=["GET"])
|
@bp.route("/system-metrics", methods=["GET"])
|
||||||
def system_metrics():
|
def system_metrics():
|
||||||
"""Retrieve system-wide metrics.
|
"""Retrieve system-wide metrics.
|
||||||
|
|||||||
+51
-116
@@ -1,4 +1,5 @@
|
|||||||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||||||
|
import { SUBSYSTEMS } from '/static/hoover/schema.js';
|
||||||
|
|
||||||
import DashboardPage from '/static/pages/dashboard.js';
|
import DashboardPage from '/static/pages/dashboard.js';
|
||||||
import InterfacesPage from '/static/pages/interfaces.js';
|
import InterfacesPage from '/static/pages/interfaces.js';
|
||||||
@@ -44,66 +45,47 @@ function getNav() {
|
|||||||
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
||||||
modelRegister('auth', createAuthModel());
|
modelRegister('auth', createAuthModel());
|
||||||
|
|
||||||
modelRegister('firewall', {
|
/* ── State-backed models ──────────────────────────────────── */
|
||||||
subsystem: 'firewall',
|
/* All state-backed models stream over the WS (snapshot on connect,
|
||||||
fetch: async () => {
|
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
|
||||||
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
* POST /api/status/refresh with a subsystem filter and returns the
|
||||||
apiFetch('/api/firewall/config'),
|
* subsystem state verbatim — the exact shape the state store holds. */
|
||||||
apiFetch('/api/firewall/zones'),
|
function _stateModelFetch(subsystem) {
|
||||||
apiFetch('/api/firewall/services'),
|
return async () => {
|
||||||
apiFetch('/api/firewall/interfaces'),
|
const r = await apiFetch('/api/status/refresh', {
|
||||||
apiFetch('/api/firewall/state'),
|
method: 'POST',
|
||||||
]);
|
body: { subsystems: [subsystem] },
|
||||||
const result = {};
|
});
|
||||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
|
||||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
|
||||||
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
|
||||||
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
|
||||||
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
|
||||||
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
|
||||||
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
|
||||||
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
|
||||||
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modelRegister('network', {
|
|
||||||
subsystem: 'networkd',
|
|
||||||
fetch: async () => {
|
|
||||||
const r = await apiFetch('/api/network/interfaces');
|
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
return r.data || { interfaces: {} };
|
const payload = r.data?.[subsystem];
|
||||||
},
|
// Collector failure: the daemon returns null for that subsystem.
|
||||||
});
|
// Throw instead of returning {} so modelFetch keeps the current
|
||||||
|
// data (schema defaults) and sets model.error rather than
|
||||||
|
// clobbering it with an empty object.
|
||||||
|
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
modelRegister('dnsmasq', {
|
// Each maps to one subsystem in the state store. Model name may differ
|
||||||
subsystem: 'dnsmasq',
|
// from subsystem name (e.g. `network` → `networkd`).
|
||||||
fetch: async () => {
|
const STATE_MODELS = [
|
||||||
const [cfg, status, leases] = await Promise.allSettled([
|
{ name: 'firewall', subsystem: 'firewall' },
|
||||||
apiFetch('/api/dhcp/config'),
|
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||||
apiFetch('/api/dhcp/status'),
|
{ name: 'nginx', subsystem: 'nginx' },
|
||||||
apiFetch('/api/dhcp/leases'),
|
{ name: 'acme', subsystem: 'acme' },
|
||||||
]);
|
{ name: 'wireguard', subsystem: 'wireguard' },
|
||||||
const result = {};
|
{ name: 'network', subsystem: 'networkd' },
|
||||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
{ name: 'system', subsystem: 'system' },
|
||||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
];
|
||||||
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
|
||||||
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
|
||||||
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
|
||||||
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modelRegister('nginx', {
|
for (const { name, subsystem } of STATE_MODELS) {
|
||||||
subsystem: 'nginx',
|
modelRegister(name, {
|
||||||
fetch: async () => {
|
subsystem,
|
||||||
const r = await apiFetch('/api/proxy/domains');
|
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||||
if (!r.ok) throw new Error(r.error);
|
fetch: _stateModelFetch(subsystem),
|
||||||
return { domains: r.data || [] };
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
modelRegister('backends', {
|
modelRegister('backends', {
|
||||||
subsystem: 'nginx',
|
subsystem: 'nginx',
|
||||||
@@ -114,44 +96,6 @@ modelRegister('backends', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
modelRegister('acme', {
|
|
||||||
subsystem: 'acme',
|
|
||||||
fetch: async () => {
|
|
||||||
const [listR, acctR] = await Promise.allSettled([
|
|
||||||
apiFetch('/api/certs/list'),
|
|
||||||
apiFetch('/api/certs/account'),
|
|
||||||
]);
|
|
||||||
const result = {};
|
|
||||||
if (listR.status === 'fulfilled' && listR.value.ok) {
|
|
||||||
result.certs = listR.value.data || [];
|
|
||||||
} else if (listR.status === 'rejected' || !listR.value.ok) {
|
|
||||||
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
|
|
||||||
}
|
|
||||||
if (acctR.status === 'fulfilled' && acctR.value.ok) {
|
|
||||||
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modelRegister('wireguard', {
|
|
||||||
subsystem: 'wireguard',
|
|
||||||
fetch: async () => {
|
|
||||||
const [stR, pR, cfgR] = await Promise.allSettled([
|
|
||||||
apiFetch('/api/wireguard/status'),
|
|
||||||
apiFetch('/api/wireguard/peers'),
|
|
||||||
apiFetch('/api/wireguard/config'),
|
|
||||||
]);
|
|
||||||
const result = {};
|
|
||||||
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
|
||||||
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
|
||||||
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
|
||||||
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
|
||||||
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const LOG_TABS = {
|
const LOG_TABS = {
|
||||||
journal: '/api/logs/journal',
|
journal: '/api/logs/journal',
|
||||||
'nginx-access': '/api/logs/nginx/access',
|
'nginx-access': '/api/logs/nginx/access',
|
||||||
@@ -172,29 +116,20 @@ modelRegister('logs', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
modelRegister('status', {
|
|
||||||
subsystem: '*',
|
|
||||||
fetch: async () => {
|
|
||||||
const [pendingR, metricsR] = await Promise.allSettled([
|
|
||||||
apiFetch('/api/status/pending'),
|
|
||||||
apiFetch('/api/status/system-metrics'),
|
|
||||||
]);
|
|
||||||
const result = {};
|
|
||||||
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
|
|
||||||
result.pending = pendingR.value.data || {};
|
|
||||||
}
|
|
||||||
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
|
|
||||||
result.metrics = metricsR.value.data || {};
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── Initial fetch (after auth check) ───────────────────────── */
|
/* ── Initial fetch (after auth check) ───────────────────────── */
|
||||||
function fetchInitialData() {
|
function fetchInitialData() {
|
||||||
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
// State-backed models: first data arrives via the WS snapshot.
|
||||||
modelFetch(name);
|
// If WS hasn't delivered data within 3s, fall back to HTTP.
|
||||||
|
for (const { name } of STATE_MODELS) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const model = getModel(name);
|
||||||
|
if (model.loading) { // snapshot (or a prior fetch) hasn't completed
|
||||||
|
modelFetch(name);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
// Non-state models fetch immediately
|
||||||
|
modelFetch('backends');
|
||||||
modelFetch('logs', 'journal');
|
modelFetch('logs', 'journal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,8 +268,11 @@ export function formAction(fn) {
|
|||||||
* @param {string} [opts.method] - HTTP method (default: 'POST')
|
* @param {string} [opts.method] - HTTP method (default: 'POST')
|
||||||
* @param {function} [opts.body] - () => object, body builder
|
* @param {function} [opts.body] - () => object, body builder
|
||||||
* @param {function} [opts.validate] - (body) => string|null, validation function
|
* @param {function} [opts.validate] - (body) => string|null, validation function
|
||||||
|
* @param {function} [opts.confirm] - (body) => string|null; if a message is
|
||||||
|
* returned, a native confirm() dialog gates
|
||||||
|
* the submit; on approval the body gains
|
||||||
|
* force=true (server-side guard override)
|
||||||
* @param {string} [opts.successMsg] - Success toast message
|
* @param {string} [opts.successMsg] - Success toast message
|
||||||
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
|
|
||||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||||
* @returns {object[]} Array of action descriptors
|
* @returns {object[]} Array of action descriptors
|
||||||
*/
|
*/
|
||||||
@@ -279,8 +282,8 @@ export function apiSubmit(opts) {
|
|||||||
method = 'POST',
|
method = 'POST',
|
||||||
body,
|
body,
|
||||||
validate,
|
validate,
|
||||||
|
confirm,
|
||||||
successMsg = 'Saved',
|
successMsg = 'Saved',
|
||||||
refresh,
|
|
||||||
submitText = 'Submit',
|
submitText = 'Submit',
|
||||||
closeModal,
|
closeModal,
|
||||||
} = opts;
|
} = opts;
|
||||||
@@ -300,6 +303,13 @@ export function apiSubmit(opts) {
|
|||||||
const err = validate(b);
|
const err = validate(b);
|
||||||
if (err) { toast(err, 'error'); return; }
|
if (err) { toast(err, 'error'); return; }
|
||||||
}
|
}
|
||||||
|
if (confirm) {
|
||||||
|
const msg = confirm(b);
|
||||||
|
if (msg) {
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
b.force = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshModals();
|
refreshModals();
|
||||||
const res = await apiFetch(url, { method, body: b });
|
const res = await apiFetch(url, { method, body: b });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -307,14 +317,10 @@ export function apiSubmit(opts) {
|
|||||||
let msg = successMsg;
|
let msg = successMsg;
|
||||||
if (synced && synced.length) {
|
if (synced && synced.length) {
|
||||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||||
synced.forEach(s => modelFetch(s));
|
|
||||||
}
|
}
|
||||||
toast(msg, 'success');
|
toast(msg, 'success');
|
||||||
if (closeModal) closeModal();
|
if (closeModal) closeModal();
|
||||||
if (refresh) {
|
// No modelFetch — WS delta updates all affected subsystems.
|
||||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
|
||||||
await Promise.all(models.map(m => modelFetch(m)));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || 'Failed', 'error');
|
toast(res.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { h } from '../vdom.js';
|
|||||||
import { html } from '../html.js';
|
import { html } from '../html.js';
|
||||||
import { reactive } from '../reactivity.js';
|
import { reactive } from '../reactivity.js';
|
||||||
import { apiFetch, toast } from '../api.js';
|
import { apiFetch, toast } from '../api.js';
|
||||||
import { modelFetch } from '../model.js';
|
|
||||||
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
|
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
|
||||||
|
|
||||||
export const SUBSYSTEM_LIST = [
|
export const SUBSYSTEM_LIST = [
|
||||||
@@ -56,9 +55,10 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST apply-all, toast result, close modal, refresh models.
|
* POST apply-all, toast result, close modal. State-store models update from
|
||||||
|
* the daemon's WS delta — no explicit refresh.
|
||||||
*/
|
*/
|
||||||
async function doApply(successMsg, refreshTargets) {
|
async function doApply(successMsg) {
|
||||||
if (isModalProcessing()) return;
|
if (isModalProcessing()) return;
|
||||||
setModalProcessing(true);
|
setModalProcessing(true);
|
||||||
try {
|
try {
|
||||||
@@ -66,10 +66,7 @@ async function doApply(successMsg, refreshTargets) {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
toast(successMsg, 'success');
|
toast(successMsg, 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
if (refreshTargets) {
|
// No modelFetch — WS delta updates all affected subsystems.
|
||||||
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
|
|
||||||
names.forEach(n => modelFetch(n));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast(resp.error || 'Apply failed', 'error');
|
toast(resp.error || 'Apply failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -82,7 +79,7 @@ async function doApply(successMsg, refreshTargets) {
|
|||||||
/**
|
/**
|
||||||
* Fetch pending state, then open the confirmation modal.
|
* Fetch pending state, then open the confirmation modal.
|
||||||
*/
|
*/
|
||||||
async function openApplyModal(successMsg, refreshTargets) {
|
async function openApplyModal(successMsg) {
|
||||||
const pendingResp = await apiFetch('/api/status/pending');
|
const pendingResp = await apiFetch('/api/status/pending');
|
||||||
if (!pendingResp.ok) {
|
if (!pendingResp.ok) {
|
||||||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||||||
@@ -109,7 +106,7 @@ async function openApplyModal(successMsg, refreshTargets) {
|
|||||||
modalVNodes(inner, html`<div>
|
modalVNodes(inner, html`<div>
|
||||||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||||||
<div class="modal-body">${rows}</div>
|
<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 class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
|
||||||
</div>`);
|
</div>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,7 +120,7 @@ async function openApplyModal(successMsg, refreshTargets) {
|
|||||||
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
|
* @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.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} [props.successMsg] - Success toast message (default: 'All changes applied')
|
||||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply
|
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||||
*/
|
*/
|
||||||
export function ApplyConfirm(props = {}) {
|
export function ApplyConfirm(props = {}) {
|
||||||
const label = props.label || 'Apply';
|
const label = props.label || 'Apply';
|
||||||
@@ -139,7 +136,7 @@ export function ApplyConfirm(props = {}) {
|
|||||||
toast(successMsg || 'All synced', 'info');
|
toast(successMsg || 'All synced', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openApplyModal(successMsg, props.refresh);
|
openApplyModal(successMsg);
|
||||||
},
|
},
|
||||||
}, props.pending ? label : syncedLabel);
|
}, props.pending ? label : syncedLabel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { h } from '../vdom.js';
|
import { h } from '../vdom.js';
|
||||||
import { esc } from '../helpers.js';
|
import { esc } from '../helpers.js';
|
||||||
import { apiFetch, toast } from '../api.js';
|
import { apiFetch, toast } from '../api.js';
|
||||||
import { modelFetch } from '../model.js';
|
|
||||||
import { requestUpdate } from '../reactivity.js';
|
import { requestUpdate } from '../reactivity.js';
|
||||||
|
|
||||||
const _actionPending = new Map();
|
const _actionPending = new Map();
|
||||||
@@ -69,16 +68,16 @@ export function Card(props = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
|
* A Remove button that confirms, deletes via API, and toasts. State-store
|
||||||
* When the response includes a ``synced`` array (list of subsystem names
|
* models update from the daemon's WS delta — no explicit refresh. When the
|
||||||
* that were auto-updated), shows a secondary toast and refreshes those
|
* response includes a ``synced`` array (list of subsystem names that were
|
||||||
* models.
|
* auto-updated), appends them to the success toast.
|
||||||
*
|
*
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} props.url - API DELETE URL
|
* @param {string} props.url - API DELETE URL
|
||||||
* @param {string} props.message - Confirmation prompt text
|
* @param {string} props.message - Confirmation prompt text
|
||||||
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
||||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||||
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
|
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
|
||||||
@@ -104,31 +103,20 @@ export function ConfirmDelete(props = {}) {
|
|||||||
let msg = props.success || 'Removed';
|
let msg = props.success || 'Removed';
|
||||||
if (synced && synced.length) {
|
if (synced && synced.length) {
|
||||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||||
synced.forEach(s => modelFetch(s));
|
|
||||||
}
|
}
|
||||||
toast(msg, 'success');
|
toast(msg, 'success');
|
||||||
|
|
||||||
if (props.deleteKey) {
|
if (props.deleteKey) {
|
||||||
_deleting.add(props.deleteKey);
|
_deleting.add(props.deleteKey);
|
||||||
|
// The WS delta (~50ms) removes the deleted item from
|
||||||
|
// model.data and re-renders the row away. This timeout
|
||||||
|
// purges _deleting if the delta is slow or the row was
|
||||||
|
// already unmounted.
|
||||||
|
setTimeout(() => _deleting.delete(props.deleteKey), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.refresh) {
|
if (props.onComplete) props.onComplete();
|
||||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
// No modelFetch — WS delta updates state store models.
|
||||||
const promises = names.map(n => modelFetch(n));
|
|
||||||
|
|
||||||
if (props.deleteKey && promises.length) {
|
|
||||||
Promise.all(promises).finally(() => {
|
|
||||||
_deleting.delete(props.deleteKey);
|
|
||||||
if (props.onComplete) props.onComplete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (props.deleteKey) {
|
|
||||||
_deleting.delete(props.deleteKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.onComplete && !props.refresh) {
|
|
||||||
props.onComplete();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast(r.error || 'Failed', 'error');
|
toast(r.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -141,11 +129,11 @@ export function ConfirmDelete(props = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An action button that POSTs to an API endpoint, toasts on result,
|
* An action button that POSTs to an API endpoint and toasts on result.
|
||||||
* and optionally refreshes models. Supports toggle labels for on/off buttons.
|
* Supports toggle labels for on/off buttons. State-store models update from
|
||||||
* When the response includes a ``synced`` array (list of subsystem names
|
* the daemon's WS delta — no explicit refresh. When the response includes a
|
||||||
* that were auto-updated), shows a secondary toast and refreshes those
|
* ``synced`` array (list of subsystem names that were auto-updated), appends
|
||||||
* models.
|
* them to the success toast.
|
||||||
*
|
*
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} props.url - API URL
|
* @param {string} props.url - API URL
|
||||||
@@ -157,7 +145,8 @@ export function ConfirmDelete(props = {}) {
|
|||||||
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
||||||
* @param {string} [props.successMsg] - Success toast message
|
* @param {string} [props.successMsg] - Success toast message
|
||||||
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
||||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||||
|
* @param {function} [props.onSuccess] - Callback after the success toast
|
||||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||||
* @param {boolean} [props.disabled] - Disabled state
|
* @param {boolean} [props.disabled] - Disabled state
|
||||||
*/
|
*/
|
||||||
@@ -187,13 +176,10 @@ export function ActionButton(props = {}) {
|
|||||||
if (synced && synced.length) {
|
if (synced && synced.length) {
|
||||||
if (msg) msg += ' ';
|
if (msg) msg += ' ';
|
||||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||||
synced.forEach(s => modelFetch(s));
|
|
||||||
}
|
}
|
||||||
if (msg) toast(msg, 'success');
|
if (msg) toast(msg, 'success');
|
||||||
if (props.refresh) {
|
if (props.onSuccess) props.onSuccess();
|
||||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
// No modelFetch — WS delta updates state store models.
|
||||||
names.forEach(n => modelFetch(n));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||||
}
|
}
|
||||||
@@ -324,7 +310,7 @@ export function ServiceStatus(props = {}) {
|
|||||||
* @param {string} props.removeUrl - API DELETE URL
|
* @param {string} props.removeUrl - API DELETE URL
|
||||||
* @param {string} props.removeMessage - Confirmation prompt text
|
* @param {string} props.removeMessage - Confirmation prompt text
|
||||||
* @param {string} [props.removeSuccess] - Success toast message
|
* @param {string} [props.removeSuccess] - Success toast message
|
||||||
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
|
* @param {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat)
|
||||||
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
||||||
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
||||||
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ export function formModal(inner, title, fields, actions) {
|
|||||||
* @param {string[]} props.selected - Currently selected values
|
* @param {string[]} props.selected - Currently selected values
|
||||||
* @param {string} props.fieldKey - JSON key for the field
|
* @param {string} props.fieldKey - JSON key for the field
|
||||||
* @param {string} [props.successMsg] - Success toast message
|
* @param {string} [props.successMsg] - Success toast message
|
||||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||||
|
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
|
||||||
* @returns {function} () => void, calls openModal
|
* @returns {function} () => void, calls openModal
|
||||||
*/
|
*/
|
||||||
export function MultiSelectModal(props = {}) {
|
export function MultiSelectModal(props = {}) {
|
||||||
@@ -242,6 +243,7 @@ export function MultiSelectModal(props = {}) {
|
|||||||
}),
|
}),
|
||||||
successMsg: props.successMsg || 'Updated',
|
successMsg: props.successMsg || 'Updated',
|
||||||
refresh: props.refresh,
|
refresh: props.refresh,
|
||||||
|
confirm: props.confirm,
|
||||||
closeModal: () => closeModal(),
|
closeModal: () => closeModal(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -263,7 +265,7 @@ export function MultiSelectModal(props = {}) {
|
|||||||
* @param {function} [props.submit.body] - (data) => object
|
* @param {function} [props.submit.body] - (data) => object
|
||||||
* @param {function} [props.submit.validate] - (body) => string|null
|
* @param {function} [props.submit.validate] - (body) => string|null
|
||||||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
||||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||||||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||||||
* @returns {function} (data) => void, calls openModal
|
* @returns {function} (data) => void, calls openModal
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export { definePage, hComp } from './component.js';
|
|||||||
export { createRouter, Link } from './router.js';
|
export { createRouter, Link } from './router.js';
|
||||||
|
|
||||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||||
export { connect, onMessage, disconnect } from './websocket.js';
|
export { connect, disconnect } from './websocket.js';
|
||||||
|
|
||||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction }
|
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction }
|
||||||
@@ -38,7 +38,7 @@ export { createAuthModel, getAuthToken, isAuthenticated, refreshAuth, getAuthDat
|
|||||||
from './auth_model.js';
|
from './auth_model.js';
|
||||||
|
|
||||||
/* ── Model ───────────────────────────────────────────────────── */
|
/* ── Model ───────────────────────────────────────────────────── */
|
||||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js';
|
export { modelRegister, getModel, modelFetch, modelSet, collectLoadingModels } from './model.js';
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||||
export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js';
|
export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js';
|
||||||
|
|||||||
@@ -135,6 +135,23 @@ export function modelFetch(name, signalOrParam, signal) {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set model data from WebSocket. Bypasses fetch cycle, no refreshing flag.
|
||||||
|
* Directly assigns to reactive proxy → triggers re-render.
|
||||||
|
* Clears loading unconditionally on arrival of real data.
|
||||||
|
*
|
||||||
|
* @param {string} name - Model name
|
||||||
|
* @param {any} data - State payload from the WS snapshot/delta
|
||||||
|
*/
|
||||||
|
export function modelSet(name, data) {
|
||||||
|
const entry = _models.get(name);
|
||||||
|
if (!entry) return;
|
||||||
|
const model = entry.model;
|
||||||
|
if (model.loading) model.loading = false; // real data ends the initial load
|
||||||
|
model.data = data;
|
||||||
|
model.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh all models whose subsystem topic matches the given topic.
|
* Refresh all models whose subsystem topic matches the given topic.
|
||||||
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Hoover — schema.js
|
||||||
|
*
|
||||||
|
* State-store schema defaults — one module per subsystem.
|
||||||
|
*
|
||||||
|
* `defaults` initializes model.data so pages don't need null guards
|
||||||
|
* during the first render (before the WS snapshot or HTTP fallback
|
||||||
|
* delivers real data). The shapes match the daemon state store
|
||||||
|
* (docs/state-model.md); the WebSocket streams these exact shapes.
|
||||||
|
*
|
||||||
|
* `POLL_INTERVALS` is client-side awareness of the daemon's expected
|
||||||
|
* refresh cadence per subsystem (for "last updated" displays).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SUBSYSTEMS = {
|
||||||
|
firewall: {
|
||||||
|
defaults: {
|
||||||
|
config: {},
|
||||||
|
active_zones: {},
|
||||||
|
interfaces: [],
|
||||||
|
available_services: [],
|
||||||
|
zones: {},
|
||||||
|
rich_rules: {},
|
||||||
|
pending: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dnsmasq: {
|
||||||
|
defaults: {
|
||||||
|
config: {},
|
||||||
|
status: { service_active: false, config_file_exists: false, active_leases: 0, pending_changes: false },
|
||||||
|
leases: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nginx: {
|
||||||
|
defaults: {
|
||||||
|
config: {},
|
||||||
|
domains: [],
|
||||||
|
status: { pending_changes: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acme: {
|
||||||
|
defaults: {
|
||||||
|
certs: [],
|
||||||
|
email: '',
|
||||||
|
account: { registered: false, email: '', ca: '', key_length: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wireguard: {
|
||||||
|
defaults: {
|
||||||
|
config: {},
|
||||||
|
status: { up: false, interface: {}, peers: [], classes: {}, pending_changes: false },
|
||||||
|
peers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
networkd: {
|
||||||
|
defaults: {
|
||||||
|
config: {},
|
||||||
|
interfaces: {},
|
||||||
|
status: { pending_changes: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
defaults: {
|
||||||
|
load: { load1: 0, load5: 0, load15: 0 },
|
||||||
|
memory: { total: 0, available: 0, used: 0, used_pct: 0 },
|
||||||
|
swap: { total: 0, used: 0, used_pct: 0 },
|
||||||
|
traffic: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLL_INTERVALS = {
|
||||||
|
firewall: 30,
|
||||||
|
wireguard: 10,
|
||||||
|
dnsmasq: 10,
|
||||||
|
networkd: 10,
|
||||||
|
system: 1, // Phase 5: 30 → 1 (real-time metrics)
|
||||||
|
nginx: 60, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
|
||||||
|
acme: 300, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Hoover — websocket.js
|
* Hoover — websocket.js
|
||||||
*
|
*
|
||||||
* WebSocket connection manager with auto-reconnect. WS messages are routed
|
* WebSocket connection manager with auto-reconnect. WS messages carry
|
||||||
* to model-based refresh and direct onMessage handlers.
|
* state data directly: a full snapshot on connect, then per-subsystem
|
||||||
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
* deltas. handleMessage patches the matching models in place via
|
||||||
|
* modelSet — no HTTP round-trip for auto-refresh.
|
||||||
*
|
*
|
||||||
* The JWT is read from the auth model (single source of truth). After 3
|
* The JWT is read from the auth model (single source of truth). After 3
|
||||||
* failed close attempts a token refresh is triggered through the auth
|
* failed close attempts a token refresh is triggered through the auth
|
||||||
@@ -19,9 +20,21 @@
|
|||||||
* reloaded; the UI keeps working via the REST API.
|
* reloaded; the UI keeps working via the REST API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { refreshByTopic } from './model.js';
|
import { modelSet } from './model.js';
|
||||||
import { refreshAuth, getAuthToken } from './auth_model.js';
|
import { refreshAuth, getAuthToken } from './auth_model.js';
|
||||||
|
|
||||||
|
// Maps subsystem name → registered model name.
|
||||||
|
// Most subsystems use the same name. `networkd` maps to `network`.
|
||||||
|
const _SUBSYSTEM_TO_MODEL = {
|
||||||
|
firewall: 'firewall',
|
||||||
|
dnsmasq: 'dnsmasq',
|
||||||
|
nginx: 'nginx',
|
||||||
|
acme: 'acme',
|
||||||
|
wireguard: 'wireguard',
|
||||||
|
networkd: 'network',
|
||||||
|
system: 'system',
|
||||||
|
};
|
||||||
|
|
||||||
let _wsConn = null;
|
let _wsConn = null;
|
||||||
let _wsReconnectMs = 0;
|
let _wsReconnectMs = 0;
|
||||||
let _wsFailCount = 0;
|
let _wsFailCount = 0;
|
||||||
@@ -32,9 +45,6 @@ let _wsRefreshStreak = 0;
|
|||||||
let _wsGivingUp = false;
|
let _wsGivingUp = false;
|
||||||
let _wsClosingHandled = false;
|
let _wsClosingHandled = false;
|
||||||
|
|
||||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
|
||||||
const _directHandlers = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the WebSocket URL from the current origin. nginx proxies /ws to
|
* Build the WebSocket URL from the current origin. nginx proxies /ws to
|
||||||
* the daemon's WebSocket port.
|
* the daemon's WebSocket port.
|
||||||
@@ -126,55 +136,41 @@ function _wsConnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route an incoming WS message to model refresh and direct handlers.
|
* Patch models in place from a data-carrying WS message.
|
||||||
*
|
*
|
||||||
* Expected message shapes:
|
* Expected message shapes (daemon → client):
|
||||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
* { type: 'snapshot', data: {subsystem: state|null, …} } // on connect
|
||||||
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
|
* { type: 'versions', subsystem: 'firewall', data: state } // structural change
|
||||||
* { type: 'notify', topic: 'firewall' }
|
* { type: 'tick', subsystem: 'system', data: state } // volatile change
|
||||||
* { type: 'status', topic: 'firewall', … }
|
*
|
||||||
|
* The daemon only sends these three types after the WS push-stream
|
||||||
|
* migration; unknown / retired types (refresh/notify/status, legacy
|
||||||
|
* versions.updated, tick.subsystems) are ignored — no backward compat.
|
||||||
*/
|
*/
|
||||||
function handleMessage(msg) {
|
function handleMessage(msg) {
|
||||||
const topics = [];
|
if (msg.type === 'snapshot') {
|
||||||
|
// Full state on connect — set all models (null = collector failed, skip)
|
||||||
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
|
for (const [subsystem, data] of Object.entries(msg.data)) {
|
||||||
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
|
if (data !== null) {
|
||||||
} else if (msg.type === 'notify') {
|
const modelName = _SUBSYSTEM_TO_MODEL[subsystem] || subsystem;
|
||||||
topics.push(msg.topic);
|
modelSet(modelName, data);
|
||||||
} else if (msg.type === 'status') {
|
}
|
||||||
topics.push(msg.topic || '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh models for each topic
|
|
||||||
for (const topic of topics) {
|
|
||||||
refreshByTopic(topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify direct onMessage handlers
|
|
||||||
for (const h of _directHandlers) {
|
|
||||||
if (h.unsubscribed) continue;
|
|
||||||
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
|
||||||
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if ((msg.type === 'versions' || msg.type === 'tick')
|
||||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
&& msg.subsystem && msg.data != null) {
|
||||||
* Handler receives the raw parsed message when a matching topic arrives.
|
// Delta for one subsystem — patch the corresponding model.
|
||||||
* @param {string|string[]} topics
|
// Guard is `!= null` (not `!== undefined`): a null payload means the
|
||||||
* @param {function} handler
|
// collector failed — never overwrite good model data (defense in depth;
|
||||||
* @returns {function} unsubscribe
|
// the daemon skips null broadcasts).
|
||||||
*/
|
const modelName = _SUBSYSTEM_TO_MODEL[msg.subsystem] || msg.subsystem;
|
||||||
export function onMessage(topics, handler) {
|
modelSet(modelName, msg.data);
|
||||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
return;
|
||||||
const entry = { topics: tArray, handler, unsubscribed: false };
|
}
|
||||||
_directHandlers.push(entry);
|
|
||||||
return () => {
|
// Everything else is unknown / retired — ignored.
|
||||||
entry.unsubscribed = true;
|
|
||||||
const idx = _directHandlers.indexOf(entry);
|
|
||||||
if (idx !== -1) _directHandlers.splice(idx, 1);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start the WebSocket connection. */
|
/** Start the WebSocket connection. */
|
||||||
|
|||||||
@@ -239,8 +239,8 @@ export function openBackendModal(state, backend) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
|
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
await modelFetch('backends');
|
await modelFetch('backends'); // not state-store-backed — explicit fetch
|
||||||
modelFetch('nginx');
|
// No nginx modelFetch — daemon broadcasts nginx via WS delta.
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || 'Failed', 'error');
|
toast(res.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -290,7 +290,7 @@ export default definePage({
|
|||||||
deleteKey=${name}
|
deleteKey=${name}
|
||||||
message=${'Remove backend ' + enc(name) + '?'}
|
message=${'Remove backend ' + enc(name) + '?'}
|
||||||
success="Backend removed"
|
success="Backend removed"
|
||||||
refresh=["backends", "nginx"]
|
onComplete=${() => modelFetch('backends')}
|
||||||
label="Delete" />`
|
label="Delete" />`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
@@ -303,7 +303,7 @@ export default definePage({
|
|||||||
url: '/api/proxy/apply',
|
url: '/api/proxy/apply',
|
||||||
successMsg: 'Nginx applied & reloaded',
|
successMsg: 'Nginx applied & reloaded',
|
||||||
label: 'Apply',
|
label: 'Apply',
|
||||||
refresh: ['backends', 'nginx'],
|
onSuccess: () => modelFetch('backends'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
|
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
|
||||||
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js';
|
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js';
|
||||||
|
|
||||||
function _accountCard(account) {
|
function _accountCard(account) {
|
||||||
@@ -56,7 +56,7 @@ function registerAccountModal() {
|
|||||||
if (!resp.ok) throw resp.error || 'Registration failed';
|
if (!resp.ok) throw resp.error || 'Registration failed';
|
||||||
toast('ACME account registered', 'success');
|
toast('ACME account registered', 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
modelFetch('acme');
|
// No modelFetch — WS delta updates the acme model.
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -100,7 +100,7 @@ function settingsModal(account) {
|
|||||||
if (!resp.ok) throw resp.error || 'Failed';
|
if (!resp.ok) throw resp.error || 'Failed';
|
||||||
toast('Email updated', 'success');
|
toast('Email updated', 'success');
|
||||||
closeModal(idx);
|
closeModal(idx);
|
||||||
modelFetch('acme');
|
// No modelFetch — WS delta updates the acme model.
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ function settingsModal(account) {
|
|||||||
if (!resp.ok) throw resp.error || 'Failed';
|
if (!resp.ok) throw resp.error || 'Failed';
|
||||||
toast('Account deactivated', 'success');
|
toast('Account deactivated', 'success');
|
||||||
closeModal(idx);
|
closeModal(idx);
|
||||||
modelFetch('acme');
|
// No modelFetch — WS delta updates the acme model.
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -262,7 +262,7 @@ async function pollCertIssue(rid) {
|
|||||||
onErrorKey: (d) => d.status === 'failed',
|
onErrorKey: (d) => d.status === 'failed',
|
||||||
onComplete: (d) => {
|
onComplete: (d) => {
|
||||||
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
||||||
modelFetch('acme');
|
// No modelFetch — WS delta updates the acme model.
|
||||||
},
|
},
|
||||||
onError: (d) => {
|
onError: (d) => {
|
||||||
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
||||||
@@ -299,7 +299,6 @@ export default definePage({
|
|||||||
removeUrl=${'/api/certs/' + enc(c.domain)}
|
removeUrl=${'/api/certs/' + enc(c.domain)}
|
||||||
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
||||||
removeSuccess="Certificate removed"
|
removeSuccess="Certificate removed"
|
||||||
removeRefresh="acme"
|
|
||||||
deleteKey=${c.domain} />
|
deleteKey=${c.domain} />
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export default definePage({
|
|||||||
wireguard: getModel('wireguard'),
|
wireguard: getModel('wireguard'),
|
||||||
acme: getModel('acme'),
|
acme: getModel('acme'),
|
||||||
nginx: getModel('nginx'),
|
nginx: getModel('nginx'),
|
||||||
status: getModel('status'),
|
system: getModel('system'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuardMulti('Dashboard', 'System overview',
|
const guard = renderGuardMulti('Dashboard', 'System overview',
|
||||||
state.firewall, state.network, state.dnsmasq, state.wireguard,
|
state.firewall, state.network, state.dnsmasq, state.wireguard,
|
||||||
state.acme, state.nginx, state.status);
|
state.acme, state.nginx, state.system);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
// Extract data
|
// Extract data
|
||||||
@@ -33,19 +33,22 @@ export default definePage({
|
|||||||
const allCerts = state.acme.data?.certs || [];
|
const allCerts = state.acme.data?.certs || [];
|
||||||
const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30));
|
const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30));
|
||||||
|
|
||||||
// System metrics from status model
|
// System metrics from the system model
|
||||||
const sysMetrics = state.status.data?.metrics || {};
|
const sysLoad = state.system.data?.load || {};
|
||||||
const sysLoad = sysMetrics.load || {};
|
const sysMem = state.system.data?.memory || {};
|
||||||
const sysMem = sysMetrics.memory || {};
|
const sysSwap = state.system.data?.swap || {};
|
||||||
const sysSwap = sysMetrics.swap || {};
|
const sysTraffic = state.system.data?.traffic || {};
|
||||||
const sysTraffic = sysMetrics.traffic || {};
|
|
||||||
|
|
||||||
// Pending changes
|
// Pending changes — derived from the config-backed subsystem models.
|
||||||
const pend = state.status.data?.pending || {};
|
// Firewall uses pending.needs_apply (config_pending() output); all
|
||||||
const totalChanges = pend.total_changes || 0;
|
// others use status.pending_changes. `system` is metrics-only.
|
||||||
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k =>
|
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => {
|
||||||
k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes)
|
const model = getModel(k === 'networkd' ? 'network' : k);
|
||||||
);
|
const d = model.data || {};
|
||||||
|
if (k === 'firewall') return !!d.pending?.needs_apply;
|
||||||
|
return !!d.status?.pending_changes;
|
||||||
|
});
|
||||||
|
const totalChanges = pendKeys.length;
|
||||||
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
||||||
|
|
||||||
// Build merged interface list
|
// Build merged interface list
|
||||||
@@ -53,13 +56,10 @@ export default definePage({
|
|||||||
const ifaces = allNames.map(name => {
|
const ifaces = allNames.map(name => {
|
||||||
const fw = fwIfaces.find(f => f.name === name);
|
const fw = fwIfaces.find(f => f.name === name);
|
||||||
const netEntry = netIfaces[name] || {};
|
const netEntry = netIfaces[name] || {};
|
||||||
// /api/network/interfaces returns {config, runtime} per interface —
|
|
||||||
// state fields (state, addresses, mac) live under runtime.
|
|
||||||
const runtime = netEntry.runtime || {};
|
|
||||||
const traffic = sysTraffic[name] || {};
|
const traffic = sysTraffic[name] || {};
|
||||||
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
||||||
const addrs = runtime.addresses || [];
|
const addrs = netEntry.addresses || [];
|
||||||
const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s));
|
const isUp = ['routable', 'degraded', 'carrier'].some(s => (netEntry.state || '').startsWith(s));
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
mac: fw?.mac || null,
|
mac: fw?.mac || null,
|
||||||
@@ -91,7 +91,7 @@ export default definePage({
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
||||||
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
||||||
successMsg="All changes applied" refresh="status"
|
successMsg="All changes applied"
|
||||||
cls="btn btn-sm btn-primary" />
|
cls="btn btn-sm btn-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
|
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
|
||||||
|
|
||||||
function makeAddRange(activeZones, interfaces) {
|
function makeAddRange(activeZones, interfaces) {
|
||||||
const opts = [
|
const opts = [
|
||||||
@@ -70,7 +70,6 @@ function makeAddRange(activeZones, interfaces) {
|
|||||||
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
||||||
successMsg: 'Range added',
|
successMsg: 'Range added',
|
||||||
},
|
},
|
||||||
refresh: 'dnsmasq',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +90,6 @@ const addLease = QuickModal({
|
|||||||
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
||||||
successMsg: 'Lease added',
|
successMsg: 'Lease added',
|
||||||
},
|
},
|
||||||
refresh: 'dnsmasq',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const addDns = QuickModal({
|
const addDns = QuickModal({
|
||||||
@@ -106,7 +104,6 @@ const addDns = QuickModal({
|
|||||||
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
||||||
successMsg: 'DNS record added',
|
successMsg: 'DNS record added',
|
||||||
},
|
},
|
||||||
refresh: 'dnsmasq',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
@@ -140,8 +137,7 @@ export default definePage({
|
|||||||
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
|
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
|
||||||
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
|
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
|
||||||
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
||||||
success="Range removed"
|
success="Range removed" />
|
||||||
refresh="dnsmasq" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
|
|
||||||
@@ -154,8 +150,7 @@ export default definePage({
|
|||||||
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
|
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
|
||||||
deleteKey=${l.mac}
|
deleteKey=${l.mac}
|
||||||
message=${'Remove lease ' + l.mac + '?'}
|
message=${'Remove lease ' + l.mac + '?'}
|
||||||
success="Lease removed"
|
success="Lease removed" />
|
||||||
refresh="dnsmasq" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
|
|
||||||
@@ -167,8 +162,7 @@ export default definePage({
|
|||||||
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
|
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
|
||||||
deleteKey=${rec.name || 'unnamed'}
|
deleteKey=${rec.name || 'unnamed'}
|
||||||
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
||||||
success="Record removed"
|
success="Record removed" />
|
||||||
refresh="dnsmasq" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
|
|
||||||
@@ -179,7 +173,7 @@ export default definePage({
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast('DNS domain updated', 'success');
|
toast('DNS domain updated', 'success');
|
||||||
modelFetch('dnsmasq');
|
// No modelFetch — WS delta updates the dnsmasq model.
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || 'Failed to update', 'error');
|
toast(res.error || 'Failed to update', 'error');
|
||||||
}
|
}
|
||||||
@@ -198,7 +192,7 @@ export default definePage({
|
|||||||
|
|
||||||
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
|
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.active_zones, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
|
||||||
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
|
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
|
||||||
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
|
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
|
||||||
(() => {
|
(() => {
|
||||||
@@ -213,10 +207,9 @@ export default definePage({
|
|||||||
let msg = 'dnsmasq applied';
|
let msg = 'dnsmasq applied';
|
||||||
if (synced && synced.length) {
|
if (synced && synced.length) {
|
||||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||||
synced.forEach(s => modelFetch(s));
|
|
||||||
}
|
}
|
||||||
toast(msg, 'success');
|
toast(msg, 'success');
|
||||||
modelFetch('dnsmasq');
|
// No modelFetch — WS delta updates the dnsmasq model.
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || 'Apply failed', 'error');
|
toast(res.error || 'Apply failed', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
|
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
|
||||||
|
|
||||||
async function changeZone(name, zone, state) {
|
async function changeZone(name, zone, state) {
|
||||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||||
@@ -7,8 +7,7 @@ async function changeZone(name, zone, state) {
|
|||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
toast(name + ' \u2192 ' + zone, 'success');
|
toast(name + ' \u2192 ' + zone, 'success');
|
||||||
modelFetch('firewall');
|
// No modelFetch — daemon broadcasts both subsystems via WS delta.
|
||||||
modelFetch('network');
|
|
||||||
} else {
|
} else {
|
||||||
toast(r.error || 'Failed', 'error');
|
toast(r.error || 'Failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,6 @@ const cfgModalFn = QuickModal({
|
|||||||
}),
|
}),
|
||||||
successMsg: 'Config saved',
|
successMsg: 'Config saved',
|
||||||
},
|
},
|
||||||
refresh: ['firewall', 'network'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
@@ -49,8 +47,11 @@ export default definePage({
|
|||||||
|
|
||||||
const fwZones = state.firewall.data?.zones || {};
|
const fwZones = state.firewall.data?.zones || {};
|
||||||
const netData = state.network.data?.interfaces || {};
|
const netData = state.network.data?.interfaces || {};
|
||||||
const zones = fwZones.available || [];
|
const zones = Object.keys(fwZones);
|
||||||
const activeZones = fwZones.active || {};
|
const activeZones = state.firewall.data?.active_zones || {};
|
||||||
|
// Per-interface config lives in the top-level config (flat runtime
|
||||||
|
// entries carry no per-interface config).
|
||||||
|
const netCfgIfaces = state.network.data?.config?.interfaces || {};
|
||||||
|
|
||||||
// Loopback has no networkd config to manage — show real NICs only.
|
// Loopback has no networkd config to manage — show real NICs only.
|
||||||
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
|
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
|
||||||
@@ -63,11 +64,11 @@ export default definePage({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
mac: entry?.runtime?.mac || null,
|
mac: entry?.mac || null,
|
||||||
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
|
ips: [...(netCfgIfaces[name]?.addresses || []), ...(entry?.addresses || [])],
|
||||||
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
|
state: (entry?.state || '').startsWith('routable') || (entry?.state || '').startsWith('carrier') ? 'up' : 'down',
|
||||||
zone,
|
zone,
|
||||||
config: entry?.config || {},
|
config: netCfgIfaces[name] || {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
|
||||||
|
|
||||||
const addFwd = QuickModal({
|
const addFwd = QuickModal({
|
||||||
title: 'Add Port Forward',
|
title: 'Add Port Forward',
|
||||||
@@ -21,7 +21,6 @@ const addFwd = QuickModal({
|
|||||||
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
||||||
successMsg: 'Forward rule added',
|
successMsg: 'Forward rule added',
|
||||||
},
|
},
|
||||||
refresh: 'firewall',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
@@ -37,7 +36,7 @@ export default definePage({
|
|||||||
const cfg = state.firewall.data?.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
|
|
||||||
const sIface = (state.firewall.data?.state || {}).interfaces || [];
|
const sIface = state.firewall.data?.interfaces || [];
|
||||||
// With nftables, masquerade is propagated to the public zone at runtime for
|
// With nftables, masquerade is propagated to the public zone at runtime for
|
||||||
// POSTROUTING to work. The config-side masquerade flag indicates which
|
// POSTROUTING to work. The config-side masquerade flag indicates which
|
||||||
// zones source NAT traffic (LAN / internal), not where traffic exits (WAN).
|
// zones source NAT traffic (LAN / internal), not where traffic exits (WAN).
|
||||||
@@ -92,8 +91,7 @@ export default definePage({
|
|||||||
cls="btn btn-sm btn-outline"
|
cls="btn btn-sm btn-outline"
|
||||||
labelOn="Disable" labelOff="Enable" condition=${masq}
|
labelOn="Disable" labelOff="Enable" condition=${masq}
|
||||||
body=${() => ({ zone, enable: !masq })}
|
body=${() => ({ zone, enable: !masq })}
|
||||||
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
|
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} />
|
||||||
refresh="firewall" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
@@ -115,8 +113,7 @@ export default definePage({
|
|||||||
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
|
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
|
||||||
deleteKey=${zone + '/' + port + '/' + proto}
|
deleteKey=${zone + '/' + port + '/' + proto}
|
||||||
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
|
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
|
||||||
success="Rule removed"
|
success="Rule removed" />
|
||||||
refresh="firewall" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ function addDomain(state, preselectedBackend) {
|
|||||||
!b.backend ? 'Backend is required' : null,
|
!b.backend ? 'Backend is required' : null,
|
||||||
successMsg: 'Domain added',
|
successMsg: 'Domain added',
|
||||||
},
|
},
|
||||||
refresh: ['nginx', 'acme'],
|
|
||||||
postRender: (inner) => {
|
postRender: (inner) => {
|
||||||
if (preselectedBackend) {
|
if (preselectedBackend) {
|
||||||
const backendSelect = inner.querySelector('#p-backend');
|
const backendSelect = inner.querySelector('#p-backend');
|
||||||
@@ -161,7 +160,6 @@ function editDomain(d, state) {
|
|||||||
validate: (b) => !b.cert ? 'Cert is required' : null,
|
validate: (b) => !b.cert ? 'Cert is required' : null,
|
||||||
successMsg: 'Domain updated',
|
successMsg: 'Domain updated',
|
||||||
},
|
},
|
||||||
refresh: ['nginx', 'acme'],
|
|
||||||
postRender: (inner) => {
|
postRender: (inner) => {
|
||||||
const certSelect = inner.querySelector('#pe-cert');
|
const certSelect = inner.querySelector('#pe-cert');
|
||||||
if (certSelect) certSelect.value = selectedCert;
|
if (certSelect) certSelect.value = selectedCert;
|
||||||
@@ -213,7 +211,6 @@ function domainRow(domainName, domainPaths, state) {
|
|||||||
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
|
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
|
||||||
removeMessage=${'Remove ' + enc(domainName) + '?'}
|
removeMessage=${'Remove ' + enc(domainName) + '?'}
|
||||||
removeSuccess="Domain removed"
|
removeSuccess="Domain removed"
|
||||||
removeRefresh=["nginx", "acme"]
|
|
||||||
removeLabel="Delete" />
|
removeLabel="Delete" />
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -231,7 +228,7 @@ function backendSection(section, state) {
|
|||||||
deleteKey=${backendName}
|
deleteKey=${backendName}
|
||||||
message=${'Remove backend ' + enc(backendName) + '?'}
|
message=${'Remove backend ' + enc(backendName) + '?'}
|
||||||
success="Backend removed"
|
success="Backend removed"
|
||||||
refresh=["backends", "nginx"]
|
onComplete=${() => modelFetch('backends')}
|
||||||
label="Delete" />`);
|
label="Delete" />`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +264,7 @@ export default definePage({
|
|||||||
const sectionVNodes = sections.map(s => backendSection(s, state));
|
const sectionVNodes = sections.map(s => backendSection(s, state));
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
||||||
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
|
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js';
|
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
|
||||||
|
|
||||||
const addRule = QuickModal({
|
const addRule = QuickModal({
|
||||||
title: 'Add Rich Rule',
|
title: 'Add Rich Rule',
|
||||||
@@ -12,7 +12,6 @@ const addRule = QuickModal({
|
|||||||
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
||||||
successMsg: 'Rule added',
|
successMsg: 'Rule added',
|
||||||
},
|
},
|
||||||
refresh: 'firewall',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
@@ -26,7 +25,7 @@ export default definePage({
|
|||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const cfg = state.firewall.data?.config || {};
|
const cfg = state.firewall.data?.config || {};
|
||||||
const zones = state.firewall.data?.zones?.available || [];
|
const zones = Object.keys(state.firewall.data?.zones || {});
|
||||||
const zoneData = cfg.zones || {};
|
const zoneData = cfg.zones || {};
|
||||||
const zoneRules = {};
|
const zoneRules = {};
|
||||||
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
||||||
@@ -46,8 +45,7 @@ export default definePage({
|
|||||||
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
|
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
|
||||||
deleteKey=${zone + '-' + (ruleId || i)}
|
deleteKey=${zone + '-' + (ruleId || i)}
|
||||||
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
|
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
|
||||||
success="Rule removed"
|
success="Rule removed" />
|
||||||
refresh="firewall" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** WireGuard page — tunnel & peer management. */
|
/** WireGuard page — tunnel & peer management. */
|
||||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
|
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
|
||||||
|
|
||||||
/* ── LAN detection helper ────────────────────────────────────── */
|
/* ── LAN detection helper ────────────────────────────────────── */
|
||||||
function getLanSubnets() {
|
function getLanSubnets() {
|
||||||
@@ -76,7 +76,6 @@ const addPeer = QuickModal({
|
|||||||
!b.access_class ? 'Access Class is required' : null,
|
!b.access_class ? 'Access Class is required' : null,
|
||||||
successMsg: 'Peer added',
|
successMsg: 'Peer added',
|
||||||
},
|
},
|
||||||
refresh: 'wireguard',
|
|
||||||
postRender: (inner, data) => {
|
postRender: (inner, data) => {
|
||||||
const presetEl = document.getElementById('wg-allowed-preset');
|
const presetEl = document.getElementById('wg-allowed-preset');
|
||||||
if (presetEl) {
|
if (presetEl) {
|
||||||
@@ -256,7 +255,7 @@ function settingsModal(wireguardData, state) {
|
|||||||
if (!resp.ok) throw resp.error || 'Failed to save';
|
if (!resp.ok) throw resp.error || 'Failed to save';
|
||||||
toast('Settings saved', 'success');
|
toast('Settings saved', 'success');
|
||||||
closeModal(idx);
|
closeModal(idx);
|
||||||
modelFetch('wireguard');
|
// No modelFetch — WS delta updates the wireguard model.
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -291,7 +290,6 @@ const addClass = QuickModal({
|
|||||||
!b.listen_port ? 'Listen port is required' : null,
|
!b.listen_port ? 'Listen port is required' : null,
|
||||||
successMsg: 'Class added',
|
successMsg: 'Class added',
|
||||||
},
|
},
|
||||||
refresh: 'wireguard',
|
|
||||||
postRender: (inner) => {
|
postRender: (inner) => {
|
||||||
const sel = document.getElementById('wc-lan');
|
const sel = document.getElementById('wc-lan');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
@@ -330,7 +328,7 @@ function editClassModal(key, cls, peerCount) {
|
|||||||
if (!resp.ok) throw resp.error || 'Failed';
|
if (!resp.ok) throw resp.error || 'Failed';
|
||||||
toast('Class updated', 'success');
|
toast('Class updated', 'success');
|
||||||
closeModal(idx);
|
closeModal(idx);
|
||||||
modelFetch('wireguard');
|
// No modelFetch — WS delta updates the wireguard model.
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -374,17 +372,17 @@ function renderAccessClasses(config, status) {
|
|||||||
<td>
|
<td>
|
||||||
${!hasKeys
|
${!hasKeys
|
||||||
? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys"
|
? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys"
|
||||||
cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} refresh="wireguard" />`
|
cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} />`
|
||||||
: ''}
|
: ''}
|
||||||
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
|
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
|
||||||
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
|
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
|
||||||
cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
|
cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
|
||||||
successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} refresh="wireguard" />
|
successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} />
|
||||||
${(pCount > 0)
|
${(pCount > 0)
|
||||||
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
|
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
|
||||||
: html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }}
|
: html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }}
|
||||||
deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted"
|
deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted"
|
||||||
refresh="wireguard" label="Delete" />`}
|
label="Delete" />`}
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
@@ -469,7 +467,6 @@ export default definePage({
|
|||||||
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
|
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
|
||||||
removeMessage=${'Remove peer ' + p.name + '?'}
|
removeMessage=${'Remove peer ' + p.name + '?'}
|
||||||
removeSuccess="Peer removed"
|
removeSuccess="Peer removed"
|
||||||
removeRefresh="wireguard"
|
|
||||||
deleteKey=${p.name} />
|
deleteKey=${p.name} />
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
@@ -492,12 +489,12 @@ export default definePage({
|
|||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span>Subnet: ${esc(v.subnet || '-')}</span>
|
<span>Subnet: ${esc(v.subnet || '-')}</span>
|
||||||
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
|
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
|
||||||
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} refresh="wireguard" />`}</span>
|
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} />`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 4px;">
|
<div style="margin-top: 4px;">
|
||||||
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
|
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
|
||||||
cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
|
cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
|
||||||
successMsg=${isUp ? 'Stopped' : 'Started'} refresh="wireguard" />
|
successMsg=${isUp ? 'Stopped' : 'Started'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -512,12 +509,10 @@ export default definePage({
|
|||||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||||
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
||||||
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
||||||
refresh: 'wireguard',
|
|
||||||
}),
|
}),
|
||||||
ApplyConfirm({
|
ApplyConfirm({
|
||||||
pending: st.pending_changes || false,
|
pending: st.pending_changes || false,
|
||||||
successMsg: 'WireGuard applied',
|
successMsg: 'WireGuard applied',
|
||||||
refresh: ['wireguard', 'firewall'],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const addZone = QuickModal({
|
|||||||
validate: (b) => !b.name ? 'Zone name required' : null,
|
validate: (b) => !b.name ? 'Zone name required' : null,
|
||||||
successMsg: 'Zone created',
|
successMsg: 'Zone created',
|
||||||
},
|
},
|
||||||
refresh: 'firewall',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
@@ -25,8 +24,8 @@ export default definePage({
|
|||||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const zones = state.firewall.data?.zones?.available || [];
|
const zones = Object.keys(state.firewall.data?.zones || {});
|
||||||
const activeZones = state.firewall.data?.zones?.active || {};
|
const activeZones = state.firewall.data?.active_zones || {};
|
||||||
const zoneDetails = {};
|
const zoneDetails = {};
|
||||||
for (const name of zones) {
|
for (const name of zones) {
|
||||||
const activeIfaces = activeZones[name];
|
const activeIfaces = activeZones[name];
|
||||||
@@ -67,24 +66,32 @@ export default definePage({
|
|||||||
selected: ifacesArr,
|
selected: ifacesArr,
|
||||||
fieldKey: 'interfaces',
|
fieldKey: 'interfaces',
|
||||||
successMsg: 'Interfaces updated',
|
successMsg: 'Interfaces updated',
|
||||||
refresh: 'firewall',
|
|
||||||
})()}>Interfaces</button>
|
})()}>Interfaces</button>
|
||||||
<button class="btn btn-sm btn-outline"
|
<button class="btn btn-sm btn-outline"
|
||||||
onClick=${() => MultiSelectModal({
|
onClick=${() => MultiSelectModal({
|
||||||
title: 'Services: ' + name,
|
title: 'Services: ' + name,
|
||||||
url: '/api/firewall/zones/' + enc(name) + '/services',
|
url: '/api/firewall/zones/' + enc(name) + '/services',
|
||||||
options: state.firewall.data?.services || [],
|
options: state.firewall.data?.available_services || [],
|
||||||
selected: svcsArr,
|
selected: svcsArr,
|
||||||
fieldKey: 'services',
|
fieldKey: 'services',
|
||||||
successMsg: 'Services updated',
|
successMsg: 'Services updated',
|
||||||
refresh: 'firewall',
|
confirm: (b) => {
|
||||||
|
const svcs = (b && b.services) || [];
|
||||||
|
const isDefault = name === state.firewall.data?.default_zone;
|
||||||
|
if (isDefault && !svcs.includes('https') && !svcs.includes('ssh')) {
|
||||||
|
return 'This removes both HTTPS and SSH from the default zone ' +
|
||||||
|
"'" + name + "'. Management access and remote recovery " +
|
||||||
|
'through this zone will be blocked until you reach the ' +
|
||||||
|
'appliance via console or another route.\n\nRemove them anyway?';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
})()}>Services</button>
|
})()}>Services</button>
|
||||||
<${ConfirmDelete}
|
<${ConfirmDelete}
|
||||||
url=${'/api/firewall/zones/' + enc(name)}
|
url=${'/api/firewall/zones/' + enc(name)}
|
||||||
deleteKey=${name}
|
deleteKey=${name}
|
||||||
message=${'Delete zone ' + name + '?'}
|
message=${'Delete zone ' + name + '?'}
|
||||||
success=${'Zone ' + name + ' deleted'}
|
success=${'Zone ' + name + ' deleted'}
|
||||||
refresh="firewall"
|
|
||||||
label="Delete" />
|
label="Delete" />
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user