Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55309cfd86 | |||
| 30b51ad7d3 | |||
| a77cee821b | |||
| 94705490b9 | |||
| 332d14e37d | |||
| 9c9f92ad04 | |||
| 4bd4c374fd | |||
| 183904faad | |||
| 0ed275835d | |||
| 1980043afd | |||
| 4f74192302 | |||
| 64f3a77411 | |||
| 11a398ce89 | |||
| 382bbd989b | |||
| 5d84710d1a | |||
| 61d95b99a4 | |||
| c7593f8a1e | |||
| 9ae2cca801 | |||
| 818721ae5c | |||
| 85d8770ba6 | |||
| 0889ef0d08 | |||
| 6404508519 | |||
| 76300e281f | |||
| e01574c67e | |||
| 3654209b78 | |||
| 6f728cf853 | |||
| ba0c7bfa9b | |||
| c64f988ba2 | |||
| b69ca330f4 | |||
| 43b44ad340 | |||
| 48f8d0be18 | |||
| 6d30f1387e | |||
| 8ae60ab8cf | |||
| a82578f342 | |||
| 8bb3619ddc | |||
| 244576b8eb | |||
| 3de82e3b9b | |||
| c943d17bb3 | |||
| f77473c13c | |||
| ca27ea5522 | |||
| d52a0fad12 | |||
| c4a10c7129 | |||
| 739253b2e5 | |||
| 358573567d | |||
| 76cd219050 | |||
| e48ba72b81 | |||
| cc5679a1cd | |||
| ca110c321d | |||
| d4213fb93b | |||
| edaf16a433 | |||
| a365059976 | |||
| 56b200d233 | |||
| 04417cf05c | |||
| dadabd7954 | |||
| c21639b7f1 | |||
| 2e49dec633 | |||
| 05524f3756 | |||
| 803258cf18 | |||
| 5135de0921 | |||
| fb39af126a | |||
| b4d13c4bd5 | |||
| 20266e4a2f | |||
| 8c13ad55ce | |||
| 575cf06a4b | |||
| 9088f34345 | |||
| 348bbfbca6 | |||
| baa441fa13 | |||
| 391466664e | |||
| d8d8425340 | |||
| 25a1943fce | |||
| 80dd4e3272 |
@@ -8,6 +8,12 @@ __pycache__/
|
||||
# Package build
|
||||
*.egg-info/
|
||||
|
||||
# vendor dirs
|
||||
vendor/*
|
||||
!vendor/.empty
|
||||
webui/static/vendor/*
|
||||
!webui/static/vendor/.empty
|
||||
|
||||
# Tool caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -8,7 +8,7 @@ Deploys on Debian 13 (trixie). Serves from repo root by default.
|
||||
## 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)
|
||||
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
|
||||
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
|
||||
|
||||
- **`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`.
|
||||
@@ -24,74 +33,73 @@ through the daemon client over a Unix socket.
|
||||
|
||||
### Code Layout
|
||||
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA catch-all renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja).
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) serves `index.html` (no templating). All other paths return 404.
|
||||
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
|
||||
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
|
||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh.
|
||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
|
||||
- `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 an endpoint here auto-updates both server registry and client calls. All blueprints and handlers import from 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/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers.
|
||||
- `lib/logging.py` — Logging setup used by both webui and daemon.
|
||||
- `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.
|
||||
- `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/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.
|
||||
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htmx`, `json-enc`).
|
||||
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
|
||||
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
||||
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
|
||||
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth).
|
||||
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`).
|
||||
`__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
|
||||
|
||||
### Frontend (hoover)
|
||||
|
||||
Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
|
||||
|
||||
Conventions:
|
||||
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
|
||||
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
|
||||
- All imports from `/static/hoover/index.js` (barrel export).
|
||||
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })`.
|
||||
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
|
||||
- `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
|
||||
- Events: `h()` uses `on:click` prefix. `html` templates use camelCase `onClick` (adapter translates to `on:click`).
|
||||
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
|
||||
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
|
||||
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
|
||||
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
|
||||
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
|
||||
- No build step — ES modules served raw. Cache controlled via HTTP headers.
|
||||
|
||||
### Daemon Endpoints
|
||||
|
||||
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var)
|
||||
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications
|
||||
- Can be started as `python -m daemon.server` or via the `vacuum-walld` console script
|
||||
- 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 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`)
|
||||
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `VACUUM_WALL_DEV` — dev mode flag; when set, disables aggressive static asset caching
|
||||
- `VACUUM_WALLD_SOCKET` — override daemon socket path
|
||||
- `VACUUM_WALL_DEV` — dev mode flag; disables aggressive static asset caching
|
||||
- `VACUUM_WALL_LOG_LEVEL` — log level (default `INFO`)
|
||||
- `VACUUM_WALLD_SOCKET` — override daemon socket path (default `data/daemon.sock`)
|
||||
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
|
||||
|
||||
## Deployment
|
||||
|
||||
`install.sh` installs only system components and configures them; the project serves from the repo root by default. Options via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override. Use `--dev` to auto-detect repo owner as service user; non-dev requires `--user`.
|
||||
|
||||
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs at `PROJECT_DIR/data/acme/`.
|
||||
|
||||
### Service Start Order
|
||||
|
||||
`firewalld` → `avahi-daemon` → `dnsmasq` → `vacuum-walld` → `vacuum-wall`
|
||||
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
|
||||
- `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
|
||||
- `VACUUM_WALL_SEED_BUILTIN_ADMIN` — set to `0` to skip the last-resort builtin admin seed in `get_db()`. The seed only runs on a completely empty DB (no users); `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
|
||||
# Setup
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
bash scripts/update-vendor.sh # fetches acme.sh + htm.js
|
||||
|
||||
# Start (Flask only, binds 127.0.0.1:9090)
|
||||
.venv/bin/python webui/server.py
|
||||
```
|
||||
|
||||
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
|
||||
|
||||
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking.
|
||||
## Blueprint / Handler / lib Mapping
|
||||
|
||||
## Blueprint ↔ Handler ↔ lib Mapping
|
||||
|
||||
| Blueprint | URL Prefix | Handler Module | lib Module |
|
||||
|-----------------------|---------------------|--------------------------|-----------------|
|
||||
| Blueprint | URL Prefix | Handler | lib Module |
|
||||
|-----------------------|---------------------|----------------------------|-----------------|
|
||||
| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
|
||||
| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
|
||||
| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` |
|
||||
@@ -99,6 +107,7 @@ In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` |
|
||||
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
|
||||
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
|
||||
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
|
||||
|
||||
## Privileged Operations
|
||||
|
||||
@@ -111,20 +120,27 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
|
||||
## API Response Contract
|
||||
|
||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common` (Flask) or `ok(data)` from `daemon.server` (aiohttp).
|
||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common` or `error(msg, code)` from `daemon.server`.
|
||||
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except.
|
||||
- HTTP codes: `400` bad request, `404` not found, `500` internal failure.
|
||||
- Success: `{"ok": true, "data": <value>}` — `_ok(data)` (Flask) or `ok(data)` (aiohttp)
|
||||
- Error: `{"ok": false, "error": "msg"}` — `_error(msg, code)` (Flask) or `error(msg, code)` (aiohttp)
|
||||
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
|
||||
- HTTP codes: `400` bad request, `404` not found, `409` conflict, `500` internal failure
|
||||
|
||||
## Deploy
|
||||
|
||||
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname.
|
||||
`scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
|
||||
`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
|
||||
|
||||
`firewalld` → `avahi-daemon` → `dnsmasq` → `vacuum-walld` → `vacuum-wall`
|
||||
|
||||
## Lint and Tests
|
||||
|
||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
|
||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
|
||||
|
||||
**Tests:** pytest in `tests/`. Tests mock out subprocess calls — no system services required.
|
||||
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
|
||||
|
||||
```bash
|
||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||
@@ -132,24 +148,16 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
.venv/bin/python -m pytest tests/ -v # test
|
||||
```
|
||||
|
||||
Install dev tooling with `pip install -e ".[dev]"`.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below.
|
||||
`docs/` contains the authoritative reference for each subsystem. Read the relevant doc before reasoning about a subsystem.
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management |
|
||||
| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels |
|
||||
| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting |
|
||||
| `docs/deployment.md` | Install script options, what scripts/install.sh does, post-install setup, troubleshooting |
|
||||
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
|
||||
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
|
||||
| `docs/hoover.md` | Custom frontend framework API reference |
|
||||
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
|
||||
|
||||
## Important Rules
|
||||
1. Ask, don't assume. If something is unclear, ask before writing a single line.
|
||||
2. Simplest solution first. Always implement the simplest thing that could work.
|
||||
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it.
|
||||
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so.
|
||||
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
|
||||
@@ -28,13 +28,13 @@ MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
MGMT_USER="admin" \
|
||||
ACME_EMAIL="admin@example.com" \
|
||||
bash install.sh
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
### Install (Development)
|
||||
|
||||
```bash
|
||||
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||
```
|
||||
|
||||
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
|
||||
@@ -52,7 +52,7 @@ bash install.sh
|
||||
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
|
||||
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
|
||||
|
||||
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
|
||||
CLI flags take precedence over environment variables. Run `./scripts/install.sh --help` for full usage.
|
||||
|
||||
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
|
||||
|
||||
@@ -73,6 +73,7 @@ See [docs/deployment.md](docs/deployment.md) for the full guide, including troub
|
||||
|
||||
```bash
|
||||
git clone <repo-url> && cd vacuum-wall
|
||||
bash scripts/update-vendor.sh
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
@@ -94,6 +94,15 @@ def _format_path(path: str, params: dict[str, Any] | None) -> str:
|
||||
return path
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
"""Regex callback that replaces ``<key>`` segments with URL-encoded values.
|
||||
|
||||
Args:
|
||||
m: Match object containing the parameter name.
|
||||
|
||||
Returns:
|
||||
URL-encoded value from params dict, or original text if key
|
||||
not found.
|
||||
"""
|
||||
key = m.group(1)
|
||||
if key in params:
|
||||
return urllib.parse.quote(str(params[key]), safe="")
|
||||
|
||||
+139
-29
@@ -26,6 +26,7 @@ from daemon.iface import (
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_PATHS,
|
||||
GET_ACME_RENEW_STATUS,
|
||||
POST_ACME_ACCOUNT_REGISTER,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
@@ -55,7 +56,9 @@ _WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||
|
||||
# In-memory store for active issuance requests.
|
||||
_ISSUANCES: dict[str, "IssueRequest"] = {}
|
||||
|
||||
_ISSUANCE_TTL = 300 # seconds to keep completed requests
|
||||
_ISSUANCE_TASKS: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def _find_issuance(domain: str) -> "IssueRequest | None":
|
||||
@@ -172,6 +175,21 @@ def _clean_expired_issuances() -> None:
|
||||
del _ISSUANCES[rid]
|
||||
|
||||
|
||||
def _fail_op(req: IssueRequest, exc: Exception) -> None:
|
||||
"""Record the error on the first running step and mark the request failed."""
|
||||
for step in req.steps:
|
||||
if step.status == "running":
|
||||
step.status = "error"
|
||||
step.message = str(exc)
|
||||
break
|
||||
else:
|
||||
req.steps.append(
|
||||
IssueStep(name="error", label="Error", status="error", message=str(exc))
|
||||
)
|
||||
req.status = "failed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
|
||||
@@ -783,7 +801,8 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
_ISSUANCES[request_id] = req
|
||||
|
||||
# Spawn background task
|
||||
_task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own
|
||||
_task = asyncio.create_task(_run_issue(req))
|
||||
_ISSUANCE_TASKS[request_id] = _task
|
||||
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
@@ -818,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
if account_email:
|
||||
args.extend(["-m", account_email])
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
# acme.sh is a blocking subprocess — run it off the event loop so
|
||||
# polling, WS broadcasts, and other requests keep responding.
|
||||
output = await asyncio.to_thread(_run_acme, args)
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = output.strip()[:200]
|
||||
|
||||
# Step 2: deploy
|
||||
req.steps[1].status = "running"
|
||||
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
await asyncio.to_thread(
|
||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
||||
)
|
||||
req.steps[1].status = "done"
|
||||
req.steps[1].message = "Deploy hook registered"
|
||||
|
||||
@@ -841,44 +864,131 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
# Mark current running step as error, overall as failed
|
||||
for step in req.steps:
|
||||
if step.status == "running":
|
||||
step.status = "error"
|
||||
step.message = str(exc)
|
||||
break
|
||||
else:
|
||||
req.steps.append(
|
||||
IssueStep(name="error", label="Error", status="error", message=str(exc))
|
||||
)
|
||||
req.status = "failed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
_fail_op(req, exc)
|
||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||
finally:
|
||||
_ISSUANCE_TASKS.pop(req.request_id, None)
|
||||
|
||||
|
||||
@registry.register(POST_ACME_RENEW)
|
||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/renew — renew a certificate for the given domain.
|
||||
"""POST /acme/renew — start a certificate renewal request (async).
|
||||
|
||||
Deduplicates in-progress requests per domain. Spawns a background task
|
||||
for the actual renewal. When ``force`` is not set, acme.sh skips the
|
||||
renewal if the certificate's renewal window has not been reached yet
|
||||
(the request then completes with status "skipped").
|
||||
|
||||
Args:
|
||||
force: Force renewal regardless of expiry.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the renewal request id (and "existing"
|
||||
status when a renewal for the domain is already running).
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
domain = (body.get("domain") or "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
force = body.get("force", False)
|
||||
args: list[str] = ["--renew", "-d", domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
logger.info("Certificate for %s renewed", domain)
|
||||
refresh_state(["acme"])
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
force = bool(body.get("force", False))
|
||||
|
||||
_clean_expired_issuances()
|
||||
|
||||
# Dedup: if domain already has an active request, return it
|
||||
existing = _find_issuance(domain)
|
||||
if existing:
|
||||
return {
|
||||
"request_id": existing.request_id,
|
||||
"domain": domain,
|
||||
"status": "existing",
|
||||
}
|
||||
|
||||
request_id = uuid4().hex[:12]
|
||||
steps = [
|
||||
IssueStep(name="renew", label="Renewing certificate"),
|
||||
IssueStep(name="deploy", label="Registering deploy hook"),
|
||||
IssueStep(name="refresh", label="Refreshing certificate state"),
|
||||
]
|
||||
req = IssueRequest(request_id=request_id, domain=domain, steps=steps)
|
||||
_ISSUANCES[request_id] = req
|
||||
|
||||
# Spawn background task
|
||||
_task = asyncio.create_task(_run_renew(req, force))
|
||||
_ISSUANCE_TASKS[request_id] = _task
|
||||
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
|
||||
@registry.register(GET_ACME_RENEW_STATUS)
|
||||
def get_renew_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /acme/renew/status — poll status of a renewal request.
|
||||
|
||||
Raises:
|
||||
ValueError: When id is missing.
|
||||
NotFoundError: When request_id is unknown.
|
||||
"""
|
||||
request_id = (body or {}).get("id", "").strip()
|
||||
if not request_id:
|
||||
raise ValueError("'id' is required")
|
||||
|
||||
req = _ISSUANCES.get(request_id)
|
||||
if not req:
|
||||
raise NotFoundError(f"Renewal request {request_id} not found")
|
||||
|
||||
return req.to_dict()
|
||||
|
||||
|
||||
async def _run_renew(req: IssueRequest, force: bool) -> None:
|
||||
"""Background task: renew the certificate, register the deploy hook, refresh state."""
|
||||
try:
|
||||
# Step 1: renew (acme.sh skips when the cert's renewal window has not
|
||||
# been reached unless force is set)
|
||||
req.steps[0].status = "running"
|
||||
args: list[str] = ["--renew", "-d", req.domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
output = await asyncio.to_thread(_run_acme, args)
|
||||
if "Skipping." in output:
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = "Renewal not yet due — skipped"
|
||||
req.status = "skipped"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.info(
|
||||
"Renewal for %s skipped (request %s)", req.domain, req.request_id
|
||||
)
|
||||
return
|
||||
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = output.strip()[:200]
|
||||
|
||||
# Step 2: deploy
|
||||
req.steps[1].status = "running"
|
||||
await asyncio.to_thread(
|
||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
||||
)
|
||||
req.steps[1].status = "done"
|
||||
req.steps[1].message = "Deploy hook registered"
|
||||
|
||||
# Step 3: refresh state
|
||||
req.steps[2].status = "running"
|
||||
refresh_state(["acme"])
|
||||
req.steps[2].status = "done"
|
||||
req.steps[2].message = "State refreshed"
|
||||
|
||||
req.status = "completed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.info(
|
||||
"Certificate for %s renewed (request %s)", req.domain, req.request_id
|
||||
)
|
||||
except Exception as exc:
|
||||
_fail_op(req, exc)
|
||||
logger.error("Renewal for %s failed: %s", req.domain, exc)
|
||||
finally:
|
||||
_ISSUANCE_TASKS.pop(req.request_id, None)
|
||||
|
||||
|
||||
@registry.register(DELETE_ACME_REMOVE)
|
||||
@@ -976,10 +1086,10 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
raise ValueError("'domain' is required")
|
||||
days = body.get("days", 365)
|
||||
|
||||
cert_dir = _ACME_HOME / domain
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert_file = cert_dir / "fullchain.cer"
|
||||
key_file = cert_dir / f"{domain}.key"
|
||||
certs_dir = PROJECT_DIR / "data" / "certs"
|
||||
certs_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert_file = certs_dir / f"{domain}.crt"
|
||||
key_file = certs_dir / f"{domain}.key"
|
||||
|
||||
if cert_file.is_file() and key_file.is_file():
|
||||
logger.info("Self-signed cert for %s already exists, skipping", domain)
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Authentication daemon handlers.
|
||||
|
||||
Handles login, logout, token refresh, session management, password change,
|
||||
user CRUD, and WebAuthn operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_AUTH_USER,
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||
GET_AUTH_SESSION,
|
||||
GET_AUTH_USERS,
|
||||
GET_AUTH_WEBAUTHN_CAPABLE,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIALS,
|
||||
POST_AUTH_LOGIN,
|
||||
POST_AUTH_LOGOUT,
|
||||
POST_AUTH_PASSWORD,
|
||||
POST_AUTH_REFRESH,
|
||||
POST_AUTH_USER_CREATE,
|
||||
POST_AUTH_USER_UPDATE,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
|
||||
)
|
||||
from daemon.server import ConflictError, NotFoundError, registry
|
||||
from lib.auth import (
|
||||
blacklist_active_refresh_token,
|
||||
blacklist_token,
|
||||
check_login_rate,
|
||||
check_webauthn_rate,
|
||||
generate_tokens,
|
||||
get_access_ttl,
|
||||
record_login_failure,
|
||||
record_login_success,
|
||||
record_webauthn_failure,
|
||||
record_webauthn_success,
|
||||
validate_token,
|
||||
)
|
||||
from lib.auth_users import (
|
||||
create_user,
|
||||
delete_user,
|
||||
get_user,
|
||||
list_users,
|
||||
update_password,
|
||||
update_permissions,
|
||||
verify_user_password,
|
||||
)
|
||||
from lib.webauthn import (
|
||||
create_authentication_options,
|
||||
create_registration_options,
|
||||
get_all_credential_counts,
|
||||
get_management_domains,
|
||||
get_rp_name,
|
||||
list_credentials,
|
||||
remove_credential,
|
||||
verify_authentication,
|
||||
verify_registration,
|
||||
)
|
||||
from lib.webauthn import (
|
||||
is_enabled as webauthn_is_enabled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_LOGIN)
|
||||
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle user login.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username`` and ``password``.
|
||||
|
||||
Returns:
|
||||
Dict with ``tokens``, ``user``, and ``permissions``.
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are invalid.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
password = body.get("password")
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
|
||||
client_ip = body.get("client_ip")
|
||||
if not check_login_rate(username, client_ip):
|
||||
raise ValueError("Too many login attempts. Please try again later.")
|
||||
|
||||
user = verify_user_password(username, password)
|
||||
if user is None:
|
||||
record_login_failure(username, client_ip)
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
record_login_success(username, client_ip)
|
||||
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_LOGOUT)
|
||||
def auth_logout(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle user logout by blacklisting the access and refresh tokens.
|
||||
|
||||
Args:
|
||||
request: The aiohttp request.
|
||||
body: Dict with ``jti``, ``username`` from Flask user context, and
|
||||
``refresh_token`` from the client.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
jti = body.get("jti")
|
||||
if jti:
|
||||
blacklist_token(jti)
|
||||
|
||||
username = body.get("username")
|
||||
if username:
|
||||
blacklist_active_refresh_token(username)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_REFRESH)
|
||||
def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle token refresh.
|
||||
|
||||
Validates the refresh token, blacklists it, and issues a new access token.
|
||||
Requires refresh_token and session_id in the request body for session binding.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``refresh_token`` and ``session_id``.
|
||||
|
||||
Returns:
|
||||
Dict with new access token, refresh token, user, and permissions.
|
||||
|
||||
Raises:
|
||||
ValueError: If refresh token is invalid.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
refresh_token = body.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required")
|
||||
|
||||
session_id = body.get("session_id")
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
|
||||
payload = validate_token(
|
||||
refresh_token, token_type="refresh", session_id=session_id, require_session=True
|
||||
)
|
||||
if payload is None:
|
||||
raise ValueError("Invalid or expired refresh token")
|
||||
|
||||
username = payload["sub"]
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Persist new tokens first, then invalidate the old ones.
|
||||
# This prevents data loss if generate_tokens fails mid-way:
|
||||
# the old refresh token remains valid and the user is not locked out.
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti:
|
||||
blacklist_token(jti, token_type="refresh")
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_SESSION)
|
||||
def auth_session(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Return current user session info.
|
||||
|
||||
Args:
|
||||
request: The aiohttp request.
|
||||
body: Dict with ``username`` from Flask user context.
|
||||
|
||||
Returns:
|
||||
Dict with user info and permissions.
|
||||
"""
|
||||
username = body.get("username") if body else None
|
||||
if username is None:
|
||||
raise ValueError("No active session")
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_PASSWORD)
|
||||
def auth_change_password(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Change user password.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username``, ``oldPassword``, ``newPassword``.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
|
||||
Raises:
|
||||
ValueError: If password change fails.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
old_password = body.get("oldPassword")
|
||||
new_password = body.get("newPassword")
|
||||
|
||||
if not username or not old_password or not new_password:
|
||||
raise ValueError("username, oldPassword, and newPassword are required")
|
||||
|
||||
if len(new_password) < 8:
|
||||
raise ValueError("New password must be at least 8 characters")
|
||||
|
||||
update_password(username, old_password, new_password)
|
||||
logger.info("Password changed for user %r", username)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_USERS)
|
||||
def auth_list_users(_request: Any, body: Any) -> list[dict[str, Any]]:
|
||||
"""List all users.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Unused.
|
||||
|
||||
Returns:
|
||||
List of user summary dicts.
|
||||
"""
|
||||
return list_users()
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_USER_CREATE)
|
||||
def auth_create_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Create a new user.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username``, ``password``, ``permissions``.
|
||||
|
||||
Returns:
|
||||
Created user dict.
|
||||
|
||||
Raises:
|
||||
ConflictError: If user already exists.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
password = body.get("password")
|
||||
permissions = body.get("permissions", {})
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
|
||||
try:
|
||||
user = create_user(username, password, permissions)
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
except ValueError as e:
|
||||
raise ConflictError(str(e)) from e
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_USER_UPDATE)
|
||||
def auth_update_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Update a user's permissions.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with optional ``permissions``, ``password`` keys.
|
||||
Path param ``username`` is merged into body by the daemon.
|
||||
|
||||
Returns:
|
||||
Updated user dict.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If user not found.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise NotFoundError(f"User {username!r} not found")
|
||||
|
||||
if "permissions" in body:
|
||||
update_permissions(username, body["permissions"])
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise NotFoundError(f"User {username!r} not found")
|
||||
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
|
||||
|
||||
@registry.register(DELETE_AUTH_USER)
|
||||
def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Delete a user.
|
||||
|
||||
Self-deletion is blocked by Flask middleware (auth blueprint).
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Path param ``username`` merged by the daemon.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If user not found.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
try:
|
||||
delete_user(username)
|
||||
logger.info("User %r deleted", username)
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e):
|
||||
raise NotFoundError(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebAuthn handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check_webauthn_domain(body: Any) -> tuple[str, str]:
|
||||
"""Validate that the request domain is eligible for WebAuthn.
|
||||
|
||||
Returns (origin, rp_id) if valid. Raises ValueError otherwise.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
if not webauthn_is_enabled():
|
||||
raise ValueError("WebAuthn is disabled")
|
||||
|
||||
rp_id = body.get("webauthn_rp_id")
|
||||
origin = body.get("webauthn_origin")
|
||||
if not rp_id or not origin:
|
||||
raise ValueError("Missing WebAuthn domain configuration")
|
||||
|
||||
if rp_id not in get_management_domains():
|
||||
raise ValueError("WebAuthn is not available on this domain")
|
||||
|
||||
return origin, rp_id
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_WEBAUTHN_CAPABLE)
|
||||
def webauthn_capable(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Check if WebAuthn is available for the current request domain."""
|
||||
rp_id = body.get("webauthn_rp_id") if body else None
|
||||
origin = body.get("webauthn_origin") if body else None
|
||||
|
||||
if not webauthn_is_enabled():
|
||||
return {"enabled": False, "reason": "WebAuthn is disabled in config"}
|
||||
|
||||
if not rp_id or not origin:
|
||||
return {"enabled": False, "reason": "Domain information unavailable"}
|
||||
|
||||
mgmt_domains = get_management_domains()
|
||||
if rp_id not in mgmt_domains:
|
||||
return {"enabled": False, "reason": "Not a management domain"}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"rp_id": rp_id,
|
||||
"rp_name": get_rp_name(),
|
||||
"origin": origin,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_BEGIN)
|
||||
def webauthn_register_begin(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Begin WebAuthn registration — return options for ``credentials.create()``."""
|
||||
origin, rp_id = _check_webauthn_domain(body)
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
options = create_registration_options(
|
||||
username,
|
||||
origin=origin,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_FINISH)
|
||||
def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Finish WebAuthn registration — verify credential and persist."""
|
||||
origin, rp_id = _check_webauthn_domain(body)
|
||||
|
||||
username = body.get("username")
|
||||
credential_response = body.get("credential_response")
|
||||
registration_options = body.get("registration_options")
|
||||
credential_name = body.get("name", "")
|
||||
|
||||
if not username or not credential_response or not registration_options:
|
||||
raise ValueError(
|
||||
"username, credential_response, and registration_options are required"
|
||||
)
|
||||
|
||||
cred = verify_registration(
|
||||
username,
|
||||
credential_response,
|
||||
registration_options,
|
||||
credential_name,
|
||||
origin=origin,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
return {"ok": True, "credential": cred}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN)
|
||||
def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Begin WebAuthn authentication — return options for ``credentials.get()``.
|
||||
|
||||
Public endpoint — no JWT required. Returns ``{"noWebAuthn": true}`` if the
|
||||
user has no registered credentials (so the frontend can fall back to password).
|
||||
"""
|
||||
_, rp_id = _check_webauthn_domain(body)
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
options = create_authentication_options(
|
||||
username,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
if options is None:
|
||||
return {"no_webauthn": True}
|
||||
return options
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH)
|
||||
def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Finish WebAuthn authentication — verify assertion, issue tokens.
|
||||
|
||||
Public endpoint — no JWT required.
|
||||
"""
|
||||
origin, rp_id = _check_webauthn_domain(body)
|
||||
|
||||
username = body.get("username")
|
||||
assertion_response = body.get("assertion_response")
|
||||
auth_options = body.get("auth_options")
|
||||
client_ip = body.get("client_ip")
|
||||
|
||||
if not username or not assertion_response or not auth_options:
|
||||
raise ValueError("username, assertion_response, and auth_options are required")
|
||||
|
||||
if not check_webauthn_rate(username, client_ip):
|
||||
raise ValueError("Too many WebAuthn attempts. Please try again later.")
|
||||
|
||||
try:
|
||||
verify_authentication(
|
||||
username,
|
||||
assertion_response,
|
||||
auth_options,
|
||||
origin=origin,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
except ValueError:
|
||||
record_webauthn_failure(username, client_ip)
|
||||
raise
|
||||
|
||||
record_webauthn_success(username, client_ip)
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIALS)
|
||||
def webauthn_credentials(request: Any, body: Any) -> list[dict[str, Any]]:
|
||||
"""List WebAuthn credentials for the authenticated user."""
|
||||
username = body.get("username") if body else None
|
||||
if not username:
|
||||
raise ValueError("No active session")
|
||||
|
||||
return list_credentials(username)
|
||||
|
||||
|
||||
@registry.register(DELETE_AUTH_WEBAUTHN_CREDENTIAL)
|
||||
def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Remove a WebAuthn credential."""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("No active session")
|
||||
|
||||
credential_id = body.get("credential_id")
|
||||
if not credential_id:
|
||||
raise ValueError("credential_id is required")
|
||||
|
||||
try:
|
||||
remove_credential(username, credential_id)
|
||||
logger.info("WebAuthn credential removed for %s", username)
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e):
|
||||
raise NotFoundError(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS)
|
||||
def webauthn_credential_counts(_request: Any, body: Any) -> dict[str, int]:
|
||||
"""Return credential counts for all users (admin endpoint)."""
|
||||
return get_all_credential_counts()
|
||||
+118
-29
@@ -25,7 +25,17 @@ from daemon.iface import (
|
||||
POST_DNSMASQ_UPSTREAMS,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
|
||||
from lib.common import (
|
||||
deep_merge,
|
||||
ensure_dirs,
|
||||
get_interface_ip,
|
||||
load_json,
|
||||
run,
|
||||
save_json,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,7 +45,7 @@ DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
@@ -128,7 +138,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm:
|
||||
return dm.get("config", {})
|
||||
return _get_config()
|
||||
cfg = _get_config()
|
||||
return strip_apply_meta(cfg)
|
||||
|
||||
|
||||
@registry.register(POST_DNSMASQ_CONFIG)
|
||||
@@ -141,7 +152,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -157,7 +171,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -172,16 +189,23 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
conf_text = _generate_conf(cfg)
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
|
||||
run_proc(
|
||||
["tee", DNSMASQ_CONF, "--"],
|
||||
sudo=True,
|
||||
check=True,
|
||||
input=conf_text,
|
||||
tmp = Path("/run/vacuum-wall/dnsmasq.tmp")
|
||||
tmp.parent.mkdir(exist_ok=True)
|
||||
tmp.write_text(conf_text)
|
||||
run(["cp", "--", str(tmp), str(DNSMASQ_CONF)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
run(["systemctl", "restart", "dnsmasq"], sudo=True)
|
||||
logger.info("dnsmasq config written and restarted")
|
||||
# Store the applied config snapshot + hash so the state collector can
|
||||
# detect pending changes and report what specifically changed.
|
||||
cfg_after = _get_config()
|
||||
stamp_applied(cfg_after)
|
||||
_save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
run(["systemctl", "reload", "dnsmasq"], sudo=True)
|
||||
logger.info("dnsmasq config written and reloaded")
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"applied": True}
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"applied": True, "synced": sync_result.affected_subsystems}
|
||||
|
||||
|
||||
@registry.register(GET_DNSMASQ_STATUS)
|
||||
@@ -203,15 +227,30 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
Endpoint: POST /dnsmasq/ranges/add
|
||||
|
||||
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
|
||||
Auto-populates ``gateway`` from the interface's IPv4 address when not provided.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
iface = (body.get("interface") or "").strip()
|
||||
start = body.get("start", "").strip()
|
||||
end = body.get("end", "").strip()
|
||||
lease_time = body.get("lease_time", "12h")
|
||||
if not start or not end:
|
||||
raise ValueError("'start' and 'end' are required")
|
||||
|
||||
# Resolve gateway: explicit value > existing range value > interface IP
|
||||
gateway = body.get("gateway")
|
||||
if not gateway:
|
||||
# Check existing range for same interface
|
||||
cfg_tmp = _get_config()
|
||||
for r in cfg_tmp["dhcp"]["ranges"]:
|
||||
if r.get("interface") == iface:
|
||||
gateway = r.get("gateway")
|
||||
break
|
||||
# Fall back to interface's own IP
|
||||
if not gateway and iface:
|
||||
gateway = get_interface_ip(iface)
|
||||
|
||||
cfg = _get_config()
|
||||
ranges = cfg["dhcp"]["ranges"]
|
||||
found = False
|
||||
@@ -223,8 +262,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if body.get("gateway"):
|
||||
ranges[i]["gateway"] = body["gateway"]
|
||||
if gateway:
|
||||
ranges[i]["gateway"] = gateway
|
||||
if body.get("dns"):
|
||||
ranges[i]["dns"] = body["dns"]
|
||||
found = True
|
||||
@@ -236,13 +275,18 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if body.get("gateway"):
|
||||
entry["gateway"] = body["gateway"]
|
||||
if gateway:
|
||||
entry["gateway"] = gateway
|
||||
if body.get("dns"):
|
||||
entry["dns"] = body["dns"]
|
||||
ranges.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@@ -277,7 +321,12 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
||||
)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@@ -316,14 +365,26 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
if hostname is not None:
|
||||
leases[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq",
|
||||
"config_saved",
|
||||
{"action": "static_lease_added", "mac": mac},
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@@ -348,7 +409,12 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if len(cfg["dhcp"]["static_leases"]) == before:
|
||||
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@@ -374,14 +440,26 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
if hostname is not None:
|
||||
records[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq",
|
||||
"config_saved",
|
||||
{"action": "dns_record_added", "name": name},
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@@ -404,7 +482,12 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
if len(cfg["dns"]["custom_records"]) == before:
|
||||
raise NotFoundError(f"DNS record '{name}' not found")
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@@ -420,7 +503,10 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["upstreams"] = list(body["servers"])
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@@ -437,5 +523,8 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
|
||||
)
|
||||
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
|
||||
return {"domain": cfg["dns"]["domain"]}
|
||||
|
||||
+481
-30
@@ -33,16 +33,18 @@ from daemon.iface import (
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
|
||||
from lib.firewall import (
|
||||
_normalize_target,
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
fw_change_summary,
|
||||
)
|
||||
from lib.firewall import (
|
||||
save_backup as _save_backup,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,6 +85,35 @@ def _reload() -> None:
|
||||
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:
|
||||
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
|
||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||
@@ -104,13 +135,39 @@ def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _config_apply() -> dict[str, Any]:
|
||||
"""Apply the declarative config to live firewalld."""
|
||||
def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
"""Apply saved declarative config to live firewalld.
|
||||
|
||||
For each zone in the config, reconciles interfaces, services, target,
|
||||
masquerade, rich rules, and forward ports by removing old values first,
|
||||
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
|
||||
|
||||
cfg = _get_lib_config()
|
||||
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] = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
@@ -124,19 +181,27 @@ def _config_apply() -> dict[str, Any]:
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
applied: list[str] = []
|
||||
for zone_name, zone_cfg in cfg_zones.items():
|
||||
# Two-step reconciliation: remove current values, then add desired values.
|
||||
# This ensures idempotency — running apply twice produces the same result.
|
||||
need_create = zone_name not in available
|
||||
|
||||
if need_create:
|
||||
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
||||
# Create new zone first (--new-zone is required before --set-target)
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
],
|
||||
["firewall-cmd", f"--new-zone={zone_name}", "--permanent"],
|
||||
sudo=True,
|
||||
)
|
||||
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
||||
if target != "default":
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
else:
|
||||
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
||||
@@ -153,6 +218,9 @@ def _config_apply() -> dict[str, Any]:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Step 2: Reconcile services — remove all current, add desired list.
|
||||
# Firewall-cmd doesn't have a "set-services" bulk operation, so we
|
||||
# remove each existing service and then add each desired service.
|
||||
current_svcs: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_svcs = _parse_zone_output(
|
||||
@@ -181,6 +249,7 @@ def _config_apply() -> dict[str, Any]:
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
@@ -209,12 +278,34 @@ def _config_apply() -> dict[str, Any]:
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
mq = zone_cfg.get("masquerade", False)
|
||||
if mq is not None:
|
||||
action = "--add-masquerade" if mq else "--remove-masquerade"
|
||||
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
||||
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
||||
if zone_name != "public":
|
||||
mq = zone_cfg.get("masquerade", False)
|
||||
if mq is not None:
|
||||
action = "--add-masquerade" if mq else "--remove-masquerade"
|
||||
run(
|
||||
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 5: Reconcile rich rules — remove all current, add desired.
|
||||
# Note: firewall-cmd doesn't track rule IDs for rich rules, so we
|
||||
# must remove by full rule string match.
|
||||
current_rules = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("rich-rules", [])
|
||||
for rule_str in current_rules:
|
||||
run(
|
||||
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--remove-rich-rule={rule_str}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
for rule_entry in zone_cfg.get("rich_rules", []):
|
||||
@@ -235,6 +326,7 @@ def _config_apply() -> dict[str, Any]:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Step 6: Reconcile forward ports — same pattern.
|
||||
current_fps = _get_forward_ports(zone_name)
|
||||
for fp_str in current_fps:
|
||||
run(
|
||||
@@ -262,6 +354,33 @@ def _config_apply() -> dict[str, Any]:
|
||||
|
||||
applied.append(zone_name)
|
||||
|
||||
# Step 7: Ensure masquerade propagation for nftables backend.
|
||||
# With firewalld's nftables backend, POSTROUTING policy chains route traffic
|
||||
# to the OUTPUT interface's zone chain. Traffic from internal zones (eth1)
|
||||
# exiting through public (eth0) hits public's POSTROUTING chain, not
|
||||
# internal's. If any non-public zone has masquerade enabled but the public
|
||||
# zone doesn't, NAT silently fails — so propagate masquerade to public.
|
||||
_any_non_public_mq = any(
|
||||
z.get("masquerade", False) for zn, z in cfg_zones.items() if zn != "public"
|
||||
)
|
||||
_public_mq = cfg_zones.get("public", {}).get("masquerade", False)
|
||||
if _any_non_public_mq and not _public_mq:
|
||||
logger.info("Propagating masquerade to public zone for nftables compatibility")
|
||||
run(
|
||||
["firewall-cmd", "--zone=public", "--add-masquerade", "--permanent"],
|
||||
sudo=True,
|
||||
)
|
||||
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = True
|
||||
_save_config(cfg)
|
||||
elif not _any_non_public_mq and _public_mq:
|
||||
logger.info("No non-public zone needs masquerade, removing from public zone")
|
||||
run(
|
||||
["firewall-cmd", "--zone=public", "--remove-masquerade", "--permanent"],
|
||||
sudo=True,
|
||||
)
|
||||
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = False
|
||||
_save_config(cfg)
|
||||
|
||||
_reload()
|
||||
full_state = {
|
||||
"active_zones": {},
|
||||
@@ -272,6 +391,11 @@ def _config_apply() -> dict[str, Any]:
|
||||
"timestamp": "",
|
||||
}
|
||||
backup_path = _save_backup(full_state)
|
||||
# Record the applied config snapshot + hash so pending-changes detection
|
||||
# and cancel/revert work like the hash-based subsystems.
|
||||
applied_cfg = _get_config()
|
||||
stamp_applied(applied_cfg)
|
||||
_save_config(applied_cfg)
|
||||
logger.info("Firewall config applied to %d zones", len(applied))
|
||||
return {
|
||||
"applied_zones": applied,
|
||||
@@ -301,6 +425,16 @@ def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES)
|
||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return active + available zones from state store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``active`` (zone→interfaces mapping) and
|
||||
``available`` (list of all zone names).
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
@@ -309,6 +443,19 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES_INFO)
|
||||
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return zone config by name; ``NotFoundError`` if absent.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` key (zone name).
|
||||
|
||||
Returns:
|
||||
Zone config dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``zone`` key is missing from body.
|
||||
NotFoundError: If the specified zone does not exist.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -321,6 +468,15 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES_ALL)
|
||||
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""Return list of all active zone configs.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
List of zone config dicts for all active zones.
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
@@ -333,29 +489,80 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(GET_FIREWALL_SERVICES)
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
"""Return list of available firewall services.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
List of service names available in firewalld.
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
return fw.get("available_services", [])
|
||||
|
||||
|
||||
@registry.register(GET_FIREWALL_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
"""Return declarative config from JSON store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Full firewall config dict (apply bookkeeping keys stripped).
|
||||
"""
|
||||
return strip_apply_meta(_get_config())
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Save the full declarative firewall configuration.
|
||||
|
||||
Validates that the request body contains a ``zones`` dict, persists it
|
||||
to the JSON store, emits a ``config_saved`` sync event to the
|
||||
cross-subsystem sync bus, and refreshes the state store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with a ``zones`` dict mapping zone names to
|
||||
zone configurations.
|
||||
|
||||
Returns:
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty, missing ``zones`` key,
|
||||
or ``zones`` is not a dict.
|
||||
"""
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
if not isinstance(body["zones"], dict):
|
||||
raise ValueError("'zones' must be a dict")
|
||||
_save_config(body)
|
||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register(PATCH_FIREWALL_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Deep-merge body into current config, save, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with partial config to merge.
|
||||
|
||||
Returns:
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
from lib.common import deep_merge
|
||||
@@ -364,26 +571,78 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return pending changes with human-readable summaries.
|
||||
|
||||
Compares saved config against live firewalld state and returns a list
|
||||
of differences.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``pending`` (list of change dicts) and ``pending_summary``
|
||||
(human-readable strings).
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
return fw.get("pending", {})
|
||||
pending = fw.get("pending", {})
|
||||
|
||||
changes = pending.get("pending", [])
|
||||
|
||||
summaries = [
|
||||
fw_change_summary(c.get("zone", "unknown"), c.get("type", "unknown"), c)
|
||||
for c in changes
|
||||
]
|
||||
return {**pending, "pending_summary": summaries}
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
result = _config_apply()
|
||||
"""Apply pending config to live firewalld, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: Optional JSON body; ``{"force": true}`` overrides the
|
||||
management-lockout guard for the default zone.
|
||||
|
||||
Returns:
|
||||
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
||||
and ``synced`` (affected subsystems).
|
||||
"""
|
||||
force = bool(_body and _body.get("force"))
|
||||
result = _config_apply(force=force)
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
result["synced"] = sync_result.affected_subsystems
|
||||
return result
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_CREATE)
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Create new zone via firewall-cmd, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``name`` (zone name) and optional ``target``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` key set to the zone name.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty, missing name, or zone already exists.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone_name = body.get("name", "").strip()
|
||||
@@ -404,12 +663,30 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
|
||||
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Delete zone via firewall-cmd, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` (zone name).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` key set to the zone name.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty or missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -419,12 +696,28 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
||||
_reload()
|
||||
logger.info("Zone '%s' deleted", zone)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Replace zone interfaces, reassigning interfaces from old zones.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``interfaces`` list.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``interfaces`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -489,12 +782,33 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
_save_config(cfg)
|
||||
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Replace zone services, persist to declarative config, and refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``services`` list; optional
|
||||
``force`` (bool) overrides the management-lockout guard.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``services`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone``.
|
||||
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:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -503,6 +817,12 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
raise ValueError("'zone' is required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
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(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("services", [])
|
||||
@@ -528,12 +848,36 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
refresh_state(["firewall"])
|
||||
|
||||
# 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(
|
||||
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Add rich rule with auto-generated ID from uuid4.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` (zone name) and ``rule``
|
||||
(rich rule string).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``id`` (generated UUID), and ``rule`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``rule``.
|
||||
NotFoundError: If the specified zone does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -559,12 +903,30 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
entry = {"id": rule_id, "rule": rule}
|
||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Remove rich rule by ID, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``id`` (rule ID).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``id`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``id``.
|
||||
NotFoundError: If the zone or rule does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -597,12 +959,32 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
||||
]
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@registry.register(GET_FIREWALL_RICH_RULES)
|
||||
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Return rich rules list for a zone from state store, matched with config IDs.
|
||||
|
||||
Rules from the live firewall that match a config entry get their ID
|
||||
included; rules without a config entry are returned without an ID.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` key.
|
||||
|
||||
Returns:
|
||||
List of dicts with ``id`` (if available) and ``rule`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``zone`` key is missing from body.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -623,21 +1005,56 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
||||
|
||||
@registry.register(POST_FIREWALL_MASQUERADE)
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Enable/disable masquerade on a zone.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``enable`` (boolean).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``masquerade`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``enable``, or if
|
||||
attempting to enable masquerade on the ``public`` zone.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
raise ValueError("'zone' and 'enable' (bool) are required")
|
||||
if zone == "public" and enable:
|
||||
raise ValueError(
|
||||
"Masquerade (NAT) is not supported on the public zone — enable it on 'internal' or 'vpn' instead"
|
||||
)
|
||||
action = "--add-masquerade" if enable else "--remove-masquerade"
|
||||
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
||||
_reload()
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Add port forwarding rule with auto-generated ID.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone``, ``port``, ``proto``, and optionally
|
||||
``toaddr`` and ``toport``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``id``, ``port``, and ``proto`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing required fields.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -675,12 +1092,30 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Remove port forwarding rule.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone``, ``port``, and ``proto``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``port``, and ``proto`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing required fields.
|
||||
NotFoundError: If the zone or forward port does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -722,12 +1157,28 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
|
||||
]
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
|
||||
)
|
||||
)
|
||||
refresh_state(["firewall", *sync_result.affected_subsystems])
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register(GET_FIREWALL_STATE)
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return complete firewall state snapshot.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``active_zones``, ``interfaces``, ``available_services``,
|
||||
``zones``, ``rich_rules``, and ``timestamp`` keys. Returns empty
|
||||
dict if state is not populated yet.
|
||||
"""
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
return {}
|
||||
|
||||
@@ -20,8 +20,10 @@ from daemon.iface import (
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
POST_NETWORK_SYSCTL_SET,
|
||||
)
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run, validate_interface_name
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import run, stamp_applied, validate_interface_name
|
||||
from lib.dnsmasq import get_config as _get_dm_cfg
|
||||
from lib.dnsmasq import save_config as _save_dm_cfg
|
||||
from lib.dnsmasq import set_upstreams
|
||||
from lib.network import (
|
||||
KNOWN_INTERFACE_KEYS,
|
||||
@@ -34,22 +36,40 @@ from lib.network import (
|
||||
render_network_file,
|
||||
save_config,
|
||||
)
|
||||
from lib.sync import SyncEvent, bus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "network"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
||||
RUNTIME_DIR = Path("/run/vacuum-wall")
|
||||
|
||||
_ALLOWED_SYSCTL_KEYS: set[str] = {
|
||||
"net.ipv4.ip_forward",
|
||||
"net.ipv4.conf.all.forwarding",
|
||||
"net.ipv4.conf.all.accept_redirects",
|
||||
"net.ipv4.conf.default.accept_redirects",
|
||||
"net.ipv4.conf.all.send_redirects",
|
||||
"net.ipv4.conf.default.send_redirects",
|
||||
"net.ipv4.conf.all.rp_filter",
|
||||
"net.ipv4.icmp_echo_ignore_all",
|
||||
"net.ipv4.tcp_syncookies",
|
||||
}
|
||||
|
||||
|
||||
def _copy_and_reload(iface_name: str) -> None:
|
||||
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
|
||||
validate_interface_name(iface_name)
|
||||
src = DATA_DIR / f"99-{iface_name}.network"
|
||||
runtime_src = RUNTIME_DIR / f"99-{iface_name}.network"
|
||||
dst_dir = Path("/etc/systemd/network")
|
||||
RUNTIME_DIR.mkdir(exist_ok=True)
|
||||
runtime_src.write_text(src.read_text())
|
||||
run(["mkdir", "-p", str(dst_dir)], sudo=True)
|
||||
dst = dst_dir / f"99-{iface_name}.network"
|
||||
run(["cp", str(src), str(dst)], sudo=True)
|
||||
run(["cp", "--", str(runtime_src), str(dst)], sudo=True)
|
||||
runtime_src.unlink(missing_ok=True)
|
||||
|
||||
# Remove lower-priority .network files that match this interface
|
||||
# (they would override our config due to higher systemd priority)
|
||||
@@ -107,7 +127,7 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
runtime = parse_networkctl_status(raw)
|
||||
|
||||
merged: dict[str, Any] = {}
|
||||
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
|
||||
all_names = set(ifaces_cfg.keys()) | set(runtime.keys())
|
||||
for name in sorted(all_names):
|
||||
merged[name] = {
|
||||
"config": ifaces_cfg.get(name, {}),
|
||||
@@ -194,7 +214,23 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
)
|
||||
|
||||
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
|
||||
return {"name": name, "applied": deployed}
|
||||
# Always stamp the hash so pending-changes detection stays current
|
||||
# even when deployment fails (e.g. in containerized environments).
|
||||
# The hash represents the JSON config state, not the system state.
|
||||
cfg_after = get_config()
|
||||
stamp_applied(cfg_after)
|
||||
save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
return {
|
||||
"name": name,
|
||||
"applied": deployed,
|
||||
"synced": sync_result.affected_subsystems,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
|
||||
@@ -236,9 +272,12 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cleaned.append(f)
|
||||
|
||||
for f in generated:
|
||||
tmp = RUNTIME_DIR / f.name
|
||||
run(["cp", "--", str(f), str(tmp)], sudo=False)
|
||||
dst = sys_dir / f.name
|
||||
run(["mkdir", "-p", str(sys_dir)], sudo=True)
|
||||
run(["cp", str(f), str(dst)], sudo=True)
|
||||
run(["cp", "--", str(tmp), str(dst)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
_full_reload()
|
||||
|
||||
@@ -247,10 +286,23 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
upstreams = collect_upstream_dns(cfg)
|
||||
if upstreams:
|
||||
set_upstreams(upstreams)
|
||||
# Update dnsmasq applied snapshot + hash so pending-changes
|
||||
# detection stays correct
|
||||
dm_cfg = _get_dm_cfg()
|
||||
stamp_applied(dm_cfg)
|
||||
_save_dm_cfg(dm_cfg)
|
||||
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
|
||||
except Exception:
|
||||
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
|
||||
|
||||
cfg_after = get_config()
|
||||
stamp_applied(cfg_after)
|
||||
save_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
|
||||
logger.info(
|
||||
"Network config applied: %d interfaces, %d stale cleaned",
|
||||
len(generated),
|
||||
@@ -260,6 +312,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"applied": len(generated),
|
||||
"files": [str(p) for p in generated],
|
||||
"cleaned": [str(p) for p in cleaned],
|
||||
"synced": sync_result.affected_subsystems,
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +348,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise ValueError("'name' is required")
|
||||
if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
|
||||
raise ValueError("'name' is not a valid sysctl key")
|
||||
if name not in _ALLOWED_SYSCTL_KEYS:
|
||||
raise ValueError("'name' is not a permitted sysctl key")
|
||||
value = str(body.get("value", "")).strip()
|
||||
if not value:
|
||||
raise ValueError("'value' is required")
|
||||
@@ -311,4 +366,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
logger.info("sysctl %s set to %s", name, value)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
|
||||
)
|
||||
refresh_state(["networkd", *sync_result.affected_subsystems])
|
||||
return {"name": name, "value": value}
|
||||
|
||||
+415
-174
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -9,11 +10,15 @@ from typing import Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_BACKENDS_REMOVE,
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_BACKENDS,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_BACKENDS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_BACKENDS_ADD,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
@@ -21,9 +26,21 @@ from daemon.iface import (
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.acme import find_cert_dir
|
||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||
from lib.common import (
|
||||
deep_merge,
|
||||
ensure_dirs,
|
||||
load_json,
|
||||
run,
|
||||
run_proc,
|
||||
save_json,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
|
||||
from lib.nginx import _resolve_auth as _ngx_resolve_auth
|
||||
from lib.nginx import _resolve_paths as _ngx_resolve_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,66 +60,76 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_SSL: dict[str, Any] = {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": (
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-RSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
),
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"backends": {},
|
||||
"domains": {},
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Migrate legacy config formats to the new paths-based model."""
|
||||
if "management" in raw and raw["management"] is not None:
|
||||
mgmt = raw["management"]
|
||||
mgmt_domain = mgmt.get("domain", "")
|
||||
if mgmt_domain:
|
||||
domains = raw.setdefault("domains", {})
|
||||
if mgmt_domain not in domains:
|
||||
domains[mgmt_domain] = {
|
||||
"force_ssl": True,
|
||||
"paths": {},
|
||||
}
|
||||
dom = domains[mgmt_domain]
|
||||
paths = dom.setdefault("paths", {})
|
||||
if "/" not in paths:
|
||||
paths["/"] = {
|
||||
"backend": {
|
||||
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
}
|
||||
if mgmt.get("auth"):
|
||||
paths["/"]["auth"] = mgmt["auth"]
|
||||
if "/ws" not in paths:
|
||||
paths["/ws"] = {
|
||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
}
|
||||
del raw["management"]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Migrate legacy config to backends model. Returns (config, changed)."""
|
||||
c1 = _ensure_webui_backend(raw)
|
||||
c2 = _migrate_mgmt_domains(raw)
|
||||
return raw, c1 or c2
|
||||
|
||||
|
||||
def _ensure_webui_backend(raw: dict[str, Any]) -> bool:
|
||||
"""Create builtin webui backend if not yet migrated. Returns True if changed."""
|
||||
backends = raw.setdefault("backends", {})
|
||||
webui = backends.get("webui")
|
||||
if webui and webui.get("_migrated"):
|
||||
return False
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
# Harvest auth from legacy path-level auth if present
|
||||
for dom in raw.get("domains", {}).values():
|
||||
if "paths" not in dom and "backend" in dom:
|
||||
dom["paths"] = {
|
||||
"/": {
|
||||
"backend": dom.pop("backend"),
|
||||
"headers": dom.pop("headers", {}),
|
||||
}
|
||||
}
|
||||
return raw
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
if root.get("auth"):
|
||||
backends["webui"]["auth"] = root["auth"]
|
||||
break
|
||||
return True
|
||||
|
||||
|
||||
def _migrate_mgmt_domains(raw: dict[str, Any]) -> bool:
|
||||
"""Migrate legacy mgmt domains to backend refs. Returns True if anything changed."""
|
||||
backends = raw.get("backends", {})
|
||||
if not backends.get("webui", {}).get("_migrated"):
|
||||
return False
|
||||
domains = raw.setdefault("domains", {})
|
||||
changed = False
|
||||
for _name, dom in list(domains.items()):
|
||||
if dom.get("backend") == "webui":
|
||||
continue
|
||||
if dom.get("application") == "webui":
|
||||
del dom["application"]
|
||||
changed = True
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
ws = paths.get("/ws", {})
|
||||
root_backend = root.get("backend", {})
|
||||
ws_backend = ws.get("backend", {})
|
||||
is_mgmt_root = root.get("is_management") or (
|
||||
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
|
||||
)
|
||||
is_mgmt_ws = ws.get("is_websocket") or (
|
||||
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
|
||||
)
|
||||
if is_mgmt_root and is_mgmt_ws:
|
||||
dom["backend"] = "webui"
|
||||
dom.pop("paths", None)
|
||||
dom.pop("auth", None)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
@@ -120,9 +147,13 @@ def _get_config() -> dict[str, Any]:
|
||||
raw = deepcopy(DEFAULT_CONFIG)
|
||||
if "ssl" not in raw:
|
||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||
raw = _migrate_config(raw)
|
||||
_save_config(raw)
|
||||
return raw
|
||||
cfg, changed = _migrate_config(raw)
|
||||
if changed:
|
||||
_save_config(cfg)
|
||||
# The on-disk config format changed under the state store's feet;
|
||||
# re-collect so cached state (e.g. the domains list) matches the file.
|
||||
refresh_state(["nginx"])
|
||||
return cfg
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
@@ -130,14 +161,122 @@ def _save_config(cfg: dict[str, Any]) -> None:
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend CRUD
|
||||
|
||||
|
||||
def _get_backends() -> dict[str, Any]:
|
||||
"""Return the backends dict from config, creating builtin webui if needed."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
webui = backends.get("webui")
|
||||
if not webui or not webui.get("_migrated"):
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
cfg["backends"] = backends
|
||||
_save_config(cfg)
|
||||
# Config file was rewritten (builtin backend materialized); re-collect
|
||||
# so cached state matches the file.
|
||||
refresh_state(["nginx"])
|
||||
return backends
|
||||
|
||||
|
||||
def _validate_paths(paths: dict) -> None:
|
||||
"""Validate each path has backend with host, port, proto."""
|
||||
for path_str, path_cfg in paths.items():
|
||||
backend = path_cfg.get("backend")
|
||||
if not backend:
|
||||
raise ValueError(f"path {path_str!r} missing 'backend'")
|
||||
if not isinstance(backend, dict):
|
||||
raise ValueError(f"path {path_str!r} 'backend' must be a dict")
|
||||
if not backend.get("host"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'host'")
|
||||
if not backend.get("port"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'port'")
|
||||
if not backend.get("proto"):
|
||||
raise ValueError(f"path {path_str!r} 'backend' missing 'proto'")
|
||||
|
||||
|
||||
def _add_backend(
|
||||
name: str, label: str, paths: dict, auth: dict | None = None, builtin: bool = False
|
||||
) -> None:
|
||||
"""Add a backend. Validate name uniqueness and path schema."""
|
||||
_validate_paths(paths)
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name in backends:
|
||||
raise ValueError(f"Backend {name!r} already exists")
|
||||
entry: dict[str, Any] = {"label": label, "paths": paths}
|
||||
if builtin:
|
||||
entry["builtin"] = True
|
||||
if auth is not None:
|
||||
if "htpasswd" in auth:
|
||||
htpasswd_path = Path(auth["htpasswd"])
|
||||
if not htpasswd_path.is_absolute():
|
||||
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
|
||||
entry["auth"] = auth
|
||||
backends[name] = entry
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
def _update_backend(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
paths: dict | None = None,
|
||||
auth: dict | None | bool = None,
|
||||
) -> None:
|
||||
"""Update a backend. Cannot edit builtin backends. auth=False removes auth."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot modify builtin backend")
|
||||
entry = backends[name]
|
||||
if label is not None:
|
||||
entry["label"] = label
|
||||
if paths is not None:
|
||||
_validate_paths(paths)
|
||||
entry["paths"] = paths
|
||||
if auth is False or auth is None:
|
||||
entry.pop("auth", None)
|
||||
elif isinstance(auth, dict):
|
||||
if "htpasswd" in auth:
|
||||
htpasswd_path = Path(auth["htpasswd"])
|
||||
if not htpasswd_path.is_absolute():
|
||||
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
|
||||
entry["auth"] = auth
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
def _remove_backend(name: str) -> None:
|
||||
"""Remove a non-builtin backend. Raise ConflictError if domains reference it."""
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot remove builtin backend")
|
||||
for dom_name, dom in cfg.get("domains", {}).items():
|
||||
if dom.get("backend") == name:
|
||||
raise ConflictError(
|
||||
f"Backend {name!r} is referenced by domain {dom_name!r}"
|
||||
)
|
||||
del backends[name]
|
||||
_save_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site generation
|
||||
|
||||
|
||||
def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any]) -> str:
|
||||
"""Render an nginx server block config from a domain entry via Jinja."""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||
paths = domain_cfg.get("paths", {})
|
||||
paths = _ngx_resolve_paths(domain_cfg, backends)
|
||||
has_management = any(p.get("is_management") for p in paths.values())
|
||||
# Resolve custom cert paths for cert=="file"
|
||||
cert_cfg = domain_cfg.get("cert")
|
||||
if isinstance(cert_cfg, dict):
|
||||
cert_path = cert_cfg.get("cert_path", "")
|
||||
@@ -152,7 +291,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
cert=domain_cfg.get("cert"),
|
||||
cert_path=cert_path,
|
||||
cert_key_path=cert_key_path,
|
||||
domain_auth=domain_cfg.get("auth"),
|
||||
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
|
||||
has_management=has_management,
|
||||
acme_cert_dir=acme_cert_dir,
|
||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||
@@ -176,11 +315,11 @@ def _write_include_file() -> None:
|
||||
"""Write the system include file that references all per-site configs."""
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
tmp = Path("/run/vacuum-wall/include.tmp")
|
||||
tmp.parent.mkdir(exist_ok=True)
|
||||
tmp.write_text(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
|
||||
run(["cp", "--", str(tmp), str(INCLUDE_FILE)], sudo=True)
|
||||
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@@ -194,11 +333,11 @@ def _write_ssl_snippet() -> None:
|
||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||
content = tmpl.render(ssl=ssl_cfg)
|
||||
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
tmp = Path("/run/vacuum-wall/ssl-snippet.tmp")
|
||||
tmp.parent.mkdir(exist_ok=True)
|
||||
tmp.write_text(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
|
||||
run(["cp", "--", str(tmp), str(SSL_SNIPPET)], sudo=True)
|
||||
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@@ -222,15 +361,59 @@ def _reload_nginx() -> None:
|
||||
logger.info("nginx configuration applied and reloaded")
|
||||
|
||||
|
||||
def _ensure_self_signed_cert(domain: str) -> None:
|
||||
"""Auto-generate a self-signed cert for *domain* if not yet present."""
|
||||
certs_dir = PROJECT_DIR / "data" / "certs"
|
||||
certs_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert_file = certs_dir / f"{domain}.crt"
|
||||
key_file = certs_dir / f"{domain}.key"
|
||||
if cert_file.is_file() and key_file.is_file():
|
||||
return
|
||||
logger.info("Auto-generating self-signed cert for %s", domain)
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
str(key_file),
|
||||
"-out",
|
||||
str(cert_file),
|
||||
"-days",
|
||||
"365",
|
||||
"-nodes",
|
||||
"-subj",
|
||||
f"/CN={domain}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
cert_file.chmod(0o644)
|
||||
key_file.chmod(0o600)
|
||||
|
||||
|
||||
def _write_all_sites() -> None:
|
||||
"""Regenerate all site configs and ACME challenge site."""
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = _get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
# Auto-generate self-signed certs for management domains that need them
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
cert = dom.get("cert")
|
||||
paths = _ngx_resolve_paths(dom, backends)
|
||||
has_management = any(p.get("is_management") for p in paths.values())
|
||||
if has_management and (cert == "selfsigned" or cert is None):
|
||||
_ensure_self_signed_cert(name)
|
||||
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||
written: set[str] = set()
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
dom_copy = dict(dom, domain=name)
|
||||
conf = _generate_server_conf(dom_copy)
|
||||
conf = _generate_server_conf(dom_copy, backends)
|
||||
_write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
|
||||
@@ -252,6 +435,10 @@ def _write_all_sites() -> None:
|
||||
os.replace(tmp, site)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt via passlib."""
|
||||
from passlib.hash import sha256_crypt
|
||||
@@ -259,13 +446,19 @@ def _hash_password(password: str) -> str:
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
def _write_htpasswd(user: str, password: str) -> None:
|
||||
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing."""
|
||||
ensure_dirs(DATA_DIR)
|
||||
def _write_htpasswd(
|
||||
user: str, password: str, htpasswd_path: Path | None = None
|
||||
) -> None:
|
||||
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
|
||||
|
||||
If *htpasswd_path* is not given, defaults to :data:`HTPASSWD_FILE`.
|
||||
"""
|
||||
target = htpasswd_path or HTPASSWD_FILE
|
||||
ensure_dirs(target.parent)
|
||||
hashed = _hash_password(password)
|
||||
existing: dict[str, str] = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
if target.exists():
|
||||
with open(target) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
@@ -274,12 +467,12 @@ def _write_htpasswd(user: str, password: str) -> None:
|
||||
if len(parts) == 2:
|
||||
existing[parts[0]] = line
|
||||
existing[user] = f"{user}:{hashed}"
|
||||
tmp = HTPASSWD_FILE.with_suffix(".tmp")
|
||||
tmp = target.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
for _uname, entry in existing.items():
|
||||
f.write(entry + "\n")
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
os.replace(tmp, target)
|
||||
|
||||
|
||||
def _get_nginx_state() -> dict[str, Any]:
|
||||
@@ -300,7 +493,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
ng = _get_nginx_state()
|
||||
if ng:
|
||||
return ng.get("config", {})
|
||||
return _get_config()
|
||||
cfg = _get_config()
|
||||
return strip_apply_meta(cfg)
|
||||
|
||||
|
||||
@registry.register(POST_NGINX_CONFIG)
|
||||
@@ -318,8 +512,6 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/config — deep-merge partial updates into current config."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
@@ -338,10 +530,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(POST_NGINX_DOMAINS_ADD)
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
|
||||
|
||||
Accepts either legacy backend_* fields or a ``paths`` map.
|
||||
"""
|
||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -351,70 +540,36 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
|
||||
paths = body.get("paths")
|
||||
backend_name = body.get("backend", "").strip()
|
||||
if not backend_name:
|
||||
raise ValueError("'backend' is required")
|
||||
if backend_name not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {backend_name!r} not found")
|
||||
|
||||
cert = body.get("cert")
|
||||
force_ssl = body.get("force_ssl", True)
|
||||
|
||||
if paths is not None:
|
||||
entry: dict[str, Any] = {
|
||||
"paths": paths,
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
else:
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not backend_host:
|
||||
raise ValueError("'backend_host' is required")
|
||||
if backend_port is None:
|
||||
raise ValueError("'backend_port' is required")
|
||||
entry = {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"headers": extra_headers or {},
|
||||
}
|
||||
},
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
entry: dict[str, Any] = {
|
||||
"backend": backend_name,
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
|
||||
# Handle auth credentials for management domain
|
||||
auth_user = body.get("auth_user", "").strip()
|
||||
auth_pass = body.get("auth_pass", "")
|
||||
if auth_user and auth_pass:
|
||||
_write_htpasswd(auth_user, auth_pass)
|
||||
auth_dict = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
|
||||
paths_entry = entry.get("paths", {})
|
||||
for _ppath, pcfg in paths_entry.items():
|
||||
if pcfg.get("is_management"):
|
||||
pcfg["auth"] = auth_dict
|
||||
break
|
||||
entry["auth"] = auth_dict
|
||||
|
||||
# Handle auth credentials for management paths
|
||||
auth_user = body.get("auth_user", "").strip()
|
||||
auth_pass = body.get("auth_pass", "").strip()
|
||||
if auth_user and auth_pass:
|
||||
_write_htpasswd(auth_user, auth_pass)
|
||||
auth_entry = {
|
||||
"user": auth_user,
|
||||
"htpasswd": str(HTPASSWD_FILE),
|
||||
}
|
||||
# Store auth on root path if it exists
|
||||
root_path = entry.get("paths", {}).get("/")
|
||||
if root_path:
|
||||
root_path["auth"] = auth_entry
|
||||
# Also store at domain level for template
|
||||
entry["auth"] = auth_entry
|
||||
auth = body.get("auth")
|
||||
if auth is not None:
|
||||
# Write htpasswd file when a password is provided, then store
|
||||
# only {user, htpasswd path} — never persist the raw password.
|
||||
if auth.get("user") and auth.get("pass"):
|
||||
htpasswd_path = auth.get(
|
||||
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
|
||||
)
|
||||
if isinstance(htpasswd_path, str) and not Path(htpasswd_path).is_absolute():
|
||||
htpasswd_path = PROJECT_DIR / htpasswd_path
|
||||
_write_htpasswd(auth["user"], auth["pass"], Path(htpasswd_path))
|
||||
entry["auth"] = {"user": auth["user"], "htpasswd": str(htpasswd_path)}
|
||||
else:
|
||||
entry["auth"] = auth
|
||||
|
||||
cfg["domains"][domain] = entry
|
||||
_save_config(cfg)
|
||||
@@ -455,43 +610,126 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise NotFoundError(f"Domain {domain!r} not configured")
|
||||
entry = cfg["domains"][domain]
|
||||
|
||||
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
|
||||
path_to_remove = body.get("path")
|
||||
if path_to_remove is not None and "paths" not in body and "backend" not in body and "headers" not in body:
|
||||
paths = entry.get("paths", {})
|
||||
if path_to_remove in paths:
|
||||
del paths[path_to_remove]
|
||||
if not paths:
|
||||
entry.pop("paths", None)
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain, "path_removed": path_to_remove}
|
||||
new_backend = body.get("backend")
|
||||
if new_backend:
|
||||
new_backend = new_backend.strip()
|
||||
if new_backend not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {new_backend!r} not found")
|
||||
entry["backend"] = new_backend
|
||||
|
||||
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
|
||||
|
||||
if "paths" in updates:
|
||||
entry["paths"] = updates["paths"]
|
||||
else:
|
||||
paths = entry.setdefault("paths", {})
|
||||
if "backend" in updates:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["backend"] = updates["backend"]
|
||||
if "headers" in updates:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["headers"] = updates["headers"]
|
||||
|
||||
for key, val in updates.items():
|
||||
if key in ("backend", "headers", "paths"):
|
||||
continue
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
if "cert" in body:
|
||||
if body["cert"] is None:
|
||||
entry.pop("cert", None)
|
||||
else:
|
||||
entry[key] = val
|
||||
entry["cert"] = body["cert"]
|
||||
if "force_ssl" in body:
|
||||
entry["force_ssl"] = body["force_ssl"]
|
||||
if "auth" in body:
|
||||
if body["auth"] is None:
|
||||
entry.pop("auth", None)
|
||||
else:
|
||||
# Normalize: if auth has `pass`, write htpasswd and store only
|
||||
# `{user, htpasswd path}` — never persist the raw password.
|
||||
if body["auth"].get("user") and body["auth"].get("pass"):
|
||||
htpasswd_path = body["auth"].get(
|
||||
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
|
||||
)
|
||||
if (
|
||||
isinstance(htpasswd_path, str)
|
||||
and not Path(htpasswd_path).is_absolute()
|
||||
):
|
||||
htpasswd_path = PROJECT_DIR / htpasswd_path
|
||||
_write_htpasswd(
|
||||
body["auth"]["user"], body["auth"]["pass"], Path(htpasswd_path)
|
||||
)
|
||||
entry["auth"] = {
|
||||
"user": body["auth"]["user"],
|
||||
"htpasswd": str(htpasswd_path),
|
||||
}
|
||||
else:
|
||||
entry["auth"] = body["auth"]
|
||||
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register(GET_NGINX_BACKENDS)
|
||||
def get_backends(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /nginx/backends — return backends with secrets stripped."""
|
||||
backends = _get_backends()
|
||||
result: dict[str, Any] = {}
|
||||
for name, be in backends.items():
|
||||
entry = deepcopy(be)
|
||||
entry.pop("_migrated", None)
|
||||
if "auth" in entry:
|
||||
entry["has_auth"] = entry["auth"] is not None
|
||||
del entry["auth"]
|
||||
else:
|
||||
entry["has_auth"] = False
|
||||
result[name] = entry
|
||||
return result
|
||||
|
||||
|
||||
@registry.register(PATCH_NGINX_BACKENDS)
|
||||
def patch_backends(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/backends — deep-merge partial updates into a backend entry."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
backends = cfg.setdefault("backends", {})
|
||||
if name not in backends:
|
||||
raise KeyError(name)
|
||||
if backends[name].get("builtin"):
|
||||
raise ValueError("Cannot modify builtin backend")
|
||||
update_data = {k: v for k, v in body.items() if k != "name"}
|
||||
if "auth" in update_data and (
|
||||
update_data["auth"] is False or update_data["auth"] is None
|
||||
):
|
||||
backends[name].pop("auth", None)
|
||||
update_data.pop("auth")
|
||||
backends[name] = deep_merge(backends[name], update_data)
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(POST_NGINX_BACKENDS_ADD)
|
||||
def add_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/backends/add — add a new backend."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
label = body.get("label", "").strip()
|
||||
paths = body.get("paths")
|
||||
auth = body.get("auth")
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
if not label:
|
||||
raise ValueError("'label' is required")
|
||||
if not paths:
|
||||
raise ValueError("'paths' is required")
|
||||
_add_backend(name, label, paths, auth=auth if auth else None)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(DELETE_NGINX_BACKENDS_REMOVE)
|
||||
def remove_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /nginx/backends/remove — remove a non-builtin backend."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
_remove_backend(name)
|
||||
refresh_state(["nginx"])
|
||||
return {"backend": name}
|
||||
|
||||
|
||||
@registry.register(POST_NGINX_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/apply — render all configs, test, and reload nginx."""
|
||||
@@ -502,6 +740,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
if not ok:
|
||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||
_reload_nginx()
|
||||
cfg_after = _get_config()
|
||||
stamp_applied(cfg_after)
|
||||
_save_config(cfg_after)
|
||||
refresh_state(["nginx"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Aggregate status handler.
|
||||
|
||||
Exposes pending changes across all subsystems, a single apply-all
|
||||
endpoint that invokes each subsystem's apply in the correct order, and a
|
||||
cancel-all endpoint that reverts pending edits to the last applied config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.handlers import dnsmasq as _dnsmasq_h
|
||||
from daemon.handlers import firewall as _firewall_h
|
||||
from daemon.handlers import nginx as _nginx_h
|
||||
from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config
|
||||
from daemon.handlers.firewall import config_apply as firewall_config_apply
|
||||
from daemon.handlers.network import apply_all as network_apply_all
|
||||
from daemon.handlers.nginx import apply as nginx_apply
|
||||
from daemon.handlers.wireguard import apply as wireguard_apply
|
||||
from daemon.iface import (
|
||||
GET_STATUS_PENDING,
|
||||
POST_STATUS_APPLY_ALL,
|
||||
POST_STATUS_CANCEL_ALL,
|
||||
)
|
||||
from daemon.server import refresh_state, registry
|
||||
from lib import network as _net
|
||||
from lib import wireguard as _wg
|
||||
from lib.common import revert_to_applied
|
||||
from lib.firewall import fw_change_summary
|
||||
from lib.state import state as state_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"]
|
||||
SYS_LABELS = {
|
||||
"networkd": "Network",
|
||||
"firewall": "Firewall",
|
||||
"wireguard": "WireGuard",
|
||||
"dnsmasq": "DHCP/DNS",
|
||||
"nginx": "Nginx",
|
||||
}
|
||||
SYS_APPLY = {
|
||||
"networkd": network_apply_all,
|
||||
"firewall": firewall_config_apply,
|
||||
"wireguard": wireguard_apply,
|
||||
"dnsmasq": dnsmasq_apply_config,
|
||||
"nginx": nginx_apply,
|
||||
}
|
||||
# (module, attribute) pairs for each subsystem's on-disk config path.
|
||||
# Resolved at call time so tests can monkeypatch the module constants.
|
||||
SYS_CONFIG_PATHS: dict[str, tuple[Any, str]] = {
|
||||
"firewall": (_firewall_h, "CONFIG_FILE"),
|
||||
"dnsmasq": (_dnsmasq_h, "CONFIG_PATH"),
|
||||
"nginx": (_nginx_h, "CONFIG_FILE"),
|
||||
"wireguard": (_wg, "CONFIG_PATH"),
|
||||
"networkd": (_net, "CONFIG_FILE"),
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_STATUS_PENDING)
|
||||
def status_pending(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Aggregate pending changes across all subsystems.
|
||||
|
||||
Returns:
|
||||
Dict with per-subsystem pending status and total change count.
|
||||
"""
|
||||
fw = state_store.get("firewall") or {}
|
||||
pending_fw = fw.get("pending", {})
|
||||
fw_needs_apply = pending_fw.get("needs_apply", False)
|
||||
fw_pending_list = pending_fw.get("pending", [])
|
||||
|
||||
fw_changes = []
|
||||
for c in fw_pending_list:
|
||||
zone = c.get("zone", "unknown")
|
||||
ctype = c.get("type", "unknown")
|
||||
summary = fw_change_summary(zone, ctype, c)
|
||||
fw_changes.append({"summary": summary, "detail": ""})
|
||||
|
||||
fw_result = {
|
||||
"needs_apply": fw_needs_apply,
|
||||
"change_count": len(fw_changes),
|
||||
"changes": fw_changes,
|
||||
}
|
||||
|
||||
hash_subsystems = {
|
||||
"dnsmasq": _hash_subsystem("dnsmasq", state_store.get("dnsmasq")),
|
||||
"nginx": _hash_subsystem("nginx", state_store.get("nginx")),
|
||||
"wireguard": _hash_subsystem("wireguard", state_store.get("wireguard")),
|
||||
"networkd": _hash_subsystem("networkd", state_store.get("networkd")),
|
||||
}
|
||||
|
||||
total = len(fw_changes)
|
||||
for _name, result in hash_subsystems.items():
|
||||
total += len(result["changes"])
|
||||
|
||||
return {
|
||||
"firewall": fw_result,
|
||||
"dnsmasq": hash_subsystems["dnsmasq"],
|
||||
"nginx": hash_subsystems["nginx"],
|
||||
"wireguard": hash_subsystems["wireguard"],
|
||||
"networkd": hash_subsystems["networkd"],
|
||||
"total_changes": total,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_STATUS_APPLY_ALL)
|
||||
def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Apply pending changes for all subsystems in dependency order.
|
||||
|
||||
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
|
||||
|
||||
Returns:
|
||||
Dict with applied subsystems and any errors encountered.
|
||||
"""
|
||||
applied = []
|
||||
errors = {}
|
||||
|
||||
pending_data = status_pending(None, None)
|
||||
fw_pending = pending_data["firewall"]["needs_apply"]
|
||||
hash_pending = {
|
||||
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
|
||||
"nginx": pending_data["nginx"]["pending_changes"],
|
||||
"wireguard": pending_data["wireguard"]["pending_changes"],
|
||||
"networkd": pending_data["networkd"]["pending_changes"],
|
||||
}
|
||||
|
||||
for name in SYS_ORDER:
|
||||
if name == "firewall":
|
||||
if not fw_pending:
|
||||
continue
|
||||
else:
|
||||
if not hash_pending.get(name, False):
|
||||
continue
|
||||
|
||||
handler = SYS_APPLY[name]
|
||||
try:
|
||||
handler(None, None)
|
||||
applied.append(name)
|
||||
except Exception as exc:
|
||||
label = SYS_LABELS.get(name, name)
|
||||
errors[label] = str(exc)
|
||||
logger.error("Apply-all failed for %s: %s", name, exc)
|
||||
|
||||
refresh_state(SYS_ORDER)
|
||||
return {"applied": applied, "errors": errors}
|
||||
|
||||
|
||||
def _config_path(name: str) -> Path:
|
||||
"""Return the on-disk config path for subsystem *name*.
|
||||
|
||||
Resolved via the owning module at call time so tests can monkeypatch
|
||||
the module constants (e.g. ``lib.wireguard.CONFIG_PATH``).
|
||||
"""
|
||||
module, attr = SYS_CONFIG_PATHS[name]
|
||||
return getattr(module, attr)
|
||||
|
||||
|
||||
@registry.register(POST_STATUS_CANCEL_ALL)
|
||||
def status_cancel_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Revert pending changes for all subsystems to the last applied config.
|
||||
|
||||
Restores each pending subsystem's config file from its recorded
|
||||
``_last_applied_config`` snapshot, discarding unapplied edits.
|
||||
Subsystems without a recorded baseline (never applied) are skipped
|
||||
with a reason instead of being reset. No live-system commands run —
|
||||
cancel only touches the declarative config files.
|
||||
|
||||
Returns:
|
||||
Dict with ``cancelled`` (list of reverted subsystems),
|
||||
``skipped`` (label -> reason), and ``errors`` (label -> message).
|
||||
"""
|
||||
pending_data = status_pending(None, None)
|
||||
fw_pending = pending_data["firewall"]["needs_apply"]
|
||||
hash_pending = {
|
||||
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
|
||||
"nginx": pending_data["nginx"]["pending_changes"],
|
||||
"wireguard": pending_data["wireguard"]["pending_changes"],
|
||||
"networkd": pending_data["networkd"]["pending_changes"],
|
||||
}
|
||||
|
||||
cancelled: list[str] = []
|
||||
skipped: dict[str, str] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for name in SYS_ORDER:
|
||||
pending = fw_pending if name == "firewall" else hash_pending.get(name, False)
|
||||
if not pending:
|
||||
continue
|
||||
label = SYS_LABELS.get(name, name)
|
||||
try:
|
||||
ok, reason = revert_to_applied(_config_path(name))
|
||||
if ok:
|
||||
cancelled.append(name)
|
||||
logger.info("Cancelled pending changes for %s", name)
|
||||
else:
|
||||
skipped[label] = reason
|
||||
logger.warning("Cancel-all skipped %s: %s", label, reason)
|
||||
except Exception as exc:
|
||||
errors[label] = str(exc)
|
||||
logger.error("Cancel-all failed for %s: %s", name, exc)
|
||||
|
||||
if cancelled:
|
||||
refresh_state(SYS_ORDER)
|
||||
return {"cancelled": cancelled, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build pending result for a hash-based subsystem."""
|
||||
if state is None:
|
||||
return {"pending_changes": False, "summary": "Up to date", "changes": []}
|
||||
|
||||
status = state.get("status", {})
|
||||
pending = status.get("pending_changes", False)
|
||||
label = SYS_LABELS.get(name, name)
|
||||
|
||||
if pending:
|
||||
summary = f"{label} configuration has unapplied changes"
|
||||
return {
|
||||
"pending_changes": True,
|
||||
"summary": summary,
|
||||
"changes": [{"summary": summary, "detail": ""}],
|
||||
}
|
||||
|
||||
return {"pending_changes": False, "summary": "Up to date", "changes": []}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""System metrics handler.
|
||||
|
||||
Returns CPU load, memory usage, and per-interface network traffic stats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from daemon.iface import GET_SYSTEM_METRICS
|
||||
from daemon.server import registry
|
||||
from lib.state import state as state_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@registry.register(GET_SYSTEM_METRICS)
|
||||
def system_metrics(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return system-wide metrics.
|
||||
|
||||
Reads from pre-collected state (CPU load, memory, network traffic).
|
||||
|
||||
Returns:
|
||||
Dict with load, memory, swap, and traffic data.
|
||||
"""
|
||||
sys_state = state_store.get("system")
|
||||
if sys_state is None:
|
||||
return {
|
||||
"load": {"load1": 0.0, "load5": 0.0, "load15": 0.0},
|
||||
"memory": {"total": 0, "available": 0, "used": 0, "used_pct": 0},
|
||||
"swap": {"total": 0, "used": 0, "used_pct": 0},
|
||||
"traffic": {},
|
||||
}
|
||||
return sys_state
|
||||
+505
-149
@@ -2,92 +2,68 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_CLASSES,
|
||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||
DELETE_WIREGUARD_PEERS_REMOVE,
|
||||
GET_WIREGUARD_CLASS_STATUS,
|
||||
GET_WIREGUARD_CLASSES,
|
||||
GET_WIREGUARD_CONFIG,
|
||||
GET_WIREGUARD_PEER_STATUS,
|
||||
GET_WIREGUARD_PEERS,
|
||||
GET_WIREGUARD_STATUS,
|
||||
PATCH_WIREGUARD_CLASSES,
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_APPLY,
|
||||
POST_WIREGUARD_CLASS_INIT_KEYS,
|
||||
POST_WIREGUARD_CLASSES,
|
||||
POST_WIREGUARD_CLASSES_UP,
|
||||
POST_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_DOWN,
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
POST_WIREGUARD_INITIALIZE,
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
|
||||
from lib.sync import SyncEvent, bus
|
||||
from lib.wireguard import (
|
||||
_class_interface_name,
|
||||
_class_peers,
|
||||
_ensure_access_classes,
|
||||
_wg_conf_path,
|
||||
generate_class_conf,
|
||||
generate_class_keypair,
|
||||
generate_conf,
|
||||
generate_keypair,
|
||||
)
|
||||
from lib.wireguard import (
|
||||
generate_client_conf as _gen_client_conf,
|
||||
)
|
||||
from lib.wireguard import (
|
||||
get_config as _get_wireguard_config,
|
||||
)
|
||||
from lib.wireguard import (
|
||||
get_peers as _get_wireguard_peers,
|
||||
)
|
||||
from lib.wireguard import (
|
||||
save_config as _save_wireguard_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||
WG_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
"""Retrieve cached WireGuard state from the global state store."""
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("wireguard")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
"""Load and merge the WireGuard config with defaults."""
|
||||
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist the WireGuard config to disk."""
|
||||
save_json(CONFIG_PATH, cfg)
|
||||
|
||||
|
||||
def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
"""Render the WireGuard server config file from Jinja template."""
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=cfg["interface"],
|
||||
peers=cfg.get("peers", {}),
|
||||
)
|
||||
|
||||
|
||||
def _get_wg_state() -> dict[str, Any]:
|
||||
"""Return cached WireGuard state, or empty dict if not yet loaded."""
|
||||
wg = _get_state()
|
||||
if wg is None:
|
||||
return {}
|
||||
return wg
|
||||
from lib.state import state as state_store
|
||||
|
||||
wg = state_store.get("wireguard")
|
||||
return {} if wg is None else wg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,33 +76,56 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("config", {})
|
||||
cfg = _get_config()
|
||||
safe = dict(cfg)
|
||||
cfg = _get_wireguard_config()
|
||||
safe = strip_apply_meta(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
if "access_classes" in safe:
|
||||
for ck, cv in safe["access_classes"].items():
|
||||
if isinstance(cv, dict):
|
||||
safe["access_classes"][ck] = dict(cv)
|
||||
safe["access_classes"][ck].pop("private_key", None)
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/config — replace config, preserving existing private key.
|
||||
"""POST /wireguard/config — replace config, preserving existing private keys.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
|
||||
current = _get_wireguard_config()
|
||||
current_key = current.get("interface", {}).get("private_key", "")
|
||||
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
|
||||
if current_key:
|
||||
body.setdefault("interface", {})["private_key"] = current_key
|
||||
_save_config(body)
|
||||
refresh_state(["wireguard"])
|
||||
|
||||
# Preserve class private keys
|
||||
if "access_classes" in body:
|
||||
current_classes = current.get("access_classes", {})
|
||||
for ck, cv in body.get("access_classes", {}).items():
|
||||
if isinstance(cv, dict) and ck in current_classes:
|
||||
cur_class_pk = current_classes[ck].get("private_key", "")
|
||||
if cur_class_pk and ck in body["access_classes"]:
|
||||
body["access_classes"][ck]["private_key"] = cur_class_pk
|
||||
elif "access_classes" not in body:
|
||||
body["access_classes"] = current.get("access_classes", {})
|
||||
|
||||
_save_wireguard_config(body)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@@ -139,79 +138,290 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
current = _get_config()
|
||||
|
||||
# Strip class private keys from patch body
|
||||
if "access_classes" in body:
|
||||
for _ck, cv in body["access_classes"].items():
|
||||
if isinstance(cv, dict):
|
||||
cv.pop("private_key", None)
|
||||
|
||||
current = _get_wireguard_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
refresh_state(["wireguard"])
|
||||
_save_wireguard_config(merged)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
_save_config(cfg)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / "wg0.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
refresh_state(["wireguard"])
|
||||
return {"applied": True}
|
||||
"""POST /wireguard/apply — render config, write to disk, bring up tunnels via sudo.
|
||||
|
||||
In multi-interface mode, applies each class with assigned peers
|
||||
independently. Falls back to legacy single-interface mode.
|
||||
"""
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.get("access_classes", {})
|
||||
affected: list[str] = []
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
if not _class_peers(cfg, class_key):
|
||||
continue
|
||||
|
||||
try:
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
continue
|
||||
except ValueError as e:
|
||||
logger.warning("Skipping class '%s' during apply: %s", class_key, e)
|
||||
continue
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
|
||||
local_tmp.parent.mkdir(exist_ok=True)
|
||||
local_tmp.write_text(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
|
||||
affected.append(f"{ifname}.conf")
|
||||
|
||||
if not affected:
|
||||
# Legacy single-interface mode
|
||||
conf_text = generate_conf(cfg)
|
||||
ifname = cfg["interface"]["name"]
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
|
||||
local_tmp.parent.mkdir(exist_ok=True)
|
||||
local_tmp.write_text(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||
logger.info("WireGuard tunnel '%s' brought up", ifname)
|
||||
|
||||
cfg_after = _get_wireguard_config()
|
||||
stamp_applied(cfg_after)
|
||||
_save_wireguard_config(cfg_after)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {
|
||||
"applied": True,
|
||||
"synced": sync_result.affected_subsystems,
|
||||
"interfaces": affected,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_DOWN)
|
||||
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
|
||||
cfg = _get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||
refresh_state(["wireguard"])
|
||||
"""POST /wireguard/down — bring down all WireGuard tunnel interfaces."""
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.get("access_classes", {})
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
ifname = _class_interface_name(class_key)
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Legacy interface (skip if name matches any class interface)
|
||||
ifname = cfg["interface"].get("name", "")
|
||||
if ifname:
|
||||
class_names = {_class_interface_name(k) for k in classes}
|
||||
if ifname not in class_names:
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_CLASSES_UP)
|
||||
def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/classes/<key>/up — bring up a single class's tunnel.
|
||||
|
||||
Raises:
|
||||
ValueError: When body or key is missing.
|
||||
NotFoundError: When class does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
class_key = body.get("class_key", "").strip()
|
||||
if not class_key:
|
||||
raise ValueError("'class_key' is required")
|
||||
|
||||
cfg = _get_wireguard_config()
|
||||
class_cfg = cfg.get("access_classes", {}).get(class_key)
|
||||
if not class_cfg:
|
||||
raise NotFoundError(f"Access class '{class_key}' not found")
|
||||
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
raise ValueError(f"No peers assigned to class '{class_key}'")
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
|
||||
local_tmp.parent.mkdir(exist_ok=True)
|
||||
local_tmp.write_text(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"up": True, "interface": ifname}
|
||||
|
||||
|
||||
@registry.register(DELETE_WIREGUARD_CLASSES_DOWN)
|
||||
def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /wireguard/classes/<key>/down — bring down a single class's tunnel.
|
||||
|
||||
Raises:
|
||||
ValueError: When body or key is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
class_key = body.get("class_key", "").strip()
|
||||
if not class_key:
|
||||
raise ValueError("'class_key' is required")
|
||||
|
||||
cfg = _get_wireguard_config()
|
||||
if class_key not in cfg.get("access_classes", {}):
|
||||
raise NotFoundError(f"Access class '{class_key}' not found")
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
|
||||
except Exception:
|
||||
pass
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard",
|
||||
"config_saved",
|
||||
{"action": "class_down", "class_key": class_key},
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"down": True, "interface": ifname}
|
||||
|
||||
|
||||
@registry.register(GET_WIREGUARD_STATUS)
|
||||
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /wireguard/status — return current WireGuard status from cache."""
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("status", {"up": False, "interface": {}, "peers": []})
|
||||
return {"up": False, "interface": {}, "peers": []}
|
||||
return wg.get(
|
||||
"status", {"up": False, "interface": {}, "peers": [], "classes": {}}
|
||||
)
|
||||
return {"up": False, "interface": {}, "peers": [], "classes": {}}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_INITIALIZE)
|
||||
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
|
||||
cfg = _get_config()
|
||||
"""POST /wireguard/initialize — generate keypair and store in config (idempotent).
|
||||
|
||||
Also generates key pairs for each access class interface.
|
||||
"""
|
||||
cfg = _get_wireguard_config()
|
||||
if cfg["interface"].get("private_key"):
|
||||
_ensure_access_classes(cfg)
|
||||
|
||||
# Generate keys for classes that need them (idempotent, saves internally)
|
||||
for class_key in cfg.get("access_classes", {}):
|
||||
generate_class_keypair(class_key)
|
||||
return {"initialized": False, "reason": "already initialized"}
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
private_key = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
|
||||
public_key = res2.stdout.strip()
|
||||
cfg["interface"]["private_key"] = private_key
|
||||
cfg["interface"]["public_key"] = public_key
|
||||
_save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
|
||||
refresh_state(["wireguard"])
|
||||
|
||||
# Fresh init
|
||||
priv, pub = generate_keypair()
|
||||
cfg["interface"]["private_key"] = priv
|
||||
cfg["interface"]["public_key"] = pub
|
||||
_ensure_access_classes(cfg)
|
||||
|
||||
# Generate class keys
|
||||
for class_key in cfg.get("access_classes", {}):
|
||||
generate_class_keypair(class_key)
|
||||
|
||||
_save_wireguard_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
|
||||
safe = dict(cfg)
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
for ck, cv in safe.get("access_classes", {}).items():
|
||||
if isinstance(cv, dict):
|
||||
safe["access_classes"][ck] = dict(cv)
|
||||
safe["access_classes"][ck].pop("private_key", None)
|
||||
return {"initialized": True, "config": safe}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_CLASS_INIT_KEYS)
|
||||
def init_class_keys(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/classes/keys/<key> — generate keypair for a class.
|
||||
|
||||
Raises:
|
||||
ValueError: When body or key is missing.
|
||||
NotFoundError: When class does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
class_key = body.get("class_key", "").strip()
|
||||
if not class_key:
|
||||
raise ValueError("'class_key' is required")
|
||||
|
||||
cfg = _get_wireguard_config()
|
||||
if class_key not in cfg.get("access_classes", {}):
|
||||
raise NotFoundError(f"Access class '{class_key}' not found")
|
||||
|
||||
class_cfg = cfg["access_classes"][class_key]
|
||||
if class_cfg.get("private_key"):
|
||||
return {
|
||||
"generated": False,
|
||||
"class_key": class_key,
|
||||
"reason": "already has keys",
|
||||
}
|
||||
|
||||
_, pub = generate_class_keypair(class_key)
|
||||
return {"generated": True, "class_key": class_key, "public_key": pub}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_PEERS_ADD)
|
||||
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/peers/add — add new peer or update existing one.
|
||||
@@ -224,33 +434,45 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
cfg = _get_wireguard_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = body.get("allowed_ips", [])
|
||||
if name in peers:
|
||||
peer = peers[name]
|
||||
peer["endpoint"] = body.get("endpoint")
|
||||
peer["allowed_ips"] = allowed_ips
|
||||
peer["persistent_keepalive"] = body.get("persistent_keepalive")
|
||||
if body.get("preshared_key") is not None:
|
||||
if "endpoint" in body:
|
||||
peer["endpoint"] = body["endpoint"]
|
||||
if "allowed_ips" in body:
|
||||
peer["allowed_ips"] = body["allowed_ips"]
|
||||
if "persistent_keepalive" in body:
|
||||
peer["persistent_keepalive"] = body["persistent_keepalive"]
|
||||
if "preshared_key" in body:
|
||||
peer["preshared_key"] = body["preshared_key"]
|
||||
if "description" in body:
|
||||
peer["description"] = body["description"]
|
||||
if "access_class" in body:
|
||||
peer["access_class"] = body["access_class"]
|
||||
logger.info("WireGuard peer '%s' updated", name)
|
||||
_peer_action = "peer_updated"
|
||||
else:
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
priv = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv)
|
||||
pub = res2.stdout.strip()
|
||||
priv, pub = generate_keypair()
|
||||
peers[name] = {
|
||||
"public_key": pub,
|
||||
"private_key": priv,
|
||||
"endpoint": body.get("endpoint"),
|
||||
"allowed_ips": allowed_ips,
|
||||
"allowed_ips": body.get("allowed_ips", []),
|
||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||
"preshared_key": body.get("preshared_key"),
|
||||
"description": body.get("description", ""),
|
||||
"access_class": body.get("access_class"),
|
||||
}
|
||||
logger.info("WireGuard peer '%s' added", name)
|
||||
_save_config(cfg)
|
||||
refresh_state(["wireguard"])
|
||||
_peer_action = "peer_added"
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
peer_out = dict(peers[name])
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
@@ -269,14 +491,19 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
cfg = _get_wireguard_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
if name not in peers:
|
||||
raise NotFoundError(f"Peer '{name}' not found")
|
||||
del peers[name]
|
||||
_save_config(cfg)
|
||||
_save_wireguard_config(cfg)
|
||||
logger.info("WireGuard peer '%s' removed", name)
|
||||
refresh_state(["wireguard"])
|
||||
sync_result = bus.emit(
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@@ -286,14 +513,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("peers", [])
|
||||
cfg = _get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, info in cfg.get("peers", {}).items():
|
||||
entry = dict(info)
|
||||
entry["name"] = name
|
||||
entry.pop("private_key", None)
|
||||
result.append(entry)
|
||||
return result
|
||||
return _get_wireguard_peers()
|
||||
|
||||
|
||||
@registry.register(GET_WIREGUARD_PEER_STATUS)
|
||||
@@ -305,10 +525,32 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
@registry.register(GET_WIREGUARD_CLASS_STATUS)
|
||||
def get_class_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /wireguard/classes/<key>/status — return status for a specific class interface.
|
||||
|
||||
Raises:
|
||||
NotFoundError: When class does not exist.
|
||||
"""
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
classes = wg.get("status", {}).get("classes", {})
|
||||
if body:
|
||||
key = body.get("class_key", "")
|
||||
if key and key not in classes:
|
||||
raise NotFoundError(f"Access class '{key}' not found")
|
||||
return classes.get(key, {"up": False, "interface": {}, "peers": []})
|
||||
return classes
|
||||
return {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
|
||||
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
|
||||
|
||||
Uses the peer's access class subnet and port to derive the correct
|
||||
server address and endpoint port.
|
||||
|
||||
Raises:
|
||||
ValueError: When body, name, or server_endpoint is missing.
|
||||
NotFoundError: When peer does not exist or has no private key.
|
||||
@@ -321,30 +563,144 @@ def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
if not server_endpoint:
|
||||
raise ValueError("'server_endpoint' is required")
|
||||
cfg = _get_config()
|
||||
cfg = _get_wireguard_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
raise NotFoundError(f"Peer '{name}' not found")
|
||||
peer = cfg["peers"][name]
|
||||
client_priv = peer.get("private_key", "")
|
||||
if not client_priv:
|
||||
if not peer.get("private_key"):
|
||||
raise NotFoundError(f"Peer '{name}' has no private key")
|
||||
iface = cfg["interface"]
|
||||
sorted_peers = sorted(cfg.get("peers", {}).keys())
|
||||
peer_index = sorted_peers.index(name) + 2
|
||||
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
|
||||
addr_part, prefix = srv_addr.rsplit("/", 1)
|
||||
prefix_base = addr_part.rsplit(".", 1)[0]
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
conf = tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
|
||||
conf = _gen_client_conf(
|
||||
peer_name=name,
|
||||
client_priv=client_priv,
|
||||
client_addr=client_addr,
|
||||
server_pubkey=iface.get("public_key", ""),
|
||||
server_endpoint=server_endpoint,
|
||||
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
server_pubkey=cfg["interface"].get("public_key"),
|
||||
)
|
||||
return {"config": conf}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access Classes
|
||||
|
||||
|
||||
@registry.register(GET_WIREGUARD_CLASSES)
|
||||
def list_classes(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /wireguard/classes — return access classes (with private keys stripped)."""
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.get("access_classes", {})
|
||||
safe: dict[str, Any] = {}
|
||||
for k, v in classes.items():
|
||||
if isinstance(v, dict):
|
||||
entry = dict(v)
|
||||
entry.pop("private_key", None)
|
||||
safe[k] = entry
|
||||
else:
|
||||
safe[k] = v
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register(POST_WIREGUARD_CLASSES)
|
||||
def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/classes — create a new access class with phase-2 fields.
|
||||
|
||||
Raises:
|
||||
ValueError: When body is missing or key conflicts.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
raise ValueError("'key' is required")
|
||||
if not key.isalnum() or not key.islower():
|
||||
raise ValueError("'key' must be lowercase alphanumeric")
|
||||
name = body.get("name", key)
|
||||
description = body.get("description", "")
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if key in classes:
|
||||
raise ConflictError(f"Access class '{key}' already exists")
|
||||
|
||||
classes[key] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"subnet": body.get("subnet"),
|
||||
"listen_port": body.get("listen_port"),
|
||||
"lan_access": body.get("lan_access", False),
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
}
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
out = dict(classes[key])
|
||||
out.pop("private_key", None)
|
||||
return out
|
||||
|
||||
|
||||
@registry.register(PATCH_WIREGUARD_CLASSES)
|
||||
def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /wireguard/classes/<key> — update an access class.
|
||||
|
||||
Raises:
|
||||
ValueError: When body is missing.
|
||||
NotFoundError: When class does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
raise ValueError("'key' is required")
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if key not in classes:
|
||||
raise NotFoundError(f"Access class '{key}' not found")
|
||||
class_cfg = classes[key]
|
||||
for field in ("name", "description", "subnet", "listen_port", "lan_access"):
|
||||
if field in body:
|
||||
class_cfg[field] = body[field]
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
out = dict(classes[key])
|
||||
out.pop("private_key", None)
|
||||
return {"key": key, **out}
|
||||
|
||||
|
||||
@registry.register(DELETE_WIREGUARD_CLASSES)
|
||||
def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /wireguard/classes/<key> — remove an access class.
|
||||
|
||||
Raises:
|
||||
ValueError: When body is missing.
|
||||
NotFoundError: When class does not exist.
|
||||
ConflictError: When peers still reference the class.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
raise ValueError("'key' is required")
|
||||
cfg = _get_wireguard_config()
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if key not in classes:
|
||||
raise NotFoundError(f"Access class '{key}' not found")
|
||||
# Check if any peers reference this class
|
||||
peers_reusing = [
|
||||
name
|
||||
for name, info in cfg.get("peers", {}).items()
|
||||
if info.get("access_class") == key
|
||||
]
|
||||
if peers_reusing:
|
||||
raise ConflictError(
|
||||
f"Cannot delete class '{key}': {len(peers_reusing)} peer(s) reference it: {', '.join(peers_reusing)}"
|
||||
)
|
||||
del classes[key]
|
||||
_save_wireguard_config(cfg)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"key": key}
|
||||
|
||||
+66
-1
@@ -29,6 +29,15 @@ PathLike = str | Endpoint
|
||||
|
||||
|
||||
def _ep(method: str, path: str) -> Endpoint:
|
||||
"""Construct a frozen (method, path) endpoint tuple.
|
||||
|
||||
Args:
|
||||
method: HTTP method string (e.g. ``'GET'``).
|
||||
path: URL path pattern.
|
||||
|
||||
Returns:
|
||||
Endpoint tuple suitable for ``registry.register()`` and client calls.
|
||||
"""
|
||||
return (method, path)
|
||||
|
||||
|
||||
@@ -45,6 +54,10 @@ POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
|
||||
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
|
||||
|
||||
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
|
||||
GET_NGINX_BACKENDS: Endpoint = _ep("GET", "/nginx/backends")
|
||||
PATCH_NGINX_BACKENDS: Endpoint = _ep("PATCH", "/nginx/backends")
|
||||
POST_NGINX_BACKENDS_ADD: Endpoint = _ep("POST", "/nginx/backends/add")
|
||||
DELETE_NGINX_BACKENDS_REMOVE: Endpoint = _ep("DELETE", "/nginx/backends/remove")
|
||||
|
||||
# ---- Firewall ----
|
||||
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
|
||||
@@ -86,6 +99,20 @@ DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove
|
||||
GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
|
||||
GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
|
||||
POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
|
||||
GET_WIREGUARD_CLASSES: Endpoint = _ep("GET", "/wireguard/classes")
|
||||
POST_WIREGUARD_CLASSES: Endpoint = _ep("POST", "/wireguard/classes")
|
||||
PATCH_WIREGUARD_CLASSES: Endpoint = _ep("PATCH", "/wireguard/classes/<key>")
|
||||
DELETE_WIREGUARD_CLASSES: Endpoint = _ep("DELETE", "/wireguard/classes/<key>")
|
||||
POST_WIREGUARD_CLASSES_UP: Endpoint = _ep("POST", "/wireguard/classes/<class_key>/up")
|
||||
DELETE_WIREGUARD_CLASSES_DOWN: Endpoint = _ep(
|
||||
"DELETE", "/wireguard/classes/<class_key>/down"
|
||||
)
|
||||
GET_WIREGUARD_CLASS_STATUS: Endpoint = _ep(
|
||||
"GET", "/wireguard/classes/<class_key>/status"
|
||||
)
|
||||
POST_WIREGUARD_CLASS_INIT_KEYS: Endpoint = _ep(
|
||||
"POST", "/wireguard/classes/keys/<class_key>"
|
||||
)
|
||||
|
||||
# ---- ACME / Certs ----
|
||||
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
|
||||
@@ -94,6 +121,7 @@ POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
|
||||
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
|
||||
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
|
||||
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
|
||||
GET_ACME_RENEW_STATUS: Endpoint = _ep("GET", "/acme/renew/status")
|
||||
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
|
||||
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
||||
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
|
||||
@@ -140,12 +168,49 @@ GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
|
||||
GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
|
||||
GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
|
||||
|
||||
# ---- Authentication ----
|
||||
POST_AUTH_LOGIN: Endpoint = _ep("POST", "/auth/login")
|
||||
POST_AUTH_LOGOUT: Endpoint = _ep("POST", "/auth/logout")
|
||||
POST_AUTH_REFRESH: Endpoint = _ep("POST", "/auth/refresh")
|
||||
GET_AUTH_SESSION: Endpoint = _ep("GET", "/auth/session")
|
||||
POST_AUTH_PASSWORD: Endpoint = _ep("POST", "/auth/password")
|
||||
# User admin
|
||||
GET_AUTH_USERS: Endpoint = _ep("GET", "/auth/users")
|
||||
POST_AUTH_USER_CREATE: Endpoint = _ep("POST", "/auth/users")
|
||||
POST_AUTH_USER_UPDATE: Endpoint = _ep("POST", "/auth/users/<username>")
|
||||
DELETE_AUTH_USER: Endpoint = _ep("DELETE", "/auth/users/<username>")
|
||||
|
||||
# WebAuthn
|
||||
POST_AUTH_WEBAUTHN_REGISTER_BEGIN: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/register-begin"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_REGISTER_FINISH: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/register-finish"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/authenticate-begin"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/authenticate-finish"
|
||||
)
|
||||
GET_AUTH_WEBAUTHN_CREDENTIALS: Endpoint = _ep("GET", "/auth/webauthn/credentials")
|
||||
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS: Endpoint = _ep(
|
||||
"GET", "/auth/webauthn/credential-counts"
|
||||
)
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL: Endpoint = _ep(
|
||||
"DELETE", "/auth/webauthn/creds/<credential_id>"
|
||||
)
|
||||
GET_AUTH_WEBAUTHN_CAPABLE: Endpoint = _ep("GET", "/auth/webauthn/capable")
|
||||
|
||||
# ---- Server infra (not going through client) ----
|
||||
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||
GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
|
||||
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||
GET_WS: Endpoint = _ep("GET", "/ws")
|
||||
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
||||
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
|
||||
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
|
||||
POST_STATUS_CANCEL_ALL: Endpoint = _ep("POST", "/status/cancel-all")
|
||||
GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics")
|
||||
|
||||
# Collect all endpoint module-level constants for __all__ verification
|
||||
_all_endpoints = [
|
||||
|
||||
+220
-55
@@ -9,7 +9,9 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -17,6 +19,7 @@ from typing import Any
|
||||
from aiohttp import web
|
||||
|
||||
from daemon.iface import PathLike
|
||||
from lib.auth import blacklist_expired
|
||||
from lib.state import _DEFAULT_POLL_INTERVALS
|
||||
from lib.state import state as state_store
|
||||
|
||||
@@ -34,7 +37,15 @@ if _RAW_POLL:
|
||||
if ":" in pair:
|
||||
name, _, val = pair.partition(":")
|
||||
try:
|
||||
_POLL_OVERRIDE[name.strip()] = int(val.strip())
|
||||
parsed = int(val.strip())
|
||||
if parsed <= 0:
|
||||
logger.warning(
|
||||
"Invalid poll interval value %r for %r (must be > 0), skipping",
|
||||
val,
|
||||
name,
|
||||
)
|
||||
continue
|
||||
_POLL_OVERRIDE[name.strip()] = parsed
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid poll interval value %r for %r, skipping", val, name
|
||||
@@ -94,6 +105,7 @@ class Registry:
|
||||
method = ep_method
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
"""Inner decorator that stores *fn* in the registry and attaches Handler metadata."""
|
||||
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
|
||||
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
|
||||
return fn
|
||||
@@ -148,7 +160,14 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
|
||||
except RuntimeError:
|
||||
pass
|
||||
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)
|
||||
_ws_tasks.add(task)
|
||||
|
||||
@@ -228,7 +247,8 @@ async def _handle_request(request: web.Request) -> web.Response:
|
||||
|
||||
# Build body — merge order (highest wins): path params > JSON body > query params.
|
||||
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
||||
# be overridable by body or query parameters.
|
||||
# be overridable by body or query parameters. This prevents callers from
|
||||
# spoofing path-scoped parameters via request body.
|
||||
body: dict[str, Any] | None = pat_params if pat_params else None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
@@ -340,7 +360,6 @@ def create_app() -> web.Application:
|
||||
"""
|
||||
app = web.Application()
|
||||
app.router.add_route("GET", "/health", _health)
|
||||
app.router.add_route("GET", "/status/all", get_status_all)
|
||||
app.router.add_route("POST", "/status/refresh", refresh_status)
|
||||
app.router.add_route("POST", "/batch", _handle_batch)
|
||||
app.router.add_route("GET", "/ws", _handle_ws)
|
||||
@@ -348,23 +367,102 @@ def create_app() -> web.Application:
|
||||
return app
|
||||
|
||||
|
||||
# JWTs are dot-joined base64url segments — a subset of the RFC 6455 token
|
||||
# character set. Browsers therefore carry the access token as the
|
||||
# Sec-WebSocket-Protocol subprotocol name itself (see websocket.js); the
|
||||
# "Bearer <token>" form is not a valid subprotocol (space is not a token
|
||||
# character) and is rejected by the WebSocket constructor.
|
||||
_JWT_SUBPROTOCOL_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _extract_ws_token(subprotocols: list[str]) -> tuple[str | None, str | None]:
|
||||
"""Extract the auth JWT from parsed Sec-WebSocket-Protocol names.
|
||||
|
||||
Accepts the raw JWT as a subprotocol name (bundled client) or the
|
||||
legacy "Bearer <token>" form (non-browser clients that can send spaces).
|
||||
|
||||
Args:
|
||||
subprotocols: Parsed subprotocol names (already stripped/split).
|
||||
|
||||
Returns:
|
||||
``(token, matched_subprotocol)`` or ``(None, None)`` if no usable
|
||||
auth subprotocol was found.
|
||||
"""
|
||||
for proto in subprotocols:
|
||||
if _JWT_SUBPROTOCOL_RE.fullmatch(proto):
|
||||
return proto, proto
|
||||
for proto in subprotocols:
|
||||
if proto.startswith("Bearer "):
|
||||
token = proto[7:].strip()
|
||||
if token:
|
||||
return token, proto
|
||||
return None, None
|
||||
|
||||
|
||||
# WebSocket subscribers
|
||||
_ws_subscribers: set[web.WebSocketResponse] = set()
|
||||
_ws_tasks: set[asyncio.Task[None]] = set()
|
||||
_poll_tasks: set[asyncio.Task[None]] = set()
|
||||
_last_blacklist_cleanup: float = 0
|
||||
_last_blacklist_cleanup_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
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
|
||||
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||
On connect: sends a full state snapshot of every subsystem
|
||||
({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:
|
||||
1. WebSocket subprotocol header — the bundled client sends the raw JWT
|
||||
as the subprotocol name (Sec-WebSocket-Protocol must carry a valid
|
||||
RFC 6455 token; a JWT is one, "Bearer <token>" is not).
|
||||
2. "Bearer <token>" subprotocol — legacy form for non-browser clients.
|
||||
3. X-Auth-Token header — fallback for custom nginx setups that inject
|
||||
it (not set by the bundled nginx config).
|
||||
"""
|
||||
ws = web.WebSocketResponse()
|
||||
from aiohttp import hdrs
|
||||
|
||||
from lib.auth import validate_token
|
||||
|
||||
token_param = None
|
||||
matched_proto = None
|
||||
|
||||
# Prefer the subprotocol header. Sec-WebSocket-Protocol is a
|
||||
# comma-separated list; parse it the same way aiohttp's own handshake
|
||||
# does (Request has no subprotocol helper).
|
||||
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
|
||||
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
|
||||
token_param, matched_proto = _extract_ws_token(subprotocols)
|
||||
|
||||
if token_param is None:
|
||||
token_param = request.headers.get("X-Auth-Token")
|
||||
|
||||
if token_param is None:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "authentication required"}, status=401
|
||||
)
|
||||
|
||||
# Validate token. Session binding is skipped because browsers cannot send
|
||||
# custom headers on WebSocket connections (no X-Session-Id available).
|
||||
payload = validate_token(
|
||||
token_param,
|
||||
token_type="access",
|
||||
)
|
||||
if payload is None:
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
# Negotiate only the matched auth subprotocol (or all if token came from header)
|
||||
ws = web.WebSocketResponse(
|
||||
protocols=[matched_proto] if matched_proto else subprotocols
|
||||
)
|
||||
await ws.prepare(request)
|
||||
_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:
|
||||
async for msg in ws:
|
||||
@@ -378,39 +476,69 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
return ws
|
||||
|
||||
|
||||
async def broadcast_versions() -> None:
|
||||
"""Broadcast updated subsystem versions to all WebSocket clients."""
|
||||
updated = state_store.get_updated_versions()
|
||||
if not updated or not _ws_subscribers:
|
||||
async def _send_all(message: str) -> None:
|
||||
"""Send a message string to all WS subscribers, removing dead ones."""
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
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
|
||||
data = json.dumps({"type": "versions", "updated": updated})
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
for ws in _ws_subscribers:
|
||||
try:
|
||||
await ws.send_str(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_ws_subscribers.difference_update(dead)
|
||||
if dead:
|
||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "versions",
|
||||
"subsystem": subsystem,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
await _send_all(message)
|
||||
|
||||
|
||||
async def broadcast_tick(subsystems: list[str]) -> None:
|
||||
"""Broadcast a lightweight tick to WS clients without version payload."""
|
||||
data = json.dumps({"type": "tick", "subsystems": subsystems})
|
||||
dead: set[web.WebSocketResponse] = set()
|
||||
for ws in _ws_subscribers:
|
||||
try:
|
||||
await ws.send_str(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_ws_subscribers.difference_update(dead)
|
||||
if dead:
|
||||
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||
async def broadcast_tick(subsystem: str) -> None:
|
||||
"""Send {type: tick} with the changed subsystem data.
|
||||
|
||||
No bump — tick is volatile-only; the version counter only bumps on
|
||||
structural changes.
|
||||
"""
|
||||
data = state_store.get(subsystem)
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "tick",
|
||||
"subsystem": subsystem,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
await _send_all(message)
|
||||
|
||||
|
||||
async def _poll_loop(subsystem: str, interval: int) -> None:
|
||||
"""Periodically poll a subsystem for state changes and broadcast as needed."""
|
||||
global _last_blacklist_cleanup, _last_blacklist_cleanup_lock
|
||||
|
||||
# Lazy-init lock (requires running event loop)
|
||||
if _last_blacklist_cleanup_lock is None:
|
||||
_last_blacklist_cleanup_lock = asyncio.Lock()
|
||||
|
||||
offset = int(hashlib.md5(subsystem.encode()).hexdigest(), 16) % interval
|
||||
await asyncio.sleep(offset)
|
||||
while True:
|
||||
@@ -418,9 +546,15 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
|
||||
structural, volatile = state_store.poll(subsystem)
|
||||
if structural:
|
||||
state_store.bump(subsystem)
|
||||
await broadcast_versions()
|
||||
await broadcast_versions(subsystem) # now per-subsystem, carries data
|
||||
elif volatile:
|
||||
await broadcast_tick([subsystem])
|
||||
await broadcast_tick(subsystem) # now per-subsystem, carries data
|
||||
# Periodic blacklist cleanup — coordinated across all poll loops
|
||||
async with _last_blacklist_cleanup_lock:
|
||||
now = time.time()
|
||||
if now - _last_blacklist_cleanup >= 60:
|
||||
blacklist_expired()
|
||||
_last_blacklist_cleanup = now
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -452,15 +586,6 @@ async def _health(_request: web.Request) -> web.Response:
|
||||
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
|
||||
|
||||
|
||||
async def get_status_all(_request: web.Request) -> web.Response:
|
||||
"""Return the entire state snapshot in one call.
|
||||
|
||||
Returns:
|
||||
JSON response containing state data for all subsystems.
|
||||
"""
|
||||
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
|
||||
|
||||
|
||||
async def refresh_status(_request: web.Request) -> web.Response:
|
||||
"""Re-collect all state from system.
|
||||
|
||||
@@ -474,11 +599,24 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
||||
body = await _request.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body = None
|
||||
subsystems = None
|
||||
if body and "subsystems" in body:
|
||||
subsystems = body["subsystems"]
|
||||
subsystems = body.get("subsystems") if body else None
|
||||
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:
|
||||
@@ -501,11 +639,14 @@ def _register_routes() -> None:
|
||||
"""
|
||||
from daemon.handlers import (
|
||||
acme, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
network, # noqa: F401
|
||||
nginx, # noqa: F401
|
||||
status, # noqa: F401
|
||||
system, # noqa: F401
|
||||
wireguard, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -520,6 +661,14 @@ def main() -> None:
|
||||
|
||||
setup_logging()
|
||||
|
||||
# Startup checks
|
||||
try:
|
||||
from lib import webauthn as lib_webauthn
|
||||
|
||||
lib_webauthn.check_webauthn_config()
|
||||
except Exception:
|
||||
pass # ignore if webauthn module import failed
|
||||
|
||||
_register_routes()
|
||||
app = create_app()
|
||||
|
||||
@@ -532,13 +681,21 @@ def main() -> None:
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
def _on_shutdown(_sig: int) -> None:
|
||||
async def _shutdown() -> None:
|
||||
"""Graceful shutdown: cancel poller, close runner, teardown."""
|
||||
logger.info("Shutting down daemon...")
|
||||
_stop_polling()
|
||||
loop.stop()
|
||||
try:
|
||||
await asyncio.wait_for(runner.cleanup(), timeout=5)
|
||||
except TimeoutError:
|
||||
logger.warning("Runner cleanup timed out, abandoning")
|
||||
if Path(socket_path).exists():
|
||||
os.unlink(socket_path)
|
||||
logger.info("vacuum-walld stopped")
|
||||
loop.call_soon(loop.stop)
|
||||
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, _on_shutdown, sig)
|
||||
loop.add_signal_handler(sig, lambda: loop.create_task(_shutdown()))
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
loop.run_until_complete(runner.setup())
|
||||
@@ -549,6 +706,13 @@ def main() -> None:
|
||||
|
||||
os.chmod(socket_path, 0o660)
|
||||
|
||||
# Import system configs → JSON (blocking — OK at startup)
|
||||
from lib.system_import import import_all
|
||||
|
||||
reconciled = import_all()
|
||||
if reconciled:
|
||||
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
|
||||
|
||||
# Populate state from system (blocking — OK at startup)
|
||||
logger.info("Populating system state...")
|
||||
state_store.populate()
|
||||
@@ -562,10 +726,11 @@ def main() -> None:
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.run_until_complete(runner.cleanup())
|
||||
# _shutdown() handles cleanup when invoked via signal handler;
|
||||
# this block is only reached if shutdown didn't happen cleanly
|
||||
# (e.g., unexpected exit), in which case we unlink the socket.
|
||||
if Path(socket_path).exists():
|
||||
os.unlink(socket_path)
|
||||
logger.info("vacuum-walld stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+717
-46
@@ -1,9 +1,24 @@
|
||||
# REST API Reference
|
||||
|
||||
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://<hostname>.local/api/...`).
|
||||
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <token>` header. Public endpoints (login, WebAuthn authenticate) do not require a token.
|
||||
|
||||
Every request and response uses `Content-Type: application/json`.
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require a valid JWT access token. The token is obtained by logging in via `POST /api/auth/login` or completing a WebAuthn authentication ceremony.
|
||||
|
||||
### Obtaining a Token
|
||||
|
||||
1. Call `POST /api/auth/login` with credentials
|
||||
2. Store the returned `access_token`
|
||||
3. Include `Authorization: Bearer <access_token>` on all subsequent requests
|
||||
4. Refresh before expiry via `POST /api/auth/refresh`
|
||||
|
||||
### Permission Checks
|
||||
|
||||
Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Success Responses
|
||||
@@ -32,6 +47,7 @@ Error responses carry one of the following HTTP status codes:
|
||||
|------|---------|
|
||||
| `400` | Bad request — invalid body, missing required field, or malformed value |
|
||||
| `404` | Not found — the requested resource does not exist |
|
||||
| `409` | Conflict — the requested operation conflicts with an existing resource |
|
||||
| `500` | Internal server error — unexpected failure in the backend |
|
||||
|
||||
### Route Patterns
|
||||
@@ -40,6 +56,357 @@ Resource identification uses **path parameters** whenever possible. Exceptions o
|
||||
|
||||
---
|
||||
|
||||
## Auth API
|
||||
|
||||
Endpoints prefixed with `/api/auth/...`. Manage authentication, session, tokens, and user accounts.
|
||||
|
||||
### Login
|
||||
|
||||
#### Password Login
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
```
|
||||
|
||||
Authenticate with username and password. Returns access and refresh tokens.
|
||||
|
||||
**Auth:** Public — no JWT required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username |
|
||||
| `password` | `string` | Yes | Plain-text password (hashed for verification) |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
|
||||
| `access_ttl` | `integer` | Access token lifetime in seconds (default: `900`) |
|
||||
| `user` | `object` | User info (`username`, `id`) |
|
||||
| `permissions` | `object` | Per-subsystem permissions |
|
||||
|
||||
Returns HTTP `401` if credentials are invalid.
|
||||
|
||||
---
|
||||
|
||||
### Session
|
||||
|
||||
#### Get Current Session
|
||||
|
||||
```
|
||||
GET /api/auth/session
|
||||
```
|
||||
|
||||
Return the current authenticated user and permissions.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `user` | `object` | User info (`username`, `id`) |
|
||||
| `permissions` | `object` | Per-subsystem permissions |
|
||||
|
||||
Returns HTTP `401` if token is invalid, expired, or blacklisted.
|
||||
|
||||
#### Logout
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
Invalidate the current session by blacklisting the access token.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
#### Refresh Tokens
|
||||
|
||||
```
|
||||
POST /api/auth/refresh
|
||||
```
|
||||
|
||||
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens.
|
||||
|
||||
**Auth:** Refresh token required.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
|
||||
| `access_ttl` | `integer` | Access token lifetime in seconds |
|
||||
| `user` | `object` | User info (`username`, `id`) |
|
||||
| `permissions` | `object` | Per-subsystem permissions |
|
||||
|
||||
Returns HTTP `401` if refresh token is invalid, expired, or blacklisted.
|
||||
|
||||
#### Change Password
|
||||
|
||||
```
|
||||
POST /api/auth/password
|
||||
```
|
||||
|
||||
Change the current user's password.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | No | Auto-injected from JWT context |
|
||||
| `oldPassword` | `string` | Yes | Current password |
|
||||
| `newPassword` | `string` | Yes | New password |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `400` if old password is incorrect.
|
||||
|
||||
---
|
||||
|
||||
### User Management
|
||||
|
||||
#### List Users
|
||||
|
||||
```
|
||||
GET /api/auth/users
|
||||
```
|
||||
|
||||
List all users. Requires admin permission (`auth: "rw"`).
|
||||
|
||||
**Auth:** `auth: "rw"` required.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
|
||||
|
||||
#### Create User
|
||||
|
||||
```
|
||||
POST /api/auth/users
|
||||
```
|
||||
|
||||
Create a new user with password and per-subsystem permissions.
|
||||
|
||||
**Auth:** `auth: "rw"` required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username |
|
||||
| `password` | `string` | Yes | Plain-text password |
|
||||
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `int` | User ID |
|
||||
| `username` | `string` | Username |
|
||||
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
|
||||
|
||||
Returns HTTP `409` if username already exists.
|
||||
|
||||
#### Update User
|
||||
|
||||
```
|
||||
POST /api/auth/users/<username>
|
||||
```
|
||||
|
||||
Update user's permissions. (To change a password, use `POST /api/auth/password`.)
|
||||
|
||||
**Auth:** `auth: "rw"` required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `permissions` | `object` | No | New per-subsystem permissions |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `int` | User ID |
|
||||
| `username` | `string` | Username |
|
||||
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
|
||||
|
||||
Returns HTTP `404` if user not found.
|
||||
|
||||
#### Delete User
|
||||
|
||||
```
|
||||
DELETE /api/auth/users/<username>
|
||||
```
|
||||
|
||||
Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
|
||||
|
||||
**Auth:** `auth: "rw"` required. Cannot delete self.
|
||||
|
||||
**Response:** `data` is `{"ok": true}` on success.
|
||||
|
||||
Returns HTTP `404` if user not found.
|
||||
|
||||
---
|
||||
|
||||
### WebAuthn
|
||||
|
||||
#### Begin Registration
|
||||
|
||||
```
|
||||
POST /api/auth/webauthn/register-begin
|
||||
```
|
||||
|
||||
Start WebAuthn credential registration. Returns options for `navigator.credentials.create()`.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username to register for |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `challenge` | `string` | Base64url challenge |
|
||||
| `rp` | `object` | Relying party config (`id`, `name`) |
|
||||
| `user` | `object` | User info for registration |
|
||||
| `excludeCredentials` | `[object, ...]` | Credentials to exclude |
|
||||
|
||||
#### Finish Registration
|
||||
|
||||
```
|
||||
POST /api/auth/webauthn/register-finish
|
||||
```
|
||||
|
||||
Complete WebAuthn credential registration. Verifies the attestation response and stores the credential in the database.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username |
|
||||
| `response` | `object` | Yes | WebAuthn authenticator attestation response |
|
||||
| `name` | `string` | No | Display name for this credential |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `400` if verification fails.
|
||||
|
||||
#### Begin Authentication
|
||||
|
||||
```
|
||||
POST /api/auth/webauthn/authenticate-begin
|
||||
```
|
||||
|
||||
Start WebAuthn authentication. Returns options for `navigator.credentials.get()`.
|
||||
|
||||
**Auth:** Public — no JWT required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username to authenticate |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `challenge` | `string` | Base64url challenge |
|
||||
| `allowCredentials` | `[object, ...]` | Registered credentials for this user |
|
||||
|
||||
Returns `{"ok": true, "data": {"no_webauthn": true}}` if user has no WebAuthn credentials (use password instead).
|
||||
|
||||
#### Finish Authentication
|
||||
|
||||
```
|
||||
POST /api/auth/webauthn/authenticate-finish
|
||||
```
|
||||
|
||||
Complete WebAuthn authentication. Verifies the assertion and issues tokens on success.
|
||||
|
||||
**Auth:** Public — no JWT required.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | `string` | Yes | Username |
|
||||
| `assertion_response` | `object` | Yes | WebAuthn authenticator assertion response |
|
||||
| `auth_options` | `object` | Yes | Original authentication options from `authenticate-begin` |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
|
||||
| `access_ttl` | `integer` | Access token lifetime in seconds |
|
||||
| `user` | `object` | User info (`username`, `id`) |
|
||||
| `permissions` | `object` | Per-subsystem permissions |
|
||||
|
||||
Returns HTTP `400` if verification fails.
|
||||
|
||||
#### List Credentials
|
||||
|
||||
```
|
||||
GET /api/auth/webauthn/credentials
|
||||
```
|
||||
|
||||
List WebAuthn credentials for the current user.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`).
|
||||
|
||||
#### Credential Counts
|
||||
|
||||
```
|
||||
GET /api/auth/webauthn/credential-counts
|
||||
```
|
||||
|
||||
Return credential counts for all users. Admin endpoint.
|
||||
|
||||
**Auth:** `auth: "rw"` required.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
|
||||
|
||||
#### Remove Credential
|
||||
|
||||
```
|
||||
DELETE /api/auth/webauthn/creds/<credential_id>
|
||||
```
|
||||
|
||||
Remove a WebAuthn credential.
|
||||
|
||||
**Auth:** Access token required.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if credential not found.
|
||||
|
||||
---
|
||||
|
||||
## Firewall API
|
||||
|
||||
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
|
||||
@@ -85,7 +452,13 @@ POST /api/firewall/config/apply
|
||||
|
||||
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
|
||||
|
||||
**Response:** `data` contains `applied_zones` list and backup path.
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied_zones` | `[string, ...]` | List of zone names that were applied |
|
||||
| `backup` | `string` | Path to the firewall state backup file |
|
||||
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
|
||||
|
||||
#### Check Pending Changes
|
||||
|
||||
@@ -369,6 +742,22 @@ Toggle masquerade (source NAT) for a zone.
|
||||
| `zone` | `string` | Zone name |
|
||||
| `masquerade` | `boolean` | Whether masquerade is now enabled |
|
||||
|
||||
### State
|
||||
|
||||
#### Get Firewall State
|
||||
|
||||
```
|
||||
GET /api/firewall/state
|
||||
```
|
||||
|
||||
Return current firewall state from the daemon state store. Provides live firewall state data including active zones, services, and interfaces as polled by the daemon.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `object` | Firewall state data from the state collector |
|
||||
|
||||
### Info
|
||||
|
||||
#### Available Services
|
||||
@@ -765,20 +1154,6 @@ Returns HTTP `400` if the domain is already configured.
|
||||
|
||||
---
|
||||
|
||||
#### Get Domain Details
|
||||
|
||||
```
|
||||
GET /api/proxy/domains/<domain>
|
||||
```
|
||||
|
||||
Return the configuration for a single proxy domain.
|
||||
|
||||
**Response (`data`):** Domain name plus backend configuration fields.
|
||||
|
||||
Returns HTTP `404` if the domain is not configured.
|
||||
|
||||
---
|
||||
|
||||
#### Update Domain
|
||||
|
||||
```
|
||||
@@ -890,15 +1265,35 @@ Return details for a single certificate.
|
||||
|
||||
Returns HTTP `404` if no certificate is found for the domain.
|
||||
|
||||
### Validation
|
||||
|
||||
#### Validate Certificate Issuance
|
||||
|
||||
```
|
||||
POST /api/certs/validate
|
||||
```
|
||||
|
||||
Run pre-flight checks before certificate issuance. Verifies domain format, ACME account registration, DNS resolution, and port 80 accessibility.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Domain to validate |
|
||||
|
||||
**Response (`data`):** Validation results object with per-check status.
|
||||
|
||||
Returns HTTP `400` if the domain is missing.
|
||||
|
||||
### Operations
|
||||
|
||||
#### Issue Certificate
|
||||
#### Start Certificate Issuance
|
||||
|
||||
```
|
||||
POST /api/certs/issue
|
||||
POST /api/certs/issue/start
|
||||
```
|
||||
|
||||
Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
|
||||
Create a new certificate issuance request. Issuance runs asynchronously in the background.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
@@ -908,9 +1303,27 @@ Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
|
||||
| `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email |
|
||||
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
**Response (`data`):**
|
||||
|
||||
Returns HTTP `400` if the domain is missing. An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `request_id` | `string` | Unique identifier for polling issuance status |
|
||||
|
||||
Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
|
||||
|
||||
---
|
||||
|
||||
#### Poll Certificate Issuance Status
|
||||
|
||||
```
|
||||
GET /api/certs/issue/<request_id>
|
||||
```
|
||||
|
||||
Poll the status of a certificate issuance request started by `POST /api/certs/issue/start`.
|
||||
|
||||
**Response (`data`):** Issuance status object containing progress, logs, and result.
|
||||
|
||||
Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to repeatedly fetch this endpoint until issuance completes or fails.
|
||||
|
||||
---
|
||||
|
||||
@@ -920,11 +1333,46 @@ Returns HTTP `400` if the domain is missing. An ACME account must be registered
|
||||
POST /api/certs/<domain>/renew
|
||||
```
|
||||
|
||||
Force-renew an existing certificate.
|
||||
Start an async certificate renewal for an existing certificate. The renewal
|
||||
runs in the background and is polled via
|
||||
`GET /api/certs/renew/<request_id>`.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
**Request Body:** none (domain is taken from the path).
|
||||
|
||||
Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails.
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `request_id` | `string` | Unique identifier for polling renewal status |
|
||||
| `domain` | `string` | Domain being renewed |
|
||||
| `status` | `string` | Only when a renewal for this domain is already in progress (`"existing"` — the existing `request_id` is returned) |
|
||||
|
||||
The renewal is a **no-op** when the certificate's renewal window (default:
|
||||
30 days before expiry) has not been reached — the request then completes with
|
||||
`status: "skipped"`.
|
||||
|
||||
Returns HTTP `400` if the domain is missing. Returns HTTP `500` when the
|
||||
renewal cannot be started (e.g. daemon unreachable).
|
||||
|
||||
---
|
||||
|
||||
#### Poll Certificate Renewal Status
|
||||
|
||||
```
|
||||
GET /api/certs/renew/<request_id>
|
||||
```
|
||||
|
||||
Poll the status of a certificate renewal started by
|
||||
`POST /api/certs/<domain>/renew`.
|
||||
|
||||
**Response (`data`):** Renewal status object containing `request_id`,
|
||||
`domain`, `status` (`"running"`, `"completed"`, `"skipped"`, or `"failed"`),
|
||||
a `steps` array (each with per-step status and error message), and
|
||||
timestamps.
|
||||
|
||||
Returns HTTP `404` if the request ID is not found. The frontend uses
|
||||
`poll()` to repeatedly fetch this endpoint until the renewal completes,
|
||||
is skipped, or fails.
|
||||
|
||||
---
|
||||
|
||||
@@ -1137,7 +1585,12 @@ POST /api/wireguard/down
|
||||
|
||||
Bring down the WireGuard tunnel interface (`wg0`).
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `down` | `boolean` | Always `true` on success |
|
||||
| `synced` | `[string, ...]` | Subsystems auto-synced as a result |
|
||||
|
||||
### Status
|
||||
|
||||
@@ -1268,6 +1721,86 @@ This is the only endpoint that returns a WireGuard private key. All other endpoi
|
||||
|
||||
Returns HTTP `404` if the peer is not found.
|
||||
|
||||
### Access Classes
|
||||
|
||||
Manage VPN access classes that categorize peers by access level (e.g., full LAN access, internet-only).
|
||||
|
||||
#### List Access Classes
|
||||
|
||||
```
|
||||
GET /api/wireguard/classes
|
||||
```
|
||||
|
||||
Return all configured access classes.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
Object keyed by class identifier, each with `name` and `description` fields.
|
||||
|
||||
#### Create Access Class
|
||||
|
||||
```
|
||||
POST /api/wireguard/classes
|
||||
```
|
||||
|
||||
Create a new access class.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `key` | `string` | Yes | Class identifier (alphanumeric) |
|
||||
| `name` | `string` | No | Display name (defaults to key) |
|
||||
| `description` | `string` | No | Description text |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `key` | `string` | Class key |
|
||||
| `name` | `string` | Display name |
|
||||
| `description` | `string` | Description |
|
||||
|
||||
Returns HTTP `409` if the key already exists.
|
||||
|
||||
#### Update Access Class
|
||||
|
||||
```
|
||||
PATCH /api/wireguard/classes
|
||||
```
|
||||
|
||||
Update an existing access class.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `key` | `string` | Yes | Class identifier |
|
||||
| `name` | `string` | No | New display name |
|
||||
| `description` | `string` | No | New description |
|
||||
|
||||
**Response (`data`):** Updated class object with `key`, `name`, `description`.
|
||||
|
||||
Returns HTTP `404` if the class is not found.
|
||||
|
||||
#### Delete Access Class
|
||||
|
||||
```
|
||||
DELETE /api/wireguard/classes
|
||||
```
|
||||
|
||||
Remove an access class. Cannot delete a class that has peers assigned to it.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `key` | `string` | Yes | Class identifier |
|
||||
|
||||
**Response (`data`):** `{ "key": "<key>" }`
|
||||
|
||||
Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers reference the class.
|
||||
|
||||
---
|
||||
|
||||
## Network API
|
||||
@@ -1282,7 +1815,7 @@ Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface co
|
||||
GET /api/network/interfaces
|
||||
```
|
||||
|
||||
Return all configured interfaces with their network config and runtime state from `networkctl`.
|
||||
Return all network interfaces (configured and live, including loopback) with their network config and runtime state from `networkctl`. `runtime.state` is the networkctl operational state (`routable`, `degraded`, `carrier`, `off`, …).
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -1328,7 +1861,8 @@ Save network config for an interface, render the `.network` file, copy it to `/e
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Interface name |
|
||||
| `applied` | `boolean` | Always `true` on success |
|
||||
| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable |
|
||||
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
|
||||
|
||||
Returns HTTP `400` if the interface name is invalid.
|
||||
|
||||
@@ -1368,6 +1902,7 @@ Full sync: generate all `.network` files, remove stale files, copy to `/etc/syst
|
||||
| `applied` | `number` | Number of interfaces applied |
|
||||
| `files` | `[string, ...]` | Paths of generated files |
|
||||
| `cleaned` | `[string, ...]` | Paths of removed stale files |
|
||||
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
|
||||
|
||||
### Helpers
|
||||
|
||||
@@ -1405,12 +1940,114 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|
||||
|-------|------|-------------|
|
||||
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
|
||||
|
||||
Returns HTTP `500` if the value cannot be verified after write.
|
||||
|
||||
---
|
||||
|
||||
## Status API
|
||||
|
||||
Endpoints prefixed with `/api/status/...`. Aggregate status across all subsystems.
|
||||
|
||||
### Pending Changes
|
||||
|
||||
#### Check All Pending Changes
|
||||
|
||||
```
|
||||
GET /api/status/pending
|
||||
```
|
||||
|
||||
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` |
|
||||
| `dnsmasq` / `nginx` / `wireguard` / `networkd` | `object` | `{ pending_changes, summary, changes: [{summary, detail}] }` |
|
||||
| `total_changes` | `number` | Total count of pending changes across all subsystems |
|
||||
|
||||
---
|
||||
|
||||
#### Apply All Pending Changes
|
||||
|
||||
```
|
||||
POST /api/status/apply-all
|
||||
```
|
||||
|
||||
Apply pending changes for all subsystems in dependency order.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `[string, ...]` | List of subsystems that were applied |
|
||||
| `errors` | `[object, ...]` | Any errors encountered during apply |
|
||||
|
||||
---
|
||||
|
||||
#### Cancel All Pending Changes
|
||||
|
||||
```
|
||||
POST /api/status/cancel-all
|
||||
```
|
||||
|
||||
Revert pending changes for all subsystems to the last applied
|
||||
configuration. Restores each pending subsystem's `config.json` from its
|
||||
recorded `_last_applied_config` snapshot, discarding unapplied edits.
|
||||
Subsystems without a recorded baseline (config never applied) are
|
||||
reported as skipped and left untouched. No live-system commands run —
|
||||
only the declarative config files are written.
|
||||
|
||||
**Request Body:** none.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `cancelled` | `[string, ...]` | Subsystems reverted to their last applied config |
|
||||
| `skipped` | `object` | Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") |
|
||||
| `errors` | `object` | Map of subsystem label → error message |
|
||||
|
||||
---
|
||||
|
||||
#### 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
|
||||
|
||||
#### Set Kernel Parameter
|
||||
|
||||
```
|
||||
POST /api/sysctl/set
|
||||
POST /api/network/sysctl/set
|
||||
```
|
||||
|
||||
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
|
||||
@@ -1435,7 +2072,7 @@ Returns HTTP `500` if the value cannot be verified after write.
|
||||
|
||||
## Logs API
|
||||
|
||||
Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
|
||||
Endpoints prefixed with `/api/logs/...`. Return log lines as JSON strings, wrapped in the standard `{"ok": true, "data": ...}` response contract.
|
||||
|
||||
### System Journal
|
||||
|
||||
@@ -1445,9 +2082,13 @@ Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the s
|
||||
GET /api/logs/journal
|
||||
```
|
||||
|
||||
Return recent system journal entries as rendered HTML log lines.
|
||||
Return recent system journal entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw journal log text |
|
||||
|
||||
### Nginx Logs
|
||||
|
||||
@@ -1457,9 +2098,15 @@ Return recent system journal entries as rendered HTML log lines.
|
||||
GET /api/logs/nginx/access
|
||||
```
|
||||
|
||||
Return recent nginx access log entries as rendered HTML.
|
||||
Return recent nginx access log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw access log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
---
|
||||
|
||||
@@ -1469,9 +2116,15 @@ Return recent nginx access log entries as rendered HTML.
|
||||
GET /api/logs/nginx/error
|
||||
```
|
||||
|
||||
Return recent nginx error log entries as rendered HTML.
|
||||
Return recent nginx error log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw error log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
### Dnsmasq Log
|
||||
|
||||
@@ -1481,9 +2134,13 @@ Return recent nginx error log entries as rendered HTML.
|
||||
GET /api/logs/dnsmasq
|
||||
```
|
||||
|
||||
Return recent dnsmasq journal entries as rendered HTML.
|
||||
Return recent dnsmasq journal entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw dnsmasq journal text |
|
||||
|
||||
### Application Log
|
||||
|
||||
@@ -1493,22 +2150,36 @@ Return recent dnsmasq journal entries as rendered HTML.
|
||||
GET /api/logs/app
|
||||
```
|
||||
|
||||
Return recent application log entries as rendered HTML.
|
||||
Return recent application log entries.
|
||||
|
||||
**Response:** HTML fragment of `<div class="log-line">` elements.
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `string` | Raw application log text |
|
||||
|
||||
Returns HTTP `404` if the log file does not exist.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
|
||||
```
|
||||
### Handshake Authentication
|
||||
|
||||
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
|
||||
|
||||
- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
|
||||
- **`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.
|
||||
| Type | Sent | Fields | Meaning |
|
||||
|------|------|--------|---------|
|
||||
| `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.
|
||||
+197
-24
@@ -20,9 +20,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
|
||||
### Management WebUI Access (e.g., `<hostname>.local`)
|
||||
|
||||
1. A client sends an HTTPS request to the management domain.
|
||||
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
|
||||
3. If authentication succeeds, the request is proxied to `127.0.0.1:9090` where the Flask WebUI is listening.
|
||||
4. The Flask application processes the request and communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
|
||||
2. nginx terminates TLS and proxies the request to `127.0.0.1:9090` where the Flask WebUI is listening. No nginx-level authentication is applied.
|
||||
3. Flask validates the JWT from the `Authorization: Bearer <token>` header, checks the token against the SQLite blacklist (`data/auth.db`), and verifies per-subsystem permissions before processing the request. Public endpoints (login, WebAuthn authenticate) are exempt from validation.
|
||||
4. The Flask application communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
|
||||
5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
|
||||
6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection.
|
||||
|
||||
@@ -33,12 +33,14 @@ Because Flask binds only to `127.0.0.1`, it is unreachable directly from any ext
|
||||
The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:
|
||||
|
||||
```
|
||||
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
|
||||
External Client ──→ nginx (SSL termination, NO auth) ──→ Flask WebUI (127.0.0.1:9090, JWT + permission check)
|
||||
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
|
||||
Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db)
|
||||
vacuum-walld ──→ daemon/handlers/auth.py ──→ lib/auth.py ──→ JWT operations
|
||||
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
|
||||
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
||||
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
|
||||
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
|
||||
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider
|
||||
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
|
||||
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
|
||||
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
|
||||
@@ -54,13 +56,68 @@ Vacuum Wall uses two distinct system users bridged by a shared group:
|
||||
|
||||
This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`.
|
||||
|
||||
**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
|
||||
**Dev mode variant**: When `scripts/install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
|
||||
|
||||
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
||||
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `scripts/install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
||||
|
||||
## JWT Token Model
|
||||
|
||||
Vacuum Wall uses JWT-based authentication with access/refresh token rotation. Tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API never reads cookies — authentication is header-only.
|
||||
|
||||
| Token | Lifetime | Storage | Purpose |
|
||||
|---|---|---|---|
|
||||
| Access | 15 min | sessionStorage / memory | API auth, permission checks |
|
||||
| Refresh | 7 days | sessionStorage | Token rotation, new access tokens |
|
||||
|
||||
JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`.
|
||||
|
||||
Each user has a unique signing secret stored in the `users.jwt_secret` database column (generated as a 32-byte base64url token via `secrets.token_urlsafe(32)`). This per-user secret model means tokens signed for one user cannot be validated as another user's tokens. Both Flask and daemon processes validate tokens by extracting `sub` from the unverified payload, looking up the user's secret, and verifying the signature with that secret. Expired and blacklisted tokens are rejected against the SQLite `token_blacklist` table (via `data/auth.db`).
|
||||
|
||||
Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. The blacklist is cleaned of expired entries on every refresh operation.
|
||||
|
||||
## Permission Model
|
||||
|
||||
Each user has per-subsystem permissions with two levels:
|
||||
|
||||
- **`"read"`** — `GET /api/<subsystem>/*` allowed; `POST`/`PATCH`/`DELETE` rejected with 403
|
||||
- **`"rw"`** — all HTTP methods allowed for the subsystem
|
||||
|
||||
Flask `before_request` middleware enforces permissions by extracting the subsystem name from the blueprint route prefix (e.g., `/api/firewall/` → `"firewall"`). The middleware checks `request.user.permissions[subsystem]`. If the permission level doesn't match the required level, a 403 response is returned.
|
||||
|
||||
The `auth` subsystem controls user management. User CRUD endpoints (`/api/auth/users/*`) require `auth: "rw"` ("admin required").
|
||||
|
||||
Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`.
|
||||
|
||||
## Database Layer
|
||||
|
||||
Vacuum Wall uses SQLite for authentication and user management data. Subsystem configuration remains as JSON in `config/*/`.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- `lib/db.py` — Query ID constants + abstract `Database` baseclass (no SQL strings)
|
||||
- `lib/db_sqlite.py` — `QUERY_MAP` (query_id → SQLite SQL) + concrete implementation
|
||||
- Subsystems call by **query ID only** — never write SQL
|
||||
|
||||
The abstract `Database` baseclass provides:
|
||||
- Connection caching via `self.conn` property (lazy initialization)
|
||||
- Prepared statement auto-cache (cached on first use, reused subsequently)
|
||||
- `query(query_id, params)` — returns row dicts
|
||||
- `run(query_id, params)` — returns rowcount
|
||||
- `run_one(query_id, params)` — returns last_insert_id
|
||||
- `in_transaction()` context manager — provides `BEGIN`/`COMMIT`/`ROLLBACK` with auto-commit suppressed inside
|
||||
|
||||
Environment variables (not config files) control database access:
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---|---|---|
|
||||
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
|
||||
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
|
||||
|
||||
Both Flask (`webui/server.py`) and daemon (`daemon/server.py`) call `get_db()` at startup. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite.
|
||||
|
||||
## Install-Time Templating
|
||||
|
||||
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
|
||||
System configuration files in `system/` are Jinja2 templates rendered by `scripts/install.sh` at install time:
|
||||
|
||||
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
|
||||
- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user.
|
||||
@@ -79,7 +136,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
|
||||
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
||||
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||
| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. |
|
||||
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
|
||||
| ACME | `config/acme/config.json` | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. Account registration (email, CA provider) is stored in the declarative config. |
|
||||
|
||||
#### Background Polling
|
||||
|
||||
@@ -91,19 +148,117 @@ The daemon runs background polling tasks for subsystems with external runtime st
|
||||
| wireguard | 10s | Peer connections/handshakes change frequently |
|
||||
| dnsmasq | 10s | Lease file + service status |
|
||||
| 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 and acme 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:
|
||||
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load
|
||||
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch
|
||||
- **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", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump
|
||||
- **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,...`).
|
||||
|
||||
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
|
||||
|
||||
On daemon startup, `lib/system_import.py` reconciles live system configurations
|
||||
with the declarative JSON configs. This ensures that configurations created
|
||||
by `scripts/install.sh` or edited manually in system files are imported into
|
||||
the JSON source of truth, preventing drift.
|
||||
|
||||
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
|
||||
import function:
|
||||
|
||||
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
|
||||
block between comment markers) → `config/dnsmasq/config.json`. Only writes
|
||||
if config doesn't exist or differs.
|
||||
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf` →
|
||||
`config/wireguard/config.json`. Skips if configs match.
|
||||
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
|
||||
(install-time files) → `config/network/config.json`. Only adds/updates
|
||||
interfaces; doesn't remove interfaces without a file (they may be pending apply).
|
||||
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf` →
|
||||
`config/nginx/config.json`. Only touches vacuum-wall-managed files
|
||||
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
|
||||
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones` →
|
||||
`config/firewall/config.json`. Only writes if no config file exists
|
||||
(firewalld state always takes precedence).
|
||||
|
||||
Import failures are silently logged as warnings — they never abort daemon startup.
|
||||
The returned list of updated subsystems is logged for debugging.
|
||||
|
||||
## Cross-Subsystem Sync Event Bus
|
||||
|
||||
When a subsystem's configuration changes, related subsystems are automatically
|
||||
updated to stay consistent. An in-process event bus (`lib/sync.py`) decouples
|
||||
subsystems — no handler calls into another handler's logic directly.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. A mutation handler saves its config (e.g., adding a DHCP range).
|
||||
2. The handler emits a `SyncEvent` on the event bus.
|
||||
3. Subscribers react by updating related subsystem configs:
|
||||
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
|
||||
for each interface serving a DHCP range. Back-propagates gateway (interface
|
||||
IP) into DHCP ranges so clients receive their default route.
|
||||
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
|
||||
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
|
||||
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
|
||||
entries when no active peers exist.
|
||||
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
|
||||
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
|
||||
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
|
||||
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
|
||||
ranges. Syncs firewall zone interface assignments — adding new interfaces
|
||||
and removing stale ones no longer in network config.
|
||||
4. The handler refreshes state for the originating subsystem plus all
|
||||
transitively affected subsystems.
|
||||
|
||||
### Guard Rails
|
||||
|
||||
- **Idempotency**: Each subscriber reads current state, computes desired state,
|
||||
writes the diff. Running twice is safe.
|
||||
- **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle.
|
||||
Re-entrant emits for the same key are silently dropped.
|
||||
- **Firewall-cmd separation**: Sync subscribers only write JSON config. They
|
||||
do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to
|
||||
push to firewalld.
|
||||
- **Error handling**: Subscriber exceptions are caught, logged as warnings,
|
||||
and do NOT abort the originating handler.
|
||||
|
||||
### Frontend Impact
|
||||
|
||||
Minimal. The sync happens transparently in the backend. The "pending changes"
|
||||
indicator on the firewall page will show pending when DHCP or WireGuard saves
|
||||
(since sync writes JSON but does not call firewall-cmd).
|
||||
|
||||
## System Config Import
|
||||
|
||||
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
|
||||
to reconcile any drift between system configuration files and the declarative
|
||||
JSON configs. This is invoked from `daemon/server.py` during initialization.
|
||||
|
||||
Each subsystem import function parses the corresponding live system config and
|
||||
updates the JSON config if they differ:
|
||||
|
||||
| Subsystem | Source | Condition |
|
||||
|---|---|---|
|
||||
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
|
||||
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
|
||||
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
|
||||
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
|
||||
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
|
||||
|
||||
All imports are **idempotent** and **non-destructive**: they only write when
|
||||
configs differ, skip on failure (logged as warnings), and never abort daemon
|
||||
startup. This ensures that manual edits to system files (e.g., during install
|
||||
or troubleshooting) are reconciled into the declarative JSON source of truth.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -113,6 +268,8 @@ Config files are persistent, user-editable JSON that defines the desired state f
|
||||
|
||||
```
|
||||
config/
|
||||
├── auth/
|
||||
│ └── config.json # JWT settings, WebAuthn RP configuration
|
||||
├── dnsmasq/
|
||||
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
├── firewall/
|
||||
@@ -131,8 +288,9 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
|
||||
|
||||
```
|
||||
data/
|
||||
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
|
||||
├── nginx/
|
||||
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
||||
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only)
|
||||
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
||||
├── dnsmasq/
|
||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
||||
@@ -145,7 +303,11 @@ data/
|
||||
├── networkd/ # Generated 50-<name>.network files
|
||||
```
|
||||
|
||||
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
||||
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
||||
|
||||
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
|
||||
|
||||
`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends).
|
||||
|
||||
## File System Layout
|
||||
|
||||
@@ -159,6 +321,11 @@ The following file system locations are used for integration with system service
|
||||
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
|
||||
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
||||
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
|
||||
| `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) |
|
||||
| `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) |
|
||||
| `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) |
|
||||
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
|
||||
|
||||
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
||||
|
||||
@@ -169,25 +336,31 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
|
||||
### Request Flow (Frontend)
|
||||
|
||||
```
|
||||
Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
|
||||
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
|
||||
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
|
||||
Client requests / ──→ nginx ──→ Flask (serves index.html)
|
||||
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
|
||||
Authenticated ──→ mounts #sidebar and #main render roots
|
||||
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
||||
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
||||
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
|
||||
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
|
||||
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
|
||||
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
|
||||
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.
|
||||
|
||||
### Component Model
|
||||
|
||||
Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
|
||||
|
||||
### No Build Step
|
||||
|
||||
All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. 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
|
||||
|
||||
|
||||
+157
-28
@@ -153,7 +153,7 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
|
||||
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
|
||||
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
|
||||
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
|
||||
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htppasswd }`). Applies to all paths unless overridden at the path level. |
|
||||
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path level. |
|
||||
|
||||
### Path Entries
|
||||
|
||||
@@ -166,7 +166,7 @@ Each entry under `paths` defines a location block and its proxy backend.
|
||||
| `backend.port` | integer | Yes | Port the backend service is listening on. |
|
||||
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
|
||||
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
|
||||
| `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. |
|
||||
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain-level auth. `null` disables auth for this path. |
|
||||
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
|
||||
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
|
||||
|
||||
@@ -264,54 +264,99 @@ acme.sh stores its state under `data/acme/` (the ACME home directory). Key files
|
||||
|
||||
The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered.
|
||||
|
||||
## ACME Configuration
|
||||
## Auth Configuration
|
||||
|
||||
**File**: `config/acme/config.json`
|
||||
**File**: `config/auth/config.json`
|
||||
|
||||
This file stores the ACME account settings used by vacuum-wall for automatic certificate issuance via acme.sh. Account registration is performed exclusively through the WebUI — the Certificates page provides a "Register Account" modal where the user enters an email and selects a CA provider.
|
||||
This file defines JWT settings and WebAuthn Relying Party configuration for the authentication system.
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "",
|
||||
"ca": ""
|
||||
"jwt": {
|
||||
"access_token_ttl": 900,
|
||||
"refresh_token_ttl": 604800,
|
||||
"algorithm": "HS256"
|
||||
},
|
||||
"webauthn": {
|
||||
"rp_name": "Vacuum Wall",
|
||||
"rp_id": "<management-domain>",
|
||||
"origin": "https://<management-domain>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ACME Config Fields
|
||||
### JWT Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `email` | string | No (WebUI) | Contact email for the ACME account, used for certificate expiry notifications and recovery. Populated when the user registers an account via the WebUI. Default: `""`. |
|
||||
| `ca` | string | No (defaults to `letsencrypt`) | ACME CA provider. One of: `"letsencrypt"`, `"zerossl"`. Populated during account registration. Default: `""` (acme.sh defaults to Let's Encrypt if omitted). |
|
||||
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). |
|
||||
| `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
|
||||
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. |
|
||||
|
||||
### Account Registration Flow
|
||||
**Note:** JWT signing secrets are per-user, not shared. Each user's secret is auto-generated as a 32-byte base64url token (`secrets.token_urlsafe(32)`) and stored in the `users.jwt_secret` database column. Secrets are rotated on password change to invalidate all prior sessions.
|
||||
|
||||
1. User navigates to the Certificates page in the WebUI.
|
||||
2. Clicks "Register Account" and provides an email address, optionally selecting a CA provider.
|
||||
3. The application calls `acme.sh --register-account -m <email> --server <ca>` as a privileged operation via the daemon.
|
||||
4. On success, `config/acme/config.json` is updated with the email and CA. acme.sh writes its own state to `data/acme/.account.conf`.
|
||||
5. The `account_registered` check in the certificate validation pipeline transitions from blocking to passing, enabling certificate issuance.
|
||||
### WebAuthn Fields
|
||||
|
||||
The `account_registered` check is **blocking** — certificate issuance and validation will fail until an ACME account is registered. The `email_configured` check is **non-blocking** — it produces a warning if the email is empty but does not prevent issuance.
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. |
|
||||
| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. |
|
||||
| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://<rp_id>`. |
|
||||
|
||||
### Account Management API
|
||||
## Database Schema
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`.
|
||||
|
||||
### users
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `/api/certs/account` | `GET` | Returns account status: registered, email, CA provider. |
|
||||
| `/api/certs/account/register` | `POST` | Registers a new ACME account. Body: `{ "email": "..." }`. Optional: `{ "server": "letsencrypt" }`. |
|
||||
| `/api/certs/account` | `DELETE` | Deactivates the ACME account via `acme.sh --deactivate-account`. Clears email and CA from config. |
|
||||
| `/api/certs/email` | `POST` | Updates the contact email on an existing account. Body: `{ "email": "..." }`. |
|
||||
| `id` | INTEGER | Auto-increment primary key |
|
||||
| `username` | TEXT | Unique username |
|
||||
| `password_hash` | TEXT | Argon2id password hash |
|
||||
| `jwt_secret` | TEXT | Per-user JWT signing secret (32-byte base64url) |
|
||||
| `created_at` | INTEGER | Unix timestamp (auto-set) |
|
||||
|
||||
### ACME Home Directory
|
||||
### permissions
|
||||
|
||||
acme.sh stores its operational state under `data/acme/`. The application reads `data/acme/.account.conf` to determine whether an account is registered. Required keys: `ACME_LEEMAIL` and `ACME_MCA`. Their absence or the file's absence means the account is unregistered.
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER | Auto-increment primary key |
|
||||
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
|
||||
| `subsystem` | TEXT | Subsystem name (e.g., `"firewall"`, `"dhcp"`, `"auth"`) |
|
||||
| `level` | TEXT | Permission level: `"read"` or `"rw"` |
|
||||
|
||||
UNIQUE constraint on `(username, subsystem)`.
|
||||
|
||||
### token_blacklist
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `jti` | TEXT | Primary key — JWT unique identifier |
|
||||
| `token_type` | TEXT | `"access"` or `"refresh"` |
|
||||
| `expires` | INTEGER | Unix timestamp of token expiry |
|
||||
|
||||
Used to invalidate tokens on logout and password change. Expired entries are cleaned on every refresh operation.
|
||||
|
||||
### webauthn_creds
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER | Auto-increment primary key |
|
||||
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
|
||||
| `credential_id` | TEXT | Base64url-encoded credential ID |
|
||||
| `public_key` | TEXT | Base64url-encoded public key |
|
||||
| `sign_count` | INTEGER | Signature counter (replay prevention) |
|
||||
| `name` | TEXT | User-assigned display name |
|
||||
| `transports` | TEXT | JSON array of transport types |
|
||||
|
||||
UNIQUE constraint on `(username, credential_id)`.
|
||||
|
||||
## WireGuard Configuration
|
||||
|
||||
**File**: `config/wireguard/config.json`
|
||||
|
||||
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair via `wg genkey` / `wg pubkey`.
|
||||
This file defines the WireGuard server interface, access classes, and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair and pre-seeds default access classes.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -321,9 +366,31 @@ This file defines the WireGuard server interface and all connected peers. The ap
|
||||
"private_key": "<generated>",
|
||||
"public_key": "<generated>",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"server_endpoint": "vpn.example.com:51820",
|
||||
"description": "Main WireGuard server",
|
||||
"post_up": null,
|
||||
"post_down": null
|
||||
},
|
||||
"access_classes": {
|
||||
"full": {
|
||||
"name": "Full LAN Access",
|
||||
"description": "Peers get full access to internal networks",
|
||||
"subnet": "10.137.0.0/24",
|
||||
"listen_port": 51820,
|
||||
"lan_access": true,
|
||||
"private_key": "<generated>",
|
||||
"public_key": "<generated>"
|
||||
},
|
||||
"internet": {
|
||||
"name": "Internet Only",
|
||||
"description": "Peers can only reach the internet",
|
||||
"subnet": "10.137.1.0/24",
|
||||
"listen_port": 51821,
|
||||
"lan_access": false,
|
||||
"private_key": "<generated>",
|
||||
"public_key": "<generated>"
|
||||
}
|
||||
},
|
||||
"peers": {
|
||||
"alice": {
|
||||
"public_key": "<auto-generated>",
|
||||
@@ -331,7 +398,9 @@ This file defines the WireGuard server interface and all connected peers. The ap
|
||||
"endpoint": "203.0.113.1:51820",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
"persistent_keepalive": 25,
|
||||
"preshared_key": null
|
||||
"preshared_key": null,
|
||||
"description": "Alice's office laptop",
|
||||
"access_class": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,9 +415,39 @@ This file defines the WireGuard server interface and all connected peers. The ap
|
||||
| `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. |
|
||||
| `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. |
|
||||
| `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["10.137.0.1/24"]`. |
|
||||
| `server_endpoint` | string | No | External hostname:port for client connection. Used in generated client configs. Default: `""`. |
|
||||
| `description` | string | No | Free-text description of the WireGuard server. Default: `""`. |
|
||||
| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. Default: `null`. |
|
||||
| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. Default: `null`. |
|
||||
|
||||
### Access Classes
|
||||
|
||||
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes/<key>/up`, `POST /api/wireguard/classes/<key>/down`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `name` | string | Yes | Human-readable display name for the class. |
|
||||
| `description` | string | No | Optional description of what access level this class provides. Default: `""`. |
|
||||
| `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
|
||||
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. |
|
||||
| `lan_access` | boolean | No | When ``true``, the sync subscriber adds inter-zone accept rules for internal subnets, allowing peers to reach the LAN. When ``false``, peers can only reach the internet via masquerade. Default: ``false``. |
|
||||
| `private_key` | string | Yes (auto) | Base64-encoded private key for the class's WireGuard interface. Auto-generated via ``POST /api/wireguard/classes/keys/<key>``. |
|
||||
| `public_key` | string | Yes (auto) | Corresponding public key. Auto-generated with ``private_key``. |
|
||||
|
||||
### Multi-Interface Behavior
|
||||
|
||||
When peers are assigned to an access class, the daemon:
|
||||
|
||||
1. Renders a separate ``wg-<key>.conf`` for each class that has assigned peers.
|
||||
2. Each class interface gets its own private/public key pair.
|
||||
3. The sync subscriber creates a ``vpn-<key>`` firewall zone per class with masquerade enabled.
|
||||
4. Classes with ``lan_access=true`` get additional inter-zone rules for internal subnets.
|
||||
5. ``apply`` brings up all class interfaces independently. Per-class ``up``/``down`` endpoints control individual tunnels.
|
||||
|
||||
### Legacy Single-Interface Mode
|
||||
|
||||
When no peers are assigned to any access class, the system falls back to the legacy single-interface mode where all peers share ``wg0``.
|
||||
|
||||
### Peer Fields
|
||||
|
||||
Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses.
|
||||
@@ -361,6 +460,8 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
|
||||
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
|
||||
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. |
|
||||
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
|
||||
| `description` | string | No | Optional description for the peer. Default: `""`. |
|
||||
| `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. |
|
||||
|
||||
### Client Configuration Generation
|
||||
|
||||
@@ -433,6 +534,12 @@ 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`.
|
||||
|
||||
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.
|
||||
|
||||
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
|
||||
|
||||
## Networkd (IP Configuration)
|
||||
|
||||
**File**: `config/network/config.json`
|
||||
@@ -521,4 +628,26 @@ When `POST /api/network/apply` is called, the handler automatically collects pub
|
||||
|
||||
### Generated Files
|
||||
|
||||
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
|
||||
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
|
||||
|
||||
## Cross-Subsystem Dependencies
|
||||
|
||||
Some subsystems depend on each other. When you modify one, related subsystems
|
||||
are updated automatically through the event bus.
|
||||
|
||||
| Trigger Subsystem | Affected Subsystem | What Happens |
|
||||
|---|---|---|
|
||||
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
|
||||
| wireguard (peer add/remove) | firewall | Per-class `vpn-<key>` zones are created with `wg-<key>` interface, masquerade, UDP port rule, and inter-zone accept rules (only when ``lan_access=true``). Falls back to single `vpn` zone in legacy mode. Cleanup removes stale rules when classes have no peers. |
|
||||
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
|
||||
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
|
||||
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
|
||||
|
||||
Note: The firewall "Apply" button is still needed to push config changes to
|
||||
firewalld. Sync only updates the declarative JSON.
|
||||
|
||||
Additionally, on daemon startup, `lib/system_import.py` reconciles live system
|
||||
configs (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative
|
||||
JSON. This prevents drift when configs were created by the install script or
|
||||
edited manually in system files. Reconciliation only writes when the existing
|
||||
JSON differs or is missing — no data is lost on re-run.
|
||||
+67
-17
@@ -25,13 +25,13 @@ Download the Vacuum Wall repository onto the target machine, then run the instal
|
||||
# Production: all env vars
|
||||
MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
./install.sh --user vacuum-wall
|
||||
./scripts/install.sh --user vacuum-wall
|
||||
|
||||
# Dev mode: CLI flags, auto-detects repo owner
|
||||
./install.sh --dev --mgmt-pass strongpassword
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword
|
||||
|
||||
# mDNS (LAN-only, no DNS record needed)
|
||||
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
|
||||
./scripts/install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
|
||||
```
|
||||
|
||||
### Options
|
||||
@@ -42,7 +42,7 @@ All settings that can be passed as an environment variable also have a CLI flag
|
||||
|---|---|---|---|
|
||||
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
|
||||
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
|
||||
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
|
||||
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for the initial admin user (default: `admin`). Creates the admin user in the SQLite database with full `rw` permissions on all subsystems. |
|
||||
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
|
||||
| `--user, -u` | `USER_NAME` | Yes* | WebUI service user (created if it does not exist). Required for non-dev mode. In `--dev` mode, auto-detected from repo owner. |
|
||||
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
|
||||
@@ -51,7 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag
|
||||
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
|
||||
| `--force-venv` | — | No | Force recreation of the Python virtual environment. |
|
||||
|
||||
Run `./install.sh --help` for full usage.
|
||||
Run `./scripts/install.sh --help` for full usage.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +71,7 @@ In dev mode, the ownership model preserves the developer's ability to work with
|
||||
### Running the Installer in Dev Mode
|
||||
|
||||
```bash
|
||||
./install.sh --dev --mgmt-pass strongpassword
|
||||
./scripts/install.sh --dev --mgmt-pass strongpassword
|
||||
```
|
||||
|
||||
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
|
||||
@@ -88,7 +88,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o
|
||||
|
||||
```bash
|
||||
# Docker volume mount example
|
||||
./install.sh --path /app/vacuum-wall --user ww-app \
|
||||
./scripts/install.sh --path /app/vacuum-wall --user ww-app \
|
||||
--mgmt-domain proxy.internal --mgmt-pass strongpassword
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
|
||||
|
||||
---
|
||||
|
||||
## What install.sh Does
|
||||
## What scripts/install.sh Does
|
||||
|
||||
The installer performs the following steps automatically:
|
||||
|
||||
@@ -114,10 +114,10 @@ The installer performs the following steps automatically:
|
||||
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
|
||||
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
|
||||
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
|
||||
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
|
||||
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
|
||||
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
|
||||
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
|
||||
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
|
||||
- **Admin user**: Creates the admin user with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`). The user gets `rw` permissions on all subsystems. On re-run, updates the admin password if already present.
|
||||
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
|
||||
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
|
||||
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
|
||||
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
|
||||
- `vacuum-wall.service` — the Flask WebUI backend.
|
||||
@@ -131,15 +131,15 @@ The installer performs the following steps automatically:
|
||||
|
||||
### Idempotent Re-Runs
|
||||
|
||||
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
|
||||
`scripts/install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
|
||||
|
||||
- Skips the Python venv (use `--force-venv` to rebuild)
|
||||
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
|
||||
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
|
||||
- Preserves existing `config.json` files (skips initial write if file exists)
|
||||
- Safely updates `htpasswd` (uses update mode instead of create mode)
|
||||
- Updates admin user password if changed
|
||||
|
||||
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation.
|
||||
This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
|
||||
|
||||
---
|
||||
|
||||
@@ -165,6 +165,21 @@ https://wall.example.com
|
||||
|
||||
Log in with the username and password you provided during installation.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
|
||||
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
|
||||
|
||||
### Post-Deploy Verification
|
||||
|
||||
1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration
|
||||
2. Confirm `data/auth.db` exists with admin user present
|
||||
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. 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
|
||||
|
||||
### Certificate Note
|
||||
|
||||
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real ACME certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
|
||||
@@ -322,11 +337,46 @@ Verify that:
|
||||
- A DHCP range is configured for the correct interface. Check dnsmasq config at `data/dnsmasq/`.
|
||||
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
|
||||
|
||||
### Locked Out of WebUI
|
||||
|
||||
If you lose access to the admin account, you can reset the password directly via SQLite:
|
||||
|
||||
```bash
|
||||
# Stop the services
|
||||
sudo systemctl stop vacuum-wall vacuum-walld
|
||||
|
||||
# Reset password (replace 'newpassword' with desired password)
|
||||
sqlite3 data/auth.db "UPDATE users SET password_hash='NEW_HASH_HERE' WHERE username='admin';"
|
||||
```
|
||||
|
||||
The password hash must be an Argon2id hash. You can generate one:
|
||||
|
||||
```bash
|
||||
python3 -c "from lib.password import hash_password; print(hash_password('newpassword'))"
|
||||
```
|
||||
|
||||
Alternatively, use the SQLite prompt to directly inspect and modify user data:
|
||||
|
||||
```bash
|
||||
sqlite3 data/auth.db ".tables"
|
||||
sqlite3 data/auth.db "SELECT username FROM users;"
|
||||
sqlite3 data/auth.db "SELECT * FROM permissions WHERE username='admin';"
|
||||
```
|
||||
|
||||
### Database Corruption
|
||||
|
||||
If the SQLite database becomes corrupted:
|
||||
|
||||
1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld`
|
||||
2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"`
|
||||
3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db`
|
||||
4. Start services: `sudo systemctl start vacuum-walld vacuum-wall`
|
||||
|
||||
### WebUI Not Accessible
|
||||
|
||||
1. Verify nginx is running: `systemctl status nginx`.
|
||||
2. Test nginx configuration: `nginx -t`.
|
||||
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
|
||||
3. Check the management proxy domain configuration via the WebUI Proxy tab, or by inspecting `config/nginx/config.json`.
|
||||
4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
|
||||
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
|
||||
|
||||
@@ -338,7 +388,7 @@ Verify that:
|
||||
|---|---|---|
|
||||
| Daemon (privileged) | `vacuum-walld.service` | `daemon/` |
|
||||
| WebUI backend | `vacuum-wall.service` | `webui/` |
|
||||
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
|
||||
| Reverse proxy | `nginx` | `config/nginx/config.json` (via daemon) |
|
||||
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
|
||||
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
|
||||
| VPN | wireguard-tools | `config/wireguard/` |
|
||||
|
||||
+285
-98
@@ -11,8 +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 |
|
||||
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
||||
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
||||
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
|
||||
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
|
||||
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
|
||||
@@ -25,29 +26,31 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp
|
||||
```
|
||||
index.html — static shell with #sidebar, #main, #modal-root
|
||||
└── app.js — SPA bootstrap
|
||||
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
||||
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
||||
├── modelFetch('firewall') / modelFetch('dnsmasq') / ...
|
||||
├── render(sidebarEl, Sidebar) — sidebar render root
|
||||
├── render(mainEl, MainContent) — main content render root
|
||||
└── connect() — WebSocket lifecycle
|
||||
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
||||
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
||||
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
|
||||
├── render(sidebarEl, Sidebar) — sidebar render root
|
||||
├── render(mainEl, MainContent) — main content render root
|
||||
└── 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 server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
|
||||
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.
|
||||
|
||||
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
|
||||
→ reactivity proxy triggers render
|
||||
→ page.render(state) reads model data
|
||||
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
|
||||
(snapshot on connect, versions/tick deltas per subsystem)
|
||||
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()`.
|
||||
|
||||
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
|
||||
|
||||
@@ -55,23 +58,54 @@ The app starts from `webui/static/app.js`:
|
||||
|
||||
```javascript
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
|
||||
|
||||
// 1. Register subsystem models
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/firewall/config');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data;
|
||||
},
|
||||
});
|
||||
// 1. Register subsystem models. All state-backed models share the same
|
||||
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
|
||||
// primary data path is the WS snapshot + deltas (modelSet).
|
||||
const STATE_MODELS = [
|
||||
{ name: 'firewall', subsystem: 'firewall' },
|
||||
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||
{ 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 ...
|
||||
|
||||
// 2. Initial fetch for all models
|
||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
|
||||
modelFetch(name);
|
||||
// 2. Initial data. State-backed models receive their first data via the WS
|
||||
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
|
||||
// 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
|
||||
@@ -129,31 +163,39 @@ Manually schedule a re-render. Only one microtask is queued regardless of how ma
|
||||
|
||||
## 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)`
|
||||
|
||||
Register a subsystem model at app bootstrap.
|
||||
|
||||
```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', {
|
||||
subsystem: 'firewall', // WS topic to listen for ('*' = all)
|
||||
fetch: async (signal) => { // async fetch function
|
||||
const r = await apiFetch('/api/firewall/config', { signal });
|
||||
subsystem: 'firewall', // daemon subsystem ('*' = all)
|
||||
defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data
|
||||
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);
|
||||
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)
|
||||
// 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', {
|
||||
subsystem: '*',
|
||||
fetch: async (signal, tab) => {
|
||||
const url = LOG_TABS[tab || 'journal'];
|
||||
const r = await apiFetch(url, { signal });
|
||||
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' };
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -161,13 +203,15 @@ modelRegister('logs', {
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `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.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.onFailure(name, error)` | Optional lifecycle hook called after `model.error` is assigned. Only reachable on a real throw from `fetch` (e.g., network error). Same fire-and-forget error isolation as `onSuccess`. |
|
||||
|
||||
### `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
|
||||
// In page init
|
||||
@@ -192,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.
|
||||
|
||||
```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');
|
||||
|
||||
// Post-mutation refresh
|
||||
const r = await apiFetch('/api/firewall/zones', { method: 'POST', body });
|
||||
if (r.ok) modelFetch('firewall');
|
||||
|
||||
// Parameterized fetch (e.g., tab-aware logs)
|
||||
// Non-state models fetch directly (not backed by the daemon state store)
|
||||
modelFetch('backends');
|
||||
modelFetch('logs', 'journal');
|
||||
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:**
|
||||
- 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.
|
||||
@@ -212,11 +262,34 @@ modelFetch('logs', 'nginx-access');
|
||||
- On failure, stores error in `model.error`.
|
||||
- Flags cleared in `finally` block.
|
||||
- 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: param`.
|
||||
- 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)`
|
||||
|
||||
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? |
|
||||
|---|---|---|
|
||||
@@ -242,6 +315,77 @@ render(state) {
|
||||
|
||||
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
|
||||
|
||||
## Auth model
|
||||
|
||||
`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted
|
||||
to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
|
||||
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL − 60s timer),
|
||||
session validation, login/logout transitions, and WS reconnection coordination.
|
||||
|
||||
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
|
||||
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
|
||||
`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object).
|
||||
|
||||
**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`,
|
||||
`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and
|
||||
`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object
|
||||
`{ action, payload? }` — `check`, `refresh`, `login`, `logout` (param-less calls are treated as
|
||||
`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**:
|
||||
storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an
|
||||
`auth:logout` window event.
|
||||
|
||||
**Lifecycle:**
|
||||
|
||||
```
|
||||
app bootstrap → modelFetch('auth', { action: 'check' })
|
||||
→ 200: stores verified user/permissions + stored tokens → schedules refresh
|
||||
→ 401 with a stored refresh token (stale access token after page
|
||||
reload/restore): exactly one refresh attempt, then the same
|
||||
success or terminal path
|
||||
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
|
||||
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
|
||||
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
|
||||
(no auth:login dispatch)
|
||||
timer fires (TTL − 60s) → refreshAuth() → same path
|
||||
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
|
||||
login → modelFetch('auth', { action: 'login', payload: data })
|
||||
→ onSuccess stores + schedules + fires auth:login (login action only)
|
||||
→ app.js listener (deferred to macrotask) → fetchInitialData() + connect()
|
||||
logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects
|
||||
any terminal no-token result → onSuccess dispatches auth:logout
|
||||
→ app.js listener → disconnect() closes the WS socket
|
||||
```
|
||||
|
||||
**Invariants:**
|
||||
|
||||
- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it
|
||||
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
|
||||
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
|
||||
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
|
||||
(exactly one refresh when the stored access token is rejected at page load while a
|
||||
refresh token is still present).
|
||||
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
|
||||
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
|
||||
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
|
||||
- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the
|
||||
model's internal helpers only.
|
||||
- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in
|
||||
`onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the
|
||||
app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct
|
||||
calls. `auth:logout` fires on every terminal (no-token) transition; its only listener
|
||||
(app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js`
|
||||
(would cycle) — the event inverts the dependency.
|
||||
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any
|
||||
post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both**
|
||||
`Authorization` and `X-Session-Id` from `getAuthData()`.
|
||||
- **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object:
|
||||
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
|
||||
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
|
||||
secondary guard for the timer path.
|
||||
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
|
||||
without the terminal `auth:logout` → `disconnect()` path the previous user's socket would
|
||||
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
|
||||
|
||||
## Virtual DOM
|
||||
|
||||
### `h(tag, props, ...children)`
|
||||
@@ -287,7 +431,7 @@ html`<div class="card">
|
||||
|
||||
```javascript
|
||||
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:
|
||||
@@ -413,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.
|
||||
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.
|
||||
|
||||
### `hComp(renderer, key)`
|
||||
@@ -473,41 +617,44 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
|
||||
|
||||
### `connect()`
|
||||
|
||||
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
|
||||
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
|
||||
|
||||
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
|
||||
|
||||
### `disconnect()`
|
||||
|
||||
Close the WS socket (terminal auth transition — logout, failed session check, failed refresh,
|
||||
or the 401 session-death path). The daemon validates the WS token only at handshake, so the
|
||||
socket must be closed explicitly on a terminal transition; `app.js` listens for the
|
||||
`auth:logout` event and calls `disconnect()`.
|
||||
|
||||
### WS Message Types
|
||||
|
||||
| Type | Fields | Effect |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
|
||||
|
||||
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:
|
||||
1. `refreshByTopic(topic)` iterates registered models.
|
||||
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.
|
||||
System name → model name mapping is handled internally (`networkd` → `network`); unknown
|
||||
subsystem names fall through to the raw name.
|
||||
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
const unsub = onMessage(['firewall'], (msg) => {
|
||||
// handle raw message
|
||||
});
|
||||
// Later: unsub();
|
||||
```
|
||||
|
||||
Handler receives the parsed WS message object.
|
||||
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
|
||||
auto-refresh path for state-backed models.
|
||||
|
||||
## API
|
||||
|
||||
@@ -522,7 +669,8 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
|
||||
|
||||
- Automatically sets `Accept: application/json`.
|
||||
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
|
||||
- On HTTP 401, reloads the page (session expired).
|
||||
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
|
||||
- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
|
||||
- On non-2xx, returns `{ ok: false, error: "message", status }`.
|
||||
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
|
||||
- Passes `credentials: 'same-origin'` by default.
|
||||
@@ -547,7 +695,7 @@ function MainContent() {
|
||||
|
||||
### `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
|
||||
apiSubmit({
|
||||
@@ -556,7 +704,6 @@ apiSubmit({
|
||||
body: () => ({ name: $val('zone-name') }),
|
||||
validate: (b) => !b.name ? 'Name required' : null,
|
||||
successMsg: 'Zone created',
|
||||
refresh: 'firewall', // model name(s) to refresh after success
|
||||
closeModal: () => closeModal(), // optional, called after success toast
|
||||
}),
|
||||
```
|
||||
@@ -572,10 +719,13 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
|
||||
| `body` | `() => body` function, or `undefined` for no body |
|
||||
| `validate` | `(body) => string | null` — validation function |
|
||||
| `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()`) |
|
||||
| `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)`
|
||||
|
||||
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||||
@@ -639,7 +789,7 @@ poll({
|
||||
onErrorKey: (d) => d.status === 'failed',
|
||||
onComplete: (d) => {
|
||||
toast('Certificate issued', 'success');
|
||||
modelFetch('acme');
|
||||
// No modelFetch — the WS delta updates the acme model (state-backed).
|
||||
},
|
||||
onError: (d) => {
|
||||
toast('Issuance failed', 'error');
|
||||
@@ -738,7 +888,7 @@ Flex button container with 8px gap. Accepts VNode children directly.
|
||||
```javascript
|
||||
ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
||||
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
|
||||
ActionButton({ url: '/api/apply', label: 'Apply' }),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -803,15 +953,16 @@ Card container with optional header.
|
||||
|
||||
#### `ConfirmDelete(props)`
|
||||
|
||||
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh.
|
||||
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
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/zones/myzone',
|
||||
message: 'Delete zone myzone?',
|
||||
success: 'Zone deleted',
|
||||
refresh: 'firewall',
|
||||
label: 'Delete',
|
||||
deleteKey: 'myzone',
|
||||
onComplete: () => { /* optional, runs after successful delete */ },
|
||||
})
|
||||
```
|
||||
|
||||
@@ -822,13 +973,15 @@ ConfirmDelete({
|
||||
| `url` | API DELETE URL |
|
||||
| `message` | Confirmation prompt text |
|
||||
| `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'`) |
|
||||
| `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; 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)`
|
||||
|
||||
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||
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
|
||||
ActionButton({
|
||||
@@ -838,7 +991,7 @@ ActionButton({
|
||||
label: 'Apply',
|
||||
successMsg: 'Applied',
|
||||
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
|
||||
disabled: false,
|
||||
})
|
||||
@@ -850,7 +1003,6 @@ ActionButton({
|
||||
labelOn: 'Disable',
|
||||
labelOff: 'Enable',
|
||||
condition: z.masquerade,
|
||||
refresh: 'firewall',
|
||||
})
|
||||
```
|
||||
|
||||
@@ -866,13 +1018,14 @@ ActionButton({
|
||||
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
||||
| `successMsg` | Success toast message |
|
||||
| `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'`) |
|
||||
| `disabled` | Disabled state |
|
||||
|
||||
#### `ActionCell(props)`
|
||||
|
||||
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
|
||||
Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
|
||||
|
||||
```javascript
|
||||
ActionCell({
|
||||
@@ -881,8 +1034,8 @@ ActionCell({
|
||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||
removeSuccess: 'Domain removed',
|
||||
removeRefresh: 'proxy',
|
||||
removeLabel: 'Delete',
|
||||
deleteKey: d.domain,
|
||||
})
|
||||
```
|
||||
|
||||
@@ -895,10 +1048,13 @@ ActionCell({
|
||||
| `removeUrl` | API DELETE URL |
|
||||
| `removeMessage` | Confirmation prompt text |
|
||||
| `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'`) |
|
||||
| `removeBody` | Optional JSON body to send with DELETE |
|
||||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
||||
| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). |
|
||||
| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) |
|
||||
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
|
||||
|
||||
#### `certStatusBadge(props)`
|
||||
|
||||
@@ -1010,13 +1166,45 @@ Table({
|
||||
url: '/api/item/' + enc(i.id),
|
||||
message: 'Delete ' + esc(i.name) + '?',
|
||||
success: 'Item removed',
|
||||
refresh: 'firewall',
|
||||
})),
|
||||
)),
|
||||
emptyText: 'No items',
|
||||
})
|
||||
```
|
||||
|
||||
### Apply / Cancel
|
||||
|
||||
`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a
|
||||
shared expandable-subsystems modal. Both fetch `/api/status/pending` to
|
||||
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
|
||||
dnsmasq, nginx, wireguard, networkd).
|
||||
|
||||
#### `ApplyConfirm(props)`
|
||||
|
||||
Button that opens the confirmation modal listing pending subsystems, then
|
||||
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a
|
||||
disabled "synced" button that toasts on click.
|
||||
|
||||
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`,
|
||||
`successMsg`, `refresh` (legacy, ignored).
|
||||
|
||||
#### `CancelConfirm(props)`
|
||||
|
||||
Button that opens the confirmation modal listing the subsystems that
|
||||
would be reverted ("Restores the listed subsystems to their last applied
|
||||
configuration, discarding changes saved since the last apply"), then
|
||||
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem
|
||||
details when the response has a non-empty `skipped` map; errors from the
|
||||
response are toasted separately. State-store models update from the
|
||||
daemon's WS delta — no explicit `modelFetch`.
|
||||
|
||||
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
|
||||
(default `'btn btn-danger'`).
|
||||
|
||||
```javascript
|
||||
CancelConfirm({ cls: 'btn btn-sm btn-danger' })
|
||||
```
|
||||
|
||||
### Modal
|
||||
|
||||
#### `openModal(renderFn)`
|
||||
@@ -1079,7 +1267,6 @@ const addZone = QuickModal({
|
||||
validate: (b) => !b.name ? 'Name required' : null,
|
||||
successMsg: 'Zone created', // or (data) => string
|
||||
},
|
||||
refresh: 'firewall', // model name(s) to refresh after success
|
||||
});
|
||||
|
||||
// Usage in render:
|
||||
@@ -1095,9 +1282,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
||||
| `submit.url` | API URL or `(data) => string` |
|
||||
| `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.validate` | `(body) => string | null`, validation function |
|
||||
| `submit.validate` | `(body) => string \| null`, validation function |
|
||||
| `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 |
|
||||
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
||||
|
||||
@@ -1113,7 +1300,6 @@ const editIface = MultiSelectModal({
|
||||
selected: zone.interfaces,
|
||||
fieldKey: 'interfaces',
|
||||
successMsg: 'Interfaces updated',
|
||||
refresh: 'firewall',
|
||||
});
|
||||
|
||||
// Usage:
|
||||
@@ -1130,7 +1316,7 @@ h('button', { 'on:click': editIface }, 'Edit')
|
||||
| `selected` | Currently selected values (`string[]`) |
|
||||
| `fieldKey` | JSON key for the submitted field |
|
||||
| `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
|
||||
|
||||
@@ -1149,19 +1335,20 @@ Render the toast notification container. Include in the main render root. See AP
|
||||
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
|
||||
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
|
||||
|
||||
## Versioned Imports
|
||||
## Static Asset Caching
|
||||
|
||||
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
|
||||
The server handles caching headers for static assets. Browser cache invalidation is managed
|
||||
through server-side cache-control headers rather than query string version pins.
|
||||
|
||||
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
+23
-12
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
|
||||
|
||||
## 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
|
||||
|
||||
@@ -43,22 +43,24 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
|
||||
|
||||
## Quick Start
|
||||
|
||||
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
|
||||
To install Vacuum Wall on a Debian 13 system, run `scripts/install.sh` as root with required settings (CLI flags or environment variables):
|
||||
|
||||
```bash
|
||||
# Production
|
||||
./install.sh --mgmt-pass yourpassword
|
||||
./scripts/install.sh --mgmt-pass yourpassword
|
||||
|
||||
# Development (auto-detects your user)
|
||||
./install.sh --dev --mgmt-pass yourpassword
|
||||
./scripts/install.sh --dev --mgmt-pass yourpassword
|
||||
```
|
||||
|
||||
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options.
|
||||
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `scripts/install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./scripts/install.sh --help` for all options.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
│ └── update-vendor.sh # Download vendored libraries (acme.sh, htm)
|
||||
├── pyproject.toml # Project metadata + dependencies
|
||||
├── .venv/ # Python virtual environment
|
||||
├── config/ # Declarative JSON configuration (source of truth)
|
||||
@@ -66,7 +68,8 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── network/ # systemd-networkd per-interface config
|
||||
│ ├── firewall/ # Firewall zone & rule config
|
||||
│ ├── nginx/ # Proxy domain & SSL config
|
||||
│ └── wireguard/ # VPN interface & peer config
|
||||
│ ├── wireguard/ # VPN interface & peer config
|
||||
│ └── acme/ # ACME account settings (email, CA provider)
|
||||
├── data/ # Runtime artifacts & generated files
|
||||
│ ├── nginx/sites-enabled/ # Generated server blocks
|
||||
│ ├── dnsmasq/fragments/ # User config fragments
|
||||
@@ -84,21 +87,25 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── systemd/ # Service and timer unit files
|
||||
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
|
||||
│ │ └── vacuum-wall-acme.timer # Renewal schedule
|
||||
│ │ ├── vacuum-wall-acme.timer # Renewal schedule
|
||||
│ │ └── vacuum-walld.service # Privileged daemon (rendered at install)
|
||||
│ ├── sudoers.d/ # Sudo whitelist (rendered at install)
|
||||
│ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim)
|
||||
│ ├── nginx/ # Nginx config templates (rendered at runtime)
|
||||
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
|
||||
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
|
||||
├── lib/ # Subsystem abstraction layer
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip)
|
||||
│ ├── logging.py # Logging setup
|
||||
│ ├── firewall.py # firewalld bindings
|
||||
│ ├── network.py # systemd-networkd rendering & parsing
|
||||
│ ├── dnsmasq.py # DHCP/DNS configuration
|
||||
│ ├── nginx.py # Reverse proxy configuration
|
||||
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
|
||||
│ ├── acme.py # Certificate management
|
||||
│ └── wireguard.py # VPN tunnel management
|
||||
│ ├── sync.py # Cross-subsystem event bus
|
||||
│ ├── acme.py # Certificate management (ACME helpers)
|
||||
│ ├── wireguard.py # VPN tunnel and peer management
|
||||
│ └── system_import.py # Startup reconciler (imports live system configs into JSON)
|
||||
├── webui/ # Flask web application
|
||||
│ ├── server.py # Application entry point
|
||||
│ ├── api/ # REST API route modules (blueprints)
|
||||
@@ -123,6 +130,9 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ │ ├── helpers.js
|
||||
│ │ └── components/ # Layout, data display, modal, toast
|
||||
│ └── pages/ # Page modules (each defines a route via definePage)
|
||||
├── vendor/ # Vendored scripts and JS libraries
|
||||
│ ├── acme.sh # ACME certificate client
|
||||
│ └── htm.js # JS tagged-template HTML adapter
|
||||
├── docs/ # Documentation
|
||||
│ ├── overview.md # This file
|
||||
│ ├── deployment.md
|
||||
@@ -132,7 +142,8 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── config.md
|
||||
│ └── hoover.md # Hoover SPA framework
|
||||
└── scripts/ # Utility scripts
|
||||
└── update-vendor.sh # Vendor frontend library updates
|
||||
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
└── update-vendor.sh # Download vendored libraries (acme.sh, htm)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+68
-16
@@ -7,10 +7,12 @@ Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI
|
||||
- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`.
|
||||
- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.
|
||||
|
||||
ACME certificate operations via `acme.sh` run as the WebUI user — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports.
|
||||
ACME certificate operations via `acme.sh` run as the daemon user — not as root. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh runs as the daemon process invoking it, using webroot validation that does not require binding to privileged ports.
|
||||
|
||||
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, 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
|
||||
|
||||
The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:<group>` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands.
|
||||
@@ -24,25 +26,32 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
|
||||
| Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) |
|
||||
| Nginx | `nginx -s reload` | Graceful nginx configuration reload |
|
||||
| Nginx | `nginx -t` | Nginx configuration syntax validation |
|
||||
| Nginx file ops | `cp -- * /etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
||||
| Nginx file ops | `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of SSL snippet |
|
||||
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
|
||||
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/` | Copy rendered config files |
|
||||
| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
|
||||
| Nginx status | `systemctl is-active nginx` | Check nginx service status |
|
||||
| Nginx file ops | `cp * /etc/nginx/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
||||
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
|
||||
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
|
||||
| Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
|
||||
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
|
||||
| Dnsmasq file ops | `cp * /etc/dnsmasq.d/*` | Copy rendered config files |
|
||||
| Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table |
|
||||
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
||||
| WireGuard | `wg *` | WireGuard status and peer management |
|
||||
| WireGuard file ops | `cp -- * /etc/wireguard/` | Copy rendered config files |
|
||||
| WireGuard file ops | `cp * /etc/wireguard/*` | Copy rendered config files |
|
||||
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
|
||||
| Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) |
|
||||
| Certificates | (none) | acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used) |
|
||||
| Network queries | `ip -o link show` | List network interfaces |
|
||||
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
|
||||
| Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) |
|
||||
| Networkd | `networkctl status *` | Query interface status from networkd |
|
||||
| Networkd | `networkctl reload *` | Reload networkd for a specific interface |
|
||||
| Networkd | `networkctl reload` | Reload networkd for all interfaces |
|
||||
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
|
||||
| Networkd file ops | `cp * /etc/systemd/network/*` | Copy rendered network unit files |
|
||||
| Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files |
|
||||
| Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
|
||||
| Sysctl | `sysctl -w *` | Set kernel parameters |
|
||||
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
|
||||
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
|
||||
|
||||
@@ -61,9 +70,11 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
|
||||
|
||||
### Management Interface
|
||||
|
||||
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
|
||||
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
|
||||
|
||||
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary.
|
||||
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
|
||||
|
||||
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.
|
||||
|
||||
### Proxy Domains
|
||||
|
||||
@@ -77,7 +88,46 @@ Every proxied domain configured in Vacuum Wall enforces:
|
||||
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
|
||||
|
||||
Additional proxy headers (`extra_headers` in the domain config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
|
||||
Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
|
||||
|
||||
### JWT Authentication Lifecycle
|
||||
|
||||
JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:
|
||||
|
||||
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued.
|
||||
2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
|
||||
3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
|
||||
4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`.
|
||||
|
||||
Token theft protection:
|
||||
- Short-lived access tokens (15 min) limit the window of exploitation
|
||||
- Token blacklist prevents reuse after logout or password change
|
||||
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
|
||||
|
||||
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
|
||||
|
||||
### WebAuthn Security
|
||||
|
||||
WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:
|
||||
|
||||
- **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain.
|
||||
- **Private key protection**: The private key never leaves the authenticator device. The server only stores the public key and signature counter in the `webauthn_creds` table.
|
||||
- **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
|
||||
- **RP configuration**: `rp_id` and `origin` are configurable per deployment in `config/auth/config.json`.
|
||||
- **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.
|
||||
|
||||
### Header-Only Authentication and CSRF
|
||||
|
||||
The API exclusively reads the `Authorization` header — never cookies. This architecture eliminates CSRF risk:
|
||||
|
||||
- Cross-site requests cannot set custom HTTP headers due to browser CORS restrictions
|
||||
- No cookie-based session to exploit
|
||||
- No SameSite, double-submit, or origin checking needed
|
||||
|
||||
**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include:
|
||||
- CSP headers on the management domain (configured in nginx)
|
||||
- `X-XSS-Protection` header
|
||||
- Short-lived access tokens (15 min) with blacklist on logout
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
@@ -95,7 +145,9 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
|
||||
| Directive | Value | Effect |
|
||||
|---|---|---|
|
||||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||||
| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
|
||||
| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`); (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it |
|
||||
| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop |
|
||||
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself) |
|
||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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.
|
||||
- Config-backed subsystems record their applied baseline inside the config
|
||||
file itself: `_last_applied_config` (the full merged config at last
|
||||
apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's
|
||||
`status.pending_changes` is true when the current (merged) config hash
|
||||
differs from the recorded hash; `status.pending_diff` lists the field
|
||||
changes since that snapshot. All apply operations (including firewall
|
||||
`config_apply`) re-stamp the baseline. These bookkeeping keys are
|
||||
internal and stripped from every state/API config payload. Canceling
|
||||
pending changes (`POST /api/status/cancel-all`) restores a pending
|
||||
config file from its snapshot; a subsystem with no recorded baseline
|
||||
(never applied) is reported as skipped, not reset.
|
||||
|
||||
## 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.
|
||||
+55
-4
@@ -248,7 +248,7 @@ def list_certs() -> list[dict]:
|
||||
A list of dicts, one per certificate, with keys matching
|
||||
the cert-info schema (domain, ca, cert_path, etc.).
|
||||
"""
|
||||
raw = _run_acme(["--list"])
|
||||
raw = _run_acme(["--list", "--listraw"])
|
||||
certs: list[dict] = []
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
@@ -472,7 +472,7 @@ def deploy(domain: str) -> None:
|
||||
|
||||
def _split_line(line: str, separator: str | None) -> list[str]:
|
||||
"""Split a line by *separator*, falling back to whitespace for column output."""
|
||||
if separator in line:
|
||||
if separator is not None and separator in line:
|
||||
return line.split(separator)
|
||||
return line.split()
|
||||
|
||||
@@ -503,8 +503,8 @@ def _parse_list_output(raw: str) -> list[dict]:
|
||||
headers = _split_line(header_line, "\t")
|
||||
separator = "\t"
|
||||
else:
|
||||
headers = _split_line(header_line, None) # whitespace
|
||||
separator = None
|
||||
# Column-aligned: use position-based parsing via helper
|
||||
return _parse_column_aligned(header_line, lines[1:])
|
||||
|
||||
if "Main_Domain" not in headers:
|
||||
raise ValueError(
|
||||
@@ -526,6 +526,57 @@ def _parse_list_output(raw: str) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def _find_header_positions(header_line: str):
|
||||
"""Find start positions of each header word in a column-aligned header."""
|
||||
names: list[str] = []
|
||||
starts: list[int] = []
|
||||
i = 0
|
||||
while i < len(header_line):
|
||||
while i < len(header_line) and header_line[i] == " ":
|
||||
i += 1
|
||||
j = i
|
||||
while j < len(header_line) and header_line[j] != " ":
|
||||
j += 1
|
||||
if j > i:
|
||||
names.append(header_line[i:j])
|
||||
starts.append(i)
|
||||
i = j
|
||||
return names, starts
|
||||
|
||||
|
||||
def _parse_column_aligned(header_line: str, data_lines: list[str]) -> list[dict]:
|
||||
"""Parse column-aligned output using header positions to locate fields.
|
||||
|
||||
Unlike simple whitespace splitting, this preserves empty fields by using
|
||||
character positions rather than token counts. Empty columns (e.g. missing
|
||||
Profile or SAN_Domains) are correctly handled.
|
||||
"""
|
||||
names, starts = _find_header_positions(header_line)
|
||||
|
||||
if "Main_Domain" not in names:
|
||||
raise ValueError(
|
||||
f"acme.sh --list output is not in expected format: {header_line!r}"
|
||||
)
|
||||
|
||||
# Column ends: midpoint before next header starts (or end of line for last)
|
||||
ends: list[int] = len(starts) * [len(header_line)]
|
||||
for i in range(len(starts) - 1):
|
||||
ends[i] = (starts[i] + starts[i + 1]) // 2
|
||||
|
||||
entries: list[dict] = []
|
||||
for line in data_lines:
|
||||
line = line.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
for k, name in enumerate(names):
|
||||
s, e = starts[k], ends[k]
|
||||
cell = line[s:e] if len(line) > s else ""
|
||||
entry[name.lower()] = cell.strip().strip('"')
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
"""Parse an ISO date string and return days until that date from now."""
|
||||
if not date_str:
|
||||
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
"""JWT authentication module for Vacuum Wall.
|
||||
|
||||
Handles token creation, validation, refresh, and blacklisting.
|
||||
Each user has their own JWT signing secret stored in the database.
|
||||
Configuration comes from config/auth/config.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from lib.common import load_json
|
||||
from lib.db import (
|
||||
Q_DELETE_EXPIRED_BLACKLIST,
|
||||
Q_DELETE_REFRESH_TOKEN,
|
||||
Q_INSERT_BLACKLIST,
|
||||
Q_SELECT_BLACKLIST,
|
||||
Q_SELECT_REFRESH_TOKEN,
|
||||
Q_SELECT_USER_JWT_SECRET,
|
||||
Q_UPDATE_JWT_SECRET,
|
||||
Q_UPSERT_REFRESH_TOKEN,
|
||||
get_db,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_CONFIG_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "config" / "auth" / "config.json"
|
||||
)
|
||||
|
||||
_DEFAULT_JWT_CONFIG = {
|
||||
"access_token_ttl": 900,
|
||||
"refresh_token_ttl": 604800,
|
||||
"algorithm": "HS256",
|
||||
}
|
||||
|
||||
|
||||
def _get_jwt_config() -> dict[str, Any]:
|
||||
"""Load JWT configuration from auth config."""
|
||||
raw = load_json(AUTH_CONFIG_PATH)
|
||||
return raw.get("jwt", _DEFAULT_JWT_CONFIG)
|
||||
|
||||
|
||||
def get_user_jwt_secret(username: str) -> str | None:
|
||||
"""Return the JWT signing secret for *username*, or ``None`` if not found."""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_USER_JWT_SECRET, (username,))
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0]["jwt_secret"]
|
||||
|
||||
|
||||
def rotate_user_secret(username: str) -> None:
|
||||
"""Rotate the JWT secret for *username*, invalidating all their existing tokens.
|
||||
|
||||
Used when a user's password is changed to ensure all prior sessions
|
||||
are immediately terminated regardless of token expiration.
|
||||
"""
|
||||
new_secret = secrets.token_urlsafe(32)
|
||||
db = get_db()
|
||||
db.run(Q_UPDATE_JWT_SECRET, (new_secret, username))
|
||||
logger.warning(
|
||||
"JWT secret rotated for %r — their existing tokens are now invalid", username
|
||||
)
|
||||
|
||||
|
||||
def get_access_ttl() -> int:
|
||||
"""Return access token TTL in seconds."""
|
||||
return _get_jwt_config().get("access_token_ttl", 900)
|
||||
|
||||
|
||||
def get_refresh_ttl() -> int:
|
||||
"""Return refresh token TTL in seconds."""
|
||||
return _get_jwt_config().get("refresh_token_ttl", 604800)
|
||||
|
||||
|
||||
def get_algorithm() -> str:
|
||||
"""Return the JWT algorithm."""
|
||||
return _get_jwt_config().get("algorithm", "HS256")
|
||||
|
||||
|
||||
def generate_access_token(
|
||||
username: str,
|
||||
permissions: dict[str, str],
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a new access token for *username*.
|
||||
|
||||
Args:
|
||||
username: The authenticated username.
|
||||
permissions: Dict mapping subsystem names to permission levels.
|
||||
session_id: Optional session binding ID. Included in the token payload
|
||||
so the Flask middleware can tie the token to the browser session
|
||||
that created it.
|
||||
|
||||
Returns:
|
||||
JWT token string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If JWT secret is not configured.
|
||||
"""
|
||||
secret = get_user_jwt_secret(username)
|
||||
if not secret:
|
||||
raise RuntimeError(f"JWT secret not configured for user {username!r}")
|
||||
algorithm = get_algorithm()
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": username,
|
||||
"exp": now + get_access_ttl(),
|
||||
"iat": now,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"type": "access",
|
||||
"permissions": permissions,
|
||||
"session_id": session_id or secrets.token_urlsafe(16),
|
||||
}
|
||||
return jwt.encode(payload, secret, algorithm=algorithm)
|
||||
|
||||
|
||||
def generate_refresh_token(username: str, session_id: str | None = None) -> str:
|
||||
"""Generate a new refresh token for *username*.
|
||||
|
||||
Args:
|
||||
username: The authenticated username.
|
||||
session_id: Session binding ID included in the token payload.
|
||||
When present, the refresh endpoint requires a matching session_id,
|
||||
preventing a stolen refresh token from being usable without the
|
||||
originating browser session.
|
||||
|
||||
Returns:
|
||||
JWT refresh token string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If JWT secret is not configured.
|
||||
"""
|
||||
secret = get_user_jwt_secret(username)
|
||||
if not secret:
|
||||
raise RuntimeError(f"JWT secret not configured for user {username!r}")
|
||||
algorithm = get_algorithm()
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": username,
|
||||
"exp": now + get_refresh_ttl(),
|
||||
"iat": now,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"type": "refresh",
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
return jwt.encode(payload, secret, algorithm=algorithm)
|
||||
|
||||
|
||||
def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str]:
|
||||
"""Generate both access and refresh tokens.
|
||||
|
||||
Args:
|
||||
username: The authenticated username.
|
||||
permissions: Dict mapping subsystem names to permission levels.
|
||||
|
||||
Returns:
|
||||
Dict with ``access_token`` and ``refresh_token`` keys.
|
||||
"""
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
access_token = generate_access_token(username, permissions, session_id)
|
||||
refresh_token = generate_refresh_token(username, session_id)
|
||||
_persist_refresh_token(username, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
def _persist_refresh_token(username: str, refresh_token: str) -> None:
|
||||
"""Persist the active refresh token JTI for *username* in the database.
|
||||
|
||||
One row per user — replaces any existing entry on upsert.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
refresh_token: The JWT refresh token string.
|
||||
"""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None:
|
||||
return
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return
|
||||
issued_at = payload.get("iat", int(time.time()))
|
||||
db = get_db()
|
||||
db.run(Q_UPSERT_REFRESH_TOKEN, (username, jti, issued_at))
|
||||
|
||||
|
||||
def blacklist_active_refresh_token(username: str) -> None:
|
||||
"""Blacklist the user's currently active refresh token from the database.
|
||||
|
||||
Looks up the stored JTI for *username*, blacklists it, and removes the
|
||||
database entry. Safe to call when no token is registered — the query
|
||||
will simply return no rows.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_REFRESH_TOKEN, (username,))
|
||||
if rows:
|
||||
blacklist_token(rows[0]["jti"], token_type="refresh")
|
||||
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
|
||||
|
||||
|
||||
def _extract_unverified_sub(token_string: str) -> str | None:
|
||||
"""Extract the ``sub`` claim from a JWT payload without signature verification.
|
||||
|
||||
The JWT payload is the second segment (dot-separated), base64url-encoded JSON.
|
||||
This is safe because we are NOT trusting the claim value — we use it solely
|
||||
to look up the user's secret for proper verification.
|
||||
|
||||
Args:
|
||||
token_string: The JWT token string.
|
||||
|
||||
Returns:
|
||||
The ``sub`` claim value, or ``None`` if the token is malformed.
|
||||
"""
|
||||
try:
|
||||
parts = token_string.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload_b64 = parts[1]
|
||||
# Add padding
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += "=" * padding
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||
payload = json.loads(payload_json)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload.get("sub")
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def decode_token(token_string: str) -> dict[str, Any] | None:
|
||||
"""Decode and validate a JWT token using the user's secret.
|
||||
|
||||
Extracts the ``sub`` claim from the unverified payload to look up the
|
||||
correct per-user signing secret, then verifies the signature.
|
||||
|
||||
Args:
|
||||
token_string: The JWT token string (without Bearer prefix).
|
||||
|
||||
Returns:
|
||||
Payload dict if valid, None if invalid/expired or user not found.
|
||||
"""
|
||||
sub = _extract_unverified_sub(token_string)
|
||||
if not sub:
|
||||
return None
|
||||
secret = get_user_jwt_secret(sub)
|
||||
if not secret:
|
||||
return None
|
||||
algorithm = get_algorithm()
|
||||
try:
|
||||
payload = jwt.decode(token_string, secret, algorithms=[algorithm])
|
||||
return payload
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_token(
|
||||
token_string: str,
|
||||
token_type: str = "access",
|
||||
session_id: str | None = None,
|
||||
require_session: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate a JWT token and check it against the blacklist.
|
||||
|
||||
Args:
|
||||
token_string: The JWT token string.
|
||||
token_type: Expected token type ("access" or "refresh").
|
||||
session_id: Must match the ``session_id`` claim in the token payload.
|
||||
When provided, enforces session binding to prevent a stolen token
|
||||
from being usable without the originating session. When ``None``,
|
||||
the check is skipped unless ``require_session`` is True.
|
||||
require_session: If True and the token payload contains a ``session_id``,
|
||||
the request must provide a matching ``session_id``. Used by the
|
||||
refresh handler to prevent session binding bypass. When False
|
||||
(default), omitting ``session_id`` is acceptable even if the token
|
||||
contains one.
|
||||
|
||||
Returns:
|
||||
Payload dict including permissions, or None if invalid/blacklisted.
|
||||
"""
|
||||
payload = decode_token(token_string)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != token_type:
|
||||
return None
|
||||
token_session_id = payload.get("session_id")
|
||||
if require_session and token_session_id is not None:
|
||||
if session_id is None or session_id != token_session_id:
|
||||
return None
|
||||
elif session_id is not None and session_id != token_session_id:
|
||||
return None
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and is_blacklisted(jti):
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def blacklist_token(jti: str, token_type: str = "access") -> None:
|
||||
"""Add a JTI to the blacklist.
|
||||
|
||||
Args:
|
||||
jti: Token UUID to blacklist.
|
||||
token_type: Token type ("access" or "refresh"). Defaults to "access".
|
||||
The blacklist expiry is set to current time + the token type's TTL.
|
||||
"""
|
||||
db = get_db()
|
||||
ttl = get_refresh_ttl() if token_type == "refresh" else get_access_ttl()
|
||||
db.run(Q_INSERT_BLACKLIST, (jti, token_type, int(time.time()) + ttl))
|
||||
if random.random() < 0.02:
|
||||
blacklist_expired()
|
||||
|
||||
|
||||
def is_blacklisted(jti: str) -> bool:
|
||||
"""Check if a JTI is blacklisted.
|
||||
|
||||
Args:
|
||||
jti: Token UUID to check.
|
||||
|
||||
Returns:
|
||||
True if the token has been blacklisted.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_BLACKLIST, (jti,))
|
||||
return len(rows) > 0
|
||||
|
||||
|
||||
def blacklist_expired() -> None:
|
||||
"""Remove expired entries from the blacklist."""
|
||||
db = get_db()
|
||||
now = int(time.time())
|
||||
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,))
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Sliding-window rate limiter that tracks successes and failures separately.
|
||||
|
||||
Failures are counted against the limit. A successful operation resets
|
||||
the failure counter for that key.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self.failures: dict[str, list[float]] = {}
|
||||
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""Check if a request from *key* is allowed (does NOT record the attempt).
|
||||
|
||||
Args:
|
||||
key: Identifier for the rate limit bucket (e.g., username or IP).
|
||||
|
||||
Returns:
|
||||
True if the request is allowed, False if rate limited.
|
||||
"""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
timestamps = self.failures.get(key, [])
|
||||
clean = [t for t in timestamps if t > cutoff]
|
||||
return len(clean) < self.max_attempts
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
"""Record a failed attempt for *key*."""
|
||||
self.failures.setdefault(key, []).append(time.time())
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
"""Reset the failure counter for *key* on a successful operation."""
|
||||
self.failures.pop(key, None)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove expired entries from all buckets."""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
for key in list(self.failures):
|
||||
self.failures[key] = [t for t in self.failures[key] if t > cutoff]
|
||||
if not self.failures[key]:
|
||||
del self.failures[key]
|
||||
|
||||
|
||||
# Global rate limiters — in-memory only. Counts reset on daemon restart
|
||||
# (SIGHUP reload, process restart). Acceptable for a single-user appliance
|
||||
# where restarts are rare; brute-force windows briefly reset post-restart.
|
||||
_login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
|
||||
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
|
||||
|
||||
|
||||
def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if login is rate-limited for the given username.
|
||||
|
||||
Checks failure counts for both IP and username buckets without
|
||||
recording anything. Callers must invoke record_login_failure() or
|
||||
record_login_success() after the password verification step.
|
||||
|
||||
Args:
|
||||
username: The login attempt username.
|
||||
client_ip: The client IP address (from X-Real-IP header).
|
||||
|
||||
Returns:
|
||||
True if the attempt is allowed, False if rate limited.
|
||||
"""
|
||||
if client_ip and not _login_limiter.is_allowed(client_ip):
|
||||
return False
|
||||
return _login_limiter.is_allowed(username)
|
||||
|
||||
|
||||
def record_login_failure(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a failed login attempt."""
|
||||
if client_ip:
|
||||
_login_limiter.record_failure(client_ip)
|
||||
_login_limiter.record_failure(username)
|
||||
|
||||
|
||||
def record_login_success(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a successful login (resets failure counter)."""
|
||||
if client_ip:
|
||||
_login_limiter.record_success(client_ip)
|
||||
_login_limiter.record_success(username)
|
||||
|
||||
|
||||
def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if WebAuthn authentication is rate-limited for the given username.
|
||||
|
||||
Args:
|
||||
username: The WebAuthn attempt username.
|
||||
client_ip: The client IP address (from X-Real-IP header).
|
||||
|
||||
Returns:
|
||||
True if the attempt is allowed, False if rate limited.
|
||||
"""
|
||||
if client_ip and not _webauthn_limiter.is_allowed(client_ip):
|
||||
return False
|
||||
return _webauthn_limiter.is_allowed(username)
|
||||
|
||||
|
||||
def record_webauthn_failure(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a failed WebAuthn attempt."""
|
||||
if client_ip:
|
||||
_webauthn_limiter.record_failure(client_ip)
|
||||
_webauthn_limiter.record_failure(username)
|
||||
|
||||
|
||||
def record_webauthn_success(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a successful WebAuthn attempt (resets failure counter)."""
|
||||
if client_ip:
|
||||
_webauthn_limiter.record_success(client_ip)
|
||||
_webauthn_limiter.record_success(username)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""User CRUD and permission management for Vacuum Wall authentication.
|
||||
|
||||
All operations use DB query IDs through the Database abstract layer.
|
||||
Password hashing uses Argon2id via lib.password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from lib.auth import (
|
||||
blacklist_active_refresh_token,
|
||||
rotate_user_secret,
|
||||
)
|
||||
from lib.db import (
|
||||
Q_DELETE_PERMISSION_SUBSYSTEM,
|
||||
Q_DELETE_USER,
|
||||
Q_INSERT_USER,
|
||||
Q_SELECT_PERMISSIONS,
|
||||
Q_SELECT_USER_BY_NAME,
|
||||
Q_SELECT_USERS_WITH_PERMS,
|
||||
Q_UPDATE_PASSWORD,
|
||||
Q_UPSERT_PERMISSION,
|
||||
get_db,
|
||||
)
|
||||
from lib.password import hash_password, needs_rehash, verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pre-computed dummy hash for constant-time verification on nonexistent users.
|
||||
# Generated once at module load to avoid timing leaks from per-call hash generation.
|
||||
_DUMMY_HASH = hash_password(secrets.token_hex(32))
|
||||
|
||||
# Builtin admin — hardcoded, full access, cannot be modified/deleted
|
||||
BUILTIN_ADMIN_USERNAME = "admin"
|
||||
|
||||
# All subsystem names for default permission assignment
|
||||
ALL_SUBSYSTEMS = [
|
||||
"firewall",
|
||||
"network",
|
||||
"dhcp",
|
||||
"proxy",
|
||||
"certs",
|
||||
"wireguard",
|
||||
"logs",
|
||||
"status",
|
||||
"auth",
|
||||
]
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$")
|
||||
|
||||
|
||||
def _get_permissions(username: str) -> dict[str, str]:
|
||||
"""Load permissions for *username* from the database.
|
||||
|
||||
Args:
|
||||
username: The username to load permissions for.
|
||||
|
||||
Returns:
|
||||
Dict mapping subsystem names to permission levels.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_PERMISSIONS, (username,))
|
||||
return {row["subsystem"]: row["level"] for row in rows}
|
||||
|
||||
|
||||
def get_user(username: str) -> dict[str, Any] | None:
|
||||
"""Get a user by username (without password hash or JWT secret).
|
||||
|
||||
Args:
|
||||
username: The username to look up.
|
||||
|
||||
Returns:
|
||||
User dict with id, username, permissions, or None if not found.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
return None
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def find_user(username: str) -> dict[str, Any] | None:
|
||||
"""Find a user by username, including password hash and JWT secret.
|
||||
|
||||
Used for password verification and token operations. Not returned through APIs.
|
||||
|
||||
Args:
|
||||
username: The username to look up.
|
||||
|
||||
Returns:
|
||||
User dict from the database row, or None.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_USER_BY_NAME, (username,))
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0]
|
||||
|
||||
|
||||
def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
|
||||
"""Verify a user's password using Argon2id.
|
||||
|
||||
Args:
|
||||
username: The username to verify.
|
||||
password: Plain-text password.
|
||||
|
||||
Returns:
|
||||
User dict (without password hash) if the password is correct, None otherwise.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
# Run a dummy Argon2id verification against a pre-computed hash to
|
||||
# prevent timing-based user enumeration. Uses module-level hash
|
||||
# so both paths take ~1 verify call (~200ms) instead of ~400ms.
|
||||
verify_password(password, _DUMMY_HASH)
|
||||
return None
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
if needs_rehash(user["password_hash"]):
|
||||
new_hash = hash_password(password)
|
||||
db = get_db()
|
||||
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
|
||||
logger.info("Password hash rehashed for %r (param upgrade)", username)
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
permissions: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user with the given password and permissions.
|
||||
|
||||
Args:
|
||||
username: The new username (3-32 alphanumeric chars, dash, underscore).
|
||||
password: Plain-text password that will be hashed with Argon2id.
|
||||
permissions: Dict mapping subsystem names to "read" or "rw".
|
||||
|
||||
Returns:
|
||||
The created user dict with id, username, permissions.
|
||||
|
||||
Raises:
|
||||
ValueError: If username is invalid or already exists.
|
||||
"""
|
||||
if not _USERNAME_RE.match(username):
|
||||
raise ValueError(
|
||||
"Username must be 3-32 characters: letters, digits, dash, underscore"
|
||||
)
|
||||
|
||||
existing = find_user(username)
|
||||
if existing is not None:
|
||||
raise ValueError(f"User {username!r} already exists")
|
||||
|
||||
password_hash = hash_password(password)
|
||||
jwt_secret = secrets.token_urlsafe(32)
|
||||
|
||||
db = get_db()
|
||||
with db.in_transaction() as tx:
|
||||
user_id = tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret))
|
||||
if permissions:
|
||||
for subsystem, level in permissions.items():
|
||||
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
"username": username,
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def update_password(username: str, old_password: str, new_password: str) -> bool:
|
||||
"""Update a user's password and invalidate all active tokens.
|
||||
|
||||
Rotates the user's JWT secret, immediately invalidating all existing
|
||||
access and refresh tokens.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
old_password: Current password.
|
||||
new_password: New plain-text password.
|
||||
|
||||
Returns:
|
||||
True if password was updated.
|
||||
|
||||
Raises:
|
||||
ValueError: If old password is incorrect.
|
||||
"""
|
||||
if not verify_user_password(username, old_password):
|
||||
raise ValueError("Current password is incorrect")
|
||||
|
||||
blacklist_active_refresh_token(username)
|
||||
new_hash = hash_password(new_password)
|
||||
rotate_user_secret(username)
|
||||
db = get_db()
|
||||
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
|
||||
return True
|
||||
|
||||
|
||||
def reset_password(username: str, new_password: str) -> None:
|
||||
"""Force-reset a user's password without verifying the old one.
|
||||
|
||||
Non-interactive variant for install-time and lockout recovery: the
|
||||
installer does not know the previous password by construction. Rotates
|
||||
the user's JWT secret and blacklists the active refresh token,
|
||||
invalidating all existing sessions.
|
||||
|
||||
Args:
|
||||
username: The user to reset.
|
||||
new_password: New plain-text password.
|
||||
|
||||
Raises:
|
||||
ValueError: If the user does not exist.
|
||||
"""
|
||||
if find_user(username) is None:
|
||||
raise ValueError(f"User {username!r} not found")
|
||||
|
||||
blacklist_active_refresh_token(username)
|
||||
new_hash = hash_password(new_password)
|
||||
rotate_user_secret(username)
|
||||
db = get_db()
|
||||
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
|
||||
|
||||
|
||||
def update_permissions(username: str, permissions: dict[str, str]) -> None:
|
||||
"""Update a user's permissions and invalidate all existing tokens.
|
||||
|
||||
Replaces all existing permissions with the provided mapping. Rotates the
|
||||
JWT secret so that permission changes take effect immediately — existing
|
||||
tokens with stale permissions are no longer valid.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
permissions: Dict mapping subsystem names to permission levels.
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to modify builtin admin.
|
||||
"""
|
||||
if username == BUILTIN_ADMIN_USERNAME:
|
||||
raise ValueError("Cannot modify permissions for builtin admin")
|
||||
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
raise ValueError(f"User {username!r} not found")
|
||||
|
||||
db = get_db()
|
||||
with db.in_transaction() as tx:
|
||||
# Upsert all new permissions first, then remove stale ones.
|
||||
# This order ensures that if an upsert fails mid-loop, the user's
|
||||
# permissions remain intact (transaction rolls back) rather than
|
||||
# being permanently wiped.
|
||||
existing = {
|
||||
row["subsystem"]: row["level"]
|
||||
for row in db.query(Q_SELECT_PERMISSIONS, (username,))
|
||||
}
|
||||
for subsystem, level in permissions.items():
|
||||
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
||||
for subsystem in existing:
|
||||
if subsystem not in permissions:
|
||||
tx.run(Q_DELETE_PERMISSION_SUBSYSTEM, (username, subsystem))
|
||||
blacklist_active_refresh_token(username)
|
||||
rotate_user_secret(username)
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
"""List all users with their permissions.
|
||||
|
||||
Returns:
|
||||
List of user summary dicts.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_USERS_WITH_PERMS, ())
|
||||
|
||||
users: dict[int, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
uid = row["id"]
|
||||
if uid not in users:
|
||||
users[uid] = {
|
||||
"id": uid,
|
||||
"username": row["username"],
|
||||
"permissions": {},
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
if row["subsystem"] is not None:
|
||||
users[uid]["permissions"][row["subsystem"]] = row["level"]
|
||||
|
||||
return list(users.values())
|
||||
|
||||
|
||||
def delete_user(username: str) -> bool:
|
||||
"""Delete a user and all their associated data.
|
||||
|
||||
Permissions and WebAuthn credentials are CASCADE-deleted by the schema.
|
||||
|
||||
Args:
|
||||
username: The username to delete.
|
||||
|
||||
Returns:
|
||||
True if the user was deleted.
|
||||
|
||||
Raises:
|
||||
ValueError: If the user does not exist or is the builtin admin.
|
||||
"""
|
||||
if username == BUILTIN_ADMIN_USERNAME:
|
||||
raise ValueError("Cannot delete builtin admin user")
|
||||
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
raise ValueError(f"User {username!r} not found")
|
||||
|
||||
blacklist_active_refresh_token(username)
|
||||
db = get_db()
|
||||
db.run(Q_DELETE_USER, (username,))
|
||||
return True
|
||||
+153
@@ -4,6 +4,7 @@ Provides common helpers for JSON persistence, subprocess execution,
|
||||
deep merging, and directory creation used across all subsystem modules.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -12,6 +13,112 @@ from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
_APPLY_HASH_KEY = "_last_applied_hash"
|
||||
_LAST_APPLIED_CONFIG_KEY = "_last_applied_config"
|
||||
_APPLY_META_KEYS = (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY)
|
||||
|
||||
|
||||
def strip_apply_meta(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return *cfg* without any apply bookkeeping keys.
|
||||
|
||||
Strips both the last-applied hash and the last-applied config snapshot
|
||||
so the returned dict reflects only real configuration.
|
||||
"""
|
||||
return {k: v for k, v in cfg.items() if k not in _APPLY_META_KEYS}
|
||||
|
||||
|
||||
def config_hash(cfg: dict[str, Any]) -> str:
|
||||
"""Compute a SHA-256 hash of *cfg*, ignoring apply bookkeeping keys.
|
||||
|
||||
Args:
|
||||
cfg: Config dict, possibly containing ``_last_applied_hash`` and
|
||||
``_last_applied_config``.
|
||||
|
||||
Returns:
|
||||
Hex digest of the stripped config JSON.
|
||||
"""
|
||||
clean = strip_apply_meta(cfg)
|
||||
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record that *cfg* is the last applied configuration.
|
||||
|
||||
Writes both the content snapshot (``_last_applied_config``) and its hash
|
||||
(``_last_applied_hash``) so a later pending check can detect drift and a
|
||||
diff can report exactly which fields changed.
|
||||
"""
|
||||
cfg[_APPLY_HASH_KEY] = config_hash(cfg)
|
||||
cfg[_LAST_APPLIED_CONFIG_KEY] = strip_apply_meta(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def revert_to_applied(path: Path) -> tuple[bool, str]:
|
||||
"""Restore the config file at *path* to its last-applied snapshot.
|
||||
|
||||
Reads the raw file, and when it records a ``_last_applied_config``
|
||||
snapshot, rewrites the file from that snapshot (stamped with a fresh
|
||||
hash so the pending check reports the config as up to date).
|
||||
|
||||
Args:
|
||||
path: Path to the config JSON file to revert.
|
||||
|
||||
Returns:
|
||||
Tuple ``(True, "")`` when the file was restored, or
|
||||
``(False, reason)`` when it could not be (missing file or no
|
||||
recorded baseline — i.e. the config was never applied).
|
||||
"""
|
||||
cfg = load_json(path)
|
||||
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||
if not isinstance(snap, dict):
|
||||
return False, "No baseline recorded (never applied)"
|
||||
save_json(path, stamp_applied(deepcopy(snap)))
|
||||
return True, ""
|
||||
|
||||
|
||||
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
|
||||
"""Return a list of field-level changes between two configurations.
|
||||
|
||||
Each entry is ``{"path", "action", "old", "new"}`` where *action* is one
|
||||
of ``"added"``, ``"removed"`` or ``"changed"``. Dicts are recursed with
|
||||
dotted paths; lists of equal length are compared element-by-element,
|
||||
while any other value that differs is reported as a single change.
|
||||
Apply bookkeeping keys are ignored.
|
||||
"""
|
||||
if isinstance(old, dict):
|
||||
old = strip_apply_meta(old)
|
||||
if isinstance(new, dict):
|
||||
new = strip_apply_meta(new)
|
||||
out: list[dict[str, Any]] = []
|
||||
_diff_nodes(old, new, prefix, out)
|
||||
return out
|
||||
|
||||
|
||||
def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> None:
|
||||
"""Recursively collect field-level changes from *old* into *new*."""
|
||||
if isinstance(old, dict) and isinstance(new, dict):
|
||||
for key in sorted(set(old) | set(new)):
|
||||
child = f"{path}.{key}" if path else str(key)
|
||||
if key in old and key in new:
|
||||
_diff_nodes(old[key], new[key], child, out)
|
||||
elif key in old:
|
||||
out.append(
|
||||
{"path": child, "action": "removed", "old": old[key], "new": None}
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
{"path": child, "action": "added", "old": None, "new": new[key]}
|
||||
)
|
||||
return
|
||||
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
|
||||
for i, (o, n) in enumerate(zip(old, new, strict=True)):
|
||||
_diff_nodes(o, n, f"{path}[{i}]", out)
|
||||
return
|
||||
if old != new:
|
||||
out.append({"path": path, "action": "changed", "old": old, "new": new})
|
||||
|
||||
|
||||
def validate_interface_name(name: str) -> str:
|
||||
"""Validate a Linux network interface name.
|
||||
@@ -64,6 +171,7 @@ def run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -99,6 +207,7 @@ def run_proc(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
input=input,
|
||||
@@ -153,12 +262,56 @@ def ensure_dirs(*dirs: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
|
||||
|
||||
Used for nginx htpasswd files. NOT used for auth user passwords —
|
||||
those use Argon2id via ``lib.password``.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd``
|
||||
(e.g. ``$5$rounds=…$…``).
|
||||
"""
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
def get_interface_ip(iface: str) -> str | None:
|
||||
"""Return the primary IPv4 address of *iface* (without CIDR), or ``None``.
|
||||
|
||||
Uses ``ip -o addr show`` which is in the daemon sudo whitelist.
|
||||
"""
|
||||
if not iface:
|
||||
return None
|
||||
try:
|
||||
raw = run(["ip", "-o", "addr", "show", iface], sudo=True)
|
||||
for line in raw.splitlines():
|
||||
parts = line.split()
|
||||
# -o format: "NUM: IFACE inet/6 ADDR/MASK ..."
|
||||
if len(parts) >= 4 and parts[2] == "inet":
|
||||
return parts[3].split("/", 1)[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_APPLY_HASH_KEY",
|
||||
"_LAST_APPLIED_CONFIG_KEY",
|
||||
"_hash_password",
|
||||
"config_hash",
|
||||
"deep_diff",
|
||||
"deep_merge",
|
||||
"ensure_dirs",
|
||||
"get_interface_ip",
|
||||
"load_json",
|
||||
"revert_to_applied",
|
||||
"run",
|
||||
"run_proc",
|
||||
"save_json",
|
||||
"stamp_applied",
|
||||
"strip_apply_meta",
|
||||
"validate_interface_name",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Abstract database layer for Vacuum Wall.
|
||||
|
||||
Provides query ID constants and an abstract Database baseclass so subsystems
|
||||
interact with the database through opaque query identifiers, never raw SQL.
|
||||
Backend implementations (SQLite, PostgreSQL) provide the actual SQL.
|
||||
|
||||
Usage:
|
||||
from lib.db import Q_INSERT_USER, Database, get_db
|
||||
|
||||
class SQLiteBackend(Database):
|
||||
QUERY_MAP = {
|
||||
Q_INSERT_USER: "INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query ID constants — single source of truth for all database operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Q_INIT_TABLES = "init_tables"
|
||||
Q_INSERT_USER = "insert_user"
|
||||
Q_SELECT_USER_BY_NAME = "select_user_by_name"
|
||||
Q_SELECT_USER_BY_ID = "select_user_by_id"
|
||||
Q_UPDATE_PASSWORD = "update_password"
|
||||
Q_DELETE_USER = "delete_user"
|
||||
Q_UPSERT_PERMISSION = "upsert_permission"
|
||||
Q_SELECT_PERMISSIONS = "select_permissions"
|
||||
Q_DELETE_PERMISSIONS = "delete_permissions"
|
||||
Q_DELETE_PERMISSION_SUBSYSTEM = "delete_permission_subsystem"
|
||||
Q_INSERT_BLACKLIST = "insert_blacklist"
|
||||
Q_SELECT_BLACKLIST = "select_blacklist_jti"
|
||||
Q_DELETE_EXPIRED_BLACKLIST = "delete_expired_blacklist"
|
||||
Q_UPSERT_REFRESH_TOKEN = "upsert_refresh_token"
|
||||
Q_SELECT_REFRESH_TOKEN = "select_refresh_token"
|
||||
Q_DELETE_REFRESH_TOKEN = "delete_refresh_token"
|
||||
Q_INSERT_WEBAUTHN = "insert_webauthn"
|
||||
Q_SELECT_WEBAUTHN_USER = "select_webauthn_user"
|
||||
Q_SELECT_WEBAUTHN_ID = "select_webauthn_id"
|
||||
Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
|
||||
Q_DELETE_WEBAUTHN = "delete_webauthn"
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
|
||||
Q_SELECT_ALL_USERS = "select_all_users"
|
||||
Q_SELECT_USERS_WITH_PERMS = "select_users_with_perms"
|
||||
Q_SELECT_USER_JWT_SECRET = "select_user_jwt_secret"
|
||||
Q_UPDATE_JWT_SECRET = "update_jwt_secret"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema DDL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INIT_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
jwt_secret TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||
subsystem TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('read', 'rw')),
|
||||
UNIQUE(username, subsystem)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
jti TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
jti TEXT NOT NULL,
|
||||
issued_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_creds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
transports TEXT NOT NULL DEFAULT '[]',
|
||||
UNIQUE(username, credential_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS init_sequence (
|
||||
seq INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class Transaction:
|
||||
"""Context manager for database transactions.
|
||||
|
||||
Provides BEGIN/COMMIT/ROLLBACK semantics. Auto-commit is suppressed
|
||||
inside the transaction block.
|
||||
|
||||
Usage:
|
||||
with db.in_transaction() as tx:
|
||||
tx.run(Q_INSERT_USER, ("user1", "hash"))
|
||||
tx.run(Q_UPSERT_PERMISSION, ("user1", "firewall", "rw"))
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Database) -> None:
|
||||
self._parent = parent
|
||||
|
||||
def __enter__(self) -> Transaction:
|
||||
self._parent._begin()
|
||||
self._parent._suppress_auto_commit()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
|
||||
try:
|
||||
if exc_type is None:
|
||||
self._parent._commit()
|
||||
else:
|
||||
self._parent._rollback()
|
||||
finally:
|
||||
self._parent._restore_auto_commit()
|
||||
return False
|
||||
|
||||
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
|
||||
return self._parent.query(query_id, params)
|
||||
|
||||
def run(self, query_id: str, params: tuple = ()) -> int:
|
||||
return self._parent.run(query_id, params)
|
||||
|
||||
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
|
||||
return self._parent.run_one(query_id, params)
|
||||
|
||||
|
||||
class Database(ABC):
|
||||
"""Abstract database interface.
|
||||
|
||||
All subsystems interact with the database through this interface.
|
||||
Queries are identified by string IDs (e.g. Q_INSERT_USER) — never
|
||||
raw SQL strings.
|
||||
|
||||
Connection is cached via the ``conn`` property. Prepared statements
|
||||
are auto-cached on first use.
|
||||
"""
|
||||
|
||||
QUERY_MAP: ClassVar[dict[str, str]] = {}
|
||||
|
||||
def __init__(self, connection_string: str) -> None:
|
||||
self._connection_string = connection_string
|
||||
self._prepared: dict[str, Any] = {}
|
||||
self._in_transaction = False
|
||||
# Per-thread connections: backend connection objects (e.g. sqlite3)
|
||||
# are bound to the thread that created them. The Flask WebUI runs
|
||||
# requests in worker threads while the daemon uses a single event-loop
|
||||
# thread, so each thread lazily gets its own connection.
|
||||
self._local = threading.local()
|
||||
|
||||
@property
|
||||
def conn(self) -> Any:
|
||||
"""Return this thread's cached database connection, creating it lazily."""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
conn = self._connect(self._connection_string)
|
||||
self._local.conn = conn
|
||||
return conn
|
||||
|
||||
@abstractmethod
|
||||
def _connect(self, cs: str) -> Any: ...
|
||||
|
||||
@abstractmethod
|
||||
def _prepare(self, sql: str) -> Any: ...
|
||||
|
||||
@abstractmethod
|
||||
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
|
||||
"""Execute a prepared statement. Returns (rows, rowcount)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _last_insert_id(self, stmt: Any) -> int | dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def _execute_direct(self, sql: str) -> None:
|
||||
"""Execute raw SQL without prepared statements (for DDL, not implemented by base)."""
|
||||
|
||||
def _begin(self) -> None: # noqa: B027
|
||||
"""Begin a transaction (not implemented by base)."""
|
||||
|
||||
def _commit(self) -> None: # noqa: B027
|
||||
"""Commit a transaction (not implemented by base)."""
|
||||
|
||||
def _rollback(self) -> None: # noqa: B027
|
||||
"""Rollback a transaction (not implemented by base)."""
|
||||
|
||||
def _suppress_auto_commit(self) -> None: # noqa: B027
|
||||
"""Suppress auto-commit (not implemented by base)."""
|
||||
|
||||
def _restore_auto_commit(self) -> None: # noqa: B027
|
||||
"""Restore auto-commit (not implemented by base)."""
|
||||
|
||||
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
|
||||
"""Execute a SELECT query. Returns list of row dicts."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
rows, _ = self._execute(stmt, params)
|
||||
return rows
|
||||
|
||||
def run(self, query_id: str, params: tuple = ()) -> int:
|
||||
"""Execute an INSERT/UPDATE/DELETE. Returns affected row count."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
_, count = self._execute(stmt, params)
|
||||
return count
|
||||
|
||||
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
|
||||
"""Execute and return the last insert ID or row dict."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
_, _ = self._execute(stmt, params)
|
||||
return self._last_insert_id(stmt)
|
||||
|
||||
def in_transaction(self) -> Transaction:
|
||||
"""Return a transaction context manager."""
|
||||
return Transaction(self)
|
||||
|
||||
def init_tables(self) -> None:
|
||||
"""Create schema tables if they don't exist."""
|
||||
self._execute_direct(INIT_SQL)
|
||||
|
||||
def _get_prepared(self, query_id: str) -> Any:
|
||||
if query_id not in self._prepared:
|
||||
if query_id not in self.QUERY_MAP:
|
||||
raise KeyError(f"Unknown query ID: {query_id!r}")
|
||||
self._prepared[query_id] = self._prepare(self.QUERY_MAP[query_id])
|
||||
return self._prepared[query_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton accessor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_db_instance: Database | None = None
|
||||
|
||||
|
||||
def _get_backend_name() -> str:
|
||||
return os.environ.get("VACUUM_WALL_DB_BACKEND", "sqlite")
|
||||
|
||||
|
||||
def _get_db_path() -> str:
|
||||
return os.environ.get("VACUUM_WALL_DB_PATH", str(PROJECT_DIR / "data" / "auth.db"))
|
||||
|
||||
|
||||
def get_db() -> Database:
|
||||
"""Return the singleton Database instance.
|
||||
|
||||
Creates the instance on first call using the backend specified by
|
||||
``VACUUM_WALL_DB_BACKEND`` env var (default: sqlite).
|
||||
|
||||
Call this at application startup to ensure the DB is initialized.
|
||||
"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
backend = _get_backend_name()
|
||||
if backend == "sqlite":
|
||||
from lib.db_sqlite import SQLiteBackend
|
||||
|
||||
path = _get_db_path()
|
||||
_db_instance = SQLiteBackend(path)
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
_db_instance.init_tables()
|
||||
_seed_builtin_admin(_db_instance)
|
||||
else:
|
||||
raise ValueError(f"Unknown database backend: {backend!r}")
|
||||
return _db_instance
|
||||
|
||||
|
||||
def _seed_builtin_admin(db: Database) -> None:
|
||||
"""Create the builtin admin user as a last-resort fallback.
|
||||
|
||||
Seeding is skipped when:
|
||||
|
||||
- ``VACUUM_WALL_SEED_BUILTIN_ADMIN`` is set to ``0`` — bootstrap_auth.py
|
||||
sets this, since bootstrap creates the operator user itself and must
|
||||
not leave a hardcoded ``admin`` with an unrecoverable random password, or
|
||||
- the users table already contains users — accounts exist, so bootstrap
|
||||
(or a prior start) has run.
|
||||
|
||||
The seed therefore only fires on a completely empty database, i.e.
|
||||
bootstrap was genuinely skipped and a service starts first.
|
||||
|
||||
The builtin admin has full (rw) access to all subsystems and cannot
|
||||
be deleted or have permissions modified through the normal API.
|
||||
"""
|
||||
if os.environ.get("VACUUM_WALL_SEED_BUILTIN_ADMIN", "1") == "0":
|
||||
return
|
||||
|
||||
from lib.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME
|
||||
from lib.password import hash_password
|
||||
|
||||
# Last-resort guard: only seed when no users exist at all.
|
||||
if db.query(Q_SELECT_ALL_USERS):
|
||||
return
|
||||
|
||||
# Generate a random password — this fallback should only fire if
|
||||
# bootstrap_auth.py was skipped. Log the password prominently.
|
||||
random_password = secrets.token_urlsafe(24)
|
||||
placeholder_hash = hash_password(random_password)
|
||||
jwt_secret = secrets.token_urlsafe(32)
|
||||
|
||||
try:
|
||||
with db.in_transaction() as tx:
|
||||
tx.run_one(
|
||||
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
|
||||
)
|
||||
for subsystem in ALL_SUBSYSTEMS:
|
||||
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
|
||||
except Exception as exc:
|
||||
rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,))
|
||||
if not rows:
|
||||
raise
|
||||
logger.warning(
|
||||
"Concurrent builtin admin seed detected (%s); proceeding with existing user",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
auth_log = Path("/var/log/vacuum-wall/auth.log")
|
||||
auth_log_written = False
|
||||
try:
|
||||
auth_log.write_text(
|
||||
f"Builtin admin password: {random_password}\n", encoding="utf-8"
|
||||
)
|
||||
import os as _os
|
||||
|
||||
_os.chmod(str(auth_log), 0o600)
|
||||
auth_log_written = True
|
||||
except OSError:
|
||||
logger.error("Could not write admin password to %s", auth_log)
|
||||
logger.warning(
|
||||
"Builtin admin user created. THIS IS A FALLBACK — bootstrap_auth.py "
|
||||
"should have run during install. Admin password: %s... (%s)",
|
||||
random_password[:6],
|
||||
"see /var/log/vacuum-wall/auth.log for the full password"
|
||||
if auth_log_written
|
||||
else "full password NOT written — check the error above",
|
||||
)
|
||||
|
||||
|
||||
def reset_db_for_test() -> None:
|
||||
"""Reset the singleton — only for tests."""
|
||||
global _db_instance
|
||||
if _db_instance is not None:
|
||||
_db_instance = None
|
||||
@@ -0,0 +1,185 @@
|
||||
"""SQLite backend for Vacuum Wall database.
|
||||
|
||||
Concrete implementation of the Database abstract class using SQLite3.
|
||||
Uses Python 3.13+ sqlite3.Statement for prepared statements.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from lib.db import (
|
||||
Q_DELETE_EXPIRED_BLACKLIST,
|
||||
Q_DELETE_PERMISSION_SUBSYSTEM,
|
||||
Q_DELETE_PERMISSIONS,
|
||||
Q_DELETE_REFRESH_TOKEN,
|
||||
Q_DELETE_USER,
|
||||
Q_DELETE_WEBAUTHN,
|
||||
Q_INSERT_BLACKLIST,
|
||||
Q_INSERT_USER,
|
||||
Q_INSERT_WEBAUTHN,
|
||||
Q_SELECT_ALL_USERS,
|
||||
Q_SELECT_BLACKLIST,
|
||||
Q_SELECT_PERMISSIONS,
|
||||
Q_SELECT_REFRESH_TOKEN,
|
||||
Q_SELECT_USER_BY_ID,
|
||||
Q_SELECT_USER_BY_NAME,
|
||||
Q_SELECT_USER_JWT_SECRET,
|
||||
Q_SELECT_USERS_WITH_PERMS,
|
||||
Q_SELECT_WEBAUTHN_COUNTS,
|
||||
Q_SELECT_WEBAUTHN_ID,
|
||||
Q_SELECT_WEBAUTHN_USER,
|
||||
Q_UPDATE_JWT_SECRET,
|
||||
Q_UPDATE_PASSWORD,
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
Q_UPSERT_PERMISSION,
|
||||
Q_UPSERT_REFRESH_TOKEN,
|
||||
Database,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLiteBackend(Database):
|
||||
"""SQLite implementation of the Database interface.
|
||||
|
||||
Uses ``sqlite3.Connection.execute()`` for statement execution and
|
||||
``sqlite3.Row`` for row-factory dict access.
|
||||
"""
|
||||
|
||||
def __init__(self, connection_string: str) -> None:
|
||||
super().__init__(connection_string)
|
||||
self._last_rowid: int = 0
|
||||
|
||||
QUERY_MAP: ClassVar[dict[str, str]] = {
|
||||
# Schema init is handled by direct execution, not prepared statements
|
||||
# init_tables is called as _execute_direct(INIT_SQL)
|
||||
# Users
|
||||
Q_INSERT_USER: (
|
||||
"INSERT INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)"
|
||||
),
|
||||
Q_SELECT_USER_BY_NAME: (
|
||||
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE username = ?"
|
||||
),
|
||||
Q_SELECT_USER_BY_ID: (
|
||||
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE id = ?"
|
||||
),
|
||||
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
|
||||
Q_SELECT_ALL_USERS: (
|
||||
"SELECT id, username, created_at FROM users ORDER BY username"
|
||||
),
|
||||
Q_SELECT_USERS_WITH_PERMS: (
|
||||
"SELECT u.id, u.username, u.created_at, p.subsystem, p.level "
|
||||
"FROM users u LEFT JOIN permissions p ON u.username = p.username "
|
||||
"ORDER BY u.username"
|
||||
),
|
||||
Q_SELECT_USER_JWT_SECRET: ("SELECT jwt_secret FROM users WHERE username = ?"),
|
||||
Q_UPDATE_JWT_SECRET: ("UPDATE users SET jwt_secret = ? WHERE username = ?"),
|
||||
# Permissions
|
||||
Q_UPSERT_PERMISSION: (
|
||||
"INSERT INTO permissions (username, subsystem, level) "
|
||||
"VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level"
|
||||
),
|
||||
Q_SELECT_PERMISSIONS: (
|
||||
"SELECT subsystem, level FROM permissions WHERE username = ?"
|
||||
),
|
||||
Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"),
|
||||
Q_DELETE_PERMISSION_SUBSYSTEM: (
|
||||
"DELETE FROM permissions WHERE username = ? AND subsystem = ?"
|
||||
),
|
||||
# Token blacklist
|
||||
Q_INSERT_BLACKLIST: (
|
||||
"INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)"
|
||||
),
|
||||
Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?",
|
||||
Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?",
|
||||
# Refresh tokens
|
||||
Q_UPSERT_REFRESH_TOKEN: (
|
||||
"INSERT INTO refresh_tokens (username, jti, issued_at) "
|
||||
"VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at"
|
||||
),
|
||||
Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?",
|
||||
Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?",
|
||||
# WebAuthn
|
||||
Q_INSERT_WEBAUTHN: (
|
||||
"INSERT INTO webauthn_creds "
|
||||
"(username, credential_id, public_key, sign_count, name, transports) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_USER: (
|
||||
"SELECT id, credential_id, public_key, sign_count, name, transports "
|
||||
"FROM webauthn_creds WHERE username = ?"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_ID: (
|
||||
"SELECT id, username, credential_id, public_key, sign_count, name, transports "
|
||||
"FROM webauthn_creds WHERE credential_id = ?"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_COUNTS: (
|
||||
"SELECT username, COUNT(*) as cred_count FROM webauthn_creds "
|
||||
"GROUP BY username"
|
||||
),
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT: (
|
||||
"UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?"
|
||||
),
|
||||
Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?",
|
||||
}
|
||||
|
||||
def _connect(self, cs: str) -> Any:
|
||||
"""Create a SQLite connection with WAL mode and row factory."""
|
||||
conn = sqlite3.connect(cs, isolation_level=None)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
# Multiple threads/processes hold distinct connections (see
|
||||
# Database.conn); wait up to 5s for writers instead of failing
|
||||
# immediately with SQLITE_BUSY.
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _prepare(self, sql: str) -> Any:
|
||||
"""Store the SQL string for later execution.
|
||||
|
||||
SQLite in-memory DB doesn't support the Python 3.13 conn.prepare()
|
||||
API, so we store the raw SQL and execute via conn.execute().
|
||||
"""
|
||||
return sql
|
||||
|
||||
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
|
||||
"""Execute a prepared statement, returning (rows, rowcount)."""
|
||||
cursor = self.conn.execute(stmt, params)
|
||||
self._last_rowid = cursor.lastrowid
|
||||
result = cursor.fetchall()
|
||||
rows: list[dict] = []
|
||||
for row in result:
|
||||
rows.append(dict(row))
|
||||
return rows, cursor.rowcount
|
||||
|
||||
def _last_insert_id(self, stmt: Any) -> int | dict:
|
||||
"""Return the last insert row ID from the most recent execute."""
|
||||
return self._last_rowid
|
||||
|
||||
def _begin(self) -> None:
|
||||
self.conn.execute("BEGIN")
|
||||
|
||||
def _commit(self) -> None:
|
||||
self.conn.execute("COMMIT")
|
||||
|
||||
def _rollback(self) -> None:
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
self.conn.execute("ROLLBACK")
|
||||
|
||||
def _suppress_auto_commit(self) -> None:
|
||||
self._in_transaction = True
|
||||
|
||||
def _restore_auto_commit(self) -> None:
|
||||
self._in_transaction = False
|
||||
|
||||
def _execute_direct(self, sql: str) -> None:
|
||||
"""Execute raw SQL without prepared statements (for DDL)."""
|
||||
self.conn.executescript(sql)
|
||||
+5
-310
@@ -1,18 +1,15 @@
|
||||
"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
|
||||
"""Dnsmasq config persistence for Vacuum Wall.
|
||||
|
||||
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
|
||||
static leases, and custom DNS records through sudo.
|
||||
Provides load/save for the declarative JSON config and upstream-management
|
||||
helpers used by the sync bus and network handler. All mutation and
|
||||
apply logic lives in daemon/handlers/dnsmasq.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,17 +19,7 @@ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
# --- defaults ---
|
||||
DEFAULT_CFG: dict[str, Any] = {
|
||||
"dhcp": {
|
||||
"ranges": [],
|
||||
@@ -66,244 +53,7 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
logger.info("dnsmasq config saved")
|
||||
|
||||
|
||||
def apply_config() -> None:
|
||||
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
|
||||
cfg = get_config()
|
||||
conf_text = generate_conf(cfg)
|
||||
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
|
||||
subprocess.run(
|
||||
["sudo", "tee", DNSMASQ_CONF, "--"],
|
||||
input=conf_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "reload", "dnsmasq"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.info("dnsmasq config written and reloaded")
|
||||
|
||||
|
||||
# ───────── config generation ─────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
"""Render a complete dnsmasq.conf text block from the config dict."""
|
||||
dhcp_cfg = cfg.get("dhcp", {})
|
||||
dns_cfg = cfg.get("dns", {})
|
||||
|
||||
interfaces = [
|
||||
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
|
||||
]
|
||||
|
||||
# Fallback: use network-managed interface addresses for listen-address
|
||||
listen_addresses = []
|
||||
try:
|
||||
from lib.network import get_config as _get_net_config
|
||||
|
||||
net_cfg = _get_net_config()
|
||||
for _iface, info in net_cfg.get("interfaces", {}).items():
|
||||
for addr_str in info.get("addresses", []):
|
||||
if "/" in addr_str:
|
||||
addr_str = addr_str.split("/")[0]
|
||||
listen_addresses.append(addr_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tmpl = ENV.get_template("dnsmasq.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interfaces=interfaces or None,
|
||||
listen_addresses=listen_addresses if listen_addresses else None,
|
||||
dhcp=dhcp_cfg,
|
||||
dns=dns_cfg,
|
||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
||||
)
|
||||
|
||||
|
||||
# ───────── dhcp management ───────────────────────────────────────────
|
||||
|
||||
|
||||
def set_dhcp_range(
|
||||
iface: str,
|
||||
start: str,
|
||||
end: str,
|
||||
lease_time: str = "12h",
|
||||
gateway: str | None = None,
|
||||
dns: str | None = None,
|
||||
) -> None:
|
||||
"""Add or replace the DHCP range for a given interface."""
|
||||
cfg = get_config()
|
||||
ranges = cfg["dhcp"]["ranges"]
|
||||
|
||||
found = False
|
||||
for i, r in enumerate(ranges):
|
||||
if r.get("interface") == iface:
|
||||
ranges[i] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if gateway:
|
||||
ranges[i]["gateway"] = gateway
|
||||
if dns:
|
||||
ranges[i]["dns"] = dns
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
entry: dict[str, Any] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if gateway:
|
||||
entry["gateway"] = gateway
|
||||
if dns:
|
||||
entry["dns"] = dns
|
||||
ranges.append(entry)
|
||||
|
||||
save_config(cfg)
|
||||
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
|
||||
|
||||
|
||||
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
|
||||
"""Remove a DHCP range by interface + IP range."""
|
||||
cfg = get_config()
|
||||
cfg["dhcp"]["ranges"] = [
|
||||
r
|
||||
for r in cfg["dhcp"]["ranges"]
|
||||
if not (
|
||||
r.get("interface") == iface
|
||||
and r.get("start") == start
|
||||
and r.get("end") == end
|
||||
)
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
|
||||
|
||||
|
||||
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
"""Add (or update) a static DHCP lease by MAC address."""
|
||||
cfg = get_config()
|
||||
leases = cfg["dhcp"]["static_leases"]
|
||||
|
||||
for i, lease in enumerate(leases):
|
||||
if lease["mac"].lower() == mac.lower():
|
||||
leases[i].update({"mac": mac, "ip": ip})
|
||||
if hostname is not None:
|
||||
leases[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
|
||||
|
||||
|
||||
def remove_static_lease(mac: str) -> None:
|
||||
"""Remove a static DHCP lease by MAC address."""
|
||||
cfg = get_config()
|
||||
cfg["dhcp"]["static_leases"] = [
|
||||
lease
|
||||
for lease in cfg["dhcp"]["static_leases"]
|
||||
if lease["mac"].lower() != mac.lower()
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease removed for MAC %s", mac)
|
||||
|
||||
|
||||
# ───────── dns record management ─────────────────────────────────────
|
||||
|
||||
|
||||
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
|
||||
"""Add or update a custom DNS A record."""
|
||||
cfg = get_config()
|
||||
records = cfg["dns"]["custom_records"]
|
||||
|
||||
for i, r in enumerate(records):
|
||||
if r["name"] == name:
|
||||
records[i].update({"name": name, "address": address})
|
||||
if hostname is not None:
|
||||
records[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
logger.info("DNS record updated: %s -> %s", name, address)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
save_config(cfg)
|
||||
logger.info("DNS record added: %s -> %s", name, address)
|
||||
|
||||
|
||||
def remove_dns_record(name: str) -> None:
|
||||
"""Remove a custom DNS record by name."""
|
||||
cfg = get_config()
|
||||
cfg["dns"]["custom_records"] = [
|
||||
r for r in cfg["dns"]["custom_records"] if r["name"] != name
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("DNS record removed: %s", name)
|
||||
|
||||
|
||||
# ───────── lease table ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_lease_line(line: str) -> dict[str, Any] | None:
|
||||
"""Parse one line from dnsmasq.leases into a dict."""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
ts = None
|
||||
|
||||
return {
|
||||
"expires_at": ts,
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
|
||||
|
||||
def get_lease_table() -> list[dict[str, Any]]:
|
||||
"""Read and parse the current dnsmasq lease file."""
|
||||
leases: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "cat", LEASE_FILE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
||||
if entry is not None:
|
||||
leases.append(entry)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
return leases
|
||||
|
||||
|
||||
# ───────── upstream / domain helpers ─────────────────────────────────
|
||||
# ───────── upstream helpers ──────────────────────────────────────────
|
||||
|
||||
|
||||
def set_upstreams(servers: list[str]) -> None:
|
||||
@@ -322,64 +72,9 @@ def set_domain(domain: str | None) -> None:
|
||||
logger.info("DNS domain set to '%s'", domain)
|
||||
|
||||
|
||||
# ───────── status / info ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_status() -> dict[str, Any]:
|
||||
"""Return service status, config summary, and current lease count."""
|
||||
cfg = get_config()
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sudo", "systemctl", "is-active", "dnsmasq"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
active = proc.stdout.strip() == "active"
|
||||
except Exception:
|
||||
active = False
|
||||
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
if conf_exists:
|
||||
try:
|
||||
with open(DNSMASQ_CONF) as f:
|
||||
conf_on_disk = f.read()
|
||||
except PermissionError:
|
||||
conf_on_disk = ""
|
||||
else:
|
||||
conf_on_disk = ""
|
||||
|
||||
expected = generate_conf(cfg)
|
||||
|
||||
leases = get_lease_table()
|
||||
|
||||
return {
|
||||
"service_active": active,
|
||||
"config_file_exists": conf_exists,
|
||||
"config_in_sync": conf_on_disk == expected,
|
||||
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
|
||||
"static_leases": len(cfg["dhcp"]["static_leases"]),
|
||||
"custom_dns_records": len(cfg["dns"]["custom_records"]),
|
||||
"upstreams": cfg["dns"]["upstreams"],
|
||||
"domain": cfg["dns"].get("domain"),
|
||||
"active_leases": len(leases),
|
||||
"leases": leases,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"add_dns_record",
|
||||
"add_static_lease",
|
||||
"apply_config",
|
||||
"generate_conf",
|
||||
"get_config",
|
||||
"get_lease_table",
|
||||
"get_status",
|
||||
"remove_dhcp_range",
|
||||
"remove_dns_record",
|
||||
"remove_static_lease",
|
||||
"save_config",
|
||||
"set_dhcp_range",
|
||||
"set_domain",
|
||||
"set_upstreams",
|
||||
]
|
||||
|
||||
+112
-13
@@ -22,6 +22,22 @@ CONFIG_FILE: Path = CONFIG_DIR / "config.json"
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
|
||||
|
||||
# Zones firewalld ships by default. They are always present live and are
|
||||
# never meaningful to flag as "unmanaged (not in config)".
|
||||
FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset(
|
||||
{
|
||||
"block",
|
||||
"dmz",
|
||||
"drop",
|
||||
"external",
|
||||
"home",
|
||||
"host",
|
||||
"internal",
|
||||
"public",
|
||||
"trusted",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
@@ -66,7 +82,9 @@ def _parse_interfaces(output: str) -> list[str]:
|
||||
|
||||
|
||||
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
|
||||
"""Parse ``firewall-cmd --zone=Z --list-all`` output."""
|
||||
"""Parse ``firewall-cmd --zone=Z --list-all`` or a zone block
|
||||
from ``--list-all-zones`` output.
|
||||
"""
|
||||
info: dict[str, Any] = {"name": zone}
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
@@ -76,6 +94,11 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# --list-all-zones uses "rich rules" (space) while
|
||||
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
|
||||
if key == "rich rules":
|
||||
key = "rich-rules"
|
||||
|
||||
if not value:
|
||||
if key in ("masquerade", "ics"):
|
||||
info[key] = False
|
||||
@@ -116,6 +139,43 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
|
||||
return info
|
||||
|
||||
|
||||
def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
|
||||
"""Parse the combined ``firewall-cmd --list-all-zones`` output.
|
||||
|
||||
Returns a dict mapping each zone name to its parsed info dict
|
||||
(same structure as ``_parse_zone_output``).
|
||||
"""
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
current_name: str | None = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
for raw_line in output.splitlines():
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
# Non-indented line starts a new zone block
|
||||
if raw_line[0].isspace():
|
||||
if current_name is not None:
|
||||
current_lines.append(raw_line.strip())
|
||||
else:
|
||||
# Finalize previous zone
|
||||
if current_name is not None and current_lines:
|
||||
zones[current_name] = _parse_zone_output(
|
||||
current_name, "\n".join(current_lines)
|
||||
)
|
||||
# Extract zone name (discard trailing parenthetical metadata)
|
||||
name = raw_line.strip().split()[0]
|
||||
if "(" in name:
|
||||
name = name[: name.index("(")]
|
||||
current_name = name
|
||||
current_lines = []
|
||||
|
||||
# Finalize last zone
|
||||
if current_name is not None and current_lines:
|
||||
zones[current_name] = _parse_zone_output(current_name, "\n".join(current_lines))
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for parsing forward-port lines
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -269,17 +329,22 @@ def _compute_pending_changes(
|
||||
}
|
||||
)
|
||||
|
||||
cfg_mq = zone_cfg.get("masquerade", False)
|
||||
live_mq = live_zone.get("masquerade", False)
|
||||
if cfg_mq != live_mq:
|
||||
changes.append(
|
||||
{
|
||||
"zone": zone_name,
|
||||
"type": "masquerade",
|
||||
"config": cfg_mq,
|
||||
"live": live_mq,
|
||||
}
|
||||
)
|
||||
# public zone masquerade is not reconciled by apply (it is driven by
|
||||
# the nftables propagation step in daemon/handlers/firewall.py), so
|
||||
# reporting it as pending here would advertise a change that never
|
||||
# happens. Skip it to keep the diff consistent with apply.
|
||||
if zone_name != "public":
|
||||
cfg_mq = zone_cfg.get("masquerade", False)
|
||||
live_mq = live_zone.get("masquerade", False)
|
||||
if cfg_mq != live_mq:
|
||||
changes.append(
|
||||
{
|
||||
"zone": zone_name,
|
||||
"type": "masquerade",
|
||||
"config": cfg_mq,
|
||||
"live": live_mq,
|
||||
}
|
||||
)
|
||||
|
||||
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
|
||||
live_rules = set(live_zone.get("rich-rules", []))
|
||||
@@ -312,7 +377,7 @@ def _compute_pending_changes(
|
||||
)
|
||||
|
||||
for zone_name in live_zones:
|
||||
if zone_name not in cfg_zones:
|
||||
if zone_name not in cfg_zones and zone_name not in FIREWALLD_BUILTIN_ZONES:
|
||||
unknown_live[zone_name] = {
|
||||
"interfaces": live_zones[zone_name].get("interfaces", []),
|
||||
}
|
||||
@@ -335,11 +400,43 @@ def config_pending(state: dict[str, Any]) -> dict[str, Any]:
|
||||
return _compute_pending_changes(cfg, live_zones)
|
||||
|
||||
|
||||
def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str:
|
||||
"""Build a human-readable summary string for a firewall change."""
|
||||
if ctype == "interfaces":
|
||||
config_if = change.get("config", [])
|
||||
live_if = change.get("live", [])
|
||||
return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})"
|
||||
if ctype == "services":
|
||||
config_sv = change.get("config", [])
|
||||
live_sv = change.get("live", [])
|
||||
return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})"
|
||||
if ctype == "rich_rules":
|
||||
cfg_count = change.get("config_count", 0)
|
||||
live_count = change.get("live_count", 0)
|
||||
return (
|
||||
f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})"
|
||||
)
|
||||
if ctype == "forward_ports":
|
||||
cfg_count = change.get("config_count", 0)
|
||||
live_count = change.get("live_count", 0)
|
||||
return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})"
|
||||
if ctype == "masquerade":
|
||||
cfg_val = change.get("config", False)
|
||||
live_val = change.get("live", False)
|
||||
return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})"
|
||||
if ctype == "target":
|
||||
cfg_val = change.get("config", "default")
|
||||
live_val = change.get("live", "default")
|
||||
return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})"
|
||||
return f"Zone {zone}: {ctype} changed"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONFIG_DIR",
|
||||
"CONFIG_FILE",
|
||||
"DATA_DIR",
|
||||
"DEFAULT_CONFIG",
|
||||
"FIREWALLD_BUILTIN_ZONES",
|
||||
"RULES_FILE",
|
||||
"_compute_pending_changes",
|
||||
"_ensure_config_file",
|
||||
@@ -347,10 +444,12 @@ __all__ = [
|
||||
"_normalize_target",
|
||||
"_now_iso",
|
||||
"_parse_active_zones",
|
||||
"_parse_all_zones_output",
|
||||
"_parse_forward_ports",
|
||||
"_parse_interfaces",
|
||||
"_parse_zone_output",
|
||||
"config_pending",
|
||||
"fw_change_summary",
|
||||
"get_config",
|
||||
"load_backup",
|
||||
"save_backup",
|
||||
|
||||
@@ -76,6 +76,15 @@ def setup_logging(level: str | None = None) -> None:
|
||||
"""
|
||||
|
||||
def _open(self):
|
||||
"""Override to open log file with group-write permissions (0664).
|
||||
|
||||
Temporarily clears umask to ensure the file is writable by both the
|
||||
WebUI process (vacuum-wall user) and daemon process (vacuum-walld user)
|
||||
when they share a group. On existing stale files, chmod's to 0664.
|
||||
|
||||
Returns:
|
||||
Open file object in append mode.
|
||||
"""
|
||||
# Ensure group-write on an existing stale file (e.g. left by the
|
||||
# other process with a stricter umask at creation time).
|
||||
with contextlib.suppress(OSError):
|
||||
|
||||
@@ -103,30 +103,73 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
"""Append a ``Key=Value`` line to *lines* if *py_key* has a non-None value in *d*.
|
||||
|
||||
Args:
|
||||
lines: Target list to append rendered line to.
|
||||
key: INI key name for the output line.
|
||||
py_key: Python dict key to look up in *d*.
|
||||
d: Config entry dict to extract value from.
|
||||
"""
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
lines.append(f"{key}={v}")
|
||||
|
||||
|
||||
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
"""Append a ``Key=Value`` line to *lines* for integer values.
|
||||
|
||||
Args:
|
||||
lines: Target list to append rendered line to.
|
||||
key: INI key name for the output line.
|
||||
py_key: Python dict key to look up in *d*.
|
||||
d: Config entry dict to extract value from.
|
||||
"""
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
lines.append(f"{key}={v}")
|
||||
|
||||
|
||||
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
"""Append a ``Key=yes/no`` line to *lines* if *py_key* has a non-None value in *d*.
|
||||
|
||||
Args:
|
||||
lines: Target list to append rendered line to.
|
||||
key: INI key name for the output line.
|
||||
py_key: Python dict key to look up in *d*.
|
||||
d: Config entry dict to extract value from.
|
||||
"""
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||
|
||||
|
||||
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
"""Identical to :func:`_emit_bool` — kept for API compatibility.
|
||||
|
||||
Args:
|
||||
lines: Target list to append rendered line to.
|
||||
key: INI key name for the output line.
|
||||
py_key: Python dict key to look up in *d*.
|
||||
d: Config entry dict to extract value from.
|
||||
"""
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||
|
||||
|
||||
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
"""Append a ``Key=Value`` line handling both bool and non-bool types.
|
||||
|
||||
Boolean values are rendered as ``yes``/``no``; all other types are
|
||||
stringified directly.
|
||||
|
||||
Args:
|
||||
lines: Target list to append rendered line to.
|
||||
key: INI key name for the output line.
|
||||
py_key: Python dict key to look up in *d*.
|
||||
d: Config entry dict to extract value from.
|
||||
"""
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
if isinstance(v, bool):
|
||||
@@ -570,6 +613,18 @@ _IS_LOCAL = [
|
||||
|
||||
|
||||
def _is_local_dns(addr: str) -> bool:
|
||||
"""Return ``True`` if *addr* falls within a local/private IP range.
|
||||
|
||||
Checks loopback, RFC 1918 (10/8, 172.16/12, 192.168/16), link-local
|
||||
(169.254/16), and their IPv6 equivalents (fc00::/7, fe80::/10).
|
||||
|
||||
Args:
|
||||
addr: IP address string to test.
|
||||
|
||||
Returns:
|
||||
``True`` if the address is local/private, ``False`` otherwise.
|
||||
Invalid addresses are treated as non-local.
|
||||
"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
for net in _IS_LOCAL:
|
||||
|
||||
+209
-152
@@ -1,7 +1,7 @@
|
||||
"""Nginx server-block generator for Vacuum Wall SSL proxy firewall.
|
||||
|
||||
Manages per-domain SSL reverse proxy configurations, certificate
|
||||
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
||||
Manages per-domain SSL reverse proxy configurations backed by named backends,
|
||||
certificate bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from lib.acme import find_cert_dir
|
||||
from lib.common import ensure_dirs, load_json, save_json
|
||||
from lib.common import _hash_password, ensure_dirs, load_json, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,74 +47,127 @@ DEFAULT_SSL: dict[str, Any] = {
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
WEBUI_BACKEND: dict[str, Any] = {
|
||||
"label": "Vacuum Wall WebUI",
|
||||
"builtin": True,
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||
"is_management": True,
|
||||
# The WebUI is protected by JWT at the Flask layer; nginx must
|
||||
# not gate it with auth_basic (the SPA sends Bearer tokens, which
|
||||
# suppress the browser's automatic Basic credentials). auth=None
|
||||
# renders `auth_basic off` even if legacy auth was harvested.
|
||||
"auth": None,
|
||||
},
|
||||
"/ws": {
|
||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"backends": {},
|
||||
"domains": {},
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution
|
||||
|
||||
|
||||
def _resolve_paths(
|
||||
domain_cfg: dict[str, Any], backends: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve effective paths from backends[domain_cfg['backend']].paths."""
|
||||
backend_name = domain_cfg.get("backend", "")
|
||||
if backend_name and backend_name in backends:
|
||||
return backends[backend_name].get("paths", {})
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_auth(
|
||||
domain_cfg: dict[str, Any], backends: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve effective auth: domain auth -> backend auth -> None."""
|
||||
if "auth" in domain_cfg:
|
||||
return domain_cfg.get("auth")
|
||||
backend_name = domain_cfg.get("backend", "")
|
||||
if backend_name and backend_name in backends:
|
||||
backend_auth = backends[backend_name].get("auth")
|
||||
if backend_auth is not None:
|
||||
return backend_auth
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Migrate legacy config formats to the new paths-based model.
|
||||
|
||||
Handles two migrations:
|
||||
1. Legacy ``management`` top-level key -> path entry under its domain.
|
||||
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
|
||||
|
||||
Args:
|
||||
raw: Config dict as loaded from disk.
|
||||
|
||||
Returns:
|
||||
The migrated config dict.
|
||||
"""
|
||||
# Migrate management key
|
||||
if "management" in raw and raw["management"] is not None:
|
||||
mgmt = raw["management"]
|
||||
mgmt_domain = mgmt.get("domain", "")
|
||||
if mgmt_domain:
|
||||
domains = raw.setdefault("domains", {})
|
||||
if mgmt_domain not in domains:
|
||||
domains[mgmt_domain] = {
|
||||
"force_ssl": True,
|
||||
"paths": {},
|
||||
}
|
||||
dom = domains[mgmt_domain]
|
||||
paths = dom.setdefault("paths", {})
|
||||
if "/" not in paths:
|
||||
paths["/"] = {
|
||||
"backend": {
|
||||
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
}
|
||||
if mgmt.get("auth"):
|
||||
paths["/"]["auth"] = mgmt["auth"]
|
||||
# Add WebSocket path if not present
|
||||
if "/ws" not in paths:
|
||||
paths["/ws"] = {
|
||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
}
|
||||
del raw["management"]
|
||||
|
||||
# Migrate domain entries without paths
|
||||
for dom in raw.get("domains", {}).values():
|
||||
if "paths" not in dom and "backend" in dom:
|
||||
dom["paths"] = {
|
||||
"/": {
|
||||
"backend": dom.pop("backend"),
|
||||
"headers": dom.pop("headers", {}),
|
||||
}
|
||||
}
|
||||
"""Migrate legacy config to backends model."""
|
||||
_ensure_webui_backend(raw)
|
||||
_migrate_mgmt_domains(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _ensure_webui_backend(raw: dict[str, Any]) -> None:
|
||||
"""Create the builtin webui backend if not yet migrated."""
|
||||
backends = raw.setdefault("backends", {})
|
||||
webui = backends.get("webui")
|
||||
if webui and webui.get("_migrated"):
|
||||
return
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
|
||||
|
||||
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
|
||||
"""Migrate legacy management domains to backend references.
|
||||
|
||||
Legacy format: management domains had inline paths pointing to
|
||||
127.0.0.1:9090 (Flask) and 127.0.0.1:9091 (WebSocket).
|
||||
New format: domains reference the "webui" backend by name.
|
||||
|
||||
Detection heuristic: if both "/" path points to 127.0.0.1:9090
|
||||
(is_management) and "/ws" path points to 127.0.0.1:9091
|
||||
(is_websocket), the domain is a management domain and gets migrated.
|
||||
"""
|
||||
backends = raw.get("backends", {})
|
||||
if not backends.get("webui", {}).get("_migrated"):
|
||||
return
|
||||
domains = raw.setdefault("domains", {})
|
||||
for _name, dom in list(domains.items()):
|
||||
if dom.get("backend") == "webui":
|
||||
continue # Already migrated
|
||||
if dom.get("application") == "webui":
|
||||
del dom["application"]
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
ws = paths.get("/ws", {})
|
||||
root_backend = root.get("backend", {})
|
||||
ws_backend = ws.get("backend", {})
|
||||
# Check if root path points to Flask management backend
|
||||
is_mgmt_root = root.get("is_management") or (
|
||||
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
|
||||
)
|
||||
# Check if WS path points to WebSocket management backend
|
||||
is_mgmt_ws = ws.get("is_websocket") or (
|
||||
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
|
||||
)
|
||||
# If both match, migrate: set backend reference, remove inline paths/auth
|
||||
if is_mgmt_root and is_mgmt_ws:
|
||||
dom["backend"] = "webui"
|
||||
dom.pop("paths", None)
|
||||
dom.pop("auth", None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Load the current nginx config, initializing with defaults if needed.
|
||||
|
||||
@@ -122,7 +175,7 @@ def get_config() -> dict[str, Any]:
|
||||
legacy formats, then returns the config dict.
|
||||
|
||||
Returns:
|
||||
The complete config dict with ``domains`` and ``ssl`` keys.
|
||||
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
|
||||
"""
|
||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||
raw = load_json(CONFIG_FILE)
|
||||
@@ -130,6 +183,8 @@ def get_config() -> dict[str, Any]:
|
||||
raw = deepcopy(DEFAULT_CONFIG)
|
||||
if "ssl" not in raw:
|
||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||
if "backends" not in raw:
|
||||
raw["backends"] = {}
|
||||
raw = _migrate_config(raw)
|
||||
save_config(raw)
|
||||
return raw
|
||||
@@ -143,20 +198,21 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
def get_domains() -> list[dict[str, Any]]:
|
||||
"""Return a list of all configured proxy domains flattened by path.
|
||||
|
||||
Each path within a domain becomes a separate entry with domain-level
|
||||
settings repeated.
|
||||
Each path within a domain becomes a separate entry. Paths are resolved
|
||||
from the domain's referenced backend.
|
||||
|
||||
Returns:
|
||||
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
|
||||
``force_ssl``, and path-level flags.
|
||||
``force_ssl``, ``backend_name``, and path-level flags.
|
||||
"""
|
||||
cfg = get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
paths = dom.get("paths", {})
|
||||
if not paths:
|
||||
if "backend" not in dom:
|
||||
continue
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
paths = _resolve_paths(dom, backends)
|
||||
for ppath, pcfg in paths.items():
|
||||
entry: dict[str, Any] = {
|
||||
"domain": name,
|
||||
@@ -164,6 +220,7 @@ def get_domains() -> list[dict[str, Any]]:
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists(),
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
"backend_name": dom["backend"],
|
||||
}
|
||||
if pcfg.get("is_management"):
|
||||
entry["is_management"] = True
|
||||
@@ -173,6 +230,33 @@ def get_domains() -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def get_management_domains() -> list[str]:
|
||||
"""Return domain names that serve the management UI.
|
||||
|
||||
Checks both backend-referenced paths (for migrated configs) and
|
||||
inline paths (for legacy configs pending migration).
|
||||
|
||||
Returns:
|
||||
List of domain name strings.
|
||||
"""
|
||||
cfg = get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
domains: list[str] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
# Check inline paths (pre-migration format)
|
||||
inline_paths = dom.get("paths", {})
|
||||
if any(p.get("is_management") for p in inline_paths.values()):
|
||||
domains.append(name)
|
||||
continue
|
||||
# Check backend-referenced paths
|
||||
backend_name = dom.get("backend", "")
|
||||
if backend_name and backend_name in backends:
|
||||
paths = backends[backend_name].get("paths", {})
|
||||
if any(p.get("is_management") for p in paths.values()):
|
||||
domains.append(name)
|
||||
return domains
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Domain CRUD
|
||||
# ------------------------------------------------------------------
|
||||
@@ -180,57 +264,37 @@ def get_domains() -> list[dict[str, Any]]:
|
||||
|
||||
def add_domain(
|
||||
domain: str,
|
||||
backend_host: str | None = None,
|
||||
backend_port: int | None = None,
|
||||
backend_proto: str = "http",
|
||||
backend_name: str,
|
||||
cert: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
paths: dict[str, dict[str, Any]] | None = None,
|
||||
force_ssl: bool = True,
|
||||
auth: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Add a new proxy domain with the given backend and optional settings.
|
||||
"""Add a new proxy domain that references an existing backend.
|
||||
|
||||
Args:
|
||||
domain: Domain name to add.
|
||||
backend_host: Upstream host to proxy to (legacy mode).
|
||||
backend_port: Upstream port (legacy mode).
|
||||
backend_proto: Protocol (``http`` or ``https``; legacy mode).
|
||||
backend_name: Name of the backend to proxy through.
|
||||
cert: Optional certificate type identifier.
|
||||
extra_headers: Optional dict of extra headers to forward (legacy mode).
|
||||
paths: Optional path-to-config map (new mode). Each path entry must
|
||||
have a ``backend`` key with ``host``, ``port``, and ``proto``.
|
||||
force_ssl: Whether to enforce HTTPS redirect.
|
||||
auth: Optional domain-level auth override.
|
||||
|
||||
Raises:
|
||||
ValueError: If the domain is already configured.
|
||||
ValueError: If the domain is already configured or backend not found.
|
||||
"""
|
||||
cfg = get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
if backend_name not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {backend_name!r} not found")
|
||||
|
||||
if paths is not None:
|
||||
entry: dict[str, Any] = {
|
||||
"paths": paths,
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
else:
|
||||
if not backend_host or backend_port is None:
|
||||
raise ValueError("'backend_host' and 'backend_port' are required")
|
||||
entry = {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"headers": extra_headers or {},
|
||||
}
|
||||
},
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
entry: dict[str, Any] = {
|
||||
"backend": backend_name,
|
||||
"force_ssl": force_ssl,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
if auth is not None:
|
||||
entry["auth"] = auth
|
||||
|
||||
cfg["domains"][domain] = entry
|
||||
save_config(cfg)
|
||||
@@ -249,12 +313,10 @@ def remove_domain(domain: str) -> None:
|
||||
|
||||
|
||||
def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
"""Update fields of an existing domain entry in-place.
|
||||
"""Update domain-level fields of an existing domain entry.
|
||||
|
||||
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
|
||||
``paths``) and paths-level shorthand (``backend``, ``headers`` for
|
||||
the root path). When ``paths`` is provided, it is fully replaced.
|
||||
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
|
||||
Only domain-level keys are accepted: ``backend``, ``cert``, ``force_ssl``,
|
||||
``auth``. Path changes must be made on the backend.
|
||||
|
||||
Args:
|
||||
domain: Domain name to update.
|
||||
@@ -262,36 +324,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
|
||||
Raises:
|
||||
KeyError: If the domain is not configured.
|
||||
ValueError: If a new backend is specified but doesn't exist.
|
||||
"""
|
||||
cfg = get_config()
|
||||
if domain not in cfg["domains"]:
|
||||
raise KeyError(f"Domain {domain!r} not configured")
|
||||
entry = cfg["domains"][domain]
|
||||
|
||||
# If paths is given, replace entirely
|
||||
if "paths" in kwargs:
|
||||
entry["paths"] = kwargs["paths"]
|
||||
else:
|
||||
# Legacy: top-level backend/headers -> paths["/"]
|
||||
paths = entry.setdefault("paths", {})
|
||||
if "backend" in kwargs:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["backend"] = kwargs["backend"]
|
||||
if "headers" in kwargs:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["headers"] = kwargs["headers"]
|
||||
new_backend = kwargs.get("backend")
|
||||
if new_backend:
|
||||
if new_backend not in cfg.get("backends", {}):
|
||||
raise ValueError(f"Backend {new_backend!r} not found")
|
||||
entry["backend"] = new_backend
|
||||
|
||||
# Remove legacy top-level keys from domain entry
|
||||
entry.pop("backend", None)
|
||||
entry.pop("headers", None)
|
||||
allowed = ("backend", "cert", "force_ssl", "auth")
|
||||
for key in allowed:
|
||||
if key in kwargs and key != "backend":
|
||||
if key == "auth" and kwargs[key] is None:
|
||||
entry.pop("auth", None)
|
||||
else:
|
||||
entry[key] = kwargs[key]
|
||||
|
||||
for key, val in kwargs.items():
|
||||
if key in ("backend", "headers", "paths"):
|
||||
continue
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
else:
|
||||
entry[key] = val
|
||||
save_config(cfg)
|
||||
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
|
||||
|
||||
@@ -301,11 +354,15 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
def generate_server_conf(
|
||||
domain_cfg: dict[str, Any], backends: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""Render the Jinja template for a domain server block.
|
||||
|
||||
Args:
|
||||
domain_cfg: Domain entry dict including the ``domain`` key.
|
||||
backends: Optional backends dict for path/auth resolution.
|
||||
When omitted, falls back to reading from domain inline paths.
|
||||
|
||||
Returns:
|
||||
The complete nginx server-block configuration as a string.
|
||||
@@ -313,7 +370,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||
paths = domain_cfg.get("paths", {})
|
||||
|
||||
if backends is not None:
|
||||
paths = _resolve_paths(domain_cfg, backends)
|
||||
domain_auth = _resolve_auth(domain_cfg, backends)
|
||||
else:
|
||||
paths = domain_cfg.get("paths", {})
|
||||
domain_auth = domain_cfg.get("auth")
|
||||
|
||||
has_management = any(p.get("is_management") for p in paths.values())
|
||||
# Resolve custom cert paths for cert=="file"
|
||||
cert_cfg = domain_cfg.get("cert")
|
||||
@@ -330,7 +394,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
cert=domain_cfg.get("cert"),
|
||||
cert_path=cert_path,
|
||||
cert_key_path=cert_key_path,
|
||||
domain_auth=domain_cfg.get("auth"),
|
||||
domain_auth=domain_auth,
|
||||
has_management=has_management,
|
||||
acme_cert_dir=acme_cert_dir,
|
||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||
@@ -382,19 +446,20 @@ def write_acme_challenge() -> None:
|
||||
def write_all_sites() -> None:
|
||||
"""Regenerate all site configs from the current config state.
|
||||
|
||||
Writes server blocks for every configured domain (now unified, including
|
||||
any management paths), removes orphaned site files, and ensures the ACME
|
||||
challenge config is present.
|
||||
Writes server blocks for every configured domain using backend-resolved
|
||||
paths, removes orphaned site files, and ensures the ACME challenge
|
||||
config is present.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||
|
||||
written: set[str] = set()
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
dom_copy = dict(dom, domain=name)
|
||||
conf = generate_server_conf(dom_copy)
|
||||
conf = generate_server_conf(dom_copy, backends)
|
||||
write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
|
||||
@@ -533,26 +598,18 @@ def write_htpasswd(user: str, password: str) -> None:
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``).
|
||||
"""
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WEBUI_BACKEND",
|
||||
"_ensure_webui_backend",
|
||||
"_migrate_mgmt_domains",
|
||||
"_resolve_auth",
|
||||
"_resolve_paths",
|
||||
"add_domain",
|
||||
"apply",
|
||||
"generate_server_conf",
|
||||
"get_config",
|
||||
"get_domains",
|
||||
"get_management_domains",
|
||||
"remove_domain",
|
||||
"save_config",
|
||||
"test_config",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Password hashing with Argon2id.
|
||||
|
||||
Handles user password storage for the auth DB. Uses argon2-cffi (C
|
||||
implementation of the Argon2id memory-hard KDF). Random 16-byte salt is
|
||||
generated per hash by the library.
|
||||
|
||||
Argon2id is used for auth user passwords ONLY. Nginx basic-auth htpasswd
|
||||
files continue to use sha256_crypt (passlib) — that's a separate concern
|
||||
with different constraints (htpasswd format is standardized).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
from argon2.low_level import Type
|
||||
|
||||
# Argon2id: OWASP recommended parameters
|
||||
# 64 MiB memory, 3 iterations, 4 parallel threads
|
||||
_PH = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64 MiB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
type=Type.ID,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Generate an Argon2id hash of *password*.
|
||||
|
||||
A random 16-byte salt is generated automatically by argon2.
|
||||
The resulting hash string starts with ``$argon2id$`` and encodes
|
||||
the algorithm version, parameters, salt, and hash output.
|
||||
|
||||
Args:
|
||||
password: Plain-text password string.
|
||||
|
||||
Returns:
|
||||
Full Argon2id hash string (e.g. ``$argon2id$v=19$m=65536,t=3,p=4$...``).
|
||||
|
||||
Raises:
|
||||
TypeError: If *password* is not a string or contains NUL bytes.
|
||||
"""
|
||||
return _PH.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hash_string: str) -> bool:
|
||||
"""Verify *password* against an Argon2id *hash_string*.
|
||||
|
||||
The hash string must have been produced by :func:`hash_password`.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to verify.
|
||||
hash_string: Argon2id hash string to compare against.
|
||||
|
||||
Returns:
|
||||
``True`` if the password matches, ``False`` otherwise.
|
||||
|
||||
Raises:
|
||||
TypeError: If *hash_string* is not a valid Argon2id hash.
|
||||
"""
|
||||
try:
|
||||
_PH.verify(hash_string, password)
|
||||
return True
|
||||
except (InvalidHashError, VerifyMismatchError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(hash_string: str) -> bool:
|
||||
"""Check if *hash_string* needs to be rehashed with updated parameters.
|
||||
|
||||
Returns True if the hash was not produced with the current parameters
|
||||
of the hasher, indicating it should be rehashed on next login.
|
||||
|
||||
Args:
|
||||
hash_string: Argon2id hash string to check.
|
||||
|
||||
Returns:
|
||||
``True`` if the hash parameters should be upgraded.
|
||||
"""
|
||||
return _PH.check_needs_rehash(hash_string)
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
"""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 PendingChange(TypedDict):
|
||||
"""One field-level difference between the applied config and the current
|
||||
saved config (see `lib.common.deep_diff`).
|
||||
|
||||
Attributes:
|
||||
path: Dotted (or indexed) path to the changed field.
|
||||
action: "added", "removed", or "changed".
|
||||
old: Value in the last applied config (None when added).
|
||||
new: Value in the current config (None when removed).
|
||||
"""
|
||||
|
||||
path: str
|
||||
action: str
|
||||
old: Any
|
||||
new: Any
|
||||
|
||||
|
||||
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.
|
||||
pending_diff: Field-level changes since the last apply (empty when
|
||||
up to date or when no applied snapshot is recorded).
|
||||
"""
|
||||
|
||||
service_active: bool
|
||||
config_file_exists: bool
|
||||
active_leases: int
|
||||
pending_changes: bool
|
||||
pending_diff: list[PendingChange]
|
||||
|
||||
|
||||
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.
|
||||
pending_diff: Field-level changes since the last apply (empty when
|
||||
up to date or when no applied snapshot is recorded).
|
||||
"""
|
||||
|
||||
up: bool
|
||||
interface: dict[str, Any]
|
||||
peers: list[WgStatusPeer]
|
||||
classes: dict[str, WgClassStatus]
|
||||
pending_changes: bool
|
||||
pending_diff: list[PendingChange]
|
||||
|
||||
|
||||
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
|
||||
+463
-146
@@ -12,10 +12,20 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from lib.common import load_json, run, run_proc
|
||||
from lib import schema
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
load_json,
|
||||
run,
|
||||
run_proc,
|
||||
strip_apply_meta,
|
||||
)
|
||||
from lib.firewall import (
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
_parse_all_zones_output,
|
||||
)
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
@@ -36,6 +46,11 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
||||
"wireguard": 10,
|
||||
"dnsmasq": 10,
|
||||
"networkd": 10,
|
||||
"system": 1,
|
||||
# nginx/acme state derives from config files (and lazy in-place migration
|
||||
# can rewrite them without a mutation); poll so drift self-heals.
|
||||
"nginx": 60,
|
||||
"acme": 300,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +78,7 @@ class State:
|
||||
"acme",
|
||||
"wireguard",
|
||||
"networkd",
|
||||
"system",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -119,6 +135,17 @@ class State:
|
||||
"""
|
||||
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:
|
||||
"""Set state data for *subsystem*.
|
||||
|
||||
@@ -277,58 +304,72 @@ def _strip_volatile(
|
||||
for k in pop_keys:
|
||||
stripped.pop(k, None)
|
||||
for vpath in volatile:
|
||||
# Determine if this is a list-of-dicts pattern
|
||||
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
||||
if list_marker != -1:
|
||||
# Split into prefix (before []), item keys (after [])
|
||||
prefix = vpath[:list_marker].split(".")
|
||||
item_keys = (
|
||||
vpath[list_marker + 3 :].split(".")
|
||||
if list_marker + 3 < len(vpath)
|
||||
else []
|
||||
)
|
||||
parent = stripped
|
||||
for seg in prefix:
|
||||
_strip_volatile_path(stripped, vpath)
|
||||
return stripped
|
||||
|
||||
|
||||
def _strip_volatile_item(item: dict[str, Any], keys: list[str]) -> None:
|
||||
"""Recursively strip volatile keys from *item*, handling nested ``[]`` markers."""
|
||||
for i, k in enumerate(keys):
|
||||
if "[]" in k:
|
||||
base_key = k.replace("[]", "")
|
||||
rest = keys[i + 1 :]
|
||||
target = item.get(base_key, [])
|
||||
if isinstance(target, list):
|
||||
for t in target:
|
||||
if isinstance(t, dict):
|
||||
_strip_volatile_item(t, rest)
|
||||
elif isinstance(target, dict):
|
||||
for v in target.values():
|
||||
if isinstance(v, dict):
|
||||
_strip_volatile_item(v, rest)
|
||||
return
|
||||
elif i == len(keys) - 1:
|
||||
item[k] = None
|
||||
return
|
||||
else:
|
||||
if isinstance(item, dict) and k in item:
|
||||
item = item[k]
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def _strip_volatile_path(stripped: dict[str, Any], vpath: str) -> None:
|
||||
"""Strip a single volatile path from *stripped*, supporting nested ``[]`` markers."""
|
||||
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
||||
if list_marker == -1:
|
||||
segments = vpath.split(".")
|
||||
parent = stripped
|
||||
for i, seg in enumerate(segments):
|
||||
if i == len(segments) - 1:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent[seg] = None
|
||||
else:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
break
|
||||
if isinstance(parent, list):
|
||||
items = parent
|
||||
elif isinstance(parent, dict):
|
||||
logger.debug(
|
||||
"_strip_volatile: %s resolved to dict, falling back to .values()",
|
||||
vpath,
|
||||
)
|
||||
items = parent.values()
|
||||
else:
|
||||
continue
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
curr = item
|
||||
for i, ik in enumerate(item_keys):
|
||||
if i == len(item_keys) - 1:
|
||||
curr[ik] = None
|
||||
else:
|
||||
if isinstance(curr, dict) and ik in curr:
|
||||
curr = curr[ik]
|
||||
else:
|
||||
break
|
||||
return
|
||||
return
|
||||
# Split into prefix and item keys.
|
||||
prefix = vpath[:list_marker].split(".")
|
||||
item_keys = (
|
||||
vpath[list_marker + 3 :].split(".") if list_marker + 3 < len(vpath) else []
|
||||
)
|
||||
parent = stripped
|
||||
for seg in prefix:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
# Scalar/dict path
|
||||
segments = vpath.split(".")
|
||||
parent = stripped
|
||||
for i, seg in enumerate(segments):
|
||||
if i == len(segments) - 1:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent[seg] = None
|
||||
else:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
break
|
||||
return stripped
|
||||
return
|
||||
if isinstance(parent, list):
|
||||
items = parent
|
||||
elif isinstance(parent, dict):
|
||||
items = list(parent.values())
|
||||
else:
|
||||
return
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item_keys:
|
||||
_strip_volatile_item(item, item_keys)
|
||||
|
||||
|
||||
def _diff_layers(
|
||||
@@ -338,26 +379,36 @@ def _diff_layers(
|
||||
) -> tuple[bool, bool]:
|
||||
"""Compare *old* and *new* state using two-layer diff.
|
||||
|
||||
Strips ``timestamp`` from both before comparing.
|
||||
The two-layer strategy distinguishes between:
|
||||
1. Structural changes (config, topology) → triggers full client re-fetch
|
||||
2. Volatile changes (byte counters, timestamps) → triggers lightweight tick
|
||||
|
||||
If structural data changed, volatile is suppressed (False) because the
|
||||
structural change already triggers a full re-fetch, making the volatile
|
||||
signal redundant.
|
||||
|
||||
Args:
|
||||
old: Previous state data, or ``None`` if not yet populated.
|
||||
new: New state data from collector.
|
||||
volatile: Frozenset of volatile field paths.
|
||||
|
||||
Returns:
|
||||
``(structural_changed, volatile_changed)`` —
|
||||
``True`` means that layer differs between old and new.
|
||||
|
||||
If structural data changed, volatile is always ``False``
|
||||
(the structural change already triggers a full re-fetch, so
|
||||
the volatile signal is suppressed).
|
||||
``(structural_changed, volatile_changed)``.
|
||||
"""
|
||||
if old is None:
|
||||
return (True, True)
|
||||
|
||||
# Structural diff: compare with volatile/timestamp fields zeroed
|
||||
# Structural diff: compare with volatile fields zeroed out, plus timestamp
|
||||
# removed. If these differ, the configuration or topology has changed.
|
||||
pop_keys = frozenset(("timestamp",))
|
||||
old_struct = _strip_volatile(old, volatile, pop_keys)
|
||||
new_struct = _strip_volatile(new, volatile, pop_keys)
|
||||
structural = old_struct != new_struct
|
||||
|
||||
# Volatile diff: compare without timestamp
|
||||
# Volatile diff: only relevant if structural is unchanged. Compare full
|
||||
# data (minus timestamp). If this differs, only volatile fields changed
|
||||
# (e.g. WireGuard transfer counters), and a lightweight tick suffices.
|
||||
volatile_changed = False
|
||||
if not structural:
|
||||
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
|
||||
@@ -394,16 +445,16 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _collect_firewall() -> dict[str, Any]:
|
||||
def _collect_firewall() -> schema.FirewallState:
|
||||
"""Return the complete current state of firewalld.
|
||||
|
||||
Returns:
|
||||
Dict containing firewall zones, interfaces, rules, config, and
|
||||
pending changes.
|
||||
"""
|
||||
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
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 []
|
||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||
@@ -458,25 +509,25 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
|
||||
ifaces = list(iface_map.values())
|
||||
|
||||
# Collect all zones in a single call (replaces per-zone loop)
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for zn in zone_names:
|
||||
try:
|
||||
zones[zn] = _parse_zone_output(
|
||||
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||
zones = _parse_all_zones_output(all_zones_raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load config
|
||||
# Load config (strip apply bookkeeping keys, as the other collectors do)
|
||||
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
||||
config_data = {}
|
||||
if fw_config_path.exists():
|
||||
with contextlib.suppress(Exception):
|
||||
config_data = load_json(fw_config_path)
|
||||
config_data = strip_apply_meta(load_json(fw_config_path))
|
||||
|
||||
# Pending changes
|
||||
full_state = {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
@@ -489,6 +540,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"active_zones": active,
|
||||
"default_zone": default_zone,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
@@ -516,7 +568,7 @@ register_volatile(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_dnsmasq() -> dict[str, Any]:
|
||||
def _collect_dnsmasq() -> schema.DnsmasqState:
|
||||
"""Collect dnsmasq status, config, and leases.
|
||||
|
||||
Returns:
|
||||
@@ -525,7 +577,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
|
||||
|
||||
DEFAULT_CFG: dict[str, Any] = {
|
||||
"dhcp": {"ranges": [], "static_leases": []},
|
||||
@@ -577,25 +629,38 @@ def _collect_dnsmasq() -> dict[str, Any]:
|
||||
ts = None
|
||||
leases.append(
|
||||
{
|
||||
"expires_at": ts,
|
||||
"expires": ts.isoformat() if ts else "",
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
)
|
||||
except RuntimeError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check config file on disk
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
|
||||
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
||||
cfg
|
||||
)
|
||||
|
||||
safe_cfg = strip_apply_meta(cfg)
|
||||
pending_diff: list[dict[str, Any]] = []
|
||||
if pending_changes:
|
||||
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||
if isinstance(snap, dict):
|
||||
pending_diff = deep_diff(snap, safe_cfg)
|
||||
|
||||
return {
|
||||
"config": cfg,
|
||||
"config": safe_cfg,
|
||||
"status": {
|
||||
"service_active": service_active,
|
||||
"config_file_exists": conf_exists,
|
||||
"active_leases": len(leases),
|
||||
"pending_changes": pending_changes,
|
||||
"pending_diff": pending_diff,
|
||||
},
|
||||
"leases": leases,
|
||||
"timestamp": _now_iso(),
|
||||
@@ -611,7 +676,7 @@ register_collector("dnsmasq", _collect_dnsmasq)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_nginx() -> dict[str, Any]:
|
||||
def _collect_nginx() -> schema.NginxState:
|
||||
"""Collect nginx config and domains list.
|
||||
|
||||
Returns:
|
||||
@@ -652,10 +717,15 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
# Build flattened domains list (one entry per path)
|
||||
from lib.nginx import _resolve_paths as _ngx_resolve_paths
|
||||
|
||||
backends = cfg.get("backends", {})
|
||||
domains: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
if "backend" not in dom:
|
||||
continue
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
paths = dom.get("paths", {})
|
||||
paths = _ngx_resolve_paths(dom, backends)
|
||||
if not paths:
|
||||
continue
|
||||
for ppath, pcfg in paths.items():
|
||||
@@ -665,6 +735,7 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists() if SITES_DIR.exists() else False,
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
"backend_name": dom["backend"],
|
||||
"cert": dom.get("cert"),
|
||||
}
|
||||
if pcfg.get("is_management"):
|
||||
@@ -673,9 +744,24 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
entry["is_websocket"] = True
|
||||
domains.append(entry)
|
||||
|
||||
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
||||
cfg
|
||||
)
|
||||
|
||||
safe_cfg = strip_apply_meta(cfg)
|
||||
nginx_pending_diff: list[dict[str, Any]] = []
|
||||
if pending_changes:
|
||||
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||
if isinstance(snap, dict):
|
||||
nginx_pending_diff = deep_diff(snap, safe_cfg)
|
||||
|
||||
return {
|
||||
"config": cfg,
|
||||
"config": safe_cfg,
|
||||
"domains": domains,
|
||||
"status": {
|
||||
"pending_changes": pending_changes,
|
||||
"pending_diff": nginx_pending_diff,
|
||||
},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
@@ -800,7 +886,7 @@ def _get_acme_email() -> str:
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _collect_acme() -> dict[str, Any]:
|
||||
def _collect_acme() -> schema.AcmeState:
|
||||
"""Collect ACME certificate list and email.
|
||||
|
||||
Returns:
|
||||
@@ -808,48 +894,10 @@ def _collect_acme() -> dict[str, Any]:
|
||||
"""
|
||||
email = _get_acme_email()
|
||||
|
||||
certs: list[dict[str, Any]] = []
|
||||
try:
|
||||
from lib.acme import (
|
||||
_days_until,
|
||||
_has_auto_renew,
|
||||
_parse_list_output,
|
||||
_run_acme,
|
||||
)
|
||||
from lib.acme import list_certs
|
||||
|
||||
raw = _run_acme(["--list"])
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
acme_home = Path(acme_home_env)
|
||||
for entry in entries:
|
||||
main = entry.get("main_domain", "")
|
||||
if not main:
|
||||
continue
|
||||
san_domains = [
|
||||
d.strip()
|
||||
for d in entry.get("san_domains", "").split(",")
|
||||
if d.strip() and d.strip().lower() != "no"
|
||||
]
|
||||
cert_dir = acme_home / main
|
||||
days = _days_until(entry.get("renew", ""))
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"issuer": entry.get("ca", ""),
|
||||
"expiry": entry.get("renew", ""),
|
||||
"days_remaining": days,
|
||||
"expired": days is not None and days <= 0,
|
||||
"cert_path": str(cert_dir / "fullchain.cer"),
|
||||
"key_path": str(cert_dir / f"{main}.key"),
|
||||
"ca_path": str(cert_dir / "ca.cer"),
|
||||
"issued_at": entry.get("created", ""),
|
||||
"expires_at": entry.get("renew", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": _has_auto_renew(main),
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
certs = list_certs()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ACME state collection failed, returning empty cert list",
|
||||
@@ -875,11 +923,12 @@ register_collector("acme", _collect_acme)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_wireguard() -> dict[str, Any]:
|
||||
"""Collect WireGuard config, status, and peers.
|
||||
def _collect_wireguard() -> schema.WgState:
|
||||
"""Collect WireGuard config, per-class status, and peers.
|
||||
|
||||
Returns:
|
||||
Dict containing interface config, runtime status, and peers.
|
||||
Dict containing interface config, per-class runtime status,
|
||||
combined peers, and overall tunnel status.
|
||||
"""
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
|
||||
@@ -890,9 +939,12 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"server_endpoint": "",
|
||||
"description": "",
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"access_classes": {},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
@@ -907,11 +959,22 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Safe config (strip private key)
|
||||
safe = dict(cfg)
|
||||
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
|
||||
cfg
|
||||
)
|
||||
|
||||
# Safe config (strip private keys from interface and access classes)
|
||||
safe = strip_apply_meta(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
if "access_classes" in safe:
|
||||
safe["access_classes"] = {}
|
||||
for ck, cv in cfg.get("access_classes", {}).items():
|
||||
if isinstance(cv, dict):
|
||||
entry = dict(cv)
|
||||
entry.pop("private_key", None)
|
||||
safe["access_classes"][ck] = entry
|
||||
|
||||
# Peers list (safe)
|
||||
peers: list[dict[str, Any]] = []
|
||||
@@ -921,35 +984,49 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
entry.pop("private_key", None)
|
||||
peers.append(entry)
|
||||
|
||||
# Runtime status
|
||||
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
|
||||
name = cfg["interface"]["name"]
|
||||
peer_name = name if isinstance(name, str) else "wg0"
|
||||
try:
|
||||
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
# Runtime status — per-class interfaces
|
||||
status: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
"classes": {},
|
||||
}
|
||||
classes = cfg.get("access_classes", {})
|
||||
any_up = False
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not isinstance(class_cfg, dict):
|
||||
continue
|
||||
ifname = f"wg-{class_key}"
|
||||
try:
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
status["classes"][class_key] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
continue
|
||||
raw = res.stdout.strip()
|
||||
current_peer: dict[str, Any] | None = None
|
||||
status_peers: list[dict[str, Any]] = []
|
||||
class_peers: list[dict[str, Any]] = []
|
||||
cls_up = False
|
||||
cls_iface: dict[str, Any] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
status["up"] = True
|
||||
status["interface"] = {}
|
||||
cls_up = True
|
||||
cls_iface = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
cls_iface["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
status["interface"]["listen_port"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
continue
|
||||
if line.startswith("fwmark:"):
|
||||
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
||||
cls_iface["listen_port"] = int(line.split(":", 1)[1].strip())
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
@@ -962,7 +1039,7 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
status_peers.append(current_peer)
|
||||
class_peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
@@ -985,10 +1062,95 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
status["peers"] = status_peers
|
||||
status["classes"][class_key] = {
|
||||
"up": cls_up,
|
||||
"interface": cls_iface,
|
||||
"peers": class_peers,
|
||||
}
|
||||
if cls_up:
|
||||
any_up = True
|
||||
except Exception:
|
||||
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
# Also collect legacy single-interface status
|
||||
try:
|
||||
ifname = cfg["interface"].get("name", "wg0")
|
||||
legacy_peers: list[dict[str, Any]] = []
|
||||
current_peer: dict[str, Any] | None = None
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
raw = res.stdout.strip()
|
||||
status["up"] = True
|
||||
status["interface"] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
status["interface"] = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
status["interface"]["listen_port"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
current_peer = {
|
||||
"public_key": cur_key,
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": "0",
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
legacy_peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
if line.startswith("endpoint:"):
|
||||
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("allowed ips:"):
|
||||
current_peer["allowed_ips"] = (
|
||||
line.split(":", 1)[1].strip().split(", ")
|
||||
)
|
||||
elif line.startswith("latest handshake:"):
|
||||
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
rest = line.split(":", 1)[1].strip().split(", ")
|
||||
if rest:
|
||||
current_peer["transfer_received"] = rest[0].strip()
|
||||
if len(rest) > 1:
|
||||
current_peer["transfer_sent"] = rest[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
with contextlib.suppress(ValueError):
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
status["peers"] = legacy_peers
|
||||
any_up = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if any_up:
|
||||
status["up"] = True
|
||||
|
||||
status["pending_changes"] = pending_changes
|
||||
pending_diff: list[dict[str, Any]] = []
|
||||
if pending_changes:
|
||||
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||
if isinstance(snap, dict):
|
||||
# `safe` has private keys stripped; drop any private-key paths so
|
||||
# the pending summary never exposes key material.
|
||||
pending_diff = [
|
||||
d for d in deep_diff(snap, safe) if "private_key" not in d["path"]
|
||||
]
|
||||
status["pending_diff"] = pending_diff
|
||||
return {
|
||||
"config": safe,
|
||||
"status": status,
|
||||
@@ -1005,6 +1167,9 @@ register_volatile(
|
||||
"status.peers[].transfer_received",
|
||||
"status.peers[].transfer_sent",
|
||||
"status.peers[].latest_handshake",
|
||||
"status.classes[].peers[].transfer_received",
|
||||
"status.classes[].peers[].transfer_sent",
|
||||
"status.classes[].peers[].latest_handshake",
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1014,28 +1179,57 @@ register_volatile(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_networkd() -> dict[str, Any]:
|
||||
def _collect_networkd() -> schema.NetworkdState:
|
||||
"""Collect networkd interface state from networkctl.
|
||||
|
||||
Returns:
|
||||
Dict with interface runtime state parsed from networkctl output.
|
||||
Returns empty data if networkctl is not available.
|
||||
Dict with interface runtime state parsed from networkctl output,
|
||||
config, and pending changes status.
|
||||
"""
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "network" / "config.json"
|
||||
|
||||
# Load config
|
||||
net_cfg: dict[str, Any] = {}
|
||||
if CONFIG_PATH.exists():
|
||||
with contextlib.suppress(Exception):
|
||||
net_cfg = load_json(CONFIG_PATH)
|
||||
|
||||
pending_changes = _APPLY_HASH_KEY not in net_cfg or net_cfg[
|
||||
_APPLY_HASH_KEY
|
||||
] != config_hash(net_cfg)
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
safe_net_cfg = strip_apply_meta(net_cfg)
|
||||
net_status: dict[str, Any] = {"pending_changes": pending_changes}
|
||||
net_pending_diff: list[dict[str, Any]] = []
|
||||
if pending_changes:
|
||||
snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||
if isinstance(snap, dict):
|
||||
net_pending_diff = deep_diff(snap, safe_net_cfg)
|
||||
net_status["pending_diff"] = net_pending_diff
|
||||
|
||||
try:
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
result = parse_networkctl_status(raw)
|
||||
if not result:
|
||||
return {"interfaces": {}, "timestamp": _now_iso()}
|
||||
return {
|
||||
"interfaces": {},
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"interfaces": {},
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
return {
|
||||
"interfaces": result,
|
||||
"config": safe_net_cfg,
|
||||
"status": net_status,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
@@ -1050,6 +1244,129 @@ register_volatile(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System metrics collector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_meminfo() -> dict[str, Any]:
|
||||
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
|
||||
info: dict[str, int] = {}
|
||||
try:
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
parts = value.strip().split()
|
||||
val = int(parts[0])
|
||||
# Convert kB to bytes
|
||||
if parts and parts[-1] == "kB":
|
||||
val *= 1024
|
||||
info[key] = val
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return info
|
||||
|
||||
|
||||
def _collect_system() -> schema.SystemState:
|
||||
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||
|
||||
Reads from /proc and /sys — no subprocess needed.
|
||||
|
||||
Returns:
|
||||
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
|
||||
"""
|
||||
# CPU load
|
||||
loads = []
|
||||
try:
|
||||
parts = Path("/proc/loadavg").read_text().split()
|
||||
loads = [float(x) for x in parts[:3]]
|
||||
except (OSError, ValueError):
|
||||
loads = [0.0, 0.0, 0.0]
|
||||
|
||||
# Memory
|
||||
meminfo_raw = _parse_meminfo()
|
||||
mem_total = meminfo_raw.get("MemTotal", 0)
|
||||
mem_free = meminfo_raw.get("MemFree", 0)
|
||||
mem_available = meminfo_raw.get("MemAvailable", mem_free)
|
||||
mem_buffers = meminfo_raw.get("Buffers", 0)
|
||||
mem_cached = meminfo_raw.get("Cached", 0)
|
||||
mem_used = mem_total - mem_free - mem_buffers - mem_cached
|
||||
if mem_used < 0:
|
||||
mem_used = mem_total - mem_available
|
||||
|
||||
# Swap
|
||||
swap_total = meminfo_raw.get("SwapTotal", 0)
|
||||
swap_free = meminfo_raw.get("SwapFree", 0)
|
||||
swap_used = swap_total - swap_free
|
||||
|
||||
# Network traffic from /sys/class/net/<iface>/statistics/
|
||||
traffic: dict[str, dict[str, int]] = {}
|
||||
try:
|
||||
net_root = Path("/sys/class/net")
|
||||
if net_root.is_dir():
|
||||
for iface_dir in net_root.iterdir():
|
||||
stats_dir = iface_dir / "statistics"
|
||||
if not stats_dir.is_dir():
|
||||
continue
|
||||
iface_name = iface_dir.name
|
||||
rx_bytes = 0
|
||||
tx_bytes = 0
|
||||
rx_packets = 0
|
||||
tx_packets = 0
|
||||
try:
|
||||
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
|
||||
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
|
||||
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
|
||||
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
traffic[iface_name] = {
|
||||
"rx_bytes": rx_bytes,
|
||||
"tx_bytes": tx_bytes,
|
||||
"rx_packets": rx_packets,
|
||||
"tx_packets": tx_packets,
|
||||
}
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"load": {
|
||||
"load1": loads[0],
|
||||
"load5": loads[1],
|
||||
"load15": loads[2],
|
||||
},
|
||||
"memory": {
|
||||
"total": mem_total,
|
||||
"available": mem_available,
|
||||
"used": mem_used,
|
||||
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
|
||||
},
|
||||
"swap": {
|
||||
"total": swap_total,
|
||||
"used": swap_used,
|
||||
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
|
||||
},
|
||||
"traffic": traffic,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("system", _collect_system)
|
||||
register_volatile(
|
||||
"system",
|
||||
frozenset(
|
||||
{
|
||||
"load",
|
||||
"memory",
|
||||
"swap",
|
||||
"traffic",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_DEFAULT_POLL_INTERVALS",
|
||||
"State",
|
||||
|
||||
+1046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
"""System config import on daemon startup.
|
||||
|
||||
On first start, vacuum-walld parses each subsystem's live system config
|
||||
file and writes the canonical JSON config. This reconciles any drift
|
||||
caused by install.sh or manual edits to system files.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.common import load_json, run, save_json
|
||||
from lib.firewall import _live_target_to_config, _parse_all_zones_output
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _safe_int(val: str) -> int | str:
|
||||
"""Try to parse *val* as int; return original string on failure."""
|
||||
with contextlib.suppress(ValueError):
|
||||
return int(val)
|
||||
return val
|
||||
|
||||
|
||||
DNSMASQ_CONF = Path("/etc/dnsmasq.d/vacuum-wall.conf")
|
||||
WG_CONF = Path("/etc/wireguard/wg0.conf")
|
||||
NETWORKD_DIR = Path("/etc/systemd/network")
|
||||
NGINX_SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
|
||||
|
||||
DNSTART = "# ---- vacuum-wall managed dnsmasq configuration ----"
|
||||
DNEND = "# ---- end vacuum-wall config ----"
|
||||
|
||||
|
||||
def import_all() -> list[str]:
|
||||
"""Run all subsystem imports. Returns list of subsystems that were updated."""
|
||||
updated: list[str] = []
|
||||
for fn, name in [
|
||||
(import_dnsmasq, "dnsmasq"),
|
||||
(import_firewall, "firewall"),
|
||||
(import_wireguard, "wireguard"),
|
||||
(import_networkd, "network"),
|
||||
(import_nginx, "nginx"),
|
||||
]:
|
||||
try:
|
||||
if fn():
|
||||
updated.append(name)
|
||||
except Exception:
|
||||
logger.warning("Import failed for %s", name, exc_info=True)
|
||||
return updated
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dnsmasq
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_dnsmasq() -> bool:
|
||||
"""Parse /etc/dnsmasq.d/vacuum-wall.conf -> config/dnsmasq/config.json."""
|
||||
if not DNSMASQ_CONF.exists():
|
||||
logger.debug("Skipping dnsmasq: %s not found", DNSMASQ_CONF)
|
||||
return False
|
||||
|
||||
try:
|
||||
text = DNSMASQ_CONF.read_text()
|
||||
except OSError:
|
||||
logger.debug("Skipping dnsmasq: cannot read %s", DNSMASQ_CONF)
|
||||
return False
|
||||
|
||||
# Extract managed block
|
||||
start_idx = text.find(DNSTART)
|
||||
end_idx = text.rfind(DNEND)
|
||||
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
|
||||
logger.debug("Skipping dnsmasq: no managed markers found")
|
||||
return False
|
||||
|
||||
block = text[start_idx + len(DNSTART) : end_idx]
|
||||
|
||||
try:
|
||||
cfg = _parse_dnsmasq_block(block)
|
||||
except Exception:
|
||||
logger.warning("Failed to parse dnsmasq config", exc_info=True)
|
||||
return False
|
||||
|
||||
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
|
||||
if cfg_path.exists():
|
||||
existing = load_json(cfg_path)
|
||||
if _cfgs_equal(existing, cfg):
|
||||
logger.debug("Skipping dnsmasq: config already matches")
|
||||
return False
|
||||
|
||||
save_json(cfg_path, cfg)
|
||||
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
|
||||
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_dnsmasq_block(block: str) -> dict[str, Any]:
|
||||
"""Parse a dnsmasq managed block into JSON config dict."""
|
||||
upstreams: list[str] = []
|
||||
domains: str | None = None
|
||||
ranges: list[dict[str, Any]] = []
|
||||
static_leases: list[dict[str, Any]] = []
|
||||
custom_records: list[dict[str, Any]] = []
|
||||
|
||||
for line in block.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("server="):
|
||||
upstreams.append(line.split("=", 1)[1].strip())
|
||||
elif line == "no-resolv":
|
||||
upstreams = []
|
||||
elif line.startswith("domain="):
|
||||
domains = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("dhcp-range="):
|
||||
rng = _parse_dhcp_range(line.split("=", 1)[1])
|
||||
ranges.append(rng)
|
||||
elif line.startswith("dhcp-option="):
|
||||
target = _attach_dhcp_option(line.split("=", 1)[1], ranges)
|
||||
if target:
|
||||
_set_dhcp_option_option(target, line.split("=", 1)[1])
|
||||
elif line.startswith("dhcp-host="):
|
||||
lease = _parse_dhcp_host(line.split("=", 1)[1])
|
||||
if lease:
|
||||
static_leases.append(lease)
|
||||
elif line.startswith("addr/"):
|
||||
rec = _parse_addr_directive(line)
|
||||
if rec:
|
||||
custom_records.append(rec)
|
||||
elif line.startswith("host-record="):
|
||||
pass # paired with addr/, skip
|
||||
|
||||
return {
|
||||
"dhcp": {
|
||||
"ranges": ranges,
|
||||
"static_leases": static_leases,
|
||||
},
|
||||
"dns": {
|
||||
"upstreams": upstreams,
|
||||
"domain": domains,
|
||||
"custom_records": custom_records,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_dhcp_range(value: str) -> dict[str, Any]:
|
||||
"""Parse dhcp-range value into dict."""
|
||||
parts = value.split(",")
|
||||
if parts[0].startswith("set:"):
|
||||
iface = parts[0][4:]
|
||||
addr_parts = parts[1:4]
|
||||
else:
|
||||
iface = None
|
||||
addr_parts = parts[0:3]
|
||||
|
||||
rng: dict[str, Any] = {}
|
||||
if iface:
|
||||
rng["interface"] = iface
|
||||
rng["start"] = addr_parts[0] if len(addr_parts) > 0 else ""
|
||||
rng["end"] = addr_parts[1] if len(addr_parts) > 1 else ""
|
||||
rng["lease_time"] = addr_parts[2] if len(addr_parts) > 2 else "12h"
|
||||
return rng
|
||||
|
||||
|
||||
def _attach_dhcp_option(
|
||||
value: str, ranges: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Parse dhcp-option and return the target range to attach to."""
|
||||
m = re.match(r"tag:([^,]+),(\d+),(.+)", value)
|
||||
if not m:
|
||||
return None
|
||||
tag_iface = m.group(1)
|
||||
for rng in reversed(ranges):
|
||||
if rng.get("interface") == tag_iface:
|
||||
return rng
|
||||
return None
|
||||
|
||||
|
||||
def _set_dhcp_option_option(target: dict[str, Any], value: str) -> None:
|
||||
"""Parse dhcp-option value string and set gateway/dns on target range."""
|
||||
m = re.match(r"tag:[^,]+,(\d+),(.+)", value)
|
||||
if not m:
|
||||
return
|
||||
option_code = m.group(1)
|
||||
option_val = m.group(2).strip()
|
||||
if option_code == "3":
|
||||
target["gateway"] = option_val
|
||||
elif option_code == "6":
|
||||
target["dns"] = option_val
|
||||
|
||||
|
||||
def _parse_dhcp_host(value: str) -> dict[str, Any] | None:
|
||||
"""Parse dhcp-host value into dict."""
|
||||
parts = value.split(",")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lease: dict[str, Any] = {
|
||||
"mac": parts[0].strip().lower(),
|
||||
"ip": parts[1].strip(),
|
||||
}
|
||||
if len(parts) >= 3:
|
||||
lease["hostname"] = parts[2].strip()
|
||||
return lease
|
||||
|
||||
|
||||
def _parse_addr_directive(line: str) -> dict[str, Any] | None:
|
||||
"""Parse addr/name/addr into dict."""
|
||||
parts = line.split("/")
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
return {"name": parts[1].strip(), "address": parts[2].strip()}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WireGuard
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_wireguard() -> bool:
|
||||
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json."""
|
||||
try:
|
||||
exists = WG_CONF.exists()
|
||||
except OSError:
|
||||
# Parent dir may be unreadable to the daemon user (e.g. /etc/wireguard
|
||||
# is 0700). Treat as not present rather than failing the import.
|
||||
logger.debug("Skipping wireguard: cannot stat %s", WG_CONF)
|
||||
return False
|
||||
if not exists:
|
||||
logger.debug("Skipping wireguard: %s not found", WG_CONF)
|
||||
return False
|
||||
|
||||
try:
|
||||
text = WG_CONF.read_text()
|
||||
except OSError:
|
||||
logger.debug("Skipping wireguard: cannot read %s", WG_CONF)
|
||||
return False
|
||||
|
||||
try:
|
||||
cfg = _parse_wireguard_conf(text)
|
||||
except Exception:
|
||||
logger.warning("Failed to parse wireguard config", exc_info=True)
|
||||
return False
|
||||
|
||||
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
if cfg_path.exists():
|
||||
existing = load_json(cfg_path)
|
||||
if _cfgs_equal(existing, cfg):
|
||||
logger.debug("Skipping wireguard: config already matches")
|
||||
return False
|
||||
|
||||
save_json(cfg_path, cfg)
|
||||
peer_count = len(cfg.get("peers", {}))
|
||||
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_wireguard_conf(text: str) -> dict[str, Any]:
|
||||
"""Parse wg-quick INI format into JSON config dict.
|
||||
|
||||
Uses a simple state machine: [Interface] section populates the interface
|
||||
dict; each [Peer] section accumulates into current_peer until the next
|
||||
section header triggers _flush_peer() to commit it.
|
||||
"""
|
||||
interface: dict[str, Any] = {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
}
|
||||
peers: dict[str, dict[str, Any]] = {}
|
||||
|
||||
current_section = None
|
||||
current_peer_name = None
|
||||
current_peer: dict[str, Any] | None = None
|
||||
|
||||
def _flush_peer() -> None:
|
||||
"""Flush the current peer dict into the peers map if it has a public key.
|
||||
|
||||
Resets ``current_peer`` and ``current_peer_name`` to ``None``,
|
||||
preparing for the next [Peer] section.
|
||||
|
||||
Note:
|
||||
Only peers with a ``public_key`` are stored; sections without
|
||||
a key (malformed or incomplete) are silently skipped.
|
||||
"""
|
||||
nonlocal current_peer, current_peer_name
|
||||
if (
|
||||
current_peer is not None
|
||||
and current_peer_name
|
||||
and current_peer.get("public_key")
|
||||
):
|
||||
peers[current_peer_name] = current_peer
|
||||
current_peer = None
|
||||
current_peer_name = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Section headers — allow trailing content like comments
|
||||
m = re.match(r"^\[(\w+)\](.*)?$", stripped)
|
||||
if m:
|
||||
section_name = m.group(1)
|
||||
if section_name == "Interface":
|
||||
current_section = "interface"
|
||||
_flush_peer()
|
||||
elif section_name == "Peer":
|
||||
current_section = "peer"
|
||||
_flush_peer()
|
||||
|
||||
# Look for name in comment after [Peer]
|
||||
m2 = re.match(r"^\[Peer\]\s*#\s*(.+)", stripped)
|
||||
current_peer_name = m2.group(1).strip() if m2 else None
|
||||
|
||||
current_peer = {}
|
||||
else:
|
||||
current_section = None
|
||||
_flush_peer()
|
||||
continue
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
|
||||
key, _, val = stripped.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
|
||||
if current_section == "interface":
|
||||
if key == "PrivateKey":
|
||||
interface["private_key"] = val
|
||||
elif key == "Address":
|
||||
interface["addresses"] = [a.strip() for a in val.split(",")]
|
||||
elif key == "ListenPort":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
interface["listen_port"] = parsed
|
||||
elif key == "PostUp":
|
||||
interface["post_up"] = val
|
||||
elif key == "PostDown":
|
||||
interface["post_down"] = val
|
||||
|
||||
elif current_section == "peer" and current_peer is not None:
|
||||
if key == "PublicKey":
|
||||
current_peer["public_key"] = val
|
||||
if current_peer_name is None:
|
||||
current_peer_name = val
|
||||
elif key == "PresharedKey":
|
||||
current_peer["preshared_key"] = val
|
||||
elif key == "Endpoint":
|
||||
current_peer["endpoint"] = val
|
||||
elif key == "AllowedIPs":
|
||||
current_peer["allowed_ips"] = [a.strip() for a in val.split(",")]
|
||||
elif key == "PersistentKeepalive":
|
||||
parsed = _safe_int(val)
|
||||
current_peer["persistent_keepalive"] = (
|
||||
parsed if isinstance(parsed, int) else None
|
||||
)
|
||||
|
||||
# Flush any remaining peer
|
||||
if (
|
||||
current_peer is not None
|
||||
and current_peer_name
|
||||
and current_peer.get("public_key")
|
||||
):
|
||||
peers[current_peer_name] = current_peer
|
||||
|
||||
return {
|
||||
"interface": interface,
|
||||
"peers": peers,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Networkd
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_networkd() -> bool:
|
||||
"""Parse /etc/systemd/network/*.network -> config/network/config.json."""
|
||||
if not NETWORKD_DIR.exists():
|
||||
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
|
||||
return False
|
||||
|
||||
network_files = sorted(NETWORKD_DIR.glob("*.network"))
|
||||
if not network_files:
|
||||
logger.debug("Skipping network: no *.network files")
|
||||
return False
|
||||
|
||||
parsed_interfaces: dict[str, dict[str, Any]] = {}
|
||||
for nf in network_files:
|
||||
# Extract interface name from filename, stripping optional priority prefix:
|
||||
# 99-eth0.network -> eth0
|
||||
# eth0.network -> eth0
|
||||
base = nf.stem
|
||||
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||
iface_name = base.split("-", 1)[1]
|
||||
else:
|
||||
iface_name = base
|
||||
try:
|
||||
parsed_interface = _parse_network_file(nf)
|
||||
if parsed_interface:
|
||||
parsed_interfaces[iface_name] = parsed_interface
|
||||
except Exception:
|
||||
logger.warning("Failed to parse %s", nf, exc_info=True)
|
||||
|
||||
if not parsed_interfaces:
|
||||
logger.debug("Skipping network: no valid .network files")
|
||||
return False
|
||||
|
||||
cfg_path = PROJECT_DIR / "config" / "network" / "config.json"
|
||||
existing: dict[str, Any] = load_json(cfg_path, {"interfaces": {}})
|
||||
interfaces_cfg = existing.setdefault("interfaces", {})
|
||||
|
||||
# Only add/update interfaces with .network files; don't remove interfaces
|
||||
# without a file (they may be pending apply).
|
||||
changed = False
|
||||
for name, entry in parsed_interfaces.items():
|
||||
if name not in interfaces_cfg or not _cfgs_equal(interfaces_cfg[name], entry):
|
||||
interfaces_cfg[name] = entry
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
logger.debug("Skipping network: config already matches")
|
||||
return False
|
||||
|
||||
save_json(cfg_path, existing)
|
||||
logger.info(
|
||||
"Imported network config from %s/*.network: interfaces=%s",
|
||||
NETWORKD_DIR,
|
||||
", ".join(parsed_interfaces.keys()),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_network_file(path: Path) -> dict[str, Any] | None:
|
||||
"""Parse a .network INI file into interface config dict.
|
||||
|
||||
State machine: [Match] section is skipped; [Link] keys go to iface["link"];
|
||||
[Network] keys go directly on iface. Numbered sections ([Address#N], [Route#N])
|
||||
accumulate into cur_addr / cur_route dicts until a section boundary triggers
|
||||
_flush() to commit them into the corresponding list.
|
||||
"""
|
||||
text = path.read_text()
|
||||
iface: dict[str, Any] = {}
|
||||
cur_section: str | None = None
|
||||
cur_addr: dict[str, Any] | None = None
|
||||
cur_route: dict[str, Any] | None = None
|
||||
|
||||
def _flush() -> None:
|
||||
"""Commit accumulated address/route dicts into the iface lists."""
|
||||
nonlocal cur_addr, cur_route
|
||||
if cur_addr is not None:
|
||||
if "address" in cur_addr and len(cur_addr) == 1:
|
||||
iface.setdefault("addresses", []).append(cur_addr["address"])
|
||||
elif cur_addr:
|
||||
iface.setdefault("addresses", []).append(cur_addr)
|
||||
cur_addr = None
|
||||
if cur_route is not None and cur_route:
|
||||
iface.setdefault("routes", []).append(cur_route)
|
||||
cur_route = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
m = re.match(r"^\[(.+)\]$", stripped)
|
||||
if m:
|
||||
_flush()
|
||||
sec = m.group(1)
|
||||
if sec.startswith("Address"):
|
||||
cur_section = sec
|
||||
cur_addr = {}
|
||||
elif sec.startswith("Route"):
|
||||
cur_section = sec
|
||||
cur_route = {}
|
||||
else:
|
||||
cur_section = sec
|
||||
continue
|
||||
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
|
||||
key, _, val = stripped.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
|
||||
if cur_addr is not None:
|
||||
cur_addr = _parse_addr_key(cur_addr, key, val)
|
||||
continue
|
||||
|
||||
if cur_route is not None:
|
||||
cur_route = _parse_route_key(cur_route, key, val)
|
||||
continue
|
||||
|
||||
_parse_network_section(iface, cur_section, key, val)
|
||||
|
||||
_flush()
|
||||
return iface if iface else None
|
||||
|
||||
|
||||
def _parse_addr_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
|
||||
"""Parse an [Address] key-value pair, return updated entry."""
|
||||
if key == "Address":
|
||||
entry["address"] = val
|
||||
elif key == "Label":
|
||||
entry["label"] = val
|
||||
elif key == "Scope":
|
||||
entry["scope"] = val
|
||||
elif key == "RouteMetric":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
entry["route_metric"] = parsed
|
||||
elif key == "DuplicateAddressDetection":
|
||||
entry["duplicate_address_detection"] = val
|
||||
elif key == "ManageTemporaryAddress":
|
||||
entry["manage_temporary_address"] = _parse_bool(val)
|
||||
elif key == "AddPrefixRoute":
|
||||
entry["add_prefix_route"] = _parse_bool(val)
|
||||
return entry
|
||||
|
||||
|
||||
def _parse_route_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
|
||||
"""Parse a [Route] key-value pair, return updated entry."""
|
||||
if key == "Destination":
|
||||
entry["destination"] = val
|
||||
elif key == "Gateway":
|
||||
entry["gateway"] = val
|
||||
elif key == "Metric":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
entry["metric"] = parsed
|
||||
elif key == "Table":
|
||||
entry["table"] = val
|
||||
elif key == "Type":
|
||||
entry["type"] = val
|
||||
elif key == "Scope":
|
||||
entry["scope"] = val
|
||||
elif key == "GatewayOnLink":
|
||||
entry["gateway_on_link"] = _parse_bool(val)
|
||||
elif key == "IPv6Preference":
|
||||
entry["ipv6_preference"] = val
|
||||
elif key == "MTUBytes":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
entry["mtu_bytes"] = parsed
|
||||
return entry
|
||||
|
||||
|
||||
def _parse_network_section(
|
||||
iface: dict[str, Any], section: str | None, key: str, val: str
|
||||
) -> None:
|
||||
"""Parse a [Match]/[Link]/[Network] key-value pair into iface dict."""
|
||||
if section == "Match":
|
||||
return
|
||||
|
||||
if section == "Link":
|
||||
link = iface.setdefault("link", {})
|
||||
_set_link_key(link, key, val)
|
||||
return
|
||||
|
||||
if section == "Network":
|
||||
_set_network_key(iface, key, val)
|
||||
return
|
||||
|
||||
|
||||
def _set_link_key(link: dict[str, Any], key: str, val: str) -> None:
|
||||
"""Parse a [Link] section key-value pair and set the corresponding config field.
|
||||
|
||||
Maps systemd-networkd Link INI keys to snake_case config keys.
|
||||
Boolean keys (ARP, Multicast, etc.) are auto-converted via ``_parse_bool``.
|
||||
|
||||
Args:
|
||||
link: Link config dict to populate.
|
||||
key: INI key name from the .network file.
|
||||
val: Value string from the .network file.
|
||||
"""
|
||||
if key == "MTUBytes":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
link["mtu_bytes"] = parsed
|
||||
elif key == "MACAddress":
|
||||
link["mac_address"] = val
|
||||
elif key in ("ARP", "Multicast", "AllMulticast", "Promiscuous", "Unmanaged"):
|
||||
pk = {
|
||||
"ARP": "arp",
|
||||
"Multicast": "multicast",
|
||||
"AllMulticast": "all_multicast",
|
||||
"Promiscuous": "promiscuous",
|
||||
"Unmanaged": "unmanaged",
|
||||
}[key]
|
||||
link[pk] = _parse_bool(val)
|
||||
elif key == "ActivationPolicy":
|
||||
link["activation_policy"] = val
|
||||
elif key == "RequiredForOnline":
|
||||
link["required_for_online"] = val
|
||||
|
||||
|
||||
def _set_network_key(iface: dict[str, Any], key: str, val: str) -> None:
|
||||
"""Parse a [Network] section key-value pair and set the corresponding config field.
|
||||
|
||||
Maps systemd-networkd Network INI keys to snake_case config dict keys.
|
||||
Comma-separated values (DNS, Domains, etc.) are split into lists.
|
||||
Boolean and integer keys are auto-converted.
|
||||
|
||||
Args:
|
||||
iface: Interface config dict to populate.
|
||||
key: INI key name from the .network file.
|
||||
val: Value string from the .network file.
|
||||
"""
|
||||
if key == "DHCP":
|
||||
iface["dhcp"] = val
|
||||
elif key == "Gateway":
|
||||
iface["gateway"] = val
|
||||
elif key == "IPv6Gateway":
|
||||
iface["ipv6_gateway"] = val
|
||||
elif key == "DNS":
|
||||
for d in val.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
iface.setdefault("dns", []).append(d)
|
||||
elif key == "IPv6DNS":
|
||||
for d in val.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
iface.setdefault("ipv6_dns", []).append(d)
|
||||
elif key == "Domains":
|
||||
for d in val.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
iface.setdefault("domains", []).append(d)
|
||||
elif key == "IPv6Domains":
|
||||
for d in val.split(","):
|
||||
d = d.strip()
|
||||
if d:
|
||||
iface.setdefault("ipv6_domains", []).append(d)
|
||||
elif key == "DNSDefaultRoute":
|
||||
iface["dns_default_route"] = _parse_bool(val)
|
||||
elif key == "BindCarrier":
|
||||
iface.setdefault("bind_carrier", []).append(val)
|
||||
elif key == "IgnoreCarrierLoss":
|
||||
iface["ignore_carrier_loss"] = val
|
||||
elif key == "KeepConfiguration":
|
||||
iface["keep_configuration"] = val
|
||||
elif key == "ConfigureWithoutCarrier":
|
||||
iface["configure_without_carrier"] = _parse_bool(val)
|
||||
elif key == "LinkLocalAddressing":
|
||||
iface["link_local_addressing"] = val
|
||||
elif key == "IPv6LinkLocalAddressGenerationMode":
|
||||
iface["ipv6_link_local_address_generation_mode"] = val
|
||||
elif key == "IPv6StableSecretAddress":
|
||||
iface["ipv6_stable_secret_address"] = val
|
||||
elif key == "IPv4LLStartAddress":
|
||||
iface["ipv4_ll_start_address"] = val
|
||||
elif key == "IPv4LLRoute":
|
||||
iface["ipv4_ll_route"] = _parse_bool(val)
|
||||
elif key == "DefaultRouteOnDevice":
|
||||
iface["default_route_on_device"] = _parse_bool(val)
|
||||
elif key == "IPv6HopLimit":
|
||||
parsed = _safe_int(val)
|
||||
if isinstance(parsed, int):
|
||||
iface["ipv6_hop_limit"] = parsed
|
||||
elif key == "IPv6RetransmissionTimeSec":
|
||||
iface["ipv6_retransmission_time_sec"] = val
|
||||
elif key == "IPv4DuplicateAddressDetectionTimeoutSec":
|
||||
iface["ipv4_duplicate_address_detection_timeout_sec"] = val
|
||||
elif key == "IPv4ReversePathFilter":
|
||||
iface["ipv4_reverse_path_filter"] = val
|
||||
elif key == "IPv4AcceptLocal":
|
||||
iface["ipv4_accept_local"] = _parse_bool(val)
|
||||
elif key == "IPv4RouteLocalnet":
|
||||
iface["ipv4_route_localnet"] = _parse_bool(val)
|
||||
elif key == "IPv4ProxyARP":
|
||||
iface["ipv4_proxy_arp"] = _parse_bool(val)
|
||||
elif key == "IPv4ProxyARPPrivateVLAN":
|
||||
iface["ipv4_proxy_arp_private_vlan"] = _parse_bool(val)
|
||||
elif key == "IPv6ProxyNDP":
|
||||
iface["ipv6_proxy_ndp"] = _parse_bool(val)
|
||||
elif key == "IPv6ProxyNDPAddress":
|
||||
iface["ipv6_proxy_ndp_address"] = val
|
||||
elif key == "IPv6SendRA":
|
||||
iface["ipv6_send_ra"] = _parse_bool(val)
|
||||
elif key == "MPLSRouting":
|
||||
iface["m_pls_routing"] = _parse_bool(val)
|
||||
elif key == "KeepMaster":
|
||||
iface["keep_master"] = _parse_bool(val)
|
||||
elif key == "IPFamily":
|
||||
iface["ip_family"] = val
|
||||
|
||||
|
||||
def _parse_bool(val: str) -> bool | str:
|
||||
"""Parse common boolean representations to bool, return as-is otherwise."""
|
||||
if val.lower() in ("yes", "true", "1", "on"):
|
||||
return True
|
||||
if val.lower() in ("no", "false", "0", "off"):
|
||||
return False
|
||||
return val
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nginx
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_nginx() -> bool:
|
||||
"""Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json.
|
||||
|
||||
Bootstraps config.json on hosts that already have rendered sites
|
||||
(repo reinstalled over an existing data/ dir). If the declarative
|
||||
config already exists it wins: sites are vacuum-wall's own generated
|
||||
output ("do not edit manually") and re-parsing them is lossy — backend
|
||||
references get flattened to inline paths, which render empty nginx
|
||||
sites and hide domains from the WebUI.
|
||||
"""
|
||||
cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json"
|
||||
if cfg_path.exists():
|
||||
logger.debug("Skipping nginx: %s already exists", cfg_path)
|
||||
return False
|
||||
|
||||
if not NGINX_SITES_DIR.exists():
|
||||
logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR)
|
||||
return False
|
||||
|
||||
conf_files = sorted(NGINX_SITES_DIR.glob("*.conf"))
|
||||
sites: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for cf in conf_files:
|
||||
# Skip acme challenge
|
||||
if cf.name == "_acme-challenge.conf":
|
||||
continue
|
||||
|
||||
domain = cf.stem
|
||||
if not domain:
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = _parse_nginx_site(cf, domain)
|
||||
if parsed:
|
||||
sites[domain] = parsed
|
||||
except Exception:
|
||||
logger.warning("Failed to parse nginx site %s", cf, exc_info=True)
|
||||
|
||||
if not sites:
|
||||
logger.debug("Skipping nginx: no valid site files")
|
||||
return False
|
||||
|
||||
existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}})
|
||||
domains_cfg = existing.setdefault("domains", {})
|
||||
|
||||
changed = False
|
||||
for name, entry in sites.items():
|
||||
if name not in domains_cfg or not _cfgs_equal(domains_cfg[name], entry):
|
||||
domains_cfg[name] = entry
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
logger.debug("Skipping nginx: config already matches")
|
||||
return False
|
||||
|
||||
save_json(cfg_path, existing)
|
||||
logger.info(
|
||||
"Imported nginx config from %s/*.conf: domains=%s",
|
||||
NGINX_SITES_DIR,
|
||||
", ".join(sites.keys()),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_nginx_site(path: Path, domain: str) -> dict[str, Any] | None:
|
||||
"""Parse a vacuum-wall nginx site conf into domain config dict."""
|
||||
text = path.read_text()
|
||||
|
||||
# Check it's our file
|
||||
if "# Auto-generated by Vacuum Wall" not in text:
|
||||
logger.debug("Skipping nginx site %s: not vacuum-wall generated", path)
|
||||
return None
|
||||
|
||||
# Determine force_ssl: look for port 80 redirect block
|
||||
force_ssl = bool(re.search(r"listen\s+80\b", text))
|
||||
|
||||
# Parse domain-level auth
|
||||
domain_auth = None
|
||||
auth_match = re.search(
|
||||
r"auth_basic\s+.*;\s*\n\s*auth_basic_user_file\s+(.+?);", text
|
||||
)
|
||||
if auth_match:
|
||||
domain_auth = {"htpasswd": auth_match.group(1).strip()}
|
||||
|
||||
# Infer cert type
|
||||
cert = _infer_cert_type(text, domain)
|
||||
|
||||
# Parse location blocks
|
||||
paths: dict[str, dict[str, Any]] = {}
|
||||
_parse_location_blocks(text, paths, domain_auth)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"force_ssl": force_ssl,
|
||||
"paths": paths,
|
||||
}
|
||||
if cert:
|
||||
entry["cert"] = cert
|
||||
if domain_auth:
|
||||
entry["auth"] = domain_auth
|
||||
return entry
|
||||
|
||||
|
||||
def _infer_cert_type(text: str, domain: str) -> str | None:
|
||||
"""Infer cert type from ssl_certificate path."""
|
||||
m = re.search(r"ssl_certificate\s+(.+?);", text)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
cert_path = m.group(1).strip()
|
||||
if "acme" in cert_path:
|
||||
return "acme"
|
||||
elif "data/certs" in cert_path:
|
||||
return "selfsigned"
|
||||
else:
|
||||
return "file"
|
||||
|
||||
|
||||
def _parse_location_blocks(
|
||||
text: str, paths: dict[str, dict[str, Any]], domain_auth: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Extract location blocks and their annotations."""
|
||||
# Match annotation comment before location block
|
||||
# # /path -> host:port (WebSocket)
|
||||
# # /path -> host:port
|
||||
loc_re = re.compile(
|
||||
r"#\s*(/[\S]*)\s*->\s*(\S+?)(?:\s*\(WebSocket\))?\s*\n"
|
||||
r"\s*location\s+\1\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
for m in loc_re.finditer(text):
|
||||
ppath = m.group(1)
|
||||
backend_str = m.group(2)
|
||||
is_ws = "WebSocket" in m.group(0)
|
||||
|
||||
host, _, port_str = backend_str.partition(":")
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
port = 80
|
||||
|
||||
is_websocket = is_ws
|
||||
|
||||
# Check for auth_basic off inside this location block
|
||||
# Find the location block for this path
|
||||
block_start = m.end()
|
||||
# Find matching closing brace
|
||||
depth = 1
|
||||
idx = block_start
|
||||
while idx < len(text) and depth > 0:
|
||||
if text[idx] == "{":
|
||||
depth += 1
|
||||
elif text[idx] == "}":
|
||||
depth -= 1
|
||||
idx += 1
|
||||
block_text = text[block_start:idx]
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"backend": {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"proto": "http",
|
||||
},
|
||||
}
|
||||
|
||||
if is_websocket:
|
||||
entry["is_websocket"] = True
|
||||
elif "auth_basic off" in block_text:
|
||||
entry["auth"] = None
|
||||
|
||||
if "is_management" in block_text or (
|
||||
"/api" not in ppath and "management" in block_text.lower()
|
||||
):
|
||||
entry["is_management"] = True
|
||||
|
||||
# Check for WebSocket path
|
||||
if ppath == "/ws":
|
||||
entry["is_websocket"] = True
|
||||
|
||||
paths[ppath] = entry
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Firewall
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_firewall() -> bool:
|
||||
"""Run firewall-cmd --list-all-zones -> config/firewall/config.json."""
|
||||
cfg_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
||||
if cfg_path.exists():
|
||||
logger.debug("Skipping firewall: config already exists at %s", cfg_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
output = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||
except RuntimeError:
|
||||
logger.warning("Import failed for firewall: firewall-cmd unavailable")
|
||||
return False
|
||||
|
||||
try:
|
||||
zones = _parse_all_zones_output(output)
|
||||
except Exception:
|
||||
logger.warning("Failed to parse firewall zones", exc_info=True)
|
||||
return False
|
||||
|
||||
zone_configs = {
|
||||
zone_name: {
|
||||
"target": _live_target_to_config(parsed["target"]),
|
||||
"interfaces": parsed["interfaces"],
|
||||
"services": parsed["services"],
|
||||
"masquerade": parsed["masquerade"],
|
||||
"rich_rules": [{"rule": r} for r in parsed["rich-rules"]],
|
||||
"forward_ports": parsed["forward-ports"],
|
||||
}
|
||||
for zone_name, parsed in zones.items()
|
||||
if parsed["interfaces"]
|
||||
}
|
||||
|
||||
if not zone_configs:
|
||||
logger.debug("Skipping firewall: no zones with interfaces")
|
||||
return False
|
||||
|
||||
save_json(cfg_path, {"zones": zone_configs})
|
||||
logger.info(
|
||||
"Imported firewall config: zones=%s",
|
||||
", ".join(zone_configs.keys()),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
"""Compare two configs ignoring apply bookkeeping keys."""
|
||||
from lib.common import strip_apply_meta
|
||||
|
||||
return strip_apply_meta(a) == strip_apply_meta(b)
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
"""WebAuthn passkey support for Vacuum Wall.
|
||||
|
||||
Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony:
|
||||
registration, authentication, and credential management.
|
||||
|
||||
Credential data is stored in the database webauthn_creds table.
|
||||
Configuration comes from config/auth/config.json (webauthn section).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||
from webauthn.helpers.structs import (
|
||||
AttestationConveyancePreference,
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from lib.auth import AUTH_CONFIG_PATH
|
||||
from lib.common import load_json
|
||||
from lib.db import (
|
||||
Q_DELETE_WEBAUTHN,
|
||||
Q_INSERT_WEBAUTHN,
|
||||
Q_SELECT_WEBAUTHN_COUNTS,
|
||||
Q_SELECT_WEBAUTHN_ID,
|
||||
Q_SELECT_WEBAUTHN_USER,
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
get_db,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Crypto algorithms we support
|
||||
_SUPPORTED_ALGS = [
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_512,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512,
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
]
|
||||
|
||||
|
||||
# b64url helpers
|
||||
def b64u_encode(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def b64u_decode(s: str) -> bytes:
|
||||
padding = 4 - len(s) % 4
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.urlsafe_b64decode(s)
|
||||
|
||||
|
||||
def _get_webauthn_config() -> dict[str, Any]:
|
||||
"""Load WebAuthn configuration from auth config."""
|
||||
raw = load_json(AUTH_CONFIG_PATH)
|
||||
return raw.get("webauthn", {})
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Return whether WebAuthn is enabled in config."""
|
||||
cfg = _get_webauthn_config()
|
||||
return cfg.get("enabled", True)
|
||||
|
||||
|
||||
def get_rp_name() -> str:
|
||||
"""Return the Relying Party name from config."""
|
||||
return _get_webauthn_config().get("rp_name", "Vacuum Wall")
|
||||
|
||||
|
||||
def get_management_domains() -> list[str]:
|
||||
"""Return domain names eligible for WebAuthn.
|
||||
|
||||
Delegates to lib.nginx.get_management_domains() to read the
|
||||
live proxy config and discover which domains serve the management UI.
|
||||
"""
|
||||
from lib.nginx import get_management_domains as _resolve
|
||||
|
||||
return _resolve()
|
||||
|
||||
|
||||
def is_domain_valid(domain: str) -> bool:
|
||||
"""Check if *domain* is eligible for WebAuthn."""
|
||||
return domain in get_management_domains()
|
||||
|
||||
|
||||
def check_webauthn_config() -> None:
|
||||
"""Validate WebAuthn config at startup."""
|
||||
enabled = is_enabled()
|
||||
if not enabled:
|
||||
logger.info("WebAuthn is disabled in config")
|
||||
return
|
||||
try:
|
||||
domains = get_management_domains()
|
||||
if not domains:
|
||||
logger.warning(
|
||||
"WebAuthn is enabled but no management domains are configured. "
|
||||
"Add a domain with backend 'webui' to nginx config, or disable WebAuthn."
|
||||
)
|
||||
else:
|
||||
logger.info("WebAuthn enabled for domains: %s", domains)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve management domains for WebAuthn: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_registration_options(
|
||||
username: str,
|
||||
origin: str,
|
||||
rp_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create WebAuthn registration options for a new credential.
|
||||
|
||||
Args:
|
||||
username: The user registering the credential.
|
||||
origin: WebAuthn origin (must match the request origin).
|
||||
rp_id: Relying Party ID (must match the request domain).
|
||||
|
||||
Returns a dict serializable to JSON, matching the format expected by
|
||||
``navigator.credentials.create()``.
|
||||
"""
|
||||
# Load existing credential IDs to exclude
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Pad username to >= 8 bytes (required for user_id)
|
||||
user_id = username.encode("utf-8")
|
||||
if len(user_id) < 8:
|
||||
user_id = user_id + b"\x00" * (8 - len(user_id))
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=get_rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=username,
|
||||
user_id=user_id,
|
||||
attestation=AttestationConveyancePreference.NONE,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
supported_pub_key_algs=_SUPPORTED_ALGS,
|
||||
exclude_credentials=exclude_credentials,
|
||||
)
|
||||
|
||||
# Serialize using the library's built-in function
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
|
||||
def verify_registration(
|
||||
username: str,
|
||||
credential_response: dict[str, Any],
|
||||
registration_options: dict[str, Any],
|
||||
credential_name: str = "",
|
||||
origin: str = "",
|
||||
rp_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a registration response and persist the credential.
|
||||
|
||||
Args:
|
||||
username: The user registering the credential.
|
||||
credential_response: Browser response from ``credentials.create()``.
|
||||
registration_options: The options dict from ``create_registration_options``.
|
||||
credential_name: Optional human-readable label.
|
||||
origin: WebAuthn origin for verification.
|
||||
rp_id: Relying Party ID for verification.
|
||||
|
||||
Returns:
|
||||
Dict with ``id``, ``name``, ``transports``, ``sign_count``.
|
||||
"""
|
||||
challenge = b64u_decode(registration_options["challenge"])
|
||||
|
||||
# The library accepts the credential response as a JSON-serializable dict
|
||||
# We pass it directly — the library handles the parsing
|
||||
col = verify_registration_response(
|
||||
credential=credential_response,
|
||||
expected_challenge=challenge,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=rp_id,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
new_cred = col.credential
|
||||
new_credential_id = b64u_encode(new_cred.id)
|
||||
new_public_key = b64u_encode(new_cred.public_key)
|
||||
new_sign_count = new_cred.sign_count
|
||||
|
||||
transports = []
|
||||
if hasattr(new_cred.response, "transports") and new_cred.response.transports:
|
||||
transports = [str(t) for t in new_cred.response.transports]
|
||||
if not transports:
|
||||
transports_raw = credential_response.get("response", {}).get("transports", [])
|
||||
transports = [
|
||||
t
|
||||
for t in transports_raw
|
||||
if t
|
||||
in (
|
||||
"internal",
|
||||
"hybrid",
|
||||
"nfc",
|
||||
"ble",
|
||||
"usb",
|
||||
"smart-card",
|
||||
)
|
||||
] or ["internal"]
|
||||
|
||||
db = get_db()
|
||||
db.run(
|
||||
Q_INSERT_WEBAUTHN,
|
||||
(
|
||||
username,
|
||||
new_credential_id,
|
||||
new_public_key,
|
||||
new_sign_count,
|
||||
credential_name,
|
||||
json.dumps(transports),
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"WebAuthn credential registered for %s: %s",
|
||||
username,
|
||||
credential_name or new_credential_id[:16],
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_credential_id,
|
||||
"name": credential_name,
|
||||
"transports": transports,
|
||||
"sign_count": new_sign_count,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_authentication_options(
|
||||
username: str,
|
||||
rp_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create authentication options for a user.
|
||||
|
||||
Args:
|
||||
username: The user authenticating.
|
||||
rp_id: Relying Party ID (must match the request domain).
|
||||
|
||||
Returns a dict serializable to JSON (for ``navigator.credentials.get()``),
|
||||
or ``None`` if the user has no registered credentials.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
username: str,
|
||||
assertion_response: dict[str, Any],
|
||||
auth_options: dict[str, Any],
|
||||
origin: str = "",
|
||||
rp_id: str = "",
|
||||
) -> bool:
|
||||
"""Verify an authentication assertion.
|
||||
|
||||
Args:
|
||||
username: The user authenticating.
|
||||
assertion_response: Browser response from ``credentials.get()``.
|
||||
auth_options: The options dict from ``create_authentication_options``.
|
||||
origin: WebAuthn origin for verification.
|
||||
rp_id: Relying Party ID for verification.
|
||||
|
||||
Returns:
|
||||
True on successful verification.
|
||||
|
||||
Raises:
|
||||
ValueError: On verification failure.
|
||||
"""
|
||||
challenge = b64u_decode(auth_options["challenge"])
|
||||
cred_id_str = assertion_response["id"]
|
||||
|
||||
# Load credential from DB
|
||||
db = get_db()
|
||||
cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,))
|
||||
if not cred_rows:
|
||||
raise ValueError("Credential not found")
|
||||
|
||||
cred_row = cred_rows[0]
|
||||
if cred_row["username"] != username:
|
||||
raise ValueError("Credential not found")
|
||||
|
||||
public_key = b64u_decode(cred_row["public_key"])
|
||||
old_sign_count = cred_row["sign_count"]
|
||||
|
||||
col = verify_authentication_response(
|
||||
credential=assertion_response,
|
||||
expected_challenge=challenge,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=rp_id,
|
||||
credential_public_key=public_key,
|
||||
credential_current_sign_count=old_sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
new_sign_count = col.credential_sign_count
|
||||
if new_sign_count > old_sign_count:
|
||||
db.run(
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
(new_sign_count, cred_id_str),
|
||||
)
|
||||
|
||||
logger.info("WebAuthn assertion verified for %s", username)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_credentials(username: str) -> list[dict[str, Any]]:
|
||||
"""List all registered credentials for a user."""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
transports_str = row.get("transports", "[]")
|
||||
try:
|
||||
transports = json.loads(transports_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
transports = []
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": row["credential_id"],
|
||||
"name": row.get("name") or "",
|
||||
"transports": transports,
|
||||
"sign_count": row.get("sign_count", 0),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def remove_credential(username: str, credential_id: str) -> bool:
|
||||
"""Remove a credential.
|
||||
|
||||
Raises:
|
||||
ValueError: If credential not found or doesn't belong to user.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,))
|
||||
if not rows:
|
||||
raise ValueError(f"Credential {credential_id!r} not found")
|
||||
if rows[0]["username"] != username:
|
||||
raise ValueError(f"Credential {credential_id!r} not found")
|
||||
|
||||
db.run(Q_DELETE_WEBAUTHN, (credential_id,))
|
||||
logger.info("WebAuthn credential removed: %s, %s", username, credential_id)
|
||||
return True
|
||||
|
||||
|
||||
def get_all_credential_counts() -> dict[str, int]:
|
||||
"""Return credential counts for all users.
|
||||
|
||||
Returns:
|
||||
Dict mapping usernames to credential counts.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ())
|
||||
return {row["username"]: row["cred_count"] for row in rows}
|
||||
+413
-86
@@ -1,7 +1,8 @@
|
||||
"""WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
|
||||
|
||||
Generates wg-quick configurations, manages peers, and controls
|
||||
the WireGuard tunnel interface.
|
||||
the WireGuard tunnel interface. Supports multi-interface mode where
|
||||
each access class gets its own WireGuard interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -19,7 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||
WG_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
|
||||
@@ -30,6 +30,25 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _wg_conf_path(ifname: str) -> str:
|
||||
"""Return the system WG conf path for an interface name."""
|
||||
return f"/etc/wireguard/{ifname}.conf"
|
||||
|
||||
|
||||
def _default_class_fields() -> dict[str, Any]:
|
||||
"""Return the default set of fields for an access class entry."""
|
||||
return {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"subnet": None,
|
||||
"listen_port": None,
|
||||
"lan_access": False,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
@@ -37,9 +56,31 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"server_endpoint": "",
|
||||
"description": "",
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"access_classes": {
|
||||
"full": {
|
||||
"name": "Full LAN Access",
|
||||
"description": "Peers get full access to internal networks",
|
||||
"subnet": "10.137.0.0/24",
|
||||
"listen_port": 51820,
|
||||
"lan_access": True,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
"internet": {
|
||||
"name": "Internet Only",
|
||||
"description": "Peers can only reach the internet",
|
||||
"subnet": "10.137.1.0/24",
|
||||
"listen_port": 51821,
|
||||
"lan_access": False,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
@@ -62,23 +103,114 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=False)
|
||||
private_key = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
|
||||
public_key = res2.stdout.strip()
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
# --- wg0.conf generation ---
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _class_interface_name(class_key: str) -> str:
|
||||
"""Derive interface name for an access class key."""
|
||||
return f"wg-{class_key}"
|
||||
|
||||
|
||||
def _class_zone_name(class_key: str) -> str:
|
||||
"""Derive firewall zone name for an access class key."""
|
||||
return f"vpn-{class_key}"
|
||||
|
||||
|
||||
def get_class_interface_name(class_key: str) -> str:
|
||||
"""Public wrapper for `_class_interface_name`."""
|
||||
return _class_interface_name(class_key)
|
||||
|
||||
|
||||
def get_class_zone_name(class_key: str) -> str:
|
||||
"""Public wrapper for `_class_zone_name`."""
|
||||
return _class_zone_name(class_key)
|
||||
|
||||
|
||||
def _class_peers(cfg: dict[str, Any], class_key: str) -> dict[str, Any]:
|
||||
"""Return peers assigned to a given access class."""
|
||||
return {
|
||||
name: info
|
||||
for name, info in cfg.get("peers", {}).items()
|
||||
if isinstance(info, dict) and info.get("access_class") == class_key
|
||||
}
|
||||
|
||||
|
||||
# --- wg-<class>.conf generation ---
|
||||
|
||||
|
||||
def generate_class_conf(cfg: dict[str, Any], class_key: str) -> str | None:
|
||||
"""Render a wg-quick config file for a single access class.
|
||||
|
||||
Returns ``None`` when the class has no peers assigned.
|
||||
"""
|
||||
classes = cfg.get("access_classes", {})
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
return None
|
||||
|
||||
peers = _class_peers(cfg, class_key)
|
||||
if not peers:
|
||||
return None
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
subnet = class_cfg.get("subnet")
|
||||
if not subnet:
|
||||
subnet = "10.137.0.0/24"
|
||||
|
||||
_, prefix = subnet.rsplit("/", 1)
|
||||
base = subnet.rsplit(".", 1)[0]
|
||||
addr = f"{base}.1/{prefix}"
|
||||
|
||||
listen_port = class_cfg.get("listen_port")
|
||||
if not listen_port:
|
||||
listen_port = 51820
|
||||
|
||||
private_key = class_cfg.get("private_key", "")
|
||||
if not private_key:
|
||||
raise ValueError(
|
||||
f"Access class '{class_key}' has no private key — generate one first."
|
||||
)
|
||||
|
||||
class_iface = {
|
||||
"name": ifname,
|
||||
"listen_port": listen_port,
|
||||
"private_key": private_key,
|
||||
"addresses": [addr],
|
||||
"post_up": cfg.get("interface", {}).get("post_up"),
|
||||
"post_down": cfg.get("interface", {}).get("post_down"),
|
||||
}
|
||||
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=class_iface,
|
||||
peers=peers,
|
||||
)
|
||||
|
||||
|
||||
def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
|
||||
"""Render a valid wg-quick config file from *cfg* using Jinja2.
|
||||
|
||||
Legacy single-interface mode — uses the top-level ``interface`` block
|
||||
and peers without an ``access_class`` assigned.
|
||||
"""
|
||||
fallback_peers = {
|
||||
n: p
|
||||
for n, p in cfg.get("peers", {}).items()
|
||||
if isinstance(p, dict) and not p.get("access_class")
|
||||
}
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=cfg["interface"],
|
||||
peers=cfg.get("peers", {}),
|
||||
peers=fallback_peers if fallback_peers else {},
|
||||
)
|
||||
|
||||
|
||||
@@ -86,55 +218,187 @@ def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
"""Write the current config to disk and bring the tunnel up with wg-quick."""
|
||||
"""Write the current config to disk and bring tunnels up with wg-quick.
|
||||
|
||||
In multi-interface mode, applies each access class's interface independently.
|
||||
Falls back to legacy single-interface mode when no classes have peers.
|
||||
"""
|
||||
cfg = get_config()
|
||||
classes = cfg.get("access_classes", {})
|
||||
applied = False
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
if not _class_peers(cfg, class_key):
|
||||
continue
|
||||
|
||||
try:
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
|
||||
applied = True
|
||||
|
||||
if applied:
|
||||
save_config(cfg)
|
||||
elif applied is False:
|
||||
# Legacy single-interface fallback
|
||||
legacy_apply(cfg)
|
||||
|
||||
|
||||
def legacy_apply(cfg: dict[str, Any]) -> None:
|
||||
"""Legacy single-interface apply."""
|
||||
conf_text = generate_conf(cfg)
|
||||
save_config(cfg)
|
||||
|
||||
ifname = cfg["interface"]["name"]
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / "wg0.conf.tmp"
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", ifname)
|
||||
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
|
||||
def apply_class(class_key: str) -> None:
|
||||
"""Apply config for a single access class interface."""
|
||||
cfg = get_config()
|
||||
class_cfg = cfg.get("access_classes", {}).get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
raise ValueError(f"Access class '{class_key}' not found")
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
raise ValueError(f"No peers assigned to class '{class_key}'")
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
|
||||
|
||||
|
||||
def down() -> None:
|
||||
"""Bring the WireGuard tunnel interface down."""
|
||||
"""Bring all WireGuard tunnel interfaces down.
|
||||
|
||||
In multi-interface mode, brings down each class interface with peers.
|
||||
"""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||
classes = cfg.get("access_classes", {})
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
if ifname := _class_interface_name(class_key):
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try legacy interface (skip if name matches any class interface)
|
||||
ifname = cfg["interface"].get("name", "")
|
||||
if ifname:
|
||||
class_names = {_class_interface_name(k) for k in classes}
|
||||
if ifname not in class_names:
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def down_class(class_key: str) -> None:
|
||||
"""Bring down a single access class interface."""
|
||||
ifname = _class_interface_name(class_key)
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
|
||||
|
||||
# --- Status ---
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""Query the live tunnel state via ``wg show``."""
|
||||
"""Query the live tunnel state via ``wg show``.
|
||||
|
||||
In multi-interface mode, collects status for all class interfaces.
|
||||
Returns combined status dict keyed by interface name.
|
||||
"""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
"classes": {},
|
||||
}
|
||||
|
||||
# Collect per-class status
|
||||
classes = cfg.get("access_classes", {})
|
||||
for class_key in classes:
|
||||
ifname = _class_interface_name(class_key)
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
result["classes"][class_key] = {"up": False, "peers": []}
|
||||
continue
|
||||
class_status = _parse_wg_show_output(res.stdout.strip())
|
||||
result["classes"][class_key] = class_status
|
||||
if class_status["up"]:
|
||||
result["up"] = True
|
||||
except Exception:
|
||||
result["classes"][class_key] = {"up": False, "peers": []}
|
||||
|
||||
# Legacy single-interface status (still collected for backward compat)
|
||||
try:
|
||||
ifname = cfg["interface"].get("name", "wg0")
|
||||
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
parsed = _parse_wg_show_output(res.stdout.strip())
|
||||
result["up"] = parsed["up"]
|
||||
result["interface"] = parsed.get("interface", {})
|
||||
result["peers"] = parsed.get("peers", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
|
||||
"""Parse ``wg show`` output into structured dict."""
|
||||
result: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
return result
|
||||
|
||||
raw = res.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
current_peer: dict[str, Any] | None = None
|
||||
peers: list[dict[str, Any]] = []
|
||||
|
||||
@@ -169,8 +433,8 @@ def status() -> dict[str, Any]:
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": 0,
|
||||
"transfer_sent": 0,
|
||||
"transfer_received": "0",
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
peers.append(current_peer)
|
||||
@@ -214,36 +478,50 @@ def status() -> dict[str, Any]:
|
||||
|
||||
# --- Peer management ---
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def add_peer(
|
||||
name: str,
|
||||
endpoint: str | None = None,
|
||||
allowed_ips: list[str] | None = None,
|
||||
persistent_keepalive: int | None = None,
|
||||
endpoint: str | None | object = _UNSET,
|
||||
allowed_ips: list[str] | None | object = _UNSET,
|
||||
persistent_keepalive: int | None | object = _UNSET,
|
||||
preshared_key: str | None = None,
|
||||
description: str | None = None,
|
||||
access_class: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add (or update) a peer in the configuration."""
|
||||
cfg = get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = allowed_ips or []
|
||||
|
||||
if name in peers:
|
||||
peer = peers[name]
|
||||
peer["endpoint"] = endpoint
|
||||
peer["allowed_ips"] = allowed_ips
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if endpoint is not _UNSET:
|
||||
peer["endpoint"] = endpoint
|
||||
if allowed_ips is not _UNSET:
|
||||
peer["allowed_ips"] = allowed_ips if allowed_ips is not None else []
|
||||
if persistent_keepalive is not _UNSET:
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if preshared_key is not None:
|
||||
peer["preshared_key"] = preshared_key
|
||||
if description is not None:
|
||||
peer["description"] = description
|
||||
if access_class is not None:
|
||||
peer["access_class"] = access_class
|
||||
logger.info("WireGuard peer '%s' updated", name)
|
||||
else:
|
||||
priv, pub = generate_keypair()
|
||||
peer = {
|
||||
"public_key": pub,
|
||||
"private_key": priv,
|
||||
"endpoint": endpoint,
|
||||
"allowed_ips": allowed_ips,
|
||||
"persistent_keepalive": persistent_keepalive,
|
||||
"endpoint": endpoint if endpoint is not _UNSET else None,
|
||||
"allowed_ips": allowed_ips if allowed_ips is not _UNSET else [],
|
||||
"persistent_keepalive": persistent_keepalive
|
||||
if persistent_keepalive is not _UNSET
|
||||
else None,
|
||||
"preshared_key": preshared_key,
|
||||
"description": description,
|
||||
"access_class": access_class,
|
||||
}
|
||||
peers[name] = peer
|
||||
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
|
||||
@@ -275,9 +553,17 @@ def get_peers() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def get_peer_status() -> list[dict[str, Any]]:
|
||||
"""Return live peer status from ``wg show``."""
|
||||
"""Return live peer status from ``wg show`` for all interfaces."""
|
||||
st = status()
|
||||
return st.get("peers", [])
|
||||
all_peers: list[dict[str, Any]] = []
|
||||
for _class_key, class_st in st.get("classes", {}).items():
|
||||
for p in class_st.get("peers", []):
|
||||
merged = dict(p)
|
||||
merged["access_class"] = _class_key
|
||||
all_peers.append(merged)
|
||||
if not all_peers:
|
||||
all_peers = st.get("peers", [])
|
||||
return all_peers
|
||||
|
||||
|
||||
# --- Client config generation ---
|
||||
@@ -286,9 +572,13 @@ def get_peer_status() -> list[dict[str, Any]]:
|
||||
def generate_client_conf(
|
||||
peer_name: str,
|
||||
server_endpoint: str,
|
||||
server_pubkey: str,
|
||||
server_pubkey: str | None = None,
|
||||
) -> str:
|
||||
"""Build a client-side wg-quick config snippet for *peer_name*."""
|
||||
"""Build a client-side wg-quick config snippet for *peer_name*.
|
||||
|
||||
Uses the peer's access class to derive server address from the class
|
||||
subnet and the class's listen port.
|
||||
"""
|
||||
cfg = get_config()
|
||||
iface = cfg["interface"]
|
||||
peer = cfg["peers"].get(peer_name)
|
||||
@@ -301,12 +591,30 @@ def generate_client_conf(
|
||||
f"Peer '{peer_name}' has no private key — cannot generate client config."
|
||||
)
|
||||
|
||||
# Determine class info for address/port
|
||||
access_class = peer.get("access_class")
|
||||
sorted_peers = sorted(cfg.get("peers", {}).keys())
|
||||
peer_index = sorted_peers.index(peer_name) + 2
|
||||
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
|
||||
addr_part, prefix = srv_addr.rsplit("/", 1)
|
||||
prefix_base = addr_part.rsplit(".", 1)[0]
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
|
||||
if access_class and access_class in cfg.get("access_classes", {}):
|
||||
class_cfg = cfg["access_classes"][access_class]
|
||||
subnet = class_cfg.get("subnet", "10.137.0.0/24")
|
||||
listen_port = class_cfg.get("listen_port", 51820)
|
||||
else:
|
||||
subnet = iface.get("addresses", ["10.137.0.1/24"])[0]
|
||||
listen_port = iface.get("listen_port", 51820)
|
||||
|
||||
if "/" not in subnet:
|
||||
subnet = f"{subnet}/24"
|
||||
addr = subnet.rsplit(".", 1)[0]
|
||||
prefix = subnet.rsplit("/", 1)[1]
|
||||
client_addr = f"{addr}.{peer_index}/{prefix}"
|
||||
|
||||
sk = server_pubkey or iface.get("public_key", "")
|
||||
ep = server_endpoint or iface.get("server_endpoint", "")
|
||||
if ep and listen_port:
|
||||
host = ep.split(":")[0]
|
||||
ep = f"{host}:{listen_port}"
|
||||
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
conf = tmpl.render(
|
||||
@@ -314,8 +622,8 @@ def generate_client_conf(
|
||||
peer_name=peer_name,
|
||||
client_priv=client_priv,
|
||||
client_addr=client_addr,
|
||||
server_pubkey=server_pubkey,
|
||||
server_endpoint=server_endpoint,
|
||||
server_pubkey=sk,
|
||||
server_endpoint=ep,
|
||||
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
@@ -351,66 +659,85 @@ def set_post_down(cmd: str | None) -> None:
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# --- Class key generation ---
|
||||
|
||||
|
||||
def generate_class_keypair(class_key: str) -> tuple[str, str]:
|
||||
"""Generate a key pair for an access class interface."""
|
||||
cfg = get_config()
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if class_key not in classes:
|
||||
raise ValueError(f"Access class '{class_key}' not found")
|
||||
class_cfg = classes[class_key]
|
||||
if class_cfg.get("private_key"):
|
||||
return class_cfg["private_key"], class_cfg["public_key"]
|
||||
priv, pub = generate_keypair()
|
||||
class_cfg["private_key"] = priv
|
||||
class_cfg["public_key"] = pub
|
||||
save_config(cfg)
|
||||
logger.info("Key pair generated for class '%s'", class_key)
|
||||
return priv, pub
|
||||
|
||||
|
||||
# --- Initialise ---
|
||||
|
||||
|
||||
def _ensure_class_defaults(cfg: dict[str, Any]) -> None:
|
||||
"""Ensure access classes have required Phase-2 fields."""
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
for _key, c in classes.items():
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
for field, default in _default_class_fields().items():
|
||||
if field not in c:
|
||||
c[field] = default
|
||||
|
||||
|
||||
def _ensure_access_classes(cfg: dict[str, Any]) -> None:
|
||||
"""Pre-seed default access classes if missing or empty (idempotent).
|
||||
|
||||
Also upgrades existing classes with Phase-2 fields.
|
||||
"""
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if not classes:
|
||||
full_defaults = deepcopy(DEFAULT_CONFIG["access_classes"])
|
||||
classes.update(full_defaults)
|
||||
return
|
||||
_ensure_class_defaults(cfg)
|
||||
|
||||
|
||||
def initialize() -> dict[str, Any]:
|
||||
"""Perform first-time WireGuard setup."""
|
||||
cfg = get_config()
|
||||
|
||||
if cfg["interface"].get("private_key"):
|
||||
_ensure_access_classes(cfg)
|
||||
save_config(cfg)
|
||||
return cfg
|
||||
|
||||
priv, pub = generate_keypair()
|
||||
cfg["interface"]["private_key"] = priv
|
||||
cfg["interface"]["public_key"] = pub
|
||||
_ensure_access_classes(cfg)
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||
return cfg
|
||||
|
||||
|
||||
# --- Utility: parse wg show into structured peer map ---
|
||||
|
||||
|
||||
def _parse_wg_show(output: str) -> dict[str, Any]:
|
||||
"""Internal parser for ``wg show`` multiline output."""
|
||||
peers: dict[str, dict[str, Any]] = {}
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
key = line.split(":", 1)[1].strip()
|
||||
current = {"_key": key}
|
||||
peers[key] = current
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
if line.startswith("endpoint:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
current["endpoint"] = val
|
||||
elif line.startswith("allowed ips:"):
|
||||
current["allowed_ips"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("latest handshake:"):
|
||||
current["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
current["transfer_raw"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
||||
|
||||
return peers
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIG",
|
||||
"add_peer",
|
||||
"apply",
|
||||
"apply_class",
|
||||
"down",
|
||||
"down_class",
|
||||
"generate_class_conf",
|
||||
"generate_class_keypair",
|
||||
"generate_client_conf",
|
||||
"generate_conf",
|
||||
"generate_keypair",
|
||||
"get_class_interface_name",
|
||||
"get_class_zone_name",
|
||||
"get_config",
|
||||
"get_peer_status",
|
||||
"get_peers",
|
||||
|
||||
@@ -12,6 +12,9 @@ dependencies = [
|
||||
"aiohttp>=3.9,<4.0",
|
||||
"passlib>=1.7.4,<2.0",
|
||||
"requests-unixsocket>=0.2,<1.0",
|
||||
"PyJWT>=2.8,<3.0",
|
||||
"argon2-cffi>=23.1.0,<25.0",
|
||||
"webauthn>=3.0.0,<4.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Bootstrap auth: initialize DB and seed admin user at install time.
|
||||
|
||||
Idempotent — safe to run on every install (and re-install):
|
||||
|
||||
- Writes config/auth/config.json only if it does not exist (existing
|
||||
JWT/WebAuthn settings are preserved).
|
||||
- Creates the admin user if missing; if the user already exists, updates
|
||||
the admin password to the provided value (docs/deployment.md: "On
|
||||
re-run, updates the admin password if already present").
|
||||
- Suppresses the last-resort builtin admin seed (VACUUM_WALL_SEED_BUILTIN_ADMIN=0):
|
||||
bootstrap is the operator user's creator on a fresh install, so exactly
|
||||
one account exists and no hardcoded admin with an unrecoverable random
|
||||
password is left behind.
|
||||
|
||||
Usage:
|
||||
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
||||
--username admin \
|
||||
--password secret \
|
||||
--domain wall.example.com
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project lib is importable
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Vacuum Wall auth")
|
||||
parser.add_argument("--project-dir", required=True, help="Project root directory")
|
||||
parser.add_argument("--username", required=True, help="Admin username")
|
||||
parser.add_argument("--password", required=True, help="Admin password")
|
||||
parser.add_argument("--domain", required=True, help="Management domain (rp_id)")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_dir = Path(args.project_dir).resolve()
|
||||
sys.path.insert(0, str(project_dir))
|
||||
|
||||
# Set DB path before importing lib modules
|
||||
db_path = str(project_dir / "data" / "auth.db")
|
||||
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
||||
# Suppress the last-resort builtin admin seed in get_db(): bootstrap
|
||||
# creates the operator user itself, so no seeded admin may shadow it.
|
||||
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
|
||||
|
||||
from lib.auth_users import (
|
||||
ALL_SUBSYSTEMS,
|
||||
create_user,
|
||||
find_user,
|
||||
reset_password,
|
||||
)
|
||||
|
||||
# Write config — only if missing, so re-runs never clobber existing
|
||||
# JWT/WebAuthn settings (e.g. a customized rp_id/origin).
|
||||
config_dir = project_dir / "config" / "auth"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = config_dir / "config.json"
|
||||
|
||||
if not config_path.exists():
|
||||
config = {
|
||||
"jwt": {
|
||||
"access_token_ttl": 300,
|
||||
"refresh_token_ttl": 604800,
|
||||
"algorithm": "HS256",
|
||||
},
|
||||
"webauthn": {
|
||||
"rp_name": "Vacuum Wall",
|
||||
"rp_id": args.domain,
|
||||
"origin": f"https://{args.domain}",
|
||||
},
|
||||
}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"Wrote auth config: {config_path}")
|
||||
else:
|
||||
print(f"Auth config already present, leaving unchanged: {config_path}")
|
||||
|
||||
# Initialize DB and create the admin user, or sync the password on re-run
|
||||
if find_user(args.username) is not None:
|
||||
reset_password(args.username, args.password)
|
||||
print(f"Updated existing user: {args.username} (password synced)")
|
||||
else:
|
||||
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
||||
user = create_user(args.username, args.password, permissions)
|
||||
print(f"Created admin user: {user['username']} (id={user['id']})")
|
||||
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+111
-134
@@ -35,14 +35,14 @@ while [[ $# -gt 0 ]]; do
|
||||
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
printf '%s\n' \
|
||||
"Usage: install.sh [OPTIONS]" \
|
||||
"Usage: scripts/install.sh [OPTIONS]" \
|
||||
"" \
|
||||
"Options:" \
|
||||
" --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
|
||||
" --path, -p DIR Install directory (default: repo root)" \
|
||||
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
|
||||
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||
" --mgmt-pass PASS Initial admin password (required)" \
|
||||
" --mgmt-user USER Initial admin username (default: admin)" \
|
||||
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||
@@ -54,10 +54,10 @@ while [[ $# -gt 0 ]]; do
|
||||
" CLI flags take precedence over env vars." \
|
||||
"" \
|
||||
"Example (dev):" \
|
||||
" ./install.sh --dev --mgmt-pass pass" \
|
||||
" ./scripts/install.sh --dev --mgmt-pass pass" \
|
||||
"" \
|
||||
"Example (prod):" \
|
||||
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
|
||||
" MGMT_PASS=pass ./scripts/install.sh --user vacuum-wall"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -67,11 +67,11 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
# --- Resolve config: CLI flag > env var > default ---
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$(cd "$(dirname "$0")/../" && pwd)"
|
||||
|
||||
# Required settings (no defaults — must be provided)
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
# Optional settings with defaults
|
||||
# MGMT_PASS is strictly required — admin user is created at install time
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
|
||||
|
||||
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
|
||||
@@ -104,19 +104,7 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||
|
||||
# --- Validate required settings ---
|
||||
missing=()
|
||||
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||
|
||||
if (( ${#missing[@]} )); then
|
||||
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||
for v in "${missing[@]}"; do
|
||||
case "$v" in
|
||||
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
||||
esac
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
|
||||
exit 1
|
||||
fi
|
||||
[[ -n "$MGMT_PASS" ]] || err "MGMT_PASS is required (set --mgmt-pass or MGMT_PASS env var)"
|
||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
||||
|
||||
# Dev mode: auto-detect repo owner as service user
|
||||
@@ -182,6 +170,10 @@ apt-get install -y -qq \
|
||||
apache2-utils \
|
||||
avahi-daemon
|
||||
|
||||
# --- 1b. Vendored libraries ---
|
||||
log "Downloading vendored libraries..."
|
||||
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
|
||||
|
||||
# --- 2. Setup users ---
|
||||
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
|
||||
|
||||
@@ -207,17 +199,6 @@ else
|
||||
chmod -R g+x "${PROJECT_DIR}/.venv"
|
||||
fi
|
||||
|
||||
# --- 2c. Install acme.sh (vendored) ---
|
||||
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
|
||||
log "Installing acme.sh (vendored)..."
|
||||
mkdir -p "$ACME_HOME"
|
||||
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
|
||||
chmod +x "$ACME_HOME/acme.sh"
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
|
||||
else
|
||||
log "acme.sh already installed."
|
||||
fi
|
||||
|
||||
# Install the deploy hook into acme.sh's deploy directory
|
||||
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
|
||||
mkdir -p "$ACME_HOME/deploy"
|
||||
@@ -231,15 +212,26 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
|
||||
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
|
||||
mkdir -p /etc/wireguard
|
||||
mkdir -p /etc/dnsmasq
|
||||
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
|
||||
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
|
||||
# The top-level .git (directory or worktree pointer file) is left untouched so
|
||||
# the repo owner's git isn't tripped by git's dubious-ownership check.
|
||||
if [[ "$_cli_is_dev" == true ]]; then
|
||||
_dev_owner="$USER_NAME"
|
||||
else
|
||||
_dev_owner="$USER_DAEMON_NAME"
|
||||
fi
|
||||
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
|
||||
chmod -R g+rwX "$PROJECT_DIR"
|
||||
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
|
||||
(
|
||||
shopt -s dotglob nullglob
|
||||
for _entry in "$PROJECT_DIR"/*; do
|
||||
[[ "$(basename "$_entry")" == ".git" ]] && continue
|
||||
chown -R "$_dev_owner:$USER_GROUP" "$_entry"
|
||||
chmod -R g+rwX "$_entry"
|
||||
find "$_entry" -type d -exec chmod g+s '{}' +
|
||||
done
|
||||
# Top dir: ownership + shared-group access (never .git)
|
||||
chown "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
|
||||
chmod g+rwX,g+s "$PROJECT_DIR"
|
||||
)
|
||||
|
||||
# --- 4. Template rendering function ---
|
||||
# Renders Jinja2 templates by injecting env vars as template context.
|
||||
@@ -282,13 +274,23 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
|
||||
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service
|
||||
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
||||
|
||||
# Volatile /run entries (sudo, firewalld) must exist before vacuum-walld
|
||||
# spawns — systemd-tmpfiles-setup.service restores them at every boot.
|
||||
install -m 0644 "${PROJECT_DIR}/system/tmpfiles.d/vacuum-wall.conf" /etc/tmpfiles.d/vacuum-wall.conf
|
||||
systemd-tmpfiles --create
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
|
||||
# --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) ---
|
||||
log "Enabling IP forwarding..."
|
||||
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
|
||||
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
|
||||
fi
|
||||
# Apply immediately so NAT works without reboot
|
||||
if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != "1" ]; then
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 && log "IP forwarding enabled at runtime" || \
|
||||
warn "Could not enable IP forwarding at runtime"
|
||||
fi
|
||||
|
||||
# --- 8. Detect network interfaces ---
|
||||
log "Detecting network interfaces..."
|
||||
@@ -355,108 +357,87 @@ else
|
||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
|
||||
chmod 0660 "$_SOCKET" 2>/dev/null || true
|
||||
|
||||
# --- 10. Configure subsystems via daemon API ---
|
||||
log "Configuring subsystems via daemon API..."
|
||||
WAN_IFACE="$WAN_IFACE" \
|
||||
LAN_IFACES="$LAN_IFACES" \
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
MGMT_PASS="$MGMT_PASS" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import daemon.client as c
|
||||
from daemon.iface import (
|
||||
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
|
||||
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
)
|
||||
import sys
|
||||
echo ""
|
||||
echo " Setting up initial management configuration..."
|
||||
|
||||
domain = '${DOMAIN}'
|
||||
mgmt_user = '${MGMT_USER}'
|
||||
mgmt_pass = '${MGMT_PASS}'
|
||||
wan_iface = '${WAN_IFACE}'
|
||||
lan_ifaces = '${LAN_IFACES}'
|
||||
# Bootstrap auth: generate config + seed admin user. bootstrap_auth.py
|
||||
# is idempotent — on re-run it preserves the existing config and
|
||||
# updates the admin password to MGMT_PASS (docs/deployment.md).
|
||||
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
|
||||
echo ""
|
||||
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
|
||||
else
|
||||
echo ""
|
||||
echo " Syncing admin password for existing user: $MGMT_USER..."
|
||||
fi
|
||||
|
||||
# Self-signed cert for management domain
|
||||
try:
|
||||
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
|
||||
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
|
||||
except Exception as e:
|
||||
print(f' [cert] Warning: {e}', file=sys.stderr)
|
||||
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
|
||||
--project-dir "$PROJECT_DIR" \
|
||||
--username "$MGMT_USER" \
|
||||
--password "$MGMT_PASS" \
|
||||
--domain "$DOMAIN"
|
||||
|
||||
# Management proxy domain + htpasswd
|
||||
try:
|
||||
c.post(POST_NGINX_DOMAINS_ADD, {
|
||||
'domain': domain,
|
||||
'paths': {
|
||||
'/': {
|
||||
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
|
||||
'is_management': True,
|
||||
},
|
||||
'/ws': {
|
||||
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
|
||||
'is_websocket': True,
|
||||
},
|
||||
},
|
||||
'auth_user': mgmt_user,
|
||||
'auth_pass': mgmt_pass,
|
||||
})
|
||||
c.post(POST_NGINX_APPLY)
|
||||
print(f' [proxy] Management proxy configured for {domain}')
|
||||
except Exception as e:
|
||||
print(f' [proxy] Warning: {e}', file=sys.stderr)
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth"
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
|
||||
|
||||
# Firewall config (interface detection done in bash above)
|
||||
import json as _json
|
||||
zones = {}
|
||||
|
||||
if wan_iface:
|
||||
zones['public'] = {
|
||||
'target': 'DEFAULT',
|
||||
'interfaces': [i for i in wan_iface.split(',') if i],
|
||||
'services': ['http', 'https', 'ssh'],
|
||||
'masquerade': True,
|
||||
# Helper: POST JSON to daemon API over Unix socket
|
||||
_daemon_post() {
|
||||
local endpoint="$1"
|
||||
local json="$2"
|
||||
local label="${3:-POST $endpoint}"
|
||||
local resp
|
||||
if resp=$(curl -s -f --unix-socket "$_SOCKET" \
|
||||
"http://localhost${endpoint}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json" 2>&1); then
|
||||
log "$label"
|
||||
return 0
|
||||
else
|
||||
warn "$label: $resp"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if lan_ifaces:
|
||||
zones['internal'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [i for i in lan_ifaces.split(',') if i],
|
||||
'services': ['dhcp', 'dns', 'ntp'],
|
||||
'masquerade': False,
|
||||
}
|
||||
# 1A. Self-signed certificate for management domain
|
||||
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
|
||||
|
||||
# Always create vpn zone skeleton for later WireGuard setup
|
||||
zones['vpn'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [],
|
||||
'services': [],
|
||||
'masquerade': False,
|
||||
}
|
||||
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
|
||||
mgmt_json="$(jq -n \
|
||||
--arg domain "$DOMAIN" \
|
||||
'{
|
||||
domain: $domain,
|
||||
backend: "webui",
|
||||
cert: "selfsigned",
|
||||
force_ssl: true
|
||||
}')"
|
||||
_daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
|
||||
_daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
|
||||
|
||||
try:
|
||||
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
|
||||
c.post(POST_FIREWALL_CONFIG_APPLY)
|
||||
print(' [firewall] Zones configured and applied')
|
||||
except Exception as e:
|
||||
print(f' [firewall] Warning: {e}', file=sys.stderr)
|
||||
_daemon_post "/nginx/apply" "{}" "Nginx config applied"
|
||||
|
||||
# IP forwarding
|
||||
try:
|
||||
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
|
||||
print(' [network] IP forwarding enabled')
|
||||
except Exception as e:
|
||||
print(f' [network] Warning: {e}', file=sys.stderr)
|
||||
# Firewall zone assignment
|
||||
if [[ -n "$WAN_IFACE" ]]; then
|
||||
_daemon_post "/firewall/zones/interfaces" \
|
||||
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
|
||||
'{zone: $zone, interfaces: [$iface]}')" \
|
||||
"WAN interface assigned to public zone"
|
||||
|
||||
# Infer DHCP ranges (logged for user reference)
|
||||
try:
|
||||
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
|
||||
for iface, rng in ranges.get('ranges', {}).items():
|
||||
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
|
||||
except Exception:
|
||||
pass
|
||||
"
|
||||
log "Subsystem configuration complete"
|
||||
# Open management services (HTTP, HTTPS, SSH) on the public/WAN zone
|
||||
_daemon_post "/firewall/zones/services" \
|
||||
"$(jq -n '{zone: "public", services: ["http", "https", "ssh"]}')" \
|
||||
"Management services opened on public zone (http, https, ssh)"
|
||||
fi
|
||||
|
||||
if [[ -n "$LAN_IFACES" ]]; then
|
||||
# Convert comma-separated list to JSON array
|
||||
LAN_JSON=$(echo "$LAN_IFACES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s '.')
|
||||
_daemon_post "/firewall/zones/interfaces" \
|
||||
"$(jq -n --arg zone "internal" --argjson ifaces "$LAN_JSON" \
|
||||
'{zone: $zone, interfaces: $ifaces}')" \
|
||||
"LAN interfaces assigned to internal zone"
|
||||
fi
|
||||
|
||||
unset _daemon_post
|
||||
fi
|
||||
|
||||
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
|
||||
@@ -472,7 +453,7 @@ echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " Management UI: https://$DOMAIN"
|
||||
echo " User: $MGMT_USER"
|
||||
echo " Admin user: $MGMT_USER"
|
||||
echo " Daemon service: vacuum-walld.service"
|
||||
echo " WebUI service: vacuum-wall.service"
|
||||
echo " ACME renewal: vacuum-wall-acme.timer"
|
||||
@@ -496,7 +477,3 @@ echo " 3. Configure DHCP ranges for your LAN"
|
||||
echo " 4. Add proxy domains with ACME certificates"
|
||||
echo " 5. Set up WireGuard tunnel (optional)"
|
||||
echo ""
|
||||
echo " NOTE: A self-signed certificate was generated."
|
||||
echo " From the WebUI, issue a real certificate for $DOMAIN"
|
||||
echo " when DNS points to this appliance."
|
||||
echo ""
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
exec sudo "$0" "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "systemctl restart nginx"
|
||||
systemctl restart nginx
|
||||
sleep 1
|
||||
@@ -22,3 +27,5 @@ if [ "$failed" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,27 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download and vendor libraries into vendor/.
|
||||
# Run from the project root after updating the VERSION variables below.
|
||||
# Files are named {pkg}-{ver}.{ext}, with {pkg}.{ext} symlinks for stable references.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
VENDOR="$PROJECT_DIR/vendor"
|
||||
WEBUI_VENDOR="$PROJECT_DIR/webui/static/vendor"
|
||||
|
||||
# ---- Library versions ----
|
||||
ACME_VERSION="3.1.3"
|
||||
HTM_VERSION="3.1.1"
|
||||
|
||||
VENDOR="vendor"
|
||||
|
||||
# ---- Helpers ----
|
||||
download() {
|
||||
local name="$1" url="$2" dest="$3"
|
||||
if [[ -n "${SKIP_DOWNLOAD:-}" ]]; then
|
||||
echo "[skip] $name (SKIP_DOWNLOAD is set)"
|
||||
echo "[skip] $name"
|
||||
return
|
||||
fi
|
||||
if [[ -f "$dest" ]]; then
|
||||
echo "[skip] $name (already present)"
|
||||
return
|
||||
fi
|
||||
echo "[download] $name → $dest"
|
||||
curl -sfL -o "$dest" "$url"
|
||||
}
|
||||
|
||||
ensure_symlink() {
|
||||
local link="$1" target="$2"
|
||||
if [[ -L "$link" ]]; then
|
||||
cur=$(readlink "$link")
|
||||
if [[ "$cur" != "$target" ]]; then
|
||||
echo "[symlink] $link → $target (updated)"
|
||||
ln -sf "$target" "$link"
|
||||
fi
|
||||
elif [[ -e "$link" ]]; then
|
||||
echo "[symlink] $link (replacing existing file)"
|
||||
mv -f "$link" "${link}.bak" && ln -sf "$target" "$link"
|
||||
else
|
||||
echo "[symlink] $link → $target"
|
||||
ln -sf "$target" "$link"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- acme.sh ----
|
||||
ACME_VERSIONED="$VENDOR/acme-${ACME_VERSION}.sh"
|
||||
ACME_SYMLINK="$VENDOR/acme.sh"
|
||||
download "acme.sh@${ACME_VERSION}" \
|
||||
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
|
||||
"${VENDOR}/acme.sh"
|
||||
"$ACME_VERSIONED"
|
||||
chmod +x "$ACME_VERSIONED"
|
||||
ensure_symlink "$ACME_SYMLINK" "acme-${ACME_VERSION}.sh"
|
||||
|
||||
chmod +x "${VENDOR}/acme.sh"
|
||||
# ---- htm ----
|
||||
HTM_VERSIONED="$VENDOR/htm-${HTM_VERSION}.js"
|
||||
HTM_SYMLINK="$VENDOR/htm.js"
|
||||
download "htm@${HTM_VERSION}" \
|
||||
"https://cdn.jsdelivr.net/npm/htm@${HTM_VERSION}/mini/index.module.js" \
|
||||
"$HTM_VERSIONED"
|
||||
ensure_symlink "$HTM_SYMLINK" "htm-${HTM_VERSION}.js"
|
||||
|
||||
# ---- ACME_HOME (acme.sh runtime home) ----
|
||||
ACME_HOME="${ACME_HOME:-$PROJECT_DIR/data/acme}"
|
||||
mkdir -p "$ACME_HOME"
|
||||
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
|
||||
cp "$ACME_SYMLINK" "$ACME_HOME/acme.sh"
|
||||
chmod +x "$ACME_HOME/acme.sh"
|
||||
fi
|
||||
|
||||
# ---- Webui symlinks (point to versioned files) ----
|
||||
mkdir -p "$WEBUI_VENDOR"
|
||||
WEBUI_LINKS=(
|
||||
"htm.js:../../../vendor/htm-${HTM_VERSION}.js"
|
||||
)
|
||||
for entry in "${WEBUI_LINKS[@]}"; do
|
||||
IFS=':' read -r name target <<< "$entry"
|
||||
ensure_symlink "${WEBUI_VENDOR}/${name}" "$target"
|
||||
done
|
||||
|
||||
echo "[done] All libraries vendored."
|
||||
|
||||
@@ -33,8 +33,8 @@ server {
|
||||
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||
{% endif %}
|
||||
{% elif has_management %}
|
||||
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
|
||||
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
|
||||
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
||||
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||
{% endif %}
|
||||
|
||||
include snippets/vacuum-wall-ssl.conf;
|
||||
|
||||
@@ -9,37 +9,36 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active nginx
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||
|
||||
# Dnsmasq management
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/misc/dnsmasq.leases
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf
|
||||
|
||||
# WireGuard management
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg *
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/wireguard/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf
|
||||
|
||||
# Network interface queries
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show *
|
||||
|
||||
# Networkd management
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status *
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reload
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reconfigure *
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/systemd/network/*
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/systemd/network/*.network
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }}
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
||||
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp /var/log/vacuum-wall
|
||||
LogsDirectory=vacuum-wall
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
|
||||
@@ -12,14 +12,45 @@ WorkingDirectory={{ PROJECT_DIR }}
|
||||
ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=15
|
||||
Environment=PATH=/usr/local/bin:/usr/bin
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||
Environment=HOME={{ PROJECT_DIR }}
|
||||
|
||||
# Runtime directories created before namespace setup. ProtectSystem=strict
|
||||
# makes the whole hierarchy read-only, and namespace setup fails
|
||||
# (exit 226/NAMESPACE) if any ReadWritePaths= entry is missing at spawn.
|
||||
# /run is a fresh tmpfs at every boot, so volatile /run paths must be
|
||||
# created up front (RuntimeDirectory= here; /run/firewalld via
|
||||
# system/tmpfiles.d/vacuum-wall.conf and by firewalld itself) rather than
|
||||
# at first use.
|
||||
# vacuum-wall : secure temp files used during config apply
|
||||
# nginx : /run/nginx (listed in ReadWritePaths)
|
||||
RuntimeDirectory=vacuum-wall nginx
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
LogsDirectory=vacuum-wall
|
||||
|
||||
# Security hardening
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
|
||||
# NOTE: every ReadWritePaths= entry must exist when the unit spawns or namespace
|
||||
# setup fails (226/NAMESPACE). Volatile /run entries are pre-created:
|
||||
# /run/vacuum-wall, /run/nginx → RuntimeDirectory= (above)
|
||||
# /run/firewalld → system/tmpfiles.d/vacuum-wall.conf (and is
|
||||
# present while firewalld runs, which starts
|
||||
# before this unit)
|
||||
# /run/sudo is intentionally NOT listed: the daemon's sudo children use the
|
||||
# NOPASSWD whitelist and never need sudo's session directory (verified with
|
||||
# the directory absent). Listing it made the unit crash-loop whenever it
|
||||
# restarted after the last sudo session had ended and sudo removed /run/sudo.
|
||||
# /run/nginx.pid IS listed: nginx -t opens the pid file for *writing* in
|
||||
# addition to -s/acme reading it, so a read-only mount makes every daemon-side
|
||||
# `nginx -t` (and therefore /nginx/apply) fail with EROFS. The file is
|
||||
# pre-created by system/tmpfiles.d/vacuum-wall.conf so the ReadWritePaths=
|
||||
# entry always exists at spawn (nginx rewrites it on start; nginx -t does
|
||||
# not modify its contents).
|
||||
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Volatile /run entries that must exist before vacuum-walld spawns.
|
||||
#
|
||||
# vacuum-walld runs with ProtectSystem=strict and lists these paths in
|
||||
# ReadWritePaths=; if a ReadWritePaths= entry is missing at spawn time,
|
||||
# systemd's mount-namespace setup fails (exit 226/NAMESPACE) and the unit
|
||||
# crash-loops without ever creating data/daemon.sock. /run is a fresh tmpfs
|
||||
# at every boot, so every /run path the unit references needs a boot-time
|
||||
# creator. Status per path:
|
||||
#
|
||||
# /run/vacuum-wall, /run/nginx -> unit RuntimeDirectory= (daemon-owned)
|
||||
# /run/firewalld -> this file (the firewalld unit only creates
|
||||
# it while firewalld itself is running)
|
||||
# /run/nginx.pid -> this file (nginx rewrites it on start; the
|
||||
# daemon's `nginx -t` must be able to open
|
||||
# it for writing inside its ProtectSystem=strict
|
||||
# namespace, so it needs both a boot-time
|
||||
# creator and a ReadWritePaths= entry)
|
||||
# /run/sudo -> not referenced by the unit (see
|
||||
# ReadWritePaths note in vacuum-walld.service);
|
||||
# the sudo package ships its own tmpfiles spec
|
||||
#
|
||||
# Applied at early boot by systemd-tmpfiles-setup.service and by the install
|
||||
# script (`systemd-tmpfiles --create`) for existing hosts.
|
||||
d /run/firewalld 0750 root root -
|
||||
f /run/nginx.pid 0644 root root -
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Tests for hoover/components/applyconfirm.js
|
||||
*
|
||||
* Component-level tests: VNode structure, buildRows logic,
|
||||
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
||||
*/
|
||||
|
||||
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
|
||||
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(` \u2717 ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEq(a, b, msg) {
|
||||
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
|
||||
}
|
||||
|
||||
function assertIncludes(str, substr, msg) {
|
||||
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
|
||||
}
|
||||
|
||||
console.log('Testing ApplyConfirm component\n');
|
||||
|
||||
// === isPending ===
|
||||
test('isPending returns true for needs_apply', () => {
|
||||
assertEq(isPending({ needs_apply: true }), true);
|
||||
});
|
||||
|
||||
test('isPending returns true for pending_changes', () => {
|
||||
assertEq(isPending({ pending_changes: true }), true);
|
||||
});
|
||||
|
||||
test('isPending returns false when neither flag set', () => {
|
||||
assertEq(isPending({}), false);
|
||||
});
|
||||
|
||||
test('isPending returns false for explicit false', () => {
|
||||
assertEq(isPending({ needs_apply: false, pending_changes: false }), false);
|
||||
});
|
||||
|
||||
// === SUBSYSTEM_LIST ===
|
||||
test('SUBSYSTEM_LIST contains 5 subsystems', () => {
|
||||
assertEq(SUBSYSTEM_LIST.length, 5);
|
||||
});
|
||||
|
||||
test('SUBSYSTEM_LIST uses networkd key (not network)', () => {
|
||||
assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd');
|
||||
assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network');
|
||||
});
|
||||
|
||||
test('SUBSYSTEM_LIST keys match daemon response keys', () => {
|
||||
const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'];
|
||||
for (const key of expectedKeys) {
|
||||
assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// === buildRows ===
|
||||
test('buildRows returns 5 rows for empty subsystems', () => {
|
||||
const rows = buildRows({}, {});
|
||||
assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state');
|
||||
});
|
||||
|
||||
test('buildRows marks pending firewall subsystem correctly', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows changes are VNodes with proper structure', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
// Status span should contain "1 pending changes"
|
||||
const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
assert(statusSpan, 'should have status span');
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count');
|
||||
});
|
||||
|
||||
test('buildRows marks pending dnsmasq subsystem correctly', () => {
|
||||
const data = {
|
||||
dnsmasq: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'DHCP/DNS configuration has unapplied changes', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const dnsmasqRow = rows[1];
|
||||
assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows shows correct change count text', () => {
|
||||
const data = {
|
||||
dnsmasq: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'Range 1', detail: '' },
|
||||
{ summary: 'Range 2', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const dnsmasqRow = rows[1];
|
||||
const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assertEq(textChild.text, '2 pending changes');
|
||||
});
|
||||
|
||||
test('buildRows shows up-to-date for non-pending', () => {
|
||||
const data = {
|
||||
nginx: { pending_changes: false, changes: [] },
|
||||
wireguard: { pending_changes: false, changes: [] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const nginxRow = rows[2];
|
||||
assert(
|
||||
!nginxRow.props.class.includes('pending'),
|
||||
'nginx row should not have pending class',
|
||||
);
|
||||
const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assertEq(textChild.text, 'Up to date');
|
||||
});
|
||||
|
||||
test('buildRows shows expand icon and details when expanded', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
{ summary: 'Zone dmz: services changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, { firewall: true });
|
||||
assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)');
|
||||
});
|
||||
|
||||
test('buildRows hides expand icon when not expanded', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
assertEq(rows.length, 5, 'should only have 5 rows, no detail section');
|
||||
});
|
||||
|
||||
test('buildRows pending flag but no changes treated as up-to-date', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
assert(
|
||||
!fwRow.props.class.includes('pending'),
|
||||
'no changes = up to date',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRows detail section contains item VNodes', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, { firewall: true });
|
||||
const detailSection = rows[1];
|
||||
assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section');
|
||||
assert(detailSection.ch.length > 0, 'detail section should have children');
|
||||
});
|
||||
|
||||
test('buildRows handles networkd key correctly', () => {
|
||||
const data = {
|
||||
networkd: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'Network configuration has unapplied changes', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const networkdRow = rows[4]; // networkd is 5th in list
|
||||
assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows row VNodes have correct tag', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
for (const row of rows.slice(0, 5)) {
|
||||
assertEq(row.tag, 'div', 'row should be a div');
|
||||
assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class');
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Tests for hoover/auth_model.js
|
||||
*
|
||||
* Model-level tests: the 'check' action (session validation, including the
|
||||
* single refresh fallback on a 401) and the 'refresh' action (rotation).
|
||||
*
|
||||
* auth_model.js only pulls in model.js → reactivity.js (no DOM at import),
|
||||
* so the tests run under plain node with stubbed globals (fetch,
|
||||
* sessionStorage, document, window, timers).
|
||||
*
|
||||
* Run with `node tests/test-auth-model.js`.
|
||||
*/
|
||||
|
||||
import { createAuthModel } from '../webui/static/hoover/auth_model.js';
|
||||
import { modelRegister, modelFetch, getModel } from '../webui/static/hoover/model.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)}`);
|
||||
}
|
||||
|
||||
/* ── 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()],
|
||||
};
|
||||
}
|
||||
|
||||
/** Programmed URL → response queue. Missing routes are 500. */
|
||||
function makeFetch() {
|
||||
const byUrl = new Map();
|
||||
globalThis.fetch = async (url) => {
|
||||
const arr = byUrl.get(url) || [];
|
||||
const r = arr.length ? arr.shift() : { status: 500, body: { ok: false, error: 'unprogrammed route' } };
|
||||
return {
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
json: async () => r.body,
|
||||
};
|
||||
};
|
||||
const state = { calls: [], route: (url, status, body) => { if (!byUrl.has(url)) byUrl.set(url, []); state.calls.push(url); byUrl.get(url).push({ status, body }); } };
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 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; };
|
||||
|
||||
const storedSession = {
|
||||
'vw:access': 'access-old',
|
||||
'vw:refresh': 'refresh-old',
|
||||
'vw:session_id': 'sess-old',
|
||||
'vw:access_ttl': '900000',
|
||||
};
|
||||
|
||||
/**
|
||||
* Stubs + model registration for one scenario.
|
||||
* @param {{initialStorage?: object, hash?: string}} [cfg]
|
||||
* @returns {{calls: string[], route: function, events: Array}}
|
||||
*/
|
||||
function setup({ initialStorage = storedSession, hash = '#/dashboard' } = {}) {
|
||||
const { calls, route } = makeFetch();
|
||||
globalThis.sessionStorage = makeStorage(initialStorage);
|
||||
globalThis.document = { location: { hash } };
|
||||
const events = [];
|
||||
globalThis.window = {
|
||||
dispatchEvent: (e) => events.push(e),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
modelRegister('auth', createAuthModel());
|
||||
return { calls, route, events };
|
||||
}
|
||||
|
||||
/** Run the auth model action through the real modelFetch (dedup, hooks). */
|
||||
async function act(action) {
|
||||
await modelFetch('auth', { action });
|
||||
}
|
||||
|
||||
/* ── Tests ─────────────────────────────────────────────────── */
|
||||
|
||||
test('check 200 returns verified identity on the stored tokens', async () => {
|
||||
const s = setup();
|
||||
s.route('/api/auth/session', 200, {
|
||||
ok: true,
|
||||
data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } },
|
||||
});
|
||||
await act('check');
|
||||
assertEq(s.calls.length, 1, 'session check only');
|
||||
assertEq(s.calls[0], '/api/auth/session');
|
||||
const data = getModel('auth').data;
|
||||
assertEq(data.token, 'access-old', 'stored access token kept');
|
||||
assertEq(data.refresh, 'refresh-old', 'stored refresh token kept');
|
||||
assertEq(data.session_id, 'sess-old', 'stored session_id kept');
|
||||
assertEq(data.user?.username, 'alice', 'verified user merged');
|
||||
assert(data.permissions?.firewall === 'rw', 'verified permissions merged');
|
||||
});
|
||||
|
||||
test('check 401 with stored refresh token: one refresh, session preserved', async () => {
|
||||
const s = setup();
|
||||
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||
s.route('/api/auth/refresh', 200, {
|
||||
ok: true,
|
||||
data: {
|
||||
tokens: { access_token: 'access-new', refresh_token: 'refresh-new', session_id: 'sess-new' },
|
||||
user: { username: 'alice' },
|
||||
permissions: { firewall: 'rw' },
|
||||
access_ttl: 300,
|
||||
},
|
||||
});
|
||||
await act('check');
|
||||
assertEq(s.calls.length, 2, 'exactly one refresh attempt');
|
||||
assertEq(s.calls[0], '/api/auth/session', 'session check first');
|
||||
assertEq(s.calls[1], '/api/auth/refresh', 'refresh fallback second');
|
||||
const data = getModel('auth').data;
|
||||
assertEq(data.token, 'access-new', 'rotated access token');
|
||||
assertEq(data.refresh, 'refresh-new', 'rotated refresh token');
|
||||
assertEq(data.session_id, 'sess-new', 'rotated session_id binding wins');
|
||||
assertEq(data.user?.username, 'alice', 'refreshed identity');
|
||||
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 'sess-new', 'storage carries rotated session_id');
|
||||
assert(s.events.length === 0, 'no terminal event on recovered session');
|
||||
});
|
||||
|
||||
test('check 401 without refresh token: terminal, no refresh attempted', async () => {
|
||||
const s = setup({ initialStorage: { 'vw:access': 'access-old', 'vw:session_id': 'sess-old' } });
|
||||
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||
await act('check');
|
||||
assertEq(s.calls.length, 1, 'session check only');
|
||||
assertEq(s.calls[0], '/api/auth/session');
|
||||
const data = getModel('auth').data;
|
||||
assert(!data?.token, 'terminal: no token');
|
||||
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
|
||||
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
|
||||
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
|
||||
});
|
||||
|
||||
test('check 401 with failed refresh: terminal', async () => {
|
||||
const s = setup();
|
||||
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||
s.route('/api/auth/refresh', 401, { ok: false, error: 'invalid refresh token' });
|
||||
await act('check');
|
||||
assertEq(s.calls.length, 2, 'refresh was attempted');
|
||||
assert(!getModel('auth').data?.token, 'terminal: no token');
|
||||
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
|
||||
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
|
||||
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
|
||||
});
|
||||
|
||||
test('refresh action rotates tokens; new session_id wins, omitted fields fall back', async () => {
|
||||
const s = setup();
|
||||
s.route('/api/auth/refresh', 200, {
|
||||
ok: true,
|
||||
data: {
|
||||
tokens: { access_token: 'a2', refresh_token: 'r2', session_id: 's2' },
|
||||
access_ttl: 300,
|
||||
user: { username: 'alice' },
|
||||
permissions: { firewall: 'rw' },
|
||||
},
|
||||
});
|
||||
await act('refresh');
|
||||
assertEq(s.calls.length, 1, 'single refresh call');
|
||||
assertEq(s.calls[0], '/api/auth/refresh');
|
||||
const data = getModel('auth').data;
|
||||
assertEq(data.token, 'a2', 'rotated access token');
|
||||
assertEq(data.refresh, 'r2', 'rotated refresh token');
|
||||
assertEq(data.session_id, 's2', 'new session_id wins');
|
||||
assertEq(data.ttl, 300 * 1000, 'ttl from access_ttl seconds → ms');
|
||||
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 's2', 'storage rotated');
|
||||
assertEq(data.user?.username, 'alice', 'user from response');
|
||||
});
|
||||
|
||||
/* ── 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,87 @@
|
||||
/**
|
||||
* Tests for hoover/components/applyconfirm.js — CancelConfirm
|
||||
*
|
||||
* Component-level tests: VNode structure of the cancel button.
|
||||
* Run with `node tests/test-cancelconfirm.js`.
|
||||
*/
|
||||
|
||||
import { CancelConfirm, buildRows, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(` \u2717 ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEq(a, b, msg) {
|
||||
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
|
||||
}
|
||||
|
||||
function assertIncludes(str, substr, msg) {
|
||||
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
|
||||
}
|
||||
|
||||
console.log('Testing CancelConfirm component\n');
|
||||
|
||||
test('CancelConfirm renders a button vnode', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assertEq(vnode.tag, 'button');
|
||||
});
|
||||
|
||||
test('CancelConfirm default label', () => {
|
||||
const vnode = CancelConfirm();
|
||||
const text = vnode.ch.find(c => c.tag === '#text');
|
||||
assert(text, 'should have text child');
|
||||
assertEq(text.text, 'Cancel All Changes');
|
||||
});
|
||||
|
||||
test('CancelConfirm default class is danger', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assertIncludes(vnode.props.class, 'btn-danger');
|
||||
assertIncludes(vnode.props.class, 'btn');
|
||||
});
|
||||
|
||||
test('CancelConfirm accepts a custom class', () => {
|
||||
const vnode = CancelConfirm({ cls: 'btn btn-sm btn-danger' });
|
||||
assertEq(vnode.props.class, 'btn btn-sm btn-danger');
|
||||
});
|
||||
|
||||
test('CancelConfirm accepts a custom label', () => {
|
||||
const vnode = CancelConfirm({ label: 'Discard Changes' });
|
||||
const text = vnode.ch.find(c => c.tag === '#text');
|
||||
assertEq(text.text, 'Discard Changes');
|
||||
});
|
||||
|
||||
test('CancelConfirm has a click handler', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assert(typeof vnode.props['on:click'] === 'function', 'on:click should be a function');
|
||||
});
|
||||
|
||||
// === shared modal row builder (used by the cancel modal) ===
|
||||
test('buildRows still drives the cancel modal rows', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [{ summary: 'Zone internal: interfaces changed', detail: '' }] },
|
||||
dnsmasq: { pending_changes: true, changes: [{ summary: 'x', detail: '' }] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
assertEq(rows.length, SUBSYSTEM_LIST.length);
|
||||
const fwRow = rows[0];
|
||||
assertIncludes(fwRow.props.class, 'pending');
|
||||
const dmRow = rows[1];
|
||||
assertIncludes(dmRow.props.class, 'pending');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -117,6 +117,20 @@ class TestParseListOutput:
|
||||
result = acme._parse_list_output(raw)
|
||||
assert result == []
|
||||
|
||||
def test_pipe_separated_raw_format(self):
|
||||
"""Pipe-separated output with empty fields (what --listraw produces)."""
|
||||
raw = (
|
||||
"Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n"
|
||||
'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n'
|
||||
)
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["main_domain"] == "example.com"
|
||||
assert result[0]["profile"] == ""
|
||||
assert result[0]["ca"] == "ZeroSSL.com"
|
||||
assert result[0]["created"] == "2026-01-01"
|
||||
assert result[0]["renew"] == "2026-07-01"
|
||||
|
||||
|
||||
class TestDaysUntil:
|
||||
def test_future_date(self):
|
||||
|
||||
+136
-1
@@ -40,6 +40,11 @@ def _ne(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
|
||||
def client():
|
||||
from flask import Flask
|
||||
@@ -462,7 +467,7 @@ class TestProxyDomains:
|
||||
mock_post.return_value = {"domain": "ex.com"}
|
||||
resp = client.post(
|
||||
"/api/proxy/domains",
|
||||
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
|
||||
json={"domain": "ex.com", "backend": "webui"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -525,6 +530,64 @@ class TestCertsIssue:
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestCertsRenew:
|
||||
@_ce("post")
|
||||
def test_start_renew(self, mock_post, client):
|
||||
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["request_id"] == "abc123"
|
||||
args = mock_post.call_args.args
|
||||
assert args[0] == ("POST", "/acme/renew")
|
||||
assert args[1] == {"domain": "example.com"}
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_rejected(self, mock_post, client):
|
||||
from daemon.client import BadRequest
|
||||
|
||||
mock_post.side_effect = BadRequest("'domain' is required")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("daemon unreachable")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsRenewStatus:
|
||||
@_ce("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"request_id": "abc123",
|
||||
"domain": "example.com",
|
||||
"status": "completed",
|
||||
"steps": [],
|
||||
}
|
||||
resp = client.get("/api/certs/renew/abc123")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["status"] == "completed"
|
||||
args = mock_get.call_args.args
|
||||
assert args[0] == ("GET", "/acme/renew/status")
|
||||
assert args[1] == {"id": "abc123"}
|
||||
|
||||
@_ce("get")
|
||||
def test_not_found(self, mock_get, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_get.side_effect = NotFound("renewal request not found")
|
||||
resp = client.get("/api/certs/renew/unknown")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsEmail:
|
||||
def test_missing_email(self, client):
|
||||
resp = client.post("/api/certs/email", json={})
|
||||
@@ -883,3 +946,75 @@ class TestNetworkApplyAll:
|
||||
mock_post.side_effect = RuntimeError("apply failed")
|
||||
resp = client.post("/api/network/apply")
|
||||
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
|
||||
|
||||
|
||||
class TestStatusCancelAll:
|
||||
@_st("post")
|
||||
def test_success(self, mock_post, status_client):
|
||||
from daemon.iface import POST_STATUS_CANCEL_ALL
|
||||
|
||||
mock_post.return_value = {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
|
||||
resp = status_client.post("/api/status/cancel-all")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"] == {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
|
||||
mock_post.assert_called_once_with(POST_STATUS_CANCEL_ALL)
|
||||
|
||||
@_st("post")
|
||||
def test_runtime_error(self, mock_post, status_client):
|
||||
mock_post.side_effect = RuntimeError("no daemon")
|
||||
resp = status_client.post("/api/status/cancel-all")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
+1468
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for lib.common apply-metadata and diff helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
load_json,
|
||||
revert_to_applied,
|
||||
save_json,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
|
||||
|
||||
class TestStripApplyMeta:
|
||||
def test_strips_both_keys(self):
|
||||
cfg = {"a": 1, _APPLY_HASH_KEY: "h", _LAST_APPLIED_CONFIG_KEY: {}}
|
||||
assert strip_apply_meta(cfg) == {"a": 1}
|
||||
|
||||
def test_missing_keys(self):
|
||||
assert strip_apply_meta({"a": 1}) == {"a": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, _APPLY_HASH_KEY: "h"}
|
||||
strip_apply_meta(cfg)
|
||||
assert _APPLY_HASH_KEY in cfg
|
||||
|
||||
|
||||
class TestConfigHashIgnoresMeta:
|
||||
def test_hash_unaffected_by_metadata(self):
|
||||
cfg = {"a": 1}
|
||||
stamped = {"a": 1, _APPLY_HASH_KEY: "x", _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
|
||||
assert config_hash(cfg) == config_hash(stamped)
|
||||
|
||||
|
||||
class TestStampApplied:
|
||||
def test_records_snapshot_and_hash(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == {"a": 1}
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
|
||||
def test_stable(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
# A pending-style check: hash matches the current (stripped) config.
|
||||
assert _APPLY_HASH_KEY in cfg and cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
# No drift → no diff.
|
||||
assert (
|
||||
deep_diff(cfg.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(cfg))
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
class TestDeepDiff:
|
||||
def test_identical_empty(self):
|
||||
assert deep_diff({"a": 1, _APPLY_HASH_KEY: "h"}, {"a": 1}) == []
|
||||
|
||||
def test_changed_scalar(self):
|
||||
diff = deep_diff({"a": 1}, {"a": 2})
|
||||
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
|
||||
|
||||
def test_added_removed(self):
|
||||
added = deep_diff({}, {"a": 1})
|
||||
assert added[0]["action"] == "added" and added[0]["new"] == 1
|
||||
removed = deep_diff({"a": 1}, {})
|
||||
assert removed[0]["action"] == "removed" and removed[0]["old"] == 1
|
||||
|
||||
def test_nested_and_list_index(self):
|
||||
old = {"z": {"svc": ["http"], "ranges": [{"ip": "10.0.0.1", "n": 1}]}}
|
||||
new = {"z": {"svc": ["http", "ssh"], "ranges": [{"ip": "10.0.0.2", "n": 1}]}}
|
||||
paths = {d["path"] for d in deep_diff(old, new)}
|
||||
assert "z.svc" in paths
|
||||
assert "z.ranges[0].ip" in paths
|
||||
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
||||
|
||||
|
||||
class TestDashboardFallback:
|
||||
def test_hash_subsystem_unchanged_generic(self):
|
||||
# Guards that a pending status without a snapshot still yields a
|
||||
# renderable pending flag (frontend falls back to a generic line).
|
||||
status = {"pending_changes": True, "pending_diff": []}
|
||||
assert status["pending_changes"] is True
|
||||
assert status["pending_diff"] == []
|
||||
|
||||
|
||||
class TestRevertToApplied:
|
||||
def test_restores_snapshot_and_clears_pending(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"zones": {"lan": {"services": ["http"]}}}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
# Drift the file after apply (the "pending" state).
|
||||
dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}}
|
||||
dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied)
|
||||
dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
|
||||
save_json(path, dirty, indent=2)
|
||||
# Sanity: pending check would report drift.
|
||||
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty)
|
||||
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert ok and reason == ""
|
||||
|
||||
restored = load_json(path)
|
||||
assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash(
|
||||
restored
|
||||
)
|
||||
assert (
|
||||
_LAST_APPLIED_CONFIG_KEY in restored
|
||||
and restored[_LAST_APPLIED_CONFIG_KEY] == applied
|
||||
)
|
||||
assert (
|
||||
deep_diff(
|
||||
restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored)
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_no_baseline(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
save_json(path, {"zones": {}}, indent=2)
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert not ok
|
||||
assert reason
|
||||
# File untouched.
|
||||
assert _LAST_APPLIED_CONFIG_KEY not in load_json(path)
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
ok, reason = revert_to_applied(tmp_path / "nope.json")
|
||||
assert not ok
|
||||
assert reason
|
||||
|
||||
def test_stale_hash_but_snapshot_present(self, tmp_path):
|
||||
# Baseline recorded, hash stale (drifted) → still revertable.
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"a": 1}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
stamp = dict(stamped)
|
||||
stamp["a"] = 99 # edited without re-stamping
|
||||
save_json(path, stamp, indent=2)
|
||||
assert stamp[_APPLY_HASH_KEY] != config_hash(stamp)
|
||||
|
||||
ok, _ = revert_to_applied(path)
|
||||
assert ok
|
||||
restored = load_json(path)
|
||||
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
|
||||
@@ -70,87 +70,6 @@ class TestSaveConfig:
|
||||
assert loaded["dns"]["domain"] == "test.lan"
|
||||
|
||||
|
||||
class TestSetDhcpRange:
|
||||
def test_add_new_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
|
||||
|
||||
def test_replace_existing_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
|
||||
|
||||
|
||||
class TestStaticLeases:
|
||||
def test_add_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
|
||||
|
||||
def test_update_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
|
||||
|
||||
def test_remove_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
|
||||
dnsmasq.remove_static_lease("aa:bb:cc")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
|
||||
|
||||
|
||||
class TestDnsRecords:
|
||||
def test_add_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
|
||||
def test_remove_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
dnsmasq.add_dns_record("other", "10.0.0.101")
|
||||
dnsmasq.remove_dns_record("host")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
assert cfg["dns"]["custom_records"][0]["name"] == "other"
|
||||
|
||||
|
||||
class TestParseLeaseLine:
|
||||
def test_valid_line(self):
|
||||
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert result["ip"] == "10.0.0.50"
|
||||
assert result["hostname"] == "printer"
|
||||
|
||||
def test_empty_line(self):
|
||||
assert dnsmasq._parse_lease_line("") is None
|
||||
|
||||
def test_comment_line(self):
|
||||
assert dnsmasq._parse_lease_line("# comment") is None
|
||||
|
||||
def test_short_line(self):
|
||||
assert dnsmasq._parse_lease_line("incomplete") is None
|
||||
|
||||
def test_minimal_fields(self):
|
||||
line = "1700000000 AA:BB:CC 10.0.0.50"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["hostname"] == ""
|
||||
assert result["interface"] == ""
|
||||
|
||||
|
||||
class TestUpstreamsAndDomain:
|
||||
def test_set_upstreams(self, temp_data_dir):
|
||||
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
||||
|
||||
+564
-5
@@ -1,12 +1,19 @@
|
||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.handlers import firewall as daemonfirewall
|
||||
from daemon.server import NotFoundError
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
from lib import firewall
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
strip_apply_meta,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lib/firewall.py — pure parsing (no sudo)
|
||||
@@ -244,18 +251,87 @@ class TestConfigPending:
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_detects_unmanaged_zones(self, mock_cfg):
|
||||
# A custom live zone not in config is flagged as unmanaged.
|
||||
mock_cfg.return_value = {"zones": {}}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"guest": {
|
||||
"interfaces": ["eth5"],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert "public" in result["unmanaged_zones"]
|
||||
assert "guest" in result["unmanaged_zones"]
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_built_in_zones_not_unmanaged(self, mock_cfg):
|
||||
# firewalld built-in zones are always present and must not be
|
||||
# reported as unmanaged, so they never surface as noise.
|
||||
mock_cfg.return_value = {"zones": {}}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {"interfaces": ["eth0"], "services": [], "masquerade": True},
|
||||
"trusted": {"interfaces": ["lo"], "services": [], "masquerade": False},
|
||||
"dmz": {"interfaces": ["eth7"], "services": [], "masquerade": False},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert result["unmanaged_zones"] == {}
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_public_masquerade_not_pending(self, mock_cfg):
|
||||
# public zone masquerade is driven by apply's propagation step, so a
|
||||
# config-vs-live masquerade mismatch on public is not a pending change.
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert not any(c["type"] == "masquerade" for c in result["pending"])
|
||||
assert result["needs_apply"] is False
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_non_public_masquerade_is_pending(self, mock_cfg):
|
||||
# A non-public zone with a masquerade mismatch IS a pending change.
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"internal": {
|
||||
"interfaces": ["eth1"],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
state = {
|
||||
"zones": {
|
||||
"internal": {
|
||||
"interfaces": ["eth1"],
|
||||
"services": [],
|
||||
"masquerade": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert any(
|
||||
c["type"] == "masquerade" and c["zone"] == "internal"
|
||||
for c in result["pending"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -462,11 +538,237 @@ class TestDaemonConfigApply:
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config"),
|
||||
):
|
||||
result = daemonfirewall._config_apply()
|
||||
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"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config"),
|
||||
):
|
||||
result = daemonfirewall._config_apply(force=True)
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
|
||||
_STAMP_TEST_CFG = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"target": "DEFAULT",
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDaemonConfigApplyStamp:
|
||||
"""Verify _config_apply records the applied baseline in the config file."""
|
||||
|
||||
ZONE_LIST_ALL_OUT = (
|
||||
"target: default\ninterfaces: \nsources: \nservices: \nports: \n"
|
||||
"protocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \n"
|
||||
"icmp-blocks: \nmodule: \n"
|
||||
)
|
||||
|
||||
@patch(
|
||||
"lib.firewall.get_config",
|
||||
return_value=_STAMP_TEST_CFG,
|
||||
create=True,
|
||||
)
|
||||
@patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
return_value=ZONE_LIST_ALL_OUT,
|
||||
)
|
||||
def test_stamps_applied_baseline(self, 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"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value=deepcopy(_STAMP_TEST_CFG),
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
):
|
||||
result = daemonfirewall._config_apply()
|
||||
|
||||
assert result["applied_zones"] == ["public"]
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||
# The snapshot is the applied (meta-stripped) config.
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
|
||||
|
||||
|
||||
class TestDaemonGetConfigEndpoint:
|
||||
def test_strips_apply_meta(self):
|
||||
with patch.object(
|
||||
daemonfirewall,
|
||||
"_get_config",
|
||||
return_value={
|
||||
"zones": {},
|
||||
_APPLY_HASH_KEY: "h",
|
||||
_LAST_APPLIED_CONFIG_KEY: {"zones": {}},
|
||||
},
|
||||
):
|
||||
result = daemonfirewall.get_config(None, None)
|
||||
assert result == {"zones": {}}
|
||||
|
||||
@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:
|
||||
@patch("lib.state.state")
|
||||
def test_returns_pending(self, mock_st):
|
||||
@@ -477,6 +779,172 @@ class TestDaemonConfigPending:
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert result["needs_apply"] is True
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_no_state_mutation(self, mock_st):
|
||||
pending = {
|
||||
"needs_apply": True,
|
||||
"pending": [{"zone": "public", "type": "services"}],
|
||||
}
|
||||
mock_st.get.return_value = {**_mock_state(), "pending": pending}
|
||||
original_keys = set(pending.keys())
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert "pending_summary" in result
|
||||
assert set(pending.keys()) == original_keys, (
|
||||
"config_pending_handler must not mutate state store pending dict"
|
||||
)
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_interfaces(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1", "eth2"],
|
||||
"live": ["eth1"],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
|
||||
assert "eth2" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_services(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "dmz",
|
||||
"type": "services",
|
||||
"config": ["ssh", "dns"],
|
||||
"live": ["ssh"],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone dmz: services changed" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_rich_rules(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "public",
|
||||
"type": "rich_rules",
|
||||
"config_count": 3,
|
||||
"live_count": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone public: rich rules differ" in result["pending_summary"][0]
|
||||
assert "config: 3" in result["pending_summary"][0]
|
||||
assert "live: 1" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_masquerade(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "wan",
|
||||
"type": "masquerade",
|
||||
"config": True,
|
||||
"live": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone wan: masquerade changed" in result["pending_summary"][0]
|
||||
assert "config: True" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_target(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "trusted",
|
||||
"type": "target",
|
||||
"config": "ACCEPT",
|
||||
"live": "default",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone trusted: target changed" in result["pending_summary"][0]
|
||||
assert "config: ACCEPT" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_unknown_type(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [{"zone": "public", "type": "foobarLayout"}],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone public: foobarLayout changed" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_mixed_types(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1"],
|
||||
"live": [],
|
||||
},
|
||||
{
|
||||
"zone": "dmz",
|
||||
"type": "services",
|
||||
"config": ["ssh", "dns"],
|
||||
"live": ["ssh"],
|
||||
},
|
||||
{
|
||||
"zone": "public",
|
||||
"type": "rich_rules",
|
||||
"config_count": 2,
|
||||
"live_count": 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 3
|
||||
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
|
||||
assert "Zone dmz: services changed" in result["pending_summary"][1]
|
||||
assert "Zone public: rich rules differ" in result["pending_summary"][2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
|
||||
@@ -542,3 +1010,94 @@ class TestLibParseForwardPorts:
|
||||
|
||||
def test_empty_string(self):
|
||||
assert firewall._parse_forward_ports("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lib/firewall.py — parse all zones output (--list-all-zones)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseAllZonesOutput:
|
||||
def test_parses_single_zone(self):
|
||||
result = firewall._parse_all_zones_output(
|
||||
"public\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\n"
|
||||
" services: ssh http\n"
|
||||
" masquerade: yes\n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
assert "public" in result
|
||||
assert result["public"]["name"] == "public"
|
||||
assert result["public"]["interfaces"] == ["eth0"]
|
||||
assert result["public"]["services"] == ["ssh", "http"]
|
||||
assert result["public"]["masquerade"] is True
|
||||
assert result["public"]["rich-rules"] == []
|
||||
|
||||
def test_parses_multiple_zones(self):
|
||||
result = firewall._parse_all_zones_output(
|
||||
"public (default, active)\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\n"
|
||||
" services: ssh\n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
"internal (active)\n"
|
||||
" target: ACCEPT\n"
|
||||
" interfaces: eth1\n"
|
||||
" services: dhcp\n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
"trusted\n"
|
||||
" target: ACCEPT\n"
|
||||
" interfaces: \n"
|
||||
" services: \n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
assert set(result.keys()) == {"public", "internal", "trusted"}
|
||||
assert result["public"]["interfaces"] == ["eth0"]
|
||||
assert result["internal"]["target"] == "ACCEPT"
|
||||
assert result["trusted"]["services"] == []
|
||||
|
||||
def test_empty_output(self):
|
||||
assert firewall._parse_all_zones_output("") == {}
|
||||
assert firewall._parse_all_zones_output("\n \n") == {}
|
||||
|
||||
def test_handles_blank_lines_between_zones(self):
|
||||
result = firewall._parse_all_zones_output(
|
||||
"public\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\n"
|
||||
" rich rules: \n"
|
||||
"\n"
|
||||
"internal\n"
|
||||
" target: ACCEPT\n"
|
||||
" interfaces: eth1\n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
assert "public" in result
|
||||
assert "internal" in result
|
||||
assert result["public"]["interfaces"] == ["eth0"]
|
||||
assert result["internal"]["interfaces"] == ["eth1"]
|
||||
|
||||
def test_all_default_fields_present(self):
|
||||
result = firewall._parse_all_zones_output(
|
||||
"dmz\n target: default\n interfaces: \n services: \n rich rules: \n"
|
||||
)
|
||||
zone = result["dmz"]
|
||||
for field in (
|
||||
"interfaces",
|
||||
"sources",
|
||||
"services",
|
||||
"ports",
|
||||
"protocols",
|
||||
"forward-ports",
|
||||
"masquerade",
|
||||
"ics",
|
||||
"icmp-blocks",
|
||||
"module",
|
||||
"target",
|
||||
"rich-rules",
|
||||
):
|
||||
assert field in zone, f"Missing field: {field}"
|
||||
|
||||
+164
-26
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import daemon.handlers.acme as acme_mod
|
||||
from daemon.handlers.acme import (
|
||||
IssueRequest,
|
||||
_check_account_registered,
|
||||
_check_acme_account,
|
||||
_check_acme_home_writable,
|
||||
@@ -25,60 +27,75 @@ from daemon.handlers.acme import (
|
||||
deactivate_account,
|
||||
generate_self_signed,
|
||||
get_account,
|
||||
get_renew_status,
|
||||
issue_cert,
|
||||
register_account,
|
||||
renew_cert,
|
||||
)
|
||||
from daemon.server import ConflictError
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
|
||||
|
||||
def _await_renew(body):
|
||||
"""Start a renewal via renew_cert and await its background task."""
|
||||
|
||||
async def _run():
|
||||
result = renew_cert(None, body)
|
||||
task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id"))
|
||||
if task is not None:
|
||||
await task
|
||||
return result
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
class TestGenerateSelfSigned:
|
||||
def test_generate_creates_files(self, tmp_path):
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
|
||||
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
|
||||
):
|
||||
result = generate_self_signed(None, {"domain": "test.local"})
|
||||
|
||||
assert result["domain"] == "test.local"
|
||||
assert result["generated"] is True
|
||||
cert_dir = tmp_path / "acme" / "test.local"
|
||||
assert result["cert"] == str(cert_dir / "fullchain.cer")
|
||||
assert result["key"] == str(cert_dir / "test.local.key")
|
||||
assert (cert_dir / "fullchain.cer").is_file()
|
||||
assert (cert_dir / "test.local.key").is_file()
|
||||
certs_dir = tmp_path / "data" / "certs"
|
||||
assert result["cert"] == str(certs_dir / "test.local.crt")
|
||||
assert result["key"] == str(certs_dir / "test.local.key")
|
||||
assert (certs_dir / "test.local.crt").is_file()
|
||||
assert (certs_dir / "test.local.key").is_file()
|
||||
|
||||
def test_generate_idempotent_skips_existing(self, tmp_path):
|
||||
cert_dir = tmp_path / "acme" / "test.local"
|
||||
cert_dir.mkdir(parents=True)
|
||||
(cert_dir / "fullchain.cer").write_text("dummy-cert")
|
||||
(cert_dir / "test.local.key").write_text("dummy-key")
|
||||
certs_dir = tmp_path / "data" / "certs"
|
||||
certs_dir.mkdir(parents=True)
|
||||
(certs_dir / "test.local.crt").write_text("dummy-cert")
|
||||
(certs_dir / "test.local.key").write_text("dummy-key")
|
||||
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
|
||||
result = generate_self_signed(None, {"domain": "test.local"})
|
||||
|
||||
assert result["generated"] is False
|
||||
|
||||
def test_generate_partial_existing(self, tmp_path):
|
||||
cert_dir = tmp_path / "acme" / "test.local"
|
||||
cert_dir.mkdir(parents=True)
|
||||
(cert_dir / "fullchain.cer").write_text("dummy-cert")
|
||||
certs_dir = tmp_path / "data" / "certs"
|
||||
certs_dir.mkdir(parents=True)
|
||||
(certs_dir / "test.local.crt").write_text("dummy-cert")
|
||||
# key missing -> should regenerate
|
||||
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
|
||||
result = generate_self_signed(None, {"domain": "test.local"})
|
||||
|
||||
assert result["generated"] is True
|
||||
|
||||
def test_generate_custom_days(self, tmp_path):
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
|
||||
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
|
||||
def _create_files(*args, **kwargs):
|
||||
cert_dir = tmp_path / "acme" / "test.local"
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cert_dir / "fullchain.cer").touch()
|
||||
(cert_dir / "test.local.key").touch()
|
||||
certs_dir = tmp_path / "data" / "certs"
|
||||
certs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(certs_dir / "test.local.crt").touch()
|
||||
(certs_dir / "test.local.key").touch()
|
||||
return Path("")
|
||||
|
||||
mock_run.side_effect = _create_files
|
||||
@@ -88,15 +105,15 @@ class TestGenerateSelfSigned:
|
||||
idx = args.index("-days")
|
||||
assert args[idx + 1] == "730"
|
||||
|
||||
cert_dir = tmp_path / "acme" / "test.local"
|
||||
if (cert_dir / "fullchain.cer").is_file():
|
||||
assert cert_dir.is_dir()
|
||||
certs_dir = tmp_path / "data" / "certs"
|
||||
if (certs_dir / "test.local.crt").is_file():
|
||||
assert certs_dir.is_dir()
|
||||
|
||||
def test_generate_creates_directory(self, tmp_path):
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
|
||||
generate_self_signed(None, {"domain": "test.local"})
|
||||
|
||||
assert (tmp_path / "acme" / "test.local").is_dir()
|
||||
assert (tmp_path / "data" / "certs").is_dir()
|
||||
|
||||
def test_generate_requires_domain(self):
|
||||
with pytest.raises(ValueError, match="domain"):
|
||||
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
mock_run_issue.assert_called_once()
|
||||
|
||||
|
||||
class TestRenewCert:
|
||||
def test_requires_body(self):
|
||||
with pytest.raises(ValueError, match="Request body required"):
|
||||
renew_cert(None, None)
|
||||
|
||||
def test_requires_domain(self):
|
||||
with pytest.raises(ValueError, match="'domain' is required"):
|
||||
renew_cert(None, {"force": True})
|
||||
|
||||
def test_starts_background_task(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'"
|
||||
),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
assert req.status == "completed"
|
||||
assert [s.status for s in req.steps] == ["done", "done", "done"]
|
||||
mock_refresh.assert_called_once_with(["acme"])
|
||||
|
||||
def test_skip_when_not_due(self):
|
||||
output = (
|
||||
"[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. "
|
||||
"Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)"
|
||||
)
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value=output),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "skipped"
|
||||
assert req.steps[0].status == "done"
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_failure_marks_failed(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme",
|
||||
side_effect=RuntimeError("acme.sh failed with exit code 1: boom"),
|
||||
),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "failed"
|
||||
assert req.steps[0].status == "error"
|
||||
assert "boom" in req.steps[0].message
|
||||
|
||||
def test_force_appends_flag(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme,
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
_await_renew({"domain": "example.com", "force": True})
|
||||
|
||||
renew_args = mock_acme.call_args_list[0].args[0]
|
||||
assert "--force" in renew_args
|
||||
|
||||
def test_dedup_running_returns_existing(self):
|
||||
existing = IssueRequest(request_id="existing123", domain="example.com")
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme") as mock_acme,
|
||||
):
|
||||
acme_mod._ISSUANCES["existing123"] = existing
|
||||
result = renew_cert(None, {"domain": "example.com"})
|
||||
|
||||
assert result == {
|
||||
"request_id": "existing123",
|
||||
"domain": "example.com",
|
||||
"status": "existing",
|
||||
}
|
||||
mock_acme.assert_not_called()
|
||||
|
||||
|
||||
class TestGetRenewStatus:
|
||||
def test_missing_id(self):
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, None)
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, {})
|
||||
|
||||
def test_unknown_id(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
pytest.raises(NotFoundError, match="not found"),
|
||||
):
|
||||
get_renew_status(None, {"id": "unknown"})
|
||||
|
||||
def test_returns_request_dict(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="Renewed"),
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
status = get_renew_status(None, {"id": result["request_id"]})
|
||||
|
||||
assert status["request_id"] == result["request_id"]
|
||||
assert status["domain"] == "example.com"
|
||||
assert status["status"] == "completed"
|
||||
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
||||
|
||||
@@ -160,10 +160,14 @@ class TestApplyAll:
|
||||
}
|
||||
)
|
||||
|
||||
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
||||
runtime_dir.mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.network.generate_network_files") as mock_gen,
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
|
||||
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
@@ -188,11 +192,15 @@ class TestApplyAll:
|
||||
}
|
||||
)
|
||||
|
||||
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
||||
runtime_dir.mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.network.generate_network_files") as mock_gen,
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams,
|
||||
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
|
||||
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
@@ -208,6 +216,9 @@ class TestApplyAll:
|
||||
def test_apply_all_handles_dns_sync_failure(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
|
||||
|
||||
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
||||
runtime_dir.mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.network.generate_network_files") as mock_gen,
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
@@ -216,6 +227,7 @@ class TestApplyAll:
|
||||
side_effect=RuntimeError("fail"),
|
||||
),
|
||||
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
|
||||
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
@@ -233,10 +245,14 @@ class TestApplyAll:
|
||||
sys_dir.mkdir(parents=True)
|
||||
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
|
||||
|
||||
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
||||
runtime_dir.mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.network.generate_network_files") as mock_gen,
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
|
||||
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
|
||||
+632
-25
@@ -59,13 +59,27 @@ class TestGetConfig:
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
|
||||
"backends": {
|
||||
"myapp": {
|
||||
"label": "My App",
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": "localhost",
|
||||
"port": 80,
|
||||
"proto": "http",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"domains": {"example.com": {"backend": "myapp"}},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
loaded = nginx.get_config()
|
||||
assert loaded["domains"]["example.com"]["backend"] == "myapp"
|
||||
assert (
|
||||
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
|
||||
== "localhost"
|
||||
loaded["backends"]["myapp"]["paths"]["/"]["backend"]["host"] == "localhost"
|
||||
)
|
||||
|
||||
|
||||
@@ -76,46 +90,76 @@ class TestGetDomains:
|
||||
|
||||
def test_returns_domain_list(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {
|
||||
"example.com": {
|
||||
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
"backends": {
|
||||
"myapp": {
|
||||
"label": "My App",
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": "localhost",
|
||||
"port": 8080,
|
||||
"proto": "http",
|
||||
}
|
||||
},
|
||||
"/api": {
|
||||
"backend": {
|
||||
"host": "localhost",
|
||||
"port": 8081,
|
||||
"proto": "http",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"example.com": {"backend": "myapp", "force_ssl": True},
|
||||
},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
assert len(result) == 1
|
||||
assert len(result) == 2
|
||||
assert result[0]["domain"] == "example.com"
|
||||
assert result[0]["backend_name"] == "myapp"
|
||||
assert result[0]["path"] == "/"
|
||||
|
||||
|
||||
class TestAddDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_add_domain(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
def test_add_domain(self, temp_data_dir):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"myapp": {
|
||||
"label": "My App",
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": "10.0.0.5",
|
||||
"port": 8080,
|
||||
"proto": "http",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
cfg = nginx.get_config()
|
||||
assert "example.com" in cfg["domains"]
|
||||
assert (
|
||||
cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
|
||||
)
|
||||
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
|
||||
nginx.save_config(cfg)
|
||||
nginx.add_domain("example.com", "myapp")
|
||||
loaded = nginx.get_config()
|
||||
assert "example.com" in loaded["domains"]
|
||||
assert loaded["domains"]["example.com"]["backend"] == "myapp"
|
||||
assert loaded["domains"]["example.com"]["force_ssl"] is True
|
||||
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
|
||||
},
|
||||
"management": None,
|
||||
"backends": {"myapp": {}},
|
||||
"domains": {"example.com": {"backend": "myapp"}},
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.save_config(mock_get.return_value)
|
||||
with pytest.raises(ValueError):
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
nginx.add_domain("example.com", "myapp")
|
||||
|
||||
|
||||
class TestRemoveDomain:
|
||||
@@ -380,3 +424,566 @@ class TestHashPasswordFallback:
|
||||
result = nginx._hash_password("test")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend CRUD and resolution tests (daemon handler)
|
||||
# NOTE: The daemon handler modules cannot be called as-is because they
|
||||
# require sudo and live service paths. Instead we test through `lib.nginx`
|
||||
# public API where possible, and unit-test the handler-internal functions
|
||||
# by importing them directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolvePaths:
|
||||
def test_resolves_from_backend(self, temp_data_dir):
|
||||
"""Paths resolve from backends[domain_cfg['backend']].paths."""
|
||||
backends = {
|
||||
"myapp": {
|
||||
"label": "My App",
|
||||
"paths": {
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
|
||||
"/api": {"backend": {"host": "b", "port": 8080, "proto": "http"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
domain_cfg = {"backend": "myapp"}
|
||||
resolved = nginx._resolve_paths(domain_cfg, backends)
|
||||
assert resolved == backends["myapp"]["paths"]
|
||||
|
||||
def test_returns_empty_when_backend_missing(self, temp_data_dir):
|
||||
"""Empty dict when backend ref not found in backends."""
|
||||
resolved = nginx._resolve_paths({"backend": "nonexistent"}, {})
|
||||
assert resolved == {}
|
||||
|
||||
def test_returns_empty_when_no_backend_key(self, temp_data_dir):
|
||||
"""Empty dict when domain has no backend key."""
|
||||
resolved = nginx._resolve_paths({}, {"myapp": {"paths": {"/": {}}}})
|
||||
assert resolved == {}
|
||||
|
||||
|
||||
class TestResolveAuth:
|
||||
def test_domain_auth_wins(self, temp_data_dir):
|
||||
"""Domain-level auth overrides backend auth."""
|
||||
backends = {
|
||||
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
|
||||
}
|
||||
domain_cfg = {
|
||||
"backend": "myapp",
|
||||
"auth": {"user": "domain", "htpasswd": "/domain/.htpasswd"},
|
||||
}
|
||||
resolved = nginx._resolve_auth(domain_cfg, backends)
|
||||
assert resolved == {"user": "domain", "htpasswd": "/domain/.htpasswd"}
|
||||
|
||||
def test_domain_auth_null_disables(self, temp_data_dir):
|
||||
"""Domain auth set to None disables all auth."""
|
||||
backends = {
|
||||
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
|
||||
}
|
||||
domain_cfg = {"backend": "myapp", "auth": None}
|
||||
resolved = nginx._resolve_auth(domain_cfg, backends)
|
||||
assert resolved is None
|
||||
|
||||
def test_backend_auth_applies(self, temp_data_dir):
|
||||
"""Backend auth applies when domain has no auth key."""
|
||||
backends = {
|
||||
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
|
||||
}
|
||||
domain_cfg = {"backend": "myapp"}
|
||||
resolved = nginx._resolve_auth(domain_cfg, backends)
|
||||
assert resolved == {"user": "backend", "htpasswd": "/backend/.htpasswd"}
|
||||
|
||||
def test_no_auth_when_absent_everywhere(self, temp_data_dir):
|
||||
"""None when neither domain nor backend define auth."""
|
||||
backends = {"myapp": {"paths": {}}}
|
||||
domain_cfg = {"backend": "myapp"}
|
||||
resolved = nginx._resolve_auth(domain_cfg, backends)
|
||||
assert resolved is None
|
||||
|
||||
|
||||
class TestBackendCRUD:
|
||||
"""Test daemon handler backend CRUD operations directly."""
|
||||
|
||||
def test_validate_paths_valid(self, temp_data_dir):
|
||||
"""_validate_paths accepts correct schemas."""
|
||||
from daemon.handlers.nginx import _validate_paths
|
||||
|
||||
paths = {
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
|
||||
"/ws": {
|
||||
"backend": {"host": "b", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
},
|
||||
}
|
||||
_validate_paths(paths) # no exception
|
||||
|
||||
def test_validate_paths_missing_host(self, temp_data_dir):
|
||||
"""_validate_paths raises when backend.host missing."""
|
||||
from daemon.handlers.nginx import _validate_paths
|
||||
|
||||
with pytest.raises(ValueError, match="missing 'host'"):
|
||||
_validate_paths({"/": {"backend": {"port": 80, "proto": "http"}}})
|
||||
|
||||
def test_validate_paths_missing_port(self, temp_data_dir):
|
||||
"""_validate_paths raises when backend.port missing."""
|
||||
from daemon.handlers.nginx import _validate_paths
|
||||
|
||||
with pytest.raises(ValueError, match="missing 'port'"):
|
||||
_validate_paths({"/": {"backend": {"host": "a", "proto": "http"}}})
|
||||
|
||||
def test_validate_paths_missing_proto(self, temp_data_dir):
|
||||
"""_validate_paths raises when backend.proto missing."""
|
||||
from daemon.handlers.nginx import _validate_paths
|
||||
|
||||
with pytest.raises(ValueError, match="missing 'proto'"):
|
||||
_validate_paths({"/": {"backend": {"host": "a", "port": 80}}})
|
||||
|
||||
def test_validate_paths_no_backend(self, temp_data_dir):
|
||||
"""_validate_paths raises when path lacks backend dict."""
|
||||
from daemon.handlers.nginx import _validate_paths
|
||||
|
||||
with pytest.raises(ValueError, match="missing 'backend'"):
|
||||
_validate_paths({"/": {"something": "else"}})
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
@patch("daemon.handlers.nginx._save_config")
|
||||
def test_add_backend(self, mock_save, mock_get, temp_data_dir):
|
||||
"""_add_backend creates a new backend entry."""
|
||||
from daemon.handlers.nginx import _add_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
_add_backend(
|
||||
"test",
|
||||
"Test Label",
|
||||
{
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
|
||||
},
|
||||
)
|
||||
|
||||
saved_cfg = mock_save.call_args[0][0]
|
||||
assert "test" in saved_cfg["backends"]
|
||||
assert saved_cfg["backends"]["test"]["label"] == "Test Label"
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
@patch("daemon.handlers.nginx._save_config")
|
||||
def test_add_backend_duplicate_raises(self, mock_save, mock_get, temp_data_dir):
|
||||
"""_add_backend raises ValueError for duplicate name."""
|
||||
from daemon.handlers.nginx import _add_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {"test": {"label": "Existing"}},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
_add_backend(
|
||||
"test",
|
||||
"New Label",
|
||||
{
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
|
||||
},
|
||||
)
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
@patch("daemon.handlers.nginx._save_config")
|
||||
def test_update_backend(self, mock_save, mock_get, temp_data_dir):
|
||||
"""_update_backend modifies label and paths."""
|
||||
from daemon.handlers.nginx import _update_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {
|
||||
"test": {
|
||||
"label": "Old",
|
||||
"paths": {
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
_update_backend(
|
||||
"test",
|
||||
label="New",
|
||||
paths={
|
||||
"/api": {"backend": {"host": "b", "port": 9000, "proto": "http"}},
|
||||
},
|
||||
)
|
||||
|
||||
saved_cfg = mock_save.call_args[0][0]
|
||||
assert saved_cfg["backends"]["test"]["label"] == "New"
|
||||
assert "/api" in saved_cfg["backends"]["test"]["paths"]
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
def test_update_backend_notfound_raises(self, mock_get, temp_data_dir):
|
||||
"""_update_backend raises KeyError for unknown backend."""
|
||||
from daemon.handlers.nginx import _update_backend
|
||||
|
||||
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_update_backend("nonexistent", label="X")
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
def test_update_backend_builtin_raises(self, mock_get, temp_data_dir):
|
||||
"""_update_backend raises ValueError for builtin backend."""
|
||||
from daemon.handlers.nginx import _update_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot modify"):
|
||||
_update_backend("webui", label="Hacked")
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
@patch("daemon.handlers.nginx._save_config")
|
||||
def test_remove_backend(self, mock_save, mock_get, temp_data_dir):
|
||||
"""_remove_backend deletes non-builtin backend."""
|
||||
from daemon.handlers.nginx import _remove_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {"test": {"label": "Test", "paths": {}}},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
_remove_backend("test")
|
||||
|
||||
saved_cfg = mock_save.call_args[0][0]
|
||||
assert "test" not in saved_cfg["backends"]
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
def test_remove_backend_notfound_raises(self, mock_get, temp_data_dir):
|
||||
"""_remove_backend raises KeyError for unknown backend."""
|
||||
from daemon.handlers.nginx import _remove_backend
|
||||
|
||||
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_remove_backend("nonexistent")
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
def test_remove_backend_builtin_raises(self, mock_get, temp_data_dir):
|
||||
"""_remove_backend raises ValueError for builtin backend."""
|
||||
from daemon.handlers.nginx import _remove_backend
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
|
||||
"domains": {},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot remove"):
|
||||
_remove_backend("webui")
|
||||
|
||||
@patch("daemon.handlers.nginx._get_config")
|
||||
def test_remove_backend_referenced_raises_conflict(self, mock_get, temp_data_dir):
|
||||
"""_remove_backend raises ConflictError when domains reference it."""
|
||||
from daemon.handlers.nginx import _remove_backend
|
||||
from daemon.server import ConflictError
|
||||
|
||||
mock_get.return_value = {
|
||||
"backends": {"myapp": {"label": "App", "paths": {}}},
|
||||
"domains": {"example.com": {"backend": "myapp"}},
|
||||
"ssl": {},
|
||||
}
|
||||
|
||||
with pytest.raises(ConflictError, match="referenced by domain"):
|
||||
_remove_backend("myapp")
|
||||
|
||||
|
||||
class TestMigration:
|
||||
def test_ensure_webui_backend_creates(self, temp_data_dir):
|
||||
"""_ensure_webui_backend creates webui backend if missing."""
|
||||
cfg = {}
|
||||
nginx._ensure_webui_backend(cfg)
|
||||
assert "webui" in cfg["backends"]
|
||||
assert cfg["backends"]["webui"]["_migrated"] is True
|
||||
assert cfg["backends"]["webui"]["label"] == "Vacuum Wall WebUI"
|
||||
assert cfg["backends"]["webui"]["builtin"] is True
|
||||
|
||||
def test_ensure_webui_backend_skips_migrated(self, temp_data_dir):
|
||||
"""_ensure_webui_backend skips if _migrated is true."""
|
||||
cfg = {
|
||||
"backends": {
|
||||
"webui": {
|
||||
"label": "Custom",
|
||||
"_migrated": True,
|
||||
"paths": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
nginx._ensure_webui_backend(cfg)
|
||||
# Label unchanged — not recreated
|
||||
assert cfg["backends"]["webui"]["label"] == "Custom"
|
||||
|
||||
def test_migrate_mgmt_domains_application_webui(self, temp_data_dir):
|
||||
"""Domains with application='webui' and mgmt paths get backend='webui'."""
|
||||
cfg = {
|
||||
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||
"domains": {
|
||||
"mgmt.example.com": {
|
||||
"application": "webui",
|
||||
"paths": {
|
||||
"/": {"is_management": True},
|
||||
"/ws": {"is_websocket": True},
|
||||
},
|
||||
"auth": {"user": "admin"},
|
||||
}
|
||||
},
|
||||
}
|
||||
nginx._migrate_mgmt_domains(cfg)
|
||||
dom = cfg["domains"]["mgmt.example.com"]
|
||||
assert dom["backend"] == "webui"
|
||||
assert "application" not in dom
|
||||
assert "paths" not in dom
|
||||
assert "auth" not in dom
|
||||
|
||||
def test_migrate_mgmt_domains_strips_application_only(self, temp_data_dir):
|
||||
"""application='webui' stripped even when paths don't match mgmt shape."""
|
||||
cfg = {
|
||||
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||
"domains": {
|
||||
"odd.example.com": {
|
||||
"application": "webui",
|
||||
"paths": {"/": {"is_management": True}},
|
||||
}
|
||||
},
|
||||
}
|
||||
nginx._migrate_mgmt_domains(cfg)
|
||||
dom = cfg["domains"]["odd.example.com"]
|
||||
assert "application" not in dom
|
||||
assert "backend" not in dom # paths didn't match full mgmt shape
|
||||
assert "paths" in dom # not removed
|
||||
|
||||
def test_migrate_mgmt_domains_detects_webui_paths(self, temp_data_dir):
|
||||
"""Domains matching webui path shape get migrated to backend."""
|
||||
cfg = {
|
||||
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||
"domains": {
|
||||
"mgmt.example.com": {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9090,
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
},
|
||||
"/ws": {
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9091,
|
||||
"proto": "http",
|
||||
},
|
||||
"is_websocket": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
nginx._migrate_mgmt_domains(cfg)
|
||||
dom = cfg["domains"]["mgmt.example.com"]
|
||||
assert dom["backend"] == "webui"
|
||||
assert "paths" not in dom
|
||||
|
||||
def test_migrate_mgmt_domains_skips_non_mgmt(self, temp_data_dir):
|
||||
"""Non-management domains are left untouched."""
|
||||
cfg = {
|
||||
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||
"domains": {
|
||||
"app.example.com": {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
nginx._migrate_mgmt_domains(cfg)
|
||||
dom = cfg["domains"]["app.example.com"]
|
||||
assert "backend" not in dom
|
||||
assert "paths" in dom
|
||||
|
||||
def test_migrate_config_full(self, temp_data_dir):
|
||||
"""_migrate_config runs _ensure_webui_backend then _migrate_mgmt_domains."""
|
||||
cfg = {
|
||||
"domains": {
|
||||
"mgmt.example.com": {
|
||||
"application": "webui",
|
||||
"paths": {
|
||||
"/": {"is_management": True},
|
||||
"/ws": {"is_websocket": True},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
result = nginx._migrate_config(cfg)
|
||||
assert result is cfg
|
||||
assert "webui" in cfg["backends"]
|
||||
assert cfg["domains"]["mgmt.example.com"]["backend"] == "webui"
|
||||
|
||||
|
||||
class TestDomainSwap:
|
||||
"""Domain can change which backend it references."""
|
||||
|
||||
def test_swap_backend(self, temp_data_dir):
|
||||
"""update_domain allows changing backend field."""
|
||||
cfg = {
|
||||
"backends": {
|
||||
"webui": {"label": "WebUI", "paths": {}},
|
||||
"myapp": {"label": "App", "paths": {}},
|
||||
},
|
||||
"domains": {"example.com": {"backend": "webui", "force_ssl": True}},
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
nginx.update_domain("example.com", backend="myapp")
|
||||
loaded = nginx.get_config()
|
||||
assert loaded["domains"]["example.com"]["backend"] == "myapp"
|
||||
|
||||
def test_swap_backend_nonexistent_raises(self, temp_data_dir):
|
||||
"""update_domain raises ValueError when new backend not found."""
|
||||
cfg = {
|
||||
"backends": {"webui": {"label": "WebUI", "paths": {}}},
|
||||
"domains": {"example.com": {"backend": "webui"}},
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
with pytest.raises(ValueError, match="Backend 'nonexistent' not found"):
|
||||
nginx.update_domain("example.com", backend="nonexistent")
|
||||
|
||||
|
||||
class TestGetDomainsWithBackends:
|
||||
"""get_domains resolves paths from backends, includes backend_name."""
|
||||
|
||||
def test_flattens_by_backend_paths(self, temp_data_dir):
|
||||
"""Each backend path becomes a separate entry."""
|
||||
cfg = {
|
||||
"backends": {
|
||||
"myapp": {
|
||||
"label": "My App",
|
||||
"paths": {
|
||||
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
|
||||
"/api": {
|
||||
"backend": {"host": "b", "port": 8080, "proto": "http"}
|
||||
},
|
||||
"/ws": {
|
||||
"backend": {"host": "c", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"domains": {"example.com": {"backend": "myapp", "force_ssl": True}},
|
||||
"ssl": {},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
assert len(result) == 3
|
||||
paths = {r["path"] for r in result}
|
||||
assert paths == {"/", "/api", "/ws"}
|
||||
for r in result:
|
||||
assert r["backend_name"] == "myapp"
|
||||
assert r["domain"] == "example.com"
|
||||
|
||||
def test_skips_domains_without_backend(self, temp_data_dir):
|
||||
"""Domains without a backend key are skipped."""
|
||||
cfg = {
|
||||
"backends": {},
|
||||
"domains": {
|
||||
"good.com": {"backend": "webui"},
|
||||
"bad.com": {"some": "orphan"},
|
||||
},
|
||||
"ssl": {},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
domains = {r["domain"] for r in result}
|
||||
assert "good.com" in domains
|
||||
assert "bad.com" not in domains
|
||||
|
||||
def test_includes_management_and_websocket_flags(self, temp_data_dir):
|
||||
"""is_management and is_websocket flags propagate to entries."""
|
||||
cfg = {
|
||||
"backends": {
|
||||
"webui": {
|
||||
"label": "WebUI",
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9090,
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
},
|
||||
"/ws": {
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9091,
|
||||
"proto": "http",
|
||||
},
|
||||
"is_websocket": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"domains": {"mgmt.local": {"backend": "webui"}},
|
||||
"ssl": {},
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
entries_by_path = {r["path"]: r for r in result}
|
||||
assert entries_by_path["/"]["is_management"] is True
|
||||
assert entries_by_path["/ws"]["is_websocket"] is True
|
||||
|
||||
|
||||
class TestGenerateServerConfWithBackends:
|
||||
"""generate_server_conf resolves paths via backends parameter."""
|
||||
|
||||
def test_with_backends_param(self, temp_data_dir):
|
||||
"""When backends provided, paths resolve from backend."""
|
||||
backends = {
|
||||
"myapp": {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {"host": "10.0.0.1", "port": 8080, "proto": "http"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
domain_cfg = {"domain": "example.com", "backend": "myapp", "force_ssl": True}
|
||||
out = nginx.generate_server_conf(domain_cfg, backends)
|
||||
assert "proxy_pass http://10.0.0.1:8080;" in out
|
||||
|
||||
|
||||
class TestPatchConfigPreservesBackends:
|
||||
"""PATCH_NGINX_CONFIG (deep_merge) does not overwrite backends fully."""
|
||||
|
||||
def test_ssl_patch_keeps_backends(self, temp_data_dir):
|
||||
"""Patching ssl settings preserves backends dict (deep_merge semantics)."""
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = {
|
||||
"backends": {"myapp": {"label": "App", "paths": {}}},
|
||||
"domains": {"example.com": {"backend": "myapp"}},
|
||||
"ssl": {"protocols": "TLSv1.2"},
|
||||
}
|
||||
patch = {"ssl": {"protocols": "TLSv1.3"}}
|
||||
merged = deep_merge(current, patch)
|
||||
assert merged["backends"]["myapp"]["label"] == "App"
|
||||
assert merged["domains"]["example.com"]["backend"] == "myapp"
|
||||
assert merged["ssl"]["protocols"] == "TLSv1.3"
|
||||
|
||||
+17
-9
@@ -14,6 +14,11 @@ class TestPollIntervals:
|
||||
assert _POLL_INTERVALS["wireguard"] == 10
|
||||
assert _POLL_INTERVALS["dnsmasq"] == 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):
|
||||
"""VACUUM_WALL_POLL_INTERVALS env var can override values."""
|
||||
@@ -33,28 +38,31 @@ class TestPollIntervals:
|
||||
|
||||
class TestBroadcastTick:
|
||||
def test_sends_tick_message(self):
|
||||
from daemon.server import _ws_subscribers, broadcast_tick
|
||||
import daemon.server as server
|
||||
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send_str = AsyncMock()
|
||||
_ws_subscribers.add(mock_ws)
|
||||
server._ws_subscribers.add(mock_ws)
|
||||
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()
|
||||
sent = json.loads(mock_ws.send_str.call_args[0][0])
|
||||
assert sent["type"] == "tick"
|
||||
assert sent["subsystems"] == ["firewall", "wireguard"]
|
||||
assert sent["subsystem"] == "firewall"
|
||||
assert sent["data"] == {"up": True}
|
||||
finally:
|
||||
_ws_subscribers.discard(mock_ws)
|
||||
server._ws_subscribers.discard(mock_ws)
|
||||
|
||||
def test_prunes_dead_subscribers(self):
|
||||
from daemon.server import _ws_subscribers, broadcast_tick
|
||||
import daemon.server as server
|
||||
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
|
||||
_ws_subscribers.add(mock_ws)
|
||||
asyncio.run(broadcast_tick(["firewall"]))
|
||||
assert mock_ws not in _ws_subscribers
|
||||
server._ws_subscribers.add(mock_ws)
|
||||
with patch.object(server.state_store, "get", return_value={"up": True}):
|
||||
asyncio.run(server.broadcast_tick("firewall"))
|
||||
assert mock_ws not in server._ws_subscribers
|
||||
|
||||
|
||||
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
|
||||
+55
-27
@@ -4,6 +4,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from webui.server import _has_permission, _subsystem_from_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@@ -30,44 +32,33 @@ class TestSPARoutes:
|
||||
|
||||
def test_api_routes_still_work(self, client):
|
||||
resp = client.get("/api/firewall/zones")
|
||||
assert resp.status_code in (200, 500)
|
||||
assert resp.status_code in (401, 500)
|
||||
data = resp.get_json()
|
||||
assert data is not None
|
||||
assert data.get("ok") is False
|
||||
assert data.get("error") == "unauthorized"
|
||||
|
||||
|
||||
class TestWsUrlGeneration:
|
||||
def test_ws_url_ipv4_host(self, client):
|
||||
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
|
||||
assert b"ws://192.168.1.1:9090/ws" in resp.data
|
||||
|
||||
def test_ws_url_ipv6_host(self, client):
|
||||
resp = client.get("/", headers={"Host": "[::1]:9090"})
|
||||
assert b"ws://[::1]:9090/ws" in resp.data
|
||||
|
||||
|
||||
class TestApiStatusAll:
|
||||
@patch("webui.server.get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {"firewall": {"zones": {}}, "dnsmasq": {}}
|
||||
resp = client.get("/api/status/all")
|
||||
class TestSpaRoot:
|
||||
def test_serves_index_html_as_is(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "firewall" in data["data"]
|
||||
assert b"/static/app.js" in resp.data
|
||||
|
||||
@patch("webui.server.get")
|
||||
def test_error(self, mock_get, client):
|
||||
mock_get.side_effect = RuntimeError("connection refused")
|
||||
resp = client.get("/api/status/all")
|
||||
assert resp.status_code == 500
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is False
|
||||
def test_no_ws_url_substitution(self, client):
|
||||
"""index.html is served verbatim — no WS URL placeholder substitution."""
|
||||
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
|
||||
assert b"__WS_URL_PLACEHOLDER__" not in resp.data
|
||||
assert b"ws://" not in resp.data
|
||||
|
||||
|
||||
class TestBlueprintsRegistered:
|
||||
def test_all_blueprints_registered(self, client):
|
||||
from webui.server import BLUEPRINTS
|
||||
|
||||
assert len(BLUEPRINTS) == 7
|
||||
assert len(BLUEPRINTS) == 9
|
||||
names = [name for name, _ in BLUEPRINTS]
|
||||
assert "auth" in names
|
||||
assert "firewall" in names
|
||||
assert "network" in names
|
||||
assert "dhcp" in names
|
||||
@@ -109,3 +100,40 @@ class TestGroupWriteHandler:
|
||||
|
||||
mode = os.stat(log_file).st_mode & 0o777
|
||||
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
|
||||
|
||||
|
||||
class TestCSPHeaders:
|
||||
def test_csp_header_on_root(self, client):
|
||||
resp = client.get("/")
|
||||
csp = resp.headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
|
||||
|
||||
def test_x_content_type_options(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
def test_frame_ancestors_none(self, client):
|
||||
resp = client.get("/")
|
||||
csp = resp.headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
|
||||
|
||||
class TestSessionIdAuth:
|
||||
"""Test session_id binding in auth middleware."""
|
||||
|
||||
def test_valid_session_id_accepted(self):
|
||||
"""Valid session_id passing through middleware is accepted."""
|
||||
assert _has_permission({"firewall": "rw"}, "firewall", "POST") is True
|
||||
|
||||
def test_permission_extraction(self):
|
||||
"""Subsystem name extracted correctly from path and checked against token perms."""
|
||||
sub = _subsystem_from_path("/api/firewall/zones")
|
||||
assert sub == "firewall"
|
||||
|
||||
perms = {"firewall": "read"}
|
||||
assert _has_permission(perms, "firewall", "GET") is True
|
||||
assert _has_permission(perms, "firewall", "POST") is False
|
||||
|
||||
+116
-14
@@ -1,5 +1,6 @@
|
||||
"""Tests for lib/state.py — state store and collect functions."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.state import State, state
|
||||
@@ -28,6 +29,25 @@ class TestState:
|
||||
assert state is not None
|
||||
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:
|
||||
@patch("lib.state.run")
|
||||
@@ -35,24 +55,35 @@ class TestCollectAll:
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0"
|
||||
if "--get-default-zone" in args:
|
||||
return "public\n"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
if "link" in args:
|
||||
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
||||
return ""
|
||||
if "--list-all" in args:
|
||||
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n"
|
||||
return ""
|
||||
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"
|
||||
)
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = _collect_firewall()
|
||||
assert isinstance(result, dict)
|
||||
assert "active_zones" in result
|
||||
assert "default_zone" in result
|
||||
assert result["default_zone"] == "public"
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
|
||||
@@ -62,10 +93,10 @@ class TestCollectAll:
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0\ninternal eth0.100"
|
||||
return "public\n eth0\ninternal\n eth0.100"
|
||||
if "--get-default-zone" in args:
|
||||
return "public\n"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
@@ -81,14 +112,27 @@ class TestCollectAll:
|
||||
"3: eth0.100@if100 inet 10.0.0.1/24\n"
|
||||
)
|
||||
return ""
|
||||
if "--list-all" in args:
|
||||
if "--list-all-zones" in args:
|
||||
return (
|
||||
"target: default\ninterfaces: eth0\nsources: "
|
||||
"services: \nports: \nprotocols: \nforward-ports: "
|
||||
"masquerade: no\nics: no\nrich-rules: "
|
||||
"icmp-blocks: \nmodule: \n"
|
||||
"public\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\n"
|
||||
" services: \n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
"internal\n"
|
||||
" target: ACCEPT\n"
|
||||
" interfaces: eth0.100\n"
|
||||
" services: \n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" masquerade: no\n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = _collect_firewall()
|
||||
@@ -112,6 +156,64 @@ class TestCollectAll:
|
||||
assert "config" in result
|
||||
assert "leases" in result
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
||||
from lib.state import _collect_dnsmasq
|
||||
|
||||
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
||||
applied = {
|
||||
"dhcp": {
|
||||
"ranges": [
|
||||
{
|
||||
"interface": "eth1",
|
||||
"start": "10.4.20.101",
|
||||
"end": "10.4.20.200",
|
||||
"lease_time": "1h",
|
||||
"gateway": "10.4.20.1",
|
||||
}
|
||||
],
|
||||
"static_leases": [],
|
||||
},
|
||||
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
|
||||
}
|
||||
cfg = {
|
||||
"dhcp": {
|
||||
"ranges": [
|
||||
{
|
||||
"interface": "eth1",
|
||||
"start": "10.4.20.100",
|
||||
"end": "10.4.20.200",
|
||||
"lease_time": "12h",
|
||||
"gateway": "10.4.20.1",
|
||||
}
|
||||
],
|
||||
"static_leases": [],
|
||||
},
|
||||
"dns": {
|
||||
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
||||
"domain": None,
|
||||
"custom_records": [],
|
||||
},
|
||||
_LAST_APPLIED_CONFIG_KEY: applied,
|
||||
_APPLY_HASH_KEY: "stale-hash",
|
||||
}
|
||||
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
||||
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
|
||||
# service check -> active; lease file read -> no lines
|
||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||
|
||||
result = _collect_dnsmasq()
|
||||
assert result["status"]["pending_changes"] is True
|
||||
paths = {d["path"] for d in result["status"]["pending_diff"]}
|
||||
assert "dhcp.ranges[0].start" in paths
|
||||
assert "dhcp.ranges[0].lease_time" in paths
|
||||
# Apply metadata must not leak into the returned config.
|
||||
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
|
||||
assert _APPLY_HASH_KEY not in result["config"]
|
||||
|
||||
|
||||
class TestCollectFailure:
|
||||
def test_state_clears_on_failure(self):
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for daemon/handlers/status.py — cancel-all (revert to applied)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
from daemon.handlers import status
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
stamp_applied,
|
||||
)
|
||||
|
||||
|
||||
def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]:
|
||||
"""Build status_pending() output with the given subsystems pending.
|
||||
|
||||
Mirrors the real handler shape: every subsystem key is always present.
|
||||
"""
|
||||
data: dict[str, Any] = {
|
||||
"firewall": {
|
||||
"needs_apply": firewall,
|
||||
"change_count": 1 if firewall else 0,
|
||||
"changes": [],
|
||||
},
|
||||
}
|
||||
total = 1 if firewall else 0
|
||||
for name in ("dnsmasq", "nginx", "wireguard", "networkd"):
|
||||
flagged = bool(flags.get(name, False))
|
||||
data[name] = {
|
||||
"pending_changes": flagged,
|
||||
"summary": "x" if flagged else "Up to date",
|
||||
"changes": [],
|
||||
}
|
||||
total += 1 if flagged else 0
|
||||
data["total_changes"] = total
|
||||
return data
|
||||
|
||||
|
||||
class TestConfigPathResolution:
|
||||
"""SYS_CONFIG_PATHS points at the constants the handlers actually use."""
|
||||
|
||||
def test_resolves_to_module_constants(self):
|
||||
import daemon.handlers.dnsmasq as dm_h
|
||||
import daemon.handlers.firewall as fw_h
|
||||
import daemon.handlers.nginx as ngx_h
|
||||
import lib.network as net_lib
|
||||
import lib.wireguard as wg_lib
|
||||
|
||||
assert status._config_path("firewall") == fw_h.CONFIG_FILE
|
||||
assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH
|
||||
assert status._config_path("nginx") == ngx_h.CONFIG_FILE
|
||||
assert status._config_path("wireguard") == wg_lib.CONFIG_PATH
|
||||
assert status._config_path("networkd") == net_lib.CONFIG_FILE
|
||||
|
||||
def test_all_subsystems_mapped(self):
|
||||
assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER)
|
||||
|
||||
|
||||
class TestStatusCancelAll:
|
||||
"""Test the cancel-all endpoint.
|
||||
|
||||
Patches the ``_config_path`` seam directly since it resolves the real
|
||||
module constants via getattr at call time.
|
||||
"""
|
||||
|
||||
_fake_pending_all: ClassVar[dict[str, Any]] = _pending()
|
||||
|
||||
def _write_config(
|
||||
self, path: Path, cfg: dict[str, Any], baseline: dict | None
|
||||
) -> None:
|
||||
"""Write *cfg* (with an attached applied baseline when *baseline* given)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamped = dict(cfg)
|
||||
if baseline is not None:
|
||||
b = dict(baseline)
|
||||
stamp_applied(b)
|
||||
stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline)
|
||||
stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY]
|
||||
path.write_text(json.dumps(stamped, indent=2) + "\n")
|
||||
|
||||
def _read_config(self, path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text())
|
||||
|
||||
@patch("daemon.handlers.status.status_pending")
|
||||
@patch("daemon.handlers.status.refresh_state")
|
||||
def test_nothing_pending(self, mock_refresh, mock_pending):
|
||||
mock_pending.return_value = self._fake_pending_all
|
||||
with patch("daemon.handlers.status._config_path", side_effect=AssertionError):
|
||||
result = status.status_cancel_all(None, None)
|
||||
assert result == {"cancelled": [], "skipped": {}, "errors": {}}
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_reverts_pending_subsystem(self, tmp_path):
|
||||
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
||||
applied = {"dns": {"upstreams": ["8.8.8.8"]}}
|
||||
# Baseline applied, then the user edited the config (drift).
|
||||
self._write_config(
|
||||
paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied
|
||||
)
|
||||
|
||||
dirty = self._read_config(paths["dnsmasq"])
|
||||
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending
|
||||
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.status.status_pending",
|
||||
return_value=_pending(dnsmasq=True),
|
||||
),
|
||||
patch("daemon.handlers.status.refresh_state") as mock_refresh,
|
||||
patch(
|
||||
"daemon.handlers.status._config_path",
|
||||
side_effect=lambda name: paths[name],
|
||||
),
|
||||
):
|
||||
result = status.status_cancel_all(None, None)
|
||||
|
||||
assert result == {
|
||||
"cancelled": ["dnsmasq"],
|
||||
"skipped": {},
|
||||
"errors": {},
|
||||
}
|
||||
mock_refresh.assert_called_once_with(status.SYS_ORDER)
|
||||
|
||||
restored = self._read_config(paths["dnsmasq"])
|
||||
assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied
|
||||
# Cancel restored the baseline: the pending check now passes.
|
||||
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
|
||||
|
||||
def test_skipped_when_no_baseline(self, tmp_path):
|
||||
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
||||
cfg_no_baseline = {"peers": {"a": {}}}
|
||||
self._write_config(paths["wireguard"], cfg_no_baseline, None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.status.status_pending",
|
||||
return_value=_pending(wireguard=True),
|
||||
),
|
||||
patch("daemon.handlers.status.refresh_state") as mock_refresh,
|
||||
patch(
|
||||
"daemon.handlers.status._config_path",
|
||||
side_effect=lambda name: paths[name],
|
||||
),
|
||||
):
|
||||
result = status.status_cancel_all(None, None)
|
||||
|
||||
assert result["cancelled"] == []
|
||||
assert "WireGuard" in result["skipped"]
|
||||
assert result["errors"] == {}
|
||||
mock_refresh.assert_not_called()
|
||||
# File left untouched.
|
||||
assert self._read_config(paths["wireguard"]) == cfg_no_baseline
|
||||
|
||||
def test_error_in_one_subsystem_others_still_cancel(self, tmp_path):
|
||||
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
||||
for name in ("firewall", "nginx"):
|
||||
self._write_config(
|
||||
paths[name],
|
||||
{"zones": {}} if name == "firewall" else {"domains": {}},
|
||||
{"x": 1},
|
||||
)
|
||||
|
||||
def fake_revert(path):
|
||||
if "nginx" in str(path):
|
||||
raise RuntimeError("disk full")
|
||||
return True, ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.status.status_pending",
|
||||
return_value=_pending(firewall=True, nginx=True),
|
||||
),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
|
||||
patch(
|
||||
"daemon.handlers.status._config_path",
|
||||
side_effect=lambda name: paths[name],
|
||||
),
|
||||
):
|
||||
result = status.status_cancel_all(None, None)
|
||||
|
||||
# SYS_ORDER runs firewall before nginx.
|
||||
assert result["cancelled"] == ["firewall"]
|
||||
assert result["errors"] == {"Nginx": "disk full"}
|
||||
assert result["skipped"] == {}
|
||||
|
||||
def test_order_matches_sys_order(self, tmp_path):
|
||||
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
||||
call_order = []
|
||||
|
||||
def fake_revert(path):
|
||||
call_order.append(next(n for n, p in paths.items() if p == path))
|
||||
return True, ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.status.status_pending",
|
||||
return_value=_pending(
|
||||
firewall=True,
|
||||
dnsmasq=True,
|
||||
nginx=True,
|
||||
wireguard=True,
|
||||
networkd=True,
|
||||
),
|
||||
),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
|
||||
patch(
|
||||
"daemon.handlers.status._config_path",
|
||||
side_effect=lambda name: paths[name],
|
||||
),
|
||||
):
|
||||
result = status.status_cancel_all(None, None)
|
||||
|
||||
assert result["cancelled"] == status.SYS_ORDER
|
||||
assert call_order == status.SYS_ORDER
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Tests for daemon/handlers/status.py — aggregate pending + apply-all."""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from daemon.handlers import status
|
||||
from lib.state import State
|
||||
|
||||
|
||||
def _make_state(**kwargs):
|
||||
"""Create a minimal in-memory state for pending checks."""
|
||||
st = State()
|
||||
for name, data in kwargs.items():
|
||||
st.set(name, data)
|
||||
return st
|
||||
|
||||
|
||||
def _mock_state_store(state_dict):
|
||||
"""Return a mock that looks like state_store.get()."""
|
||||
mock = MagicMock()
|
||||
mock.get.side_effect = lambda name: state_dict.get(name)
|
||||
return mock
|
||||
|
||||
|
||||
class TestFwChangeSummary:
|
||||
"""Test fw_change_summary helper from status module."""
|
||||
|
||||
def test_interfaces_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"internal", "interfaces", {"config": ["eth1"], "live": []}
|
||||
)
|
||||
assert "Zone internal: interfaces changed" in s
|
||||
assert "eth1" in s
|
||||
|
||||
def test_services_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"dmz", "services", {"config": ["ssh", "dns"], "live": ["ssh"]}
|
||||
)
|
||||
assert "Zone dmz: services changed" in s
|
||||
|
||||
def test_rich_rules_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"public", "rich_rules", {"config_count": 2, "live_count": 1}
|
||||
)
|
||||
assert "config: 2" in s
|
||||
assert "live: 1" in s
|
||||
|
||||
def test_forward_ports_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"wan", "forward_ports", {"config_count": 3, "live_count": 0}
|
||||
)
|
||||
assert "Zone wan: port forwards differ" in s
|
||||
|
||||
def test_masquerade_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"lan", "masquerade", {"config": True, "live": False}
|
||||
)
|
||||
assert "Zone lan: masquerade changed" in s
|
||||
|
||||
def test_target_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"vpn", "target", {"config": "ACCEPT", "live": "default"}
|
||||
)
|
||||
assert "Zone vpn: target changed" in s
|
||||
|
||||
def test_unknown_type_summary(self):
|
||||
s = status.fw_change_summary("public", "weird", {})
|
||||
assert "Zone public: weird changed" in s
|
||||
|
||||
|
||||
class TestHashSubsystem:
|
||||
"""Test _hash_subsystem helper from status module."""
|
||||
|
||||
def test_no_state(self):
|
||||
result = status._hash_subsystem("nginx", None)
|
||||
assert result["pending_changes"] is False
|
||||
assert result["summary"] == "Up to date"
|
||||
|
||||
def test_pending_true(self):
|
||||
st = {"status": {"pending_changes": True}}
|
||||
result = status._hash_subsystem("wireguard", st)
|
||||
assert result["pending_changes"] is True
|
||||
assert "unapplied changes" in result["summary"]
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
def test_pending_false(self):
|
||||
st = {"status": {"pending_changes": False}}
|
||||
result = status._hash_subsystem("networkd", st)
|
||||
assert result["pending_changes"] is False
|
||||
|
||||
def test_empty_status(self):
|
||||
st = {}
|
||||
result = status._hash_subsystem("dnsmasq", st)
|
||||
assert result["pending_changes"] is False
|
||||
|
||||
|
||||
class TestStatusPending:
|
||||
"""Test the aggregate pending endpoint."""
|
||||
|
||||
@patch("daemon.handlers.status.state_store")
|
||||
def test_all_synced(self, mock_store):
|
||||
mock_store.get.return_value = {
|
||||
"firewall": {"pending": {"needs_apply": False, "pending": []}},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
assert not result["dnsmasq"]["pending_changes"]
|
||||
|
||||
def _patch_store(self, data):
|
||||
mock = MagicMock()
|
||||
mock.get.side_effect = lambda name: data.get(name)
|
||||
return patch("daemon.handlers.status.state_store", mock)
|
||||
|
||||
def test_firewall_pending_only(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1"],
|
||||
"live": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 1
|
||||
assert result["firewall"]["change_count"] == 1
|
||||
|
||||
def test_multiple_subsystems_pending(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "lan",
|
||||
"type": "services",
|
||||
"config": ["ssh"],
|
||||
"live": [],
|
||||
},
|
||||
{
|
||||
"zone": "wan",
|
||||
"type": "masquerade",
|
||||
"config": True,
|
||||
"live": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": True}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": True}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 4 # 2 FW + 1 DHCP + 1 WG
|
||||
assert result["firewall"]["change_count"] == 2
|
||||
assert result["firewall"]["needs_apply"] is True
|
||||
assert result["dnsmasq"]["pending_changes"] is True
|
||||
assert result["wireguard"]["pending_changes"] is True
|
||||
|
||||
def test_empty_state(self):
|
||||
with self._patch_store({}):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
|
||||
def test_firewall_no_pending_key(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": None,
|
||||
"wireguard": None,
|
||||
"networkd": None,
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
|
||||
|
||||
class TestStatusApplyAll:
|
||||
"""Test the apply-all endpoint.
|
||||
|
||||
Patches SYS_APPLY dict entries directly since they hold function
|
||||
references at import time.
|
||||
"""
|
||||
|
||||
_fake_pending_all: ClassVar[dict[str, Any]] = {
|
||||
"firewall": {"needs_apply": False, "change_count": 0, "changes": []},
|
||||
"dnsmasq": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"wireguard": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"networkd": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
}
|
||||
|
||||
@patch("daemon.handlers.status.status_pending")
|
||||
@patch("daemon.handlers.status.refresh_state")
|
||||
def test_nothing_to_apply(self, mock_refresh, mock_pending):
|
||||
mock_pending.return_value = self._fake_pending_all
|
||||
result = status.status_apply_all(None, None)
|
||||
assert result["applied"] == []
|
||||
assert result["errors"] == {}
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
def test_applies_pending_subsystems(self):
|
||||
mock_net = MagicMock()
|
||||
mock_fw = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
pending_data["networkd"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"networkd": mock_net,
|
||||
"firewall": mock_fw,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "networkd" in result["applied"]
|
||||
assert "firewall" in result["applied"]
|
||||
mock_net.assert_called_once()
|
||||
mock_fw.assert_called_once()
|
||||
|
||||
def test_error_in_subsystem(self):
|
||||
mock_fw = MagicMock(side_effect=RuntimeError("firewalld not running"))
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "firewall" not in result["applied"]
|
||||
assert "Firewall" in result["errors"]
|
||||
assert "firewalld not running" in result["errors"]["Firewall"]
|
||||
|
||||
def test_order_is_respected(self):
|
||||
call_order = []
|
||||
|
||||
def track(name):
|
||||
def wrapper(*args):
|
||||
call_order.append(name)
|
||||
|
||||
return wrapper
|
||||
|
||||
mock_net = MagicMock(side_effect=track("networkd"))
|
||||
mock_wg = MagicMock(side_effect=track("wireguard"))
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["wireguard"]["pending_changes"] = True
|
||||
pending_data["networkd"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"networkd": mock_net,
|
||||
"wireguard": mock_wg,
|
||||
},
|
||||
),
|
||||
):
|
||||
status.status_apply_all(None, None)
|
||||
assert call_order == ["networkd", "wireguard"]
|
||||
|
||||
def test_partial_failure_still_applies_others(self):
|
||||
mock_fw = MagicMock(side_effect=RuntimeError("fail"))
|
||||
mock_nginx = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
pending_data["nginx"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"firewall": mock_fw,
|
||||
"nginx": mock_nginx,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "firewall" not in result["applied"]
|
||||
assert "nginx" in result["applied"]
|
||||
assert "Firewall" in result["errors"]
|
||||
mock_nginx.assert_called_once()
|
||||
|
||||
|
||||
class TestSysOrder:
|
||||
"""Verify SYS_ORDER and SYS_LABELS constants."""
|
||||
|
||||
def test_order_network_first(self):
|
||||
assert status.SYS_ORDER[0] == "networkd"
|
||||
|
||||
def test_all_subsystems_present(self):
|
||||
expected = {"networkd", "firewall", "wireguard", "dnsmasq", "nginx"}
|
||||
assert set(status.SYS_ORDER) == expected
|
||||
|
||||
def test_labels_match(self):
|
||||
for name in status.SYS_ORDER:
|
||||
assert name in status.SYS_LABELS
|
||||
assert name in status.SYS_APPLY
|
||||
|
||||
def test_apply_functions_callable(self):
|
||||
for name in status.SYS_ORDER:
|
||||
assert callable(status.SYS_APPLY[name])
|
||||
+1029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
||||
"""Tests for lib/system_import module."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lib import system_import
|
||||
from lib.common import save_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_project(tmp_path):
|
||||
"""Patch all module-level path constants to tmp_path subdirs."""
|
||||
originals = {
|
||||
"PROJECT_DIR": system_import.PROJECT_DIR,
|
||||
"DNSMASQ_CONF": system_import.DNSMASQ_CONF,
|
||||
"WG_CONF": system_import.WG_CONF,
|
||||
"NETWORKD_DIR": system_import.NETWORKD_DIR,
|
||||
"NGINX_SITES_DIR": system_import.NGINX_SITES_DIR,
|
||||
}
|
||||
system_import.PROJECT_DIR = tmp_path
|
||||
system_import.DNSMASQ_CONF = tmp_path / "etc" / "dnsmasq.d" / "vacuum-wall.conf"
|
||||
system_import.WG_CONF = tmp_path / "etc" / "wireguard" / "wg0.conf"
|
||||
system_import.NETWORKD_DIR = tmp_path / "etc" / "systemd" / "network"
|
||||
system_import.NGINX_SITES_DIR = tmp_path / "data" / "nginx" / "sites-enabled"
|
||||
yield tmp_path
|
||||
system_import.PROJECT_DIR = originals["PROJECT_DIR"]
|
||||
system_import.DNSMASQ_CONF = originals["DNSMASQ_CONF"]
|
||||
system_import.WG_CONF = originals["WG_CONF"]
|
||||
system_import.NETWORKD_DIR = originals["NETWORKD_DIR"]
|
||||
system_import.NGINX_SITES_DIR = originals["NGINX_SITES_DIR"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Dnsmasq
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportDnsmasq:
|
||||
def _write_conf(self, tmp_path, content: str) -> Path:
|
||||
p = tmp_path / "etc" / "dnsmasq.d"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
(p / "vacuum-wall.conf").write_text(content)
|
||||
return p / "vacuum-wall.conf"
|
||||
|
||||
def _read_json(self, tmp_path) -> dict:
|
||||
p = tmp_path / "config" / "dnsmasq" / "config.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
def test_no_conf_file(self, temp_project):
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
def test_no_markers(self, temp_project, tmp_path):
|
||||
self._write_conf(tmp_path, "# some random config\nserver=1.1.1.1\n")
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
def test_empty_managed_block(self, temp_project, tmp_path):
|
||||
self._write_conf(tmp_path, f"{system_import.DNSTART}\n{system_import.DNEND}")
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["dns"]["upstreams"] == []
|
||||
assert cfg["dhcp"]["ranges"] == []
|
||||
|
||||
def test_upstreams_only(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
f"{system_import.DNSTART}\n"
|
||||
"server=8.8.8.8\n"
|
||||
"server=1.1.1.1\n"
|
||||
f"{system_import.DNEND}"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
||||
|
||||
def test_full_config(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
f"{system_import.DNSTART}\n"
|
||||
"server=8.8.8.8\n"
|
||||
"server=1.1.1.1\n"
|
||||
"domain=lan\n"
|
||||
"expand-hosts\n"
|
||||
"dhcp-range=set:eth1,192.168.2.100,192.168.2.200,12h\n"
|
||||
"dhcp-option=tag:eth1,3,192.168.2.1\n"
|
||||
"dhcp-option=tag:eth1,6,192.168.2.1\n"
|
||||
"dhcp-host=aa:bb:cc:dd:ee:ff,192.168.2.50,printer\n"
|
||||
"addr/nas.lan/192.168.2.10\n"
|
||||
f"{system_import.DNEND}"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
|
||||
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
||||
assert cfg["dns"]["domain"] == "lan"
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
rng = cfg["dhcp"]["ranges"][0]
|
||||
assert rng["interface"] == "eth1"
|
||||
assert rng["start"] == "192.168.2.100"
|
||||
assert rng["end"] == "192.168.2.200"
|
||||
assert rng["lease_time"] == "12h"
|
||||
assert rng["gateway"] == "192.168.2.1"
|
||||
assert rng["dns"] == "192.168.2.1"
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
lease = cfg["dhcp"]["static_leases"][0]
|
||||
assert lease["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert lease["ip"] == "192.168.2.50"
|
||||
assert lease["hostname"] == "printer"
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
assert cfg["dns"]["custom_records"][0] == {
|
||||
"name": "nas.lan",
|
||||
"address": "192.168.2.10",
|
||||
}
|
||||
|
||||
def test_no_resolv_resets_upstreams(self, temp_project, tmp_path):
|
||||
conf = f"{system_import.DNSTART}\nno-resolv\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["dns"]["upstreams"] == []
|
||||
|
||||
def test_idempotent(self, temp_project, tmp_path):
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
||||
# Conf with markers — parses fine, so this tests the exception handler
|
||||
# by mocking _parse_dnsmasq_block to raise
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
with patch(
|
||||
"lib.system_import._parse_dnsmasq_block", side_effect=ValueError("bad")
|
||||
):
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# WireGuard
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportWireguard:
|
||||
def _write_conf(self, tmp_path, content: str) -> Path:
|
||||
p = tmp_path / "etc" / "wireguard"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
(p / "wg0.conf").write_text(content)
|
||||
return p / "wg0.conf"
|
||||
|
||||
def _read_json(self, tmp_path) -> dict:
|
||||
p = tmp_path / "config" / "wireguard" / "config.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
def test_no_conf_file(self, temp_project):
|
||||
assert not system_import.import_wireguard()
|
||||
|
||||
def test_basic_interface(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["interface"]["private_key"] == "abc123"
|
||||
assert cfg["interface"]["addresses"] == ["10.137.0.1/24"]
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
def test_full_with_peers(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = srv-priv\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
" PostUp = iptables -I FORWARD -i wg0 -j ACCEPT\n"
|
||||
" PostDown = iptables -D FORWARD -i wg0 -j ACCEPT\n"
|
||||
"\n"
|
||||
"[Peer] # alice\n"
|
||||
" PublicKey = alice-pub\n"
|
||||
" Endpoint = 203.0.113.1:51820\n"
|
||||
" AllowedIPs = 0.0.0.0/0\n"
|
||||
" PersistentKeepalive = 25\n"
|
||||
"\n"
|
||||
"[Peer] # bob\n"
|
||||
" PublicKey = bob-pub\n"
|
||||
" AllowedIPs = 10.0.0.0/8,172.16.0.0/12\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
|
||||
assert cfg["interface"]["post_down"] == "iptables -D FORWARD -i wg0 -j ACCEPT"
|
||||
assert "alice" in cfg["peers"]
|
||||
assert cfg["peers"]["alice"]["public_key"] == "alice-pub"
|
||||
assert cfg["peers"]["alice"]["endpoint"] == "203.0.113.1:51820"
|
||||
assert cfg["peers"]["alice"]["allowed_ips"] == ["0.0.0.0/0"]
|
||||
assert cfg["peers"]["alice"]["persistent_keepalive"] == 25
|
||||
assert "bob" in cfg["peers"]
|
||||
assert cfg["peers"]["bob"]["allowed_ips"] == ["10.0.0.0/8", "172.16.0.0/12"]
|
||||
|
||||
def test_peer_without_name_uses_pubkey(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = srv-priv\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
"\n"
|
||||
"[Peer]\n"
|
||||
" PublicKey = anon-pub\n"
|
||||
" AllowedIPs = 0.0.0.0/0\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "anon-pub" in cfg["peers"]
|
||||
|
||||
def test_idempotent(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
assert not system_import.import_wireguard()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Networkd
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportNetworkd:
|
||||
def _write_network(self, tmp_path, name: str, content: str) -> Path:
|
||||
p = tmp_path / "etc" / "systemd" / "network"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
file_path = p / f"99-{name}.network"
|
||||
file_path.write_text(content)
|
||||
return file_path
|
||||
|
||||
def _read_json(self, tmp_path) -> dict:
|
||||
p = tmp_path / "config" / "network" / "config.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
def test_no_network_dir(self, temp_project):
|
||||
assert not system_import.import_networkd()
|
||||
|
||||
def test_no_files(self, temp_project, tmp_path):
|
||||
(tmp_path / "etc" / "systemd" / "network").mkdir(parents=True, exist_ok=True)
|
||||
assert not system_import.import_networkd()
|
||||
|
||||
def test_basic_interface(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Match]\n"
|
||||
"Name=eth0\n"
|
||||
"\n"
|
||||
"[Network]\n"
|
||||
"DHCP=no\n"
|
||||
"Addresses=192.168.1.1/24\n"
|
||||
"Gateway=192.168.1.254\n"
|
||||
"DNS=8.8.8.8\n"
|
||||
"DNS=1.1.1.1\n"
|
||||
)
|
||||
self._write_network(tmp_path, "eth0", conf)
|
||||
assert system_import.import_networkd()
|
||||
cfg = self._read_json(tmp_path)
|
||||
eth0 = cfg["interfaces"]["eth0"]
|
||||
assert eth0["dhcp"] == "no"
|
||||
assert eth0["gateway"] == "192.168.1.254"
|
||||
assert eth0["dns"] == ["8.8.8.8", "1.1.1.1"]
|
||||
|
||||
def test_multiple_interfaces(self, temp_project, tmp_path):
|
||||
self._write_network(
|
||||
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
|
||||
)
|
||||
self._write_network(
|
||||
tmp_path, "eth1", "[Match]\nName=eth1\n\n[Network]\nDHCP=no\n"
|
||||
)
|
||||
assert system_import.import_networkd()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "eth0" in cfg["interfaces"]
|
||||
assert "eth1" in cfg["interfaces"]
|
||||
assert cfg["interfaces"]["eth0"]["dhcp"] == "yes"
|
||||
assert cfg["interfaces"]["eth1"]["dhcp"] == "no"
|
||||
|
||||
def test_preserves_existing_interfaces(self, temp_project, tmp_path):
|
||||
# Pre-existing JSON has eth2 with no .network file
|
||||
cfg_path = tmp_path / "config" / "network"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
save_json(cfg_path / "config.json", {"interfaces": {"eth2": {"dhcp": "no"}}})
|
||||
self._write_network(
|
||||
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
|
||||
)
|
||||
assert system_import.import_networkd()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "eth0" in cfg["interfaces"]
|
||||
assert "eth2" in cfg["interfaces"]
|
||||
|
||||
def test_idempotent(self, temp_project, tmp_path):
|
||||
self._write_network(
|
||||
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
|
||||
)
|
||||
assert system_import.import_networkd()
|
||||
assert not system_import.import_networkd()
|
||||
|
||||
def test_address_section_parsed(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Match]\n"
|
||||
"Name=eth0\n"
|
||||
"\n"
|
||||
"[Network]\n"
|
||||
"DHCP=no\n"
|
||||
"\n"
|
||||
"[Address]\n"
|
||||
"Address=192.168.1.1/24\n"
|
||||
)
|
||||
self._write_network(tmp_path, "eth0", conf)
|
||||
assert system_import.import_networkd()
|
||||
cfg = self._read_json(tmp_path)
|
||||
eth0 = cfg["interfaces"]["eth0"]
|
||||
assert "192.168.1.1/24" in eth0.get("addresses", [])
|
||||
|
||||
def test_route_section_parsed(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Match]\n"
|
||||
"Name=eth0\n"
|
||||
"\n"
|
||||
"[Network]\n"
|
||||
"DHCP=no\n"
|
||||
"\n"
|
||||
"[Route]\n"
|
||||
"Destination=10.0.0.0/8\n"
|
||||
"Gateway=192.168.1.254\n"
|
||||
"Metric=100\n"
|
||||
)
|
||||
self._write_network(tmp_path, "eth0", conf)
|
||||
assert system_import.import_networkd()
|
||||
cfg = self._read_json(tmp_path)
|
||||
eth0 = cfg["interfaces"]["eth0"]
|
||||
assert len(eth0.get("routes", [])) == 1
|
||||
route = eth0["routes"][0]
|
||||
assert route["destination"] == "10.0.0.0/8"
|
||||
assert route["gateway"] == "192.168.1.254"
|
||||
assert route["metric"] == 100
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Nginx
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportNginx:
|
||||
def _write_site(self, tmp_path, domain: str, content: str) -> Path:
|
||||
p = tmp_path / "data" / "nginx" / "sites-enabled"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
file_path = p / f"{domain}.conf"
|
||||
file_path.write_text(content)
|
||||
return file_path
|
||||
|
||||
def _read_json(self, tmp_path) -> dict:
|
||||
p = tmp_path / "config" / "nginx" / "config.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
def test_no_sites_dir(self, temp_project):
|
||||
assert not system_import.import_nginx()
|
||||
|
||||
def test_no_files(self, temp_project, tmp_path):
|
||||
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
assert not system_import.import_nginx()
|
||||
|
||||
def test_acme_challenge_skipped(self, temp_project, tmp_path):
|
||||
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
(
|
||||
tmp_path / "data" / "nginx" / "sites-enabled" / "_acme-challenge.conf"
|
||||
).write_text("# stuff\n")
|
||||
assert not system_import.import_nginx()
|
||||
|
||||
def test_unrecognized_file_skipped(self, temp_project, tmp_path):
|
||||
self._write_site(tmp_path, "my-site", "# some random nginx config\nserver {}\n")
|
||||
assert not system_import.import_nginx()
|
||||
|
||||
def test_basic_site(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"# Auto-generated by Vacuum Wall — do not edit manually\n"
|
||||
"# Domain: example.com\n"
|
||||
"\n"
|
||||
"server {\n"
|
||||
" listen 80;\n"
|
||||
" listen [::]:80;\n"
|
||||
" server_name example.com;\n"
|
||||
" return 301 https://$host$request_uri;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"server {\n"
|
||||
" listen 443 ssl;\n"
|
||||
" listen [::]:443 ssl;\n"
|
||||
" server_name example.com;\n"
|
||||
"\n"
|
||||
" ssl_certificate /home/wall/vacuum-wall/data/acme/example.com/fullchain.cer;\n"
|
||||
" ssl_certificate_key /home/wall/vacuum-wall/data/acme/example.com/example.com.key;\n"
|
||||
"\n"
|
||||
" # / -> 192.168.2.50:8080\n"
|
||||
" location / {\n"
|
||||
" auth_basic off;\n"
|
||||
" proxy_pass http://192.168.2.50:8080;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
self._write_site(tmp_path, "example.com", conf)
|
||||
assert system_import.import_nginx()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "example.com" in cfg["domains"]
|
||||
dom = cfg["domains"]["example.com"]
|
||||
assert dom["force_ssl"] is True
|
||||
assert dom["cert"] == "acme"
|
||||
assert "/" in dom["paths"]
|
||||
assert dom["paths"]["/"]["backend"]["host"] == "192.168.2.50"
|
||||
assert dom["paths"]["/"]["backend"]["port"] == 8080
|
||||
|
||||
def test_websocket_path(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"# Auto-generated by Vacuum Wall — do not edit manually\n"
|
||||
"# Domain: example.com\n"
|
||||
"\n"
|
||||
"server {\n"
|
||||
" listen 443 ssl;\n"
|
||||
" server_name example.com;\n"
|
||||
"\n"
|
||||
" ssl_certificate /data/certs/example.com.crt;\n"
|
||||
" ssl_certificate_key /data/certs/example.com.key;\n"
|
||||
"\n"
|
||||
" # / -> 127.0.0.1:9090\n"
|
||||
" location / {\n"
|
||||
" auth_basic off;\n"
|
||||
" proxy_pass http://127.0.0.1:9090;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" # /ws -> 127.0.0.1:9091 (WebSocket)\n"
|
||||
" location /ws {\n"
|
||||
" auth_basic off;\n"
|
||||
" proxy_pass http://127.0.0.1:9091;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
self._write_site(tmp_path, "example.com", conf)
|
||||
assert system_import.import_nginx()
|
||||
cfg = self._read_json(tmp_path)
|
||||
dom = cfg["domains"]["example.com"]
|
||||
assert dom["cert"] == "selfsigned"
|
||||
assert dom["paths"]["/ws"]["is_websocket"] is True
|
||||
assert dom["paths"]["/ws"]["backend"]["port"] == 9091
|
||||
|
||||
def test_idempotent(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"# Auto-generated by Vacuum Wall — do not edit manually\n"
|
||||
"server {\n"
|
||||
" listen 443 ssl;\n"
|
||||
" server_name example.com;\n"
|
||||
" ssl_certificate /data/acme/example.com/fullchain.cer;\n"
|
||||
" ssl_certificate_key /data/acme/example.com/example.com.key;\n"
|
||||
" # / -> 127.0.0.1:9090\n"
|
||||
" location / {\n"
|
||||
" auth_basic off;\n"
|
||||
" proxy_pass http://127.0.0.1:9090;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
self._write_site(tmp_path, "example.com", conf)
|
||||
assert system_import.import_nginx()
|
||||
assert not system_import.import_nginx()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Firewall
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
FIREWALL_ZONES_OUTPUT = (
|
||||
"public (active)\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0 eth1\n"
|
||||
" sources: \n"
|
||||
" services: dhcpv6-cidr dns mdns ssh\n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" source-ports: \n"
|
||||
" icmp-blocks: \n"
|
||||
" rich rules: \n"
|
||||
"\n"
|
||||
"internal (active)\n"
|
||||
" target: DEFAULT\n"
|
||||
" interfaces: eth2\n"
|
||||
" sources: \n"
|
||||
" services: dhcpv6-cidr dns mdns samba-client ssh\n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" source-ports: \n"
|
||||
" icmp-blocks: \n"
|
||||
" rich rules: \n"
|
||||
"\n"
|
||||
"dmz (active)\n"
|
||||
" target: DROP\n"
|
||||
" interfaces: \n"
|
||||
" sources: \n"
|
||||
" services: dns\n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" source-ports: \n"
|
||||
" icmp-blocks: \n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
|
||||
|
||||
class TestImportFirewall:
|
||||
def _read_json(self, tmp_path) -> dict:
|
||||
p = tmp_path / "config" / "firewall" / "config.json"
|
||||
return json.loads(p.read_text()) if p.exists() else {}
|
||||
|
||||
def test_no_config_file_and_firewalld_down(self, temp_project, tmp_path):
|
||||
with patch(
|
||||
"lib.system_import.run", side_effect=RuntimeError("firewalld not running")
|
||||
):
|
||||
assert not system_import.import_firewall()
|
||||
|
||||
def test_existing_config_not_overwritten(self, temp_project, tmp_path):
|
||||
cfg_path = tmp_path / "config" / "firewall"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
save_json(
|
||||
cfg_path / "config.json", {"zones": {"public": {"interfaces": ["eth0"]}}}
|
||||
)
|
||||
assert not system_import.import_firewall()
|
||||
|
||||
def test_import_zones(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||
assert system_import.import_firewall()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "zones" in cfg
|
||||
assert "public" in cfg["zones"]
|
||||
assert "internal" in cfg["zones"]
|
||||
assert cfg["zones"]["public"]["target"] == "DEFAULT"
|
||||
assert cfg["zones"]["public"]["interfaces"] == ["eth0", "eth1"]
|
||||
assert cfg["zones"]["public"]["services"] == [
|
||||
"dhcpv6-cidr",
|
||||
"dns",
|
||||
"mdns",
|
||||
"ssh",
|
||||
]
|
||||
assert cfg["zones"]["internal"]["target"] == "DEFAULT"
|
||||
assert cfg["zones"]["internal"]["interfaces"] == ["eth2"]
|
||||
|
||||
def test_empty_interface_zones_skipped(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||
assert system_import.import_firewall()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "dmz" not in cfg["zones"]
|
||||
|
||||
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
|
||||
assert not system_import.import_firewall()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# import_all
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAll:
|
||||
def test_all_missing(self, temp_project, tmp_path):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
mock_run = MagicMock(side_effect=RuntimeError("command not found"))
|
||||
with patch.object(system_import, "run", mock_run):
|
||||
result = system_import.import_all()
|
||||
assert result == []
|
||||
|
||||
def test_returns_updated_subsystems(self, temp_project, tmp_path):
|
||||
# Create dnsmasq conf
|
||||
etc = tmp_path / "etc" / "dnsmasq.d"
|
||||
etc.mkdir(parents=True, exist_ok=True)
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
(etc / "vacuum-wall.conf").write_text(conf)
|
||||
|
||||
# Create wireguard conf
|
||||
wg_etc = tmp_path / "etc" / "wireguard"
|
||||
wg_etc.mkdir(parents=True, exist_ok=True)
|
||||
(wg_etc / "wg0.conf").write_text(
|
||||
"[Interface]\n PrivateKey = abc\n Address = 10.137.0.1/24\n ListenPort = 51820\n"
|
||||
)
|
||||
|
||||
result = system_import.import_all()
|
||||
assert "dnsmasq" in result
|
||||
assert "wireguard" in result
|
||||
assert "network" not in result
|
||||
assert "nginx" not in result
|
||||
|
||||
def test_parse_error_does_not_crash(self, temp_project, tmp_path):
|
||||
# Create a dnsmasq conf that will parse fine
|
||||
etc = tmp_path / "etc" / "dnsmasq.d"
|
||||
etc.mkdir(parents=True, exist_ok=True)
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
(etc / "vacuum-wall.conf").write_text(conf)
|
||||
|
||||
# Make wireguard import fail
|
||||
wg_etc = tmp_path / "etc" / "wireguard"
|
||||
wg_etc.mkdir(parents=True, exist_ok=True)
|
||||
(wg_etc / "wg0.conf").write_text("[Interface]\n")
|
||||
|
||||
# This should not raise, just log warning
|
||||
result = system_import.import_all()
|
||||
assert "dnsmasq" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _cfgs_equal
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCfgsEqual:
|
||||
def test_equal(self):
|
||||
assert system_import._cfgs_equal({"a": 1}, {"a": 1})
|
||||
|
||||
def test_not_equal(self):
|
||||
assert not system_import._cfgs_equal({"a": 1}, {"a": 2})
|
||||
|
||||
def test_ignores_applied_hash(self):
|
||||
a = {"a": 1, "_last_applied_hash": "abc"}
|
||||
b = {"a": 1, "_last_applied_hash": "xyz"}
|
||||
assert system_import._cfgs_equal(a, b)
|
||||
|
||||
def test_nested(self):
|
||||
a = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
|
||||
b = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
|
||||
assert system_import._cfgs_equal(a, b)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Template marker consistency
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTemplateMarker:
|
||||
"""Verify nginx templates contain the expected auto-generated marker."""
|
||||
|
||||
def test_acme_challenge_has_marker(self):
|
||||
content = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "system"
|
||||
/ "nginx"
|
||||
/ "acme-challenge.conf"
|
||||
).read_text()
|
||||
assert "# Auto-generated by Vacuum Wall" in content
|
||||
|
||||
def test_server_block_has_marker(self):
|
||||
content = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "system"
|
||||
/ "nginx"
|
||||
/ "server_block.conf"
|
||||
).read_text()
|
||||
assert "# Auto-generated by Vacuum Wall" in content
|
||||
+277
-6
@@ -23,6 +23,13 @@ class TestDefaultConfig:
|
||||
assert cfg["interface"]["private_key"] == ""
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
def test_classes_have_phase2_fields(self):
|
||||
cfg = wireguard.DEFAULT_CONFIG
|
||||
for ck, cv in cfg["access_classes"].items():
|
||||
assert "subnet" in cv, f"Class {ck} missing subnet"
|
||||
assert "listen_port" in cv, f"Class {ck} missing listen_port"
|
||||
assert "lan_access" in cv, f"Class {ck} missing lan_access"
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_returns_default_when_no_file(self, temp_config):
|
||||
@@ -74,6 +81,92 @@ class TestGenerateKeyPair:
|
||||
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
|
||||
|
||||
|
||||
class TestClassHelpers:
|
||||
def test_class_interface_name(self):
|
||||
assert wireguard.get_class_interface_name("full") == "wg-full"
|
||||
assert wireguard.get_class_interface_name("internet") == "wg-internet"
|
||||
assert wireguard.get_class_interface_name("custom") == "wg-custom"
|
||||
|
||||
def test_class_zone_name(self):
|
||||
assert wireguard.get_class_zone_name("full") == "vpn-full"
|
||||
assert wireguard.get_class_zone_name("internet") == "vpn-internet"
|
||||
|
||||
def test_class_peers(self):
|
||||
cfg = {
|
||||
"peers": {
|
||||
"alice": {"access_class": "full", "public_key": "pk1"},
|
||||
"bob": {"access_class": "internet", "public_key": "pk2"},
|
||||
"carol": {"access_class": None, "public_key": "pk3"},
|
||||
}
|
||||
}
|
||||
full_peers = wireguard._class_peers(cfg, "full")
|
||||
assert len(full_peers) == 1
|
||||
assert "alice" in full_peers
|
||||
int_peers = wireguard._class_peers(cfg, "internet")
|
||||
assert len(int_peers) == 1
|
||||
assert "bob" in int_peers
|
||||
|
||||
|
||||
class TestGenerateClassConf:
|
||||
def test_returns_none_when_no_peers(self, temp_config):
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["access_classes"]["full"]["private_key"] = "test-key"
|
||||
result = wireguard.generate_class_conf(cfg, "full")
|
||||
assert result is None
|
||||
|
||||
@patch("lib.wireguard.ENV")
|
||||
def test_renders_template_per_class(self, mock_env, temp_config):
|
||||
mock_tmpl = MagicMock()
|
||||
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
|
||||
mock_env.get_template.return_value = mock_tmpl
|
||||
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["access_classes"]["full"]["private_key"] = "test-key"
|
||||
cfg["peers"]["alice"] = {
|
||||
"access_class": "full",
|
||||
"public_key": "pub1",
|
||||
"private_key": "priv1",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
}
|
||||
|
||||
result = wireguard.generate_class_conf(cfg, "full")
|
||||
assert result is not None
|
||||
assert mock_tmpl.render.call_count == 1
|
||||
call_kwargs = mock_tmpl.render.call_args.kwargs
|
||||
assert call_kwargs["interface"]["name"] == "wg-full"
|
||||
assert call_kwargs["interface"]["private_key"] == "test-key"
|
||||
assert "alice" in call_kwargs["peers"]
|
||||
|
||||
def test_raises_when_no_private_key(self, temp_config):
|
||||
cfg = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
},
|
||||
"access_classes": {
|
||||
"full": {
|
||||
"name": "Full LAN Access",
|
||||
"description": "Full access",
|
||||
"subnet": "10.137.0.0/24",
|
||||
"listen_port": 51820,
|
||||
"lan_access": True,
|
||||
},
|
||||
},
|
||||
"peers": {
|
||||
"alice": {
|
||||
"access_class": "full",
|
||||
"public_key": "pub1",
|
||||
},
|
||||
},
|
||||
}
|
||||
wireguard.save_config(cfg)
|
||||
cfg = wireguard.get_config()
|
||||
with pytest.raises(ValueError, match="no private key"):
|
||||
wireguard.generate_class_conf(cfg, "full")
|
||||
|
||||
|
||||
class TestGetPeers:
|
||||
def test_empty_peers(self, temp_config):
|
||||
peers = wireguard.get_peers()
|
||||
@@ -238,12 +331,190 @@ class TestStatus:
|
||||
class TestGenerateWgShowParser:
|
||||
def test_parses_peer_output(self):
|
||||
output = (
|
||||
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||
"interface: wg0\n"
|
||||
" public key: IFACE-PUB\n"
|
||||
" listening port: 51820\n"
|
||||
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||
)
|
||||
result = wireguard._parse_wg_show(output)
|
||||
assert "PUBKEY1" in result
|
||||
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
|
||||
result = wireguard._parse_wg_show_output(output)
|
||||
assert result["up"] is True
|
||||
assert result["interface"]["public_key"] == "IFACE-PUB"
|
||||
assert result["interface"]["listen_port"] == 51820
|
||||
assert len(result["peers"]) == 1
|
||||
assert result["peers"][0]["public_key"] == "PUBKEY1"
|
||||
assert result["peers"][0]["endpoint"] == "203.0.113.1:51820"
|
||||
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
|
||||
|
||||
def test_empty_output(self):
|
||||
result = wireguard._parse_wg_show("")
|
||||
assert result == {}
|
||||
result = wireguard._parse_wg_show_output("")
|
||||
assert result["up"] is False
|
||||
assert result["peers"] == []
|
||||
|
||||
|
||||
class TestAccessClasses:
|
||||
def test_default_config_has_access_classes(self):
|
||||
cfg = wireguard.DEFAULT_CONFIG
|
||||
assert "access_classes" in cfg
|
||||
assert "full" in cfg["access_classes"]
|
||||
assert "internet" in cfg["access_classes"]
|
||||
|
||||
def test_ensure_access_classes_empty(self, temp_config):
|
||||
cfg = {"interface": {}, "access_classes": {}, "peers": {}}
|
||||
wireguard._ensure_access_classes(cfg)
|
||||
assert "full" in cfg["access_classes"]
|
||||
assert "internet" in cfg["access_classes"]
|
||||
|
||||
def test_ensure_access_classes_preserves_existing(self, temp_config):
|
||||
cfg = {
|
||||
"interface": {},
|
||||
"access_classes": {"custom": {"name": "Custom"}},
|
||||
"peers": {},
|
||||
}
|
||||
wireguard._ensure_access_classes(cfg)
|
||||
assert "custom" in cfg["access_classes"]
|
||||
assert "full" not in cfg["access_classes"]
|
||||
|
||||
def test_ensure_class_defaults_adds_phase2_fields(self, temp_config):
|
||||
cfg = {"interface": {}, "access_classes": {"old": {"name": "Old"}}, "peers": {}}
|
||||
wireguard._ensure_access_classes(cfg)
|
||||
c = cfg["access_classes"]["old"]
|
||||
assert "subnet" in c
|
||||
assert "listen_port" in c
|
||||
assert "lan_access" in c
|
||||
assert "private_key" in c
|
||||
assert "public_key" in c
|
||||
|
||||
|
||||
class TestAddPeerWithNewFields:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_add_peer_with_description_and_access_class(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
result = wireguard.add_peer(
|
||||
"test-peer",
|
||||
description="Test peer",
|
||||
access_class="full",
|
||||
)
|
||||
assert result["description"] == "Test peer"
|
||||
assert result["access_class"] == "full"
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["peers"]["test-peer"]["description"] == "Test peer"
|
||||
assert cfg["peers"]["test-peer"]["access_class"] == "full"
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_update_peer_preserves_existing_fields(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
wireguard.add_peer("p1", description="original", access_class="full")
|
||||
wireguard.add_peer("p1", endpoint="1.2.3.4:51820")
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["peers"]["p1"]["endpoint"] == "1.2.3.4:51820"
|
||||
assert cfg["peers"]["p1"]["description"] == "original"
|
||||
assert cfg["peers"]["p1"]["access_class"] == "full"
|
||||
|
||||
|
||||
class TestInterfaceHasNewFields:
|
||||
def test_default_has_server_endpoint(self):
|
||||
assert wireguard.DEFAULT_CONFIG["interface"].get("server_endpoint") == ""
|
||||
|
||||
def test_default_has_description(self):
|
||||
assert wireguard.DEFAULT_CONFIG["interface"].get("description") == ""
|
||||
|
||||
|
||||
class TestClassKeyGeneration:
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_generates_keypair_for_class(self, mock_run, temp_config):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="class-priv\n"),
|
||||
MagicMock(returncode=0, stdout="class-pub\n"),
|
||||
]
|
||||
# Create config with a fresh class (no prior keys, no auto-merge from file)
|
||||
cfg = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"private_key": "existing",
|
||||
"public_key": "pub",
|
||||
},
|
||||
"access_classes": {"fresh": {"name": "Fresh", "description": "New class"}},
|
||||
"peers": {},
|
||||
}
|
||||
wireguard.save_config(cfg)
|
||||
priv, pub = wireguard.generate_class_keypair("fresh")
|
||||
assert priv == "class-priv"
|
||||
assert pub == "class-pub"
|
||||
loaded = wireguard.get_config()
|
||||
assert loaded["access_classes"]["fresh"]["private_key"] == "class-priv"
|
||||
assert loaded["access_classes"]["fresh"]["public_key"] == "class-pub"
|
||||
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_returns_existing_keys(self, mock_run, temp_config):
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["access_classes"]["full"]["private_key"] = "existing-priv"
|
||||
cfg["access_classes"]["full"]["public_key"] = "existing-pub"
|
||||
wireguard.save_config(cfg)
|
||||
priv, pub = wireguard.generate_class_keypair("full")
|
||||
assert priv == "existing-priv"
|
||||
assert pub == "existing-pub"
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_raises_for_missing_class(self, temp_config):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
wireguard.generate_class_keypair("nonexistent")
|
||||
|
||||
|
||||
class TestGetPeerStatusMultiInterface:
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_aggregates_peers_across_classes(self, mock_run, temp_config):
|
||||
def side_effect(cmd, **kwargs):
|
||||
iface = cmd[-1]
|
||||
if iface == "wg-full":
|
||||
return MagicMock(
|
||||
returncode=0,
|
||||
stdout="interface:\n public key: FULL-PUB\n\npeer: PUB1\n",
|
||||
)
|
||||
if iface == "wg-internet":
|
||||
return MagicMock(
|
||||
returncode=0,
|
||||
stdout="interface:\n public key: INT-PUB\n\npeer: PUB2\n",
|
||||
)
|
||||
# Legacy interface
|
||||
if iface == "wg0":
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["access_classes"]["full"]["private_key"] = "full-priv"
|
||||
cfg["access_classes"]["internet"]["private_key"] = "int-priv"
|
||||
wireguard.save_config(cfg)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
peers = wireguard.get_peer_status()
|
||||
assert len(peers) == 2
|
||||
assert peers[0]["public_key"] == "PUB1"
|
||||
assert peers[0]["access_class"] == "full"
|
||||
assert peers[1]["public_key"] == "PUB2"
|
||||
assert peers[1]["access_class"] == "internet"
|
||||
|
||||
|
||||
class TestApplyClass:
|
||||
@patch("lib.wireguard.run")
|
||||
@patch("lib.wireguard.run_proc")
|
||||
@patch("lib.wireguard.ENV")
|
||||
def test_apply_class_writes_and_up(
|
||||
self, mock_env, mock_proc, mock_run, temp_config
|
||||
):
|
||||
mock_tmpl = MagicMock()
|
||||
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
|
||||
mock_env.get_template.return_value = mock_tmpl
|
||||
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["access_classes"]["full"]["private_key"] = "test-key"
|
||||
cfg["peers"]["alice"] = {
|
||||
"access_class": "full",
|
||||
"public_key": "pub1",
|
||||
"private_key": "priv1",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
}
|
||||
wireguard.save_config(cfg)
|
||||
|
||||
wireguard.apply_class("full")
|
||||
# Should call wg-quick up
|
||||
mock_run.assert_any_call(["wg-quick", "up", "wg-full"], sudo=True)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests for WebSocket subprotocol token extraction (daemon.server)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.server import _extract_ws_token
|
||||
|
||||
# Realistic-looking access token (base64url header.payload.signature).
|
||||
TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.Y3WNO0CTxb4dlcVCPjUnQi"
|
||||
|
||||
|
||||
class TestExtractWsToken:
|
||||
def test_raw_jwt_subprotocol(self):
|
||||
"""Bundled client path: the JWT is sent as the subprotocol name itself."""
|
||||
assert _extract_ws_token([TOKEN]) == (TOKEN, TOKEN)
|
||||
|
||||
def test_raw_jwt_among_other_subprotocols(self):
|
||||
assert _extract_ws_token(["vacuum-wall", TOKEN]) == (TOKEN, TOKEN)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
["none", "vacuum-wall", "a.b", "a.b.c.d"],
|
||||
)
|
||||
def test_no_auth_subprotocol(self, raw):
|
||||
assert _extract_ws_token([raw]) == (None, None)
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _extract_ws_token([]) == (None, None)
|
||||
|
||||
def test_bearer_prefix_form(self):
|
||||
"""Legacy non-browser form: 'Bearer <token>' subprotocol."""
|
||||
assert _extract_ws_token([f"Bearer {TOKEN}"]) == (TOKEN, f"Bearer {TOKEN}")
|
||||
|
||||
def test_bearer_with_non_jwt_token(self):
|
||||
assert _extract_ws_token(["Bearer abc123"]) == ("abc123", "Bearer abc123")
|
||||
|
||||
def test_bearer_without_token_ignored(self):
|
||||
assert _extract_ws_token(["Bearer ", "Bearer"]) == (None, None)
|
||||
|
||||
def test_jwt_like_name_preferred_over_bearer(self):
|
||||
"""A JWT-shaped subprotocol wins even when listed after a Bearer one."""
|
||||
result = _extract_ws_token([f"Bearer {TOKEN}", TOKEN])
|
||||
assert result == (TOKEN, TOKEN)
|
||||
|
||||
def test_subprotocol_with_space_rejected(self):
|
||||
"""A space is not an RFC 6455 token character — 'Bearer <token>' must
|
||||
go through the Bearer branch, never the JWT-shape branch."""
|
||||
assert _extract_ws_token(["a b.c.d"]) == (None, None)
|
||||
@@ -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()
|
||||
Vendored
Vendored
-8326
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
acme-3.1.3.sh
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
// htm mini (no caching) — https://github.com/developit/htm
|
||||
// Vendored from: htm@3.1.1/mini/index.module.js
|
||||
// License: Apache-2.0
|
||||
export default function(n){for(var l,e,s=arguments,t=1,r="",u="",a=[0],c=function(n){1===t&&(n||(r=r.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(n?s[n]:r):3===t&&(n||r)?(a[1]=n?s[n]:r,t=2):2===t&&"..."===r&&n?a[2]=Object.assign(a[2]||{},s[n]):2===t&&r&&!n?(a[2]=a[2]||{})[r]=!0:t>=5&&(5===t?((a[2]=a[2]||{})[e]=n?r?r+s[n]:s[n]:r,t=6):(n||r)&&(a[2][e]+=n?r+s[n]:r)),r=""},h=0;h<n.length;h++){h&&(1===t&&c(),c(h));for(var i=0;i<n[h].length;i++)l=n[h][i],1===t?"<"===l?(c(),a=[a,"",null],t=3):r+=l:4===t?"--"===r&&">"===l?(t=1,r=""):r=l+r[0]:u?l===u?u="":r+=l:'"'===l||"'"===l?u=l:">"===l?(c(),t=1):t&&("="===l?(t=5,e=r,r=""):"/"===l&&(t<5||">"===n[h][i+1])?(c(),3===t&&(a=a[0]),t=a,(a=a[0]).push(this.apply(null,t.slice(1))),t=0):" "===l||"\t"===l||"\n"===l||"\r"===l?(c(),t=2):r+=l),3===t&&"!--"===r&&(t=4,a=a[0])}return c(),a.length>2?a.slice(1):a[1]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
htm-3.1.1.js
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Authentication API blueprint.
|
||||
|
||||
Exposed at /api/auth/* and delegates all operations to vacuum-walld.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import Conflict, delete, get, post
|
||||
from daemon.iface import (
|
||||
DELETE_AUTH_USER,
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||
GET_AUTH_SESSION,
|
||||
GET_AUTH_USERS,
|
||||
GET_AUTH_WEBAUTHN_CAPABLE,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIALS,
|
||||
POST_AUTH_LOGIN,
|
||||
POST_AUTH_LOGOUT,
|
||||
POST_AUTH_PASSWORD,
|
||||
POST_AUTH_REFRESH,
|
||||
POST_AUTH_USER_CREATE,
|
||||
POST_AUTH_USER_UPDATE,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""Authenticate user with username and password.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/login
|
||||
Body:
|
||||
{ "username": "admin", "password": "secretpass" }
|
||||
Returns:
|
||||
{ "tokens": { "access_token": "...", "refresh_token": "..." },
|
||||
"user": { "id": 1, "username": "admin" },
|
||||
"permissions": { ... } }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
||||
return _ok(post(POST_AUTH_LOGIN, body))
|
||||
except Exception as exc:
|
||||
logger.error("Login failed: %s", exc)
|
||||
return _error(str(exc), 401)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""Invalidate current session by blacklisting access and refresh tokens.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/logout
|
||||
Body:
|
||||
{ "refresh_token": "..." } -- client-provided refresh token
|
||||
Returns:
|
||||
{ "ok": true }
|
||||
"""
|
||||
try:
|
||||
client_body = request.get_json(silent=True) or {}
|
||||
body = {
|
||||
**(request._user_ctx or {}),
|
||||
"refresh_token": client_body.get("refresh_token"),
|
||||
}
|
||||
return _ok(post(POST_AUTH_LOGOUT, body))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Logout failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/refresh", methods=["POST"])
|
||||
def refresh():
|
||||
"""Rotate tokens using a refresh token.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/refresh
|
||||
Body:
|
||||
{ "refresh_token": "..." }
|
||||
Returns:
|
||||
{ "tokens": { ... }, "user": { ... }, "permissions": { ... } }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
return _ok(post(POST_AUTH_REFRESH, body))
|
||||
except Exception as exc:
|
||||
logger.error("Token refresh failed: %s", exc)
|
||||
return _error(str(exc), 401)
|
||||
|
||||
|
||||
@bp.route("/session", methods=["GET"])
|
||||
def session():
|
||||
"""Return current user session info.
|
||||
|
||||
Endpoint:
|
||||
GET /api/auth/session
|
||||
Returns:
|
||||
{ "user": { ... }, "permissions": { ... } }
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_AUTH_SESSION, {**(request._user_ctx or {})}))
|
||||
except Exception as exc:
|
||||
logger.error("Session check failed: %s", exc)
|
||||
return _error(str(exc), 401)
|
||||
|
||||
|
||||
@bp.route("/password", methods=["POST"])
|
||||
def change_password():
|
||||
"""Change own password.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/password
|
||||
Body:
|
||||
{ "oldPassword": "...", "newPassword": "..." }
|
||||
Returns:
|
||||
{ "ok": true }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
user_ctx = getattr(request, "_user_ctx", None)
|
||||
if user_ctx is not None:
|
||||
body["username"] = user_ctx["username"]
|
||||
return _ok(post(POST_AUTH_PASSWORD, body))
|
||||
except Exception as exc:
|
||||
logger.error("Password change failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
|
||||
@bp.route("/users", methods=["GET"])
|
||||
def list_users():
|
||||
"""List all users.
|
||||
|
||||
Endpoint:
|
||||
GET /api/auth/users
|
||||
Returns:
|
||||
{ "users": [{ "id": 1, "username": "...", "permissions": { ... }, ... }] }
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_AUTH_USERS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("List users failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/users", methods=["POST"])
|
||||
def create_user():
|
||||
"""Create a new user.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/users
|
||||
Body:
|
||||
{ "username": "...", "password": "...", "permissions": { ... } }
|
||||
Returns:
|
||||
{ "ok": true, "id": ..., "username": "...", "permissions": { ... } }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
return _ok(post(POST_AUTH_USER_CREATE, body))
|
||||
except Conflict as exc:
|
||||
return _error(str(exc), 409)
|
||||
except Exception as exc:
|
||||
logger.error("Create user failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
|
||||
@bp.route("/users/<username>", methods=["POST"])
|
||||
def update_user(username: str):
|
||||
"""Update user permissions.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/users/<username>
|
||||
Body:
|
||||
{ "permissions": { ... } }
|
||||
Returns:
|
||||
{ "ok": true, "id": ..., "username": "..." }
|
||||
"""
|
||||
try:
|
||||
body = {**(request.get_json(silent=True) or {}), "username": username}
|
||||
return _ok(post(POST_AUTH_USER_UPDATE, body))
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
status = 404 if "not found" in err.lower() else 400
|
||||
logger.error("Update user failed: %s", exc)
|
||||
return _error(err, status)
|
||||
|
||||
|
||||
@bp.route("/users/<username>", methods=["DELETE"])
|
||||
def delete_user(username: str):
|
||||
"""Delete a user.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/auth/users/<username>
|
||||
Returns:
|
||||
{ "ok": true }
|
||||
"""
|
||||
# Prevent self-deletion
|
||||
user_ctx = getattr(request, "_user_ctx", None)
|
||||
if user_ctx is not None and user_ctx.get("username") == username:
|
||||
return _error("Cannot delete your own account", 403)
|
||||
try:
|
||||
return _ok(delete(DELETE_AUTH_USER, {"username": username}))
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
status = 404 if "not found" in err.lower() else 400
|
||||
logger.error("Delete user failed: %s", exc)
|
||||
return _error(err, status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebAuthn routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_webauthn_origin() -> tuple[str, str]:
|
||||
"""Extract WebAuthn origin and rp_id from the current request.
|
||||
|
||||
Returns (origin, rp_id) derived from the actual request, falling back
|
||||
to config values when the request metadata is unavailable.
|
||||
"""
|
||||
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
host = request.headers.get("X-Forwarded-Host", request.host.split(":")[0])
|
||||
origin = f"{scheme}://{host}"
|
||||
# rp_id is the registered domain (strip port numbers)
|
||||
rp_id = host.split(":")[0]
|
||||
return origin, rp_id
|
||||
|
||||
|
||||
@bp.route("/webauthn/capable", methods=["GET"])
|
||||
def webauthn_capable():
|
||||
"""Check if WebAuthn is available on the current request domain.
|
||||
|
||||
Endpoint:
|
||||
GET /api/auth/webauthn/capable
|
||||
Returns:
|
||||
{ "enabled": true/false, "rp_id": "...", "rp_name": "...", "origin": "..." }
|
||||
or { "enabled": false, "reason": "..." }
|
||||
"""
|
||||
try:
|
||||
origin, rp_id = _resolve_webauthn_origin()
|
||||
body = {"webauthn_origin": origin, "webauthn_rp_id": rp_id}
|
||||
return _ok(get(GET_AUTH_WEBAUTHN_CAPABLE, body))
|
||||
except Exception as exc:
|
||||
logger.error("WebAuthn capable check failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/webauthn/register-begin", methods=["POST"])
|
||||
def webauthn_register_begin():
|
||||
"""Begin WebAuthn registration.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/webauthn/register-begin
|
||||
Returns:
|
||||
Registration options for navigator.credentials.create()
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
user_ctx = getattr(request, "_user_ctx", None)
|
||||
if user_ctx is not None:
|
||||
body["username"] = user_ctx["username"]
|
||||
origin, rp_id = _resolve_webauthn_origin()
|
||||
body["webauthn_origin"] = origin
|
||||
body["webauthn_rp_id"] = rp_id
|
||||
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_BEGIN, body))
|
||||
except Exception as exc:
|
||||
logger.error("WebAuthn register begin failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
|
||||
@bp.route("/webauthn/register-finish", methods=["POST"])
|
||||
def webauthn_register_finish():
|
||||
"""Finish WebAuthn registration.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/webauthn/register-finish
|
||||
Body:
|
||||
{ "credential_response": {...}, "registration_options": {...}, "name": "..." }
|
||||
Returns:
|
||||
{ "ok": true, "credential": {...} }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
user_ctx = getattr(request, "_user_ctx", None)
|
||||
if user_ctx is not None:
|
||||
body["username"] = user_ctx["username"]
|
||||
origin, rp_id = _resolve_webauthn_origin()
|
||||
body["webauthn_origin"] = origin
|
||||
body["webauthn_rp_id"] = rp_id
|
||||
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_FINISH, body))
|
||||
except Exception as exc:
|
||||
logger.error("WebAuthn register finish failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
|
||||
@bp.route("/webauthn/authenticate-begin", methods=["POST"])
|
||||
def webauthn_authenticate_begin():
|
||||
"""Begin WebAuthn authentication (public endpoint).
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/webauthn/authenticate-begin
|
||||
Body:
|
||||
{ "username": "..." }
|
||||
Returns:
|
||||
Authentication options for navigator.credentials.get()
|
||||
or { "no_webauthn": true } if user has no credentials.
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
_, rp_id = _resolve_webauthn_origin()
|
||||
body["webauthn_rp_id"] = rp_id
|
||||
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, body))
|
||||
except Exception as exc:
|
||||
logger.error("WebAuthn authenticate begin failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
|
||||
@bp.route("/webauthn/authenticate-finish", methods=["POST"])
|
||||
def webauthn_authenticate_finish():
|
||||
"""Finish WebAuthn authentication (public endpoint).
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/webauthn/authenticate-finish
|
||||
Body:
|
||||
{ "username": "...", "assertion_response": {...}, "auth_options": {...} }
|
||||
Returns:
|
||||
{ "tokens": {...}, "user": {...}, "permissions": {...} }
|
||||
"""
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
||||
origin, rp_id = _resolve_webauthn_origin()
|
||||
body["webauthn_origin"] = origin
|
||||
body["webauthn_rp_id"] = rp_id
|
||||
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
|
||||
except Exception as exc:
|
||||
logger.error("WebAuthn authenticate finish failed: %s", exc)
|
||||
return _error(str(exc), 401)
|
||||
|
||||
|
||||
@bp.route("/webauthn/credentials", methods=["GET"])
|
||||
def webauthn_credentials_list():
|
||||
"""List registered WebAuthn credentials.
|
||||
|
||||
Endpoint:
|
||||
GET /api/auth/webauthn/credentials
|
||||
Returns:
|
||||
{ "credentials": [...] }
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIALS, {**(request._user_ctx or {})}))
|
||||
except Exception as exc:
|
||||
logger.error("List WebAuthn credentials failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/webauthn/credential-counts", methods=["GET"])
|
||||
def webauthn_credential_counts():
|
||||
"""Return credential counts for all users.
|
||||
|
||||
Admin endpoint — returns a dict mapping usernames to credential counts.
|
||||
|
||||
Endpoint:
|
||||
GET /api/auth/webauthn/credential-counts
|
||||
Returns:
|
||||
{ "counts": { "username": 2, ... } }
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("List credential counts failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/webauthn/creds/<credential_id>", methods=["DELETE"])
|
||||
def webauthn_remove_credential(credential_id: str):
|
||||
"""Remove a WebAuthn credential.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/auth/webauthn/creds/<credential_id>
|
||||
Returns:
|
||||
{ "ok": true }
|
||||
"""
|
||||
try:
|
||||
return _ok(
|
||||
delete(
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||
{**(request._user_ctx or {}), "credential_id": credential_id},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
status = 404 if "not found" in err.lower() else 400
|
||||
logger.error("Remove WebAuthn credential failed: %s", exc)
|
||||
return _error(err, status)
|
||||
+32
-8
@@ -15,6 +15,7 @@ from daemon.iface import (
|
||||
GET_ACME_INFO,
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_RENEW_STATUS,
|
||||
POST_ACME_ACCOUNT_REGISTER,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
@@ -144,19 +145,21 @@ def issue_status(request_id: str):
|
||||
|
||||
@bp.route("/<domain>/renew", methods=["POST"])
|
||||
def renew_bp(domain: str):
|
||||
"""POST /api/certs/<domain>/renew — renew an existing certificate.
|
||||
|
||||
Args:
|
||||
domain: Domain name whose certificate should be renewed.
|
||||
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
|
||||
|
||||
Returns:
|
||||
Response confirming renewal or an error message.
|
||||
Response containing a renewal request ID (poll it at
|
||||
``/api/certs/renew/<request_id>``) or an error message.
|
||||
"""
|
||||
try:
|
||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||
post(POST_ACME_RENEW, {"domain": domain})
|
||||
logger.info("Certificate renewed for '%s'", domain)
|
||||
return _ok(None)
|
||||
result = post(POST_ACME_RENEW, {"domain": domain})
|
||||
logger.info(
|
||||
"Certificate renewal started for '%s' (id=%s)",
|
||||
domain,
|
||||
result.get("request_id"),
|
||||
)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
|
||||
return _error(str(exc), 400)
|
||||
@@ -165,6 +168,27 @@ def renew_bp(domain: str):
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/renew/<request_id>", methods=["GET"])
|
||||
def renew_status(request_id: str):
|
||||
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
|
||||
|
||||
Args:
|
||||
request_id: Renewal request identifier returned by renew_bp.
|
||||
|
||||
Returns:
|
||||
Response containing renewal status or an error message.
|
||||
"""
|
||||
try:
|
||||
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Renewal request '%s' not found: %s", request_id, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["DELETE"])
|
||||
def remove_bp(domain: str):
|
||||
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
|
||||
|
||||
@@ -19,6 +19,7 @@ from daemon.iface import (
|
||||
POST_DNSMASQ_APPLY,
|
||||
POST_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
POST_DNSMASQ_DOMAIN,
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
)
|
||||
@@ -306,6 +307,36 @@ def add_dns_record_bp():
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS domain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/domain", methods=["POST"])
|
||||
def set_domain_bp():
|
||||
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
|
||||
|
||||
Args:
|
||||
request: JSON body with `domain` field (string or null to clear).
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
|
||||
logger.info("DNS domain updated via API: %s", body.get("domain"))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Set DNS domain rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set DNS domain: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/dns-record/<name>", methods=["DELETE"])
|
||||
def remove_dns_record_bp(name):
|
||||
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
|
||||
|
||||
+128
-38
@@ -7,13 +7,17 @@ import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_BACKENDS_REMOVE,
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_BACKENDS,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_BACKENDS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_BACKENDS_ADD,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
@@ -135,23 +139,16 @@ def list_domains():
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
"""Add a new proxy domain.
|
||||
"""Add a new proxy domain referencing a backend.
|
||||
|
||||
POST /api/proxy/domains
|
||||
|
||||
Body fields (paths mode):
|
||||
Body fields:
|
||||
domain: Domain name.
|
||||
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
|
||||
backend: Backend name to proxy through.
|
||||
cert: Optional certificate type.
|
||||
force_ssl: Optional SSL redirect flag (default ``true``).
|
||||
|
||||
Body fields (legacy mode):
|
||||
domain: Domain name.
|
||||
backend_host: Upstream host.
|
||||
backend_port: Upstream port.
|
||||
backend_proto: Protocol (``http`` or ``https``, default ``http``).
|
||||
cert: Optional certificate type.
|
||||
extra_headers: Optional extra headers dict.
|
||||
auth: Optional domain-level auth override.
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
@@ -160,33 +157,20 @@ def add_domain_bp():
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
backend = body.get("backend", "").strip()
|
||||
if not backend:
|
||||
return _error("'backend' is required", 400)
|
||||
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"backend": backend,
|
||||
"force_ssl": body.get("force_ssl", True),
|
||||
}
|
||||
if body.get("cert") is not None:
|
||||
payload["cert"] = body["cert"]
|
||||
if body.get("auth") is not None:
|
||||
payload["auth"] = body["auth"]
|
||||
|
||||
paths = body.get("paths")
|
||||
if paths is not None:
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"paths": paths,
|
||||
"cert": body.get("cert"),
|
||||
"force_ssl": body.get("force_ssl", True),
|
||||
}
|
||||
else:
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"backend_host": backend_host,
|
||||
"backend_port": int(backend_port),
|
||||
"backend_proto": backend_proto,
|
||||
"cert": cert,
|
||||
"extra_headers": extra_headers,
|
||||
}
|
||||
try:
|
||||
post(POST_NGINX_DOMAINS_ADD, payload)
|
||||
logger.info("Proxy domain added via API: %s", domain)
|
||||
@@ -285,3 +269,109 @@ def test_bp():
|
||||
except RuntimeError as exc:
|
||||
logger.error("nginx config test failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["GET"])
|
||||
def list_backends():
|
||||
"""List all configured backends.
|
||||
|
||||
GET /api/proxy/backends
|
||||
|
||||
Returns:
|
||||
Dict of backend configs with secrets stripped.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NGINX_BACKENDS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list backends: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["PATCH"])
|
||||
def patch_backend_bp():
|
||||
"""Partially update a backend entry.
|
||||
|
||||
PATCH /api/proxy/backends
|
||||
|
||||
Body fields:
|
||||
name: Backend name.
|
||||
label: Optional new label.
|
||||
paths: Optional new paths dict.
|
||||
auth: Optional new auth config.
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
patch(PATCH_NGINX_BACKENDS, body)
|
||||
logger.info("Backend '%s' patched via API", body.get("name"))
|
||||
return _ok({"backend": body.get("name")})
|
||||
except BadRequest as exc:
|
||||
logger.info("Backend patch rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch backend: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/backends", methods=["POST"])
|
||||
def add_backend_bp():
|
||||
"""Add a new backend.
|
||||
|
||||
POST /api/proxy/backends
|
||||
|
||||
Body fields:
|
||||
name: Backend name (slug, unique).
|
||||
label: Human-readable label.
|
||||
paths: Path-to-config map.
|
||||
auth: Optional auth config.
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
post(POST_NGINX_BACKENDS_ADD, body)
|
||||
logger.info("Backend added via API: %s", name)
|
||||
return _ok({"backend": name})
|
||||
except BadRequest as exc:
|
||||
logger.info("Add backend '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add backend '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/backends/<name>", methods=["DELETE"])
|
||||
def remove_backend_bp(name):
|
||||
"""Remove a non-builtin backend.
|
||||
|
||||
DELETE /api/proxy/backends/<name>
|
||||
|
||||
Returns:
|
||||
``{"backend": ...}`` on success.
|
||||
"""
|
||||
try:
|
||||
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
|
||||
logger.info("Backend removed via API: %s", name)
|
||||
return _ok({"backend": name})
|
||||
except BadRequest as exc:
|
||||
logger.info("Remove backend '%s' rejected: %s", name, exc)
|
||||
return _error(str(exc), 400)
|
||||
except Conflict as exc:
|
||||
logger.info("Remove backend '%s' conflict: %s", name, exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove backend '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Aggregate status API blueprint.
|
||||
|
||||
Exposed at /api/status/* and delegates all operations to vacuum-walld.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import get, post
|
||||
from daemon.iface import (
|
||||
GET_STATUS_PENDING,
|
||||
GET_SYSTEM_METRICS,
|
||||
POST_STATUS_APPLY_ALL,
|
||||
POST_STATUS_CANCEL_ALL,
|
||||
POST_STATUS_REFRESH,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("status", __name__)
|
||||
|
||||
|
||||
@bp.route("/pending", methods=["GET"])
|
||||
def pending():
|
||||
"""Retrieve aggregate pending changes across all subsystems.
|
||||
|
||||
Endpoint:
|
||||
GET /api/status/pending
|
||||
|
||||
Returns:
|
||||
JSON response with per-subsystem pending status and total change count.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_STATUS_PENDING))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get pending status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/apply-all", methods=["POST"])
|
||||
def apply_all():
|
||||
"""Apply pending changes for all subsystems in dependency order.
|
||||
|
||||
Endpoint:
|
||||
POST /api/status/apply-all
|
||||
|
||||
Returns:
|
||||
JSON response with applied subsystems list and any errors encountered.
|
||||
"""
|
||||
try:
|
||||
return _ok(post(POST_STATUS_APPLY_ALL))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply all pending changes: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/cancel-all", methods=["POST"])
|
||||
def cancel_all():
|
||||
"""Revert pending changes for all subsystems to the last applied config.
|
||||
|
||||
Endpoint:
|
||||
POST /api/status/cancel-all
|
||||
|
||||
Returns:
|
||||
JSON response with the reverted subsystems, skipped subsystems
|
||||
(label -> reason), and any errors encountered.
|
||||
"""
|
||||
try:
|
||||
return _ok(post(POST_STATUS_CANCEL_ALL))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to cancel all pending changes: %s", exc)
|
||||
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"])
|
||||
def system_metrics():
|
||||
"""Retrieve system-wide metrics.
|
||||
|
||||
Endpoint:
|
||||
GET /api/status/system-metrics
|
||||
|
||||
Returns:
|
||||
JSON response with CPU load, memory usage, and network traffic stats.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_SYSTEM_METRICS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get system metrics: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
+164
-1
@@ -7,15 +7,23 @@ import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_CLASSES,
|
||||
DELETE_WIREGUARD_CLASSES_DOWN,
|
||||
DELETE_WIREGUARD_PEERS_REMOVE,
|
||||
GET_WIREGUARD_CLASS_STATUS,
|
||||
GET_WIREGUARD_CLASSES,
|
||||
GET_WIREGUARD_CONFIG,
|
||||
GET_WIREGUARD_PEER_STATUS,
|
||||
GET_WIREGUARD_PEERS,
|
||||
GET_WIREGUARD_STATUS,
|
||||
PATCH_WIREGUARD_CLASSES,
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_APPLY,
|
||||
POST_WIREGUARD_CLASS_INIT_KEYS,
|
||||
POST_WIREGUARD_CLASSES,
|
||||
POST_WIREGUARD_CLASSES_UP,
|
||||
POST_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_DOWN,
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
@@ -229,6 +237,8 @@ def add_peer_bp():
|
||||
"allowed_ips": body.get("allowed_ips", []),
|
||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||
"preshared_key": body.get("preshared_key"),
|
||||
"description": body.get("description"),
|
||||
"access_class": body.get("access_class"),
|
||||
},
|
||||
)
|
||||
logger.info("WireGuard peer '%s' added via API", name)
|
||||
@@ -337,3 +347,156 @@ def generate_client_bp():
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to generate client config for '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["GET"])
|
||||
def list_classes_bp():
|
||||
"""List all access classes.
|
||||
|
||||
Endpoint: GET /api/wireguard/classes
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_WIREGUARD_CLASSES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list access classes: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["POST"])
|
||||
def create_class_bp():
|
||||
"""Create a new access class.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = post(POST_WIREGUARD_CLASSES, body)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Create access class rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except Conflict as exc:
|
||||
logger.info("Create access class conflict: %s", exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to create access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["PATCH"])
|
||||
def update_class_bp():
|
||||
"""Update an access class.
|
||||
|
||||
Endpoint: PATCH /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = patch(PATCH_WIREGUARD_CLASSES, body)
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Update access class rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Access class not found: %s", exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to update access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes", methods=["DELETE"])
|
||||
def delete_class_bp():
|
||||
"""Delete an access class.
|
||||
|
||||
Endpoint: DELETE /api/wireguard/classes
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return _error("'key' is required", 400)
|
||||
try:
|
||||
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
|
||||
logger.info("Access class '%s' deleted via API", key)
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Access class not found: %s", exc)
|
||||
return _error(str(exc), 404)
|
||||
except Conflict as exc:
|
||||
logger.info("Delete access class conflict: %s", exc)
|
||||
return _error(str(exc), 409)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to delete access class: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/up", methods=["POST"])
|
||||
def class_up_bp(key):
|
||||
"""Bring up a single access class's WireGuard tunnel.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/<key>/up
|
||||
"""
|
||||
try:
|
||||
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' tunnel brought up via API", key)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring up class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/down", methods=["POST"])
|
||||
def class_down_bp(key):
|
||||
"""Bring down a single access class's WireGuard tunnel.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/<key>/down
|
||||
"""
|
||||
try:
|
||||
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' tunnel brought down via API", key)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring down class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes/<key>/status", methods=["GET"])
|
||||
def class_status_bp(key):
|
||||
"""Get status for a single access class's tunnel.
|
||||
|
||||
Endpoint: GET /api/wireguard/classes/<key>/status
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get class '%s' status: %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/classes/keys/<key>", methods=["POST"])
|
||||
def class_init_keys_bp(key):
|
||||
"""Generate key pair for a single access class.
|
||||
|
||||
Endpoint: POST /api/wireguard/classes/keys/<key>
|
||||
"""
|
||||
try:
|
||||
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
|
||||
logger.info("WireGuard class '%s' keys generated via API", key)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
logger.info("Class '%s' not found for keys: %s", key, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to generate keys for class '%s': %s", key, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
+145
-28
@@ -14,18 +14,20 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, request
|
||||
from flask import Flask, abort, jsonify, request
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from daemon.client import get
|
||||
from daemon.iface import GET_STATUS_ALL
|
||||
from lib.auth import validate_token
|
||||
from lib.db import get_db
|
||||
from lib.logging import setup_logging
|
||||
from webui.api.auth import bp as auth_bp
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
from webui.api.logs import bp as logs_bp
|
||||
from webui.api.network import bp as network_bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.status import bp as status_bp
|
||||
from webui.api.wireguard import bp as wireguard_bp
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,6 +89,9 @@ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
|
||||
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
get_db()
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
app.register_blueprint(network_bp, url_prefix="/api/network")
|
||||
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
||||
@@ -94,8 +99,10 @@ app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
||||
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||
app.register_blueprint(status_bp, url_prefix="/api/status")
|
||||
|
||||
BLUEPRINTS = [
|
||||
("auth", auth_bp),
|
||||
("firewall", firewall_bp),
|
||||
("network", network_bp),
|
||||
("dhcp", dhcp_bp),
|
||||
@@ -103,11 +110,125 @@ BLUEPRINTS = [
|
||||
("certs", certs_bp),
|
||||
("wireguard", wireguard_bp),
|
||||
("logs", logs_bp),
|
||||
("status", status_bp),
|
||||
]
|
||||
|
||||
for name, _ in BLUEPRINTS:
|
||||
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
||||
|
||||
# ── Public endpoints (no auth required) ──
|
||||
_AUTH_EXEMPT = {
|
||||
("GET", "/"),
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/refresh"),
|
||||
("POST", "/api/auth/webauthn/authenticate-begin"),
|
||||
("POST", "/api/auth/webauthn/authenticate-finish"),
|
||||
}
|
||||
|
||||
# ── Personal auth routes (operates on own account, no subsystem permission needed) ──
|
||||
# These routes require a valid JWT but do NOT require an "auth" permission entry.
|
||||
# A user with only "firewall:read" can still view session, change password, logout, etc.
|
||||
# Method-agnostic — covers all HTTP methods for future-proofing.
|
||||
_AUTH_PERSONAL_PATHS = (
|
||||
"/api/auth/session",
|
||||
"/api/auth/password",
|
||||
"/api/auth/logout",
|
||||
)
|
||||
|
||||
_AUTH_PERSONAL_PREFIXES = (
|
||||
"/api/auth/webauthn/register-",
|
||||
"/api/auth/webauthn/credentials",
|
||||
"/api/auth/webauthn/creds/",
|
||||
)
|
||||
|
||||
|
||||
def _subsystem_from_path(path: str) -> str | None:
|
||||
"""Extract subsystem name from API path."""
|
||||
if not path.startswith("/api/"):
|
||||
return None
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def _is_personal_auth(method: str, path: str) -> bool:
|
||||
"""Check if route is a personal auth operation (no subsystem permission needed)."""
|
||||
if path in _AUTH_PERSONAL_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _AUTH_PERSONAL_PREFIXES)
|
||||
|
||||
|
||||
def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
|
||||
"""Check if user has permission for subsystem + method."""
|
||||
level = perms.get(subsystem)
|
||||
if method == "GET":
|
||||
return level in ("read", "rw")
|
||||
return level == "rw"
|
||||
|
||||
|
||||
# ── JWT authentication middleware ──
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _auth_middleware():
|
||||
"""Validate JWT from Authorization header for API routes.
|
||||
|
||||
Exempts: static routes, vendor files, and public auth endpoints.
|
||||
Attaches request._user_ctx with user info for downstream handlers.
|
||||
"""
|
||||
method = request.method
|
||||
path = request.path
|
||||
|
||||
# Exempt specific paths
|
||||
if (method, path) in _AUTH_EXEMPT:
|
||||
return
|
||||
if method == "GET" and path.startswith("/vendor/"):
|
||||
return
|
||||
if method in ("GET", "HEAD") and path.startswith("/static/"):
|
||||
return
|
||||
|
||||
# For non-API routes, skip auth
|
||||
if not path.startswith("/api/"):
|
||||
return
|
||||
|
||||
# Extract token from Authorization header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
token_string = auth_header[7:] # strip "Bearer "
|
||||
session_header = request.headers.get("X-Session-Id")
|
||||
if not session_header:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
payload = validate_token(
|
||||
token_string, token_type="access", session_id=session_header
|
||||
)
|
||||
if payload is None:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
user_permissions = payload.get("permissions", {})
|
||||
|
||||
# Check subsystem permissions (skip personal auth routes)
|
||||
subsystem = _subsystem_from_path(path)
|
||||
if subsystem and not _is_personal_auth(method, path):
|
||||
if subsystem not in user_permissions:
|
||||
return jsonify({"ok": False, "error": "forbidden"}), 403
|
||||
if not _has_permission(user_permissions, subsystem, method):
|
||||
return jsonify({"ok": False, "error": "forbidden"}), 403
|
||||
|
||||
request._user_ctx = {
|
||||
"username": username,
|
||||
"permissions": user_permissions,
|
||||
"jti": payload.get("jti"),
|
||||
}
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request logging
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -140,6 +261,26 @@ def _log_request_finish(response):
|
||||
elapsed_ms,
|
||||
)
|
||||
|
||||
# Content Security Policy — prevent inline script execution and XSS
|
||||
# NOTE: connect-src 'self' is safe because all XHR/fetch/WS calls go through
|
||||
# nginx on the same origin. If WS or API routing ever changes to use a
|
||||
# different host/port directly, the CSP must be updated accordingly.
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
# set X-Content-Type-Options to prevent MIME sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Set cache headers: short in dev, long with staleness tolerance in prod
|
||||
if response.content_type.startswith("text/html"):
|
||||
# index.html: always short cache so browser revalidates
|
||||
@@ -155,27 +296,6 @@ def _log_request_finish(response):
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API proxy routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.route("/api/status/all")
|
||||
def api_status_all():
|
||||
"""Return aggregated status from all subsystems.
|
||||
|
||||
Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
|
||||
|
||||
Returns:
|
||||
JSON response with state data for all subsystems.
|
||||
"""
|
||||
try:
|
||||
return {"ok": True, "data": get(GET_STATUS_ALL)}
|
||||
except Exception as exc:
|
||||
logger.warning("Status all failed: %s", exc)
|
||||
return {"ok": False, "error": str(exc)}, 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA entry point — serve index.html for /, 404 for everything else
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
|
||||
@app.route("/")
|
||||
def spa_root():
|
||||
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
||||
scheme = "wss" if request.is_secure else "ws"
|
||||
ws_url = f"{scheme}://{request.host}/ws"
|
||||
html = (SPA_DIR / "index.html").read_text()
|
||||
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
||||
return (SPA_DIR / "index.html").read_text()
|
||||
|
||||
|
||||
@app.route("/vendor/<path:filename>")
|
||||
|
||||
+167
-127
@@ -1,19 +1,24 @@
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
|
||||
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?v=8';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=8';
|
||||
import ZonesPage from '/static/pages/zones.js?v=8';
|
||||
import RulesPage from '/static/pages/rules.js?v=8';
|
||||
import NatPage from '/static/pages/nat.js?v=8';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=8';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=8';
|
||||
import CertsPage from '/static/pages/certs.js?v=8';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=8';
|
||||
import LogsPage from '/static/pages/logs.js?v=8';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=8';
|
||||
import DashboardPage from '/static/pages/dashboard.js';
|
||||
import InterfacesPage from '/static/pages/interfaces.js';
|
||||
import ZonesPage from '/static/pages/zones.js';
|
||||
import RulesPage from '/static/pages/rules.js';
|
||||
import NatPage from '/static/pages/nat.js';
|
||||
import DhcpPage from '/static/pages/dhcp.js';
|
||||
import ProxyPage from '/static/pages/proxy.js';
|
||||
import BackendsPage from '/static/pages/backends.js';
|
||||
import CertsPage from '/static/pages/certs.js';
|
||||
import WireguardPage from '/static/pages/wireguard.js';
|
||||
import LogsPage from '/static/pages/logs.js';
|
||||
import NotFoundPage from '/static/pages/notfound.js';
|
||||
import LoginPage from '/static/pages/login.js';
|
||||
import PasskeysPage from '/static/pages/passkeys.js';
|
||||
import UsersPage from '/static/pages/users.js';
|
||||
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
const _NavBase = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/interfaces', label: 'Interfaces' },
|
||||
{ path: '/zones', label: 'Zones' },
|
||||
@@ -21,117 +26,73 @@ const Nav = [
|
||||
{ path: '/nat', label: 'NAT' },
|
||||
{ path: '/dhcp', label: 'DHCP' },
|
||||
{ path: '/proxy', label: 'Proxy' },
|
||||
{ path: '/backends', label: 'Backends' },
|
||||
{ path: '/certs', label: 'Certs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
{ path: '/passkeys', label: 'Passkeys' },
|
||||
];
|
||||
|
||||
/* ── Model registration ────────────────────────────────────── */
|
||||
modelRegister('status', {
|
||||
subsystem: 'status',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/status/all');
|
||||
function getNav() {
|
||||
const perms = getAuthData()?.permissions;
|
||||
const nav = [..._NavBase];
|
||||
if (perms && perms.auth === 'rw') {
|
||||
nav.push({ path: '/users', label: 'Users' });
|
||||
}
|
||||
return nav;
|
||||
}
|
||||
|
||||
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
||||
modelRegister('auth', createAuthModel());
|
||||
|
||||
/* ── State-backed models ──────────────────────────────────── */
|
||||
/* All state-backed models stream over the WS (snapshot on connect,
|
||||
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
|
||||
* POST /api/status/refresh with a subsystem filter and returns the
|
||||
* subsystem state verbatim — the exact shape the state store holds. */
|
||||
function _stateModelFetch(subsystem) {
|
||||
return async () => {
|
||||
const r = await apiFetch('/api/status/refresh', {
|
||||
method: 'POST',
|
||||
body: { subsystems: [subsystem] },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data;
|
||||
},
|
||||
});
|
||||
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('firewall', {
|
||||
subsystem: 'firewall',
|
||||
fetch: async () => {
|
||||
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
||||
apiFetch('/api/firewall/config'),
|
||||
apiFetch('/api/firewall/zones'),
|
||||
apiFetch('/api/firewall/services'),
|
||||
apiFetch('/api/firewall/interfaces'),
|
||||
apiFetch('/api/firewall/state'),
|
||||
]);
|
||||
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;
|
||||
},
|
||||
});
|
||||
// Each maps to one subsystem in the state store. Model name may differ
|
||||
// from subsystem name (e.g. `network` → `networkd`).
|
||||
const STATE_MODELS = [
|
||||
{ name: 'firewall', subsystem: 'firewall' },
|
||||
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||
{ name: 'nginx', subsystem: 'nginx' },
|
||||
{ name: 'acme', subsystem: 'acme' },
|
||||
{ name: 'wireguard', subsystem: 'wireguard' },
|
||||
{ name: 'network', subsystem: 'networkd' },
|
||||
{ name: 'system', subsystem: 'system' },
|
||||
];
|
||||
|
||||
modelRegister('network', {
|
||||
subsystem: 'networkd',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/network/interfaces');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data || { interfaces: {} };
|
||||
},
|
||||
});
|
||||
for (const { name, subsystem } of STATE_MODELS) {
|
||||
modelRegister(name, {
|
||||
subsystem,
|
||||
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||
fetch: _stateModelFetch(subsystem),
|
||||
});
|
||||
}
|
||||
|
||||
modelRegister('dnsmasq', {
|
||||
subsystem: 'dnsmasq',
|
||||
fetch: async () => {
|
||||
const [cfg, status, leases] = await Promise.allSettled([
|
||||
apiFetch('/api/dhcp/config'),
|
||||
apiFetch('/api/dhcp/status'),
|
||||
apiFetch('/api/dhcp/leases'),
|
||||
]);
|
||||
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 (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', {
|
||||
modelRegister('backends', {
|
||||
subsystem: 'nginx',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/proxy/domains');
|
||||
const r = await apiFetch('/api/proxy/backends');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { domains: r.data || [] };
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
return r.data || {};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,14 +116,26 @@ modelRegister('logs', {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Initial fetch ─────────────────────────────────────────── */
|
||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
|
||||
modelFetch(name);
|
||||
/* ── Initial fetch (after auth check) ───────────────────────── */
|
||||
function fetchInitialData() {
|
||||
// State-backed models: first data arrives via the WS snapshot.
|
||||
// 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');
|
||||
|
||||
/* ── Page map ──────────────────────────────────────────────── */
|
||||
const Pages = {
|
||||
login: LoginPage,
|
||||
dashboard: DashboardPage,
|
||||
interfaces: InterfacesPage,
|
||||
zones: ZonesPage,
|
||||
@@ -170,33 +143,62 @@ const Pages = {
|
||||
nat: NatPage,
|
||||
dhcp: DhcpPage,
|
||||
proxy: ProxyPage,
|
||||
backends: BackendsPage,
|
||||
certs: CertsPage,
|
||||
wireguard: WireguardPage,
|
||||
logs: LogsPage,
|
||||
passkeys: PasskeysPage,
|
||||
users: UsersPage,
|
||||
};
|
||||
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const { path } = this.state;
|
||||
// Auth guard: unauthenticated users see the login page for any
|
||||
// protected route (manual hash entry, back/forward, runtime
|
||||
// expiry). Reactive — the auth model's data mutation re-renders
|
||||
// this function, so the real page appears the instant login
|
||||
// completes.
|
||||
if (path !== '/login' && !isAuthenticated()) {
|
||||
return hComp(LoginPage, '/login');
|
||||
}
|
||||
const name = path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
return hComp(page, this.state.path);
|
||||
return hComp(page, path);
|
||||
},
|
||||
};
|
||||
|
||||
// Set once the bootstrap session check settles (and implicitly on every
|
||||
// later login/logout transition — isAuthenticated flips reactively). Until
|
||||
// then the hashchange clamp below must NOT force unauthenticated hashes to
|
||||
// #/login: a valid-session reload arrives with its 'check' still in flight,
|
||||
// and clamping early would strand the user on login.
|
||||
let authChecked = false;
|
||||
|
||||
window.location.hash || (window.location.hash = router.state.path);
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
const raw = location.hash.slice(1) || '/dashboard';
|
||||
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
|
||||
router.state.path = path;
|
||||
// Keep the URL in sync with the clamped path (loop-safe: the follow-up
|
||||
// hashchange lands on the already-clamped '/login').
|
||||
if (location.hash.slice(1) !== path) location.hash = path;
|
||||
});
|
||||
|
||||
/* ── Sidebar render root ───────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
// No nav when logged out — unauthenticated users get the full-bleed
|
||||
// login page. Reactive: the auth model's terminal transition (logout /
|
||||
// session expiry) re-renders this root back to null.
|
||||
if (!isAuthenticated()) return null;
|
||||
const current = router.state.path;
|
||||
const nav = getNav();
|
||||
return h('div', { class: 'sidebar' },
|
||||
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
||||
h('nav', null,
|
||||
Nav.map(item =>
|
||||
nav.map(item =>
|
||||
Link({
|
||||
path: item.path,
|
||||
class: current === item.path ? 'active' : '',
|
||||
@@ -216,17 +218,55 @@ function MainContent() {
|
||||
}
|
||||
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export function initApp() {
|
||||
export async function initApp() {
|
||||
// Listen for login events to update router state after auth
|
||||
window.addEventListener('auth:login', () => {
|
||||
// Defer to a macrotask: at dispatch time (microtask) the login form's
|
||||
// hash change has not run yet — router.state.path is still '/login'.
|
||||
// The deferred check runs after the hashchange task, so a fresh login
|
||||
// fetches all models. connect() is idempotent (_wsConnect no-ops with
|
||||
// a live connection) and gives the post-login session its WS — today
|
||||
// WS only connects on an authenticated page load (pre-existing gap).
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
if (!router.state.path.startsWith('/login')) {
|
||||
fetchInitialData();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Terminal auth transition — close the WS socket so a same-tab relogin
|
||||
// establishes a fresh connection with the new user's token.
|
||||
window.addEventListener('auth:logout', () => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
// Check auth state BEFORE mounting the shell: an unauthenticated visitor
|
||||
// must never flash the sidebar or a protected page before the redirect
|
||||
// to #/login lands.
|
||||
await modelFetch('auth', { action: 'check' });
|
||||
authChecked = true;
|
||||
if (isAuthenticated()) {
|
||||
// A session restored at bootstrap (or a reload) may leave the URL on
|
||||
// #/login — the auth guard renders the login form for that hash even
|
||||
// when authenticated. Bounce to the default page so a valid session
|
||||
// never strands the user on a stale login screen.
|
||||
if (router.state.path === '/login') {
|
||||
window.location.hash = '/dashboard';
|
||||
}
|
||||
fetchInitialData();
|
||||
setTimeout(connect, 0);
|
||||
} else if (router.state.path !== '/login') {
|
||||
// No valid session — redirect to login before the first paint.
|
||||
window.location.hash = '/login';
|
||||
}
|
||||
|
||||
const sidebarEl = document.getElementById('sidebar');
|
||||
const mainEl = document.getElementById('main');
|
||||
if (sidebarEl && mainEl) {
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
// Defer connect() after the first render microtask settles to prevent
|
||||
// the initial requestUpdate() from triggering a second commit while
|
||||
// the vnode tree is still being finalized.
|
||||
setTimeout(connect, 0);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
+126
-45
@@ -1,19 +1,41 @@
|
||||
/**
|
||||
* Hoover — api.js
|
||||
*
|
||||
* JSON-friendly fetch wrapper with automatic header management.
|
||||
* JSON-friendly fetch wrapper with automatic header management (JWT headers
|
||||
* injected from the auth model) and 401 re-authentication.
|
||||
* Toast notification system with auto-dismiss.
|
||||
* ToastContainer component for rendering queued toasts.
|
||||
* Modal processing guard for async form submissions.
|
||||
*/
|
||||
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { modelFetch } from './model.js?v=7';
|
||||
import { modelFetch } from './model.js';
|
||||
import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
|
||||
import { requestUpdate } from './reactivity.js';
|
||||
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
|
||||
|
||||
/**
|
||||
* Public auth endpoints that may legitimately 401 (bad credentials) while a
|
||||
* valid session exists elsewhere. 401 recovery (refresh → retry → logout)
|
||||
* is skipped for these so a failed login doesn't tear down a live session.
|
||||
*/
|
||||
const _PUBLIC_AUTH_URLS = new Set([
|
||||
'/api/auth/login',
|
||||
'/api/auth/webauthn/authenticate-begin',
|
||||
'/api/auth/webauthn/authenticate-finish',
|
||||
]);
|
||||
|
||||
function _isPublicAuthUrl(url) {
|
||||
return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
*
|
||||
* Automatically sets Content-Type for object bodies, parses JSON
|
||||
* responses, and normalises the result to { ok, data, error, status }.
|
||||
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when a
|
||||
* token is present (read from the auth model). On 401 it runs the auth
|
||||
* model's refresh once and retries; a still-401 retry drives the model to
|
||||
* the terminal logout state (clears storage, redirects to login).
|
||||
*
|
||||
* @param {string} url – Target URL
|
||||
* @param {object} [options] – Fetch options (method, body, headers, …)
|
||||
@@ -21,7 +43,16 @@ import { modelFetch } from './model.js?v=7';
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { method = 'GET', body, ...opts } = options;
|
||||
const headers = { 'Accept': 'application/json', ...opts.headers };
|
||||
// Drop the caller's raw headers so the merged object (with the injected
|
||||
// Authorization / X-Session-Id) always wins when spread into the fetch options.
|
||||
const { headers: _callerHeaders, ...safeOpts } = opts;
|
||||
const headers = { 'Accept': 'application/json', ..._callerHeaders };
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
const auth = getAuthData();
|
||||
if (auth?.session_id) headers['X-Session-Id'] = auth.session_id;
|
||||
}
|
||||
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
@@ -29,12 +60,33 @@ export async function apiFetch(url, options = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
||||
if (opts.signal?.aborted) {
|
||||
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
|
||||
if (safeOpts.signal?.aborted) {
|
||||
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.location.reload();
|
||||
if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
|
||||
await refreshAuth();
|
||||
const auth = getAuthData();
|
||||
if (auth?.token) {
|
||||
headers['Authorization'] = 'Bearer ' + auth.token;
|
||||
headers['X-Session-Id'] = auth.session_id; // rotated — re-read from model
|
||||
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
|
||||
if (retryRes.ok) {
|
||||
const json = await retryRes.json().catch(() => null);
|
||||
return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status };
|
||||
}
|
||||
if (retryRes.status === 401) {
|
||||
// Refresh succeeded but the retry is still 401 — the session is
|
||||
// dead. Drive the model to the terminal logout state; its
|
||||
// onSuccess clears storage and redirects to #/login.
|
||||
modelFetch('auth', { action: 'logout' });
|
||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||
}
|
||||
const json = await retryRes.json().catch(() => null);
|
||||
return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status };
|
||||
}
|
||||
// No token after the refresh — onSuccess already cleared storage
|
||||
// and redirected to #/login.
|
||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||
}
|
||||
const json = await res.json();
|
||||
@@ -65,6 +117,7 @@ const _toastIds = { next: 1 };
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
const id = _toastIds.next++;
|
||||
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
|
||||
requestUpdate();
|
||||
|
||||
if (duration > 0) setTimeout(() => dismissToast(id), duration);
|
||||
return id;
|
||||
@@ -76,26 +129,7 @@ export function toast(message, type = 'info', duration = 4000) {
|
||||
export function dismissToast(id) {
|
||||
const idx = _toasts.findIndex(t => t.id === id);
|
||||
if (idx !== -1) _toasts.splice(idx, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the queued toast notifications.
|
||||
*
|
||||
* @returns {VNode} – Toast container (empty text node when no toasts)
|
||||
*/
|
||||
export function ToastContainer() {
|
||||
if (!_toasts.length) return h('#text', '');
|
||||
|
||||
const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' };
|
||||
|
||||
return h('div', { class: 'toast-container' },
|
||||
..._toasts.map(t =>
|
||||
h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) },
|
||||
h('span', null, t.message),
|
||||
h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'),
|
||||
),
|
||||
),
|
||||
);
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +231,32 @@ export async function poll(opts) {
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized handler wrapper that encapsulates processing guard,
|
||||
* processing state, error handling, and modal re-render.
|
||||
*
|
||||
* Used by any handler not using `apiSubmit`. The async function receives
|
||||
* no arguments and should perform validation (via `throw`), API calls,
|
||||
* success/error toasting, modal closing, and data refreshing.
|
||||
*
|
||||
* @param {function} fn – Async handler function
|
||||
* @returns {function} Wrapped handler
|
||||
*/
|
||||
export function formAction(fn) {
|
||||
return async () => {
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed', 'error');
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action button descriptors for modal form submission.
|
||||
*
|
||||
@@ -208,8 +268,11 @@ export async function poll(opts) {
|
||||
* @param {string} [opts.method] - HTTP method (default: 'POST')
|
||||
* @param {function} [opts.body] - () => object, body builder
|
||||
* @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|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||
* @returns {object[]} Array of action descriptors
|
||||
*/
|
||||
@@ -219,8 +282,8 @@ export function apiSubmit(opts) {
|
||||
method = 'POST',
|
||||
body,
|
||||
validate,
|
||||
confirm,
|
||||
successMsg = 'Saved',
|
||||
refresh,
|
||||
submitText = 'Submit',
|
||||
closeModal,
|
||||
} = opts;
|
||||
@@ -230,22 +293,40 @@ export function apiSubmit(opts) {
|
||||
label: submitText,
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
processing: true,
|
||||
handler: async () => {
|
||||
const b = body ? body() : {};
|
||||
if (validate) {
|
||||
const err = validate(b);
|
||||
if (err) { toast(err, 'error'); return; }
|
||||
}
|
||||
const res = await apiFetch(url, { method, body: b });
|
||||
if (res.ok) {
|
||||
toast(successMsg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (refresh) {
|
||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||
await Promise.all(models.map(m => modelFetch(m)));
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const b = body ? body() : {};
|
||||
if (validate) {
|
||||
const err = validate(b);
|
||||
if (err) { toast(err, 'error'); return; }
|
||||
}
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
if (confirm) {
|
||||
const msg = confirm(b);
|
||||
if (msg) {
|
||||
if (!window.confirm(msg)) return;
|
||||
b.force = true;
|
||||
}
|
||||
}
|
||||
refreshModals();
|
||||
const res = await apiFetch(url, { method, body: b });
|
||||
if (res.ok) {
|
||||
const synced = res.data?.synced;
|
||||
let msg = successMsg;
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
}
|
||||
toast(msg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
// No modelFetch — WS delta updates all affected subsystems.
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Hoover — auth_model.js
|
||||
*
|
||||
* First-class Hoover model for the token/session lifecycle. Single source
|
||||
* of truth for:
|
||||
* - token storage and retrieval (sessionStorage via internal helpers)
|
||||
* - refresh scheduling (TTL timer) and execution
|
||||
* - session validation ('check' action)
|
||||
* - login/logout state transitions
|
||||
* - WS reconnection coordination (refreshAuth for websocket.js)
|
||||
*
|
||||
* Registered at app bootstrap: modelRegister('auth', createAuthModel()).
|
||||
*
|
||||
* Invariants:
|
||||
* - subsystem topic 'auth' is silent — the daemon only broadcasts topics
|
||||
* for its collectors and never emits 'auth', so refreshByTopic() cannot
|
||||
* touch this model. Refresh is timer/401/WS-fail driven only.
|
||||
* - fetch() uses vanilla fetch() — never apiFetch — preventing recursion
|
||||
* (apiFetch 401 → refreshAuth → auth fetch → apiFetch → 401 …).
|
||||
* - This module never imports api.js or websocket.js (would cycle:
|
||||
* websocket.js imports the auth model's exports).
|
||||
* - modelFetch() never rejects — consumers branch on model state
|
||||
* (getAuthToken / isAuthenticated), never on promise rejection.
|
||||
*/
|
||||
|
||||
import { modelFetch, getModel } from './model.js';
|
||||
|
||||
/** Refresh timer handle. Not reactive — only set/cleared in lifecycle hooks. */
|
||||
let _refreshTimer = null;
|
||||
|
||||
/** In-flight refresh guard — prevents concurrent refresh attempts (timer path). */
|
||||
let _refreshing = false;
|
||||
|
||||
/** All-nulls data shape — returned by the 'logout' action and used as the canonical
|
||||
* "not authenticated" state. */
|
||||
const EMPTY = { token: null, refresh: null, session_id: null, user: null, permissions: null, ttl: null };
|
||||
|
||||
/**
|
||||
* Return the model definition object for `modelRegister('auth', ...)`.
|
||||
* @returns {object} Hoover model definition
|
||||
*/
|
||||
export function createAuthModel() {
|
||||
return {
|
||||
// Silent topic — the daemon never broadcasts 'auth', so refreshByTopic()
|
||||
// will never fetch this model. Refresh is timer/401/WS-fail driven only.
|
||||
subsystem: 'auth',
|
||||
defaultData: { ...EMPTY },
|
||||
async fetch(signal, param) {
|
||||
// Param-less calls are treated as 'check' (defensive; refreshByTopic
|
||||
// never reaches this model thanks to subsystem: 'auth').
|
||||
const action = param?.action || 'check';
|
||||
|
||||
if (action === 'check') {
|
||||
const stored = readStorage();
|
||||
if (!stored.access) return null; // no stored session
|
||||
const r = await fetch('/api/auth/session', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer ' + stored.access,
|
||||
'X-Session-Id': stored.session_id || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!r.ok && stored.refresh) {
|
||||
// Stale access token (e.g. page reload/restore: the in-memory
|
||||
// TTL timer is gone and the token may have expired server-side,
|
||||
// while the 7-day refresh token is still in sessionStorage) —
|
||||
// attempt exactly one refresh before treating the session
|
||||
// as dead. A failed refresh falls through to the terminal path.
|
||||
return _doRefresh();
|
||||
}
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
if (!json.ok || !json.data?.user) return null;
|
||||
// Server returns ONLY { user, permissions } — merge verified identity
|
||||
// onto the stored token state.
|
||||
return {
|
||||
token: stored.access,
|
||||
refresh: stored.refresh,
|
||||
session_id: stored.session_id,
|
||||
user: json.data.user,
|
||||
permissions: json.data.permissions,
|
||||
ttl: stored.ttl || 900 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
return _doRefresh();
|
||||
}
|
||||
|
||||
if (action === 'login') {
|
||||
const payload = param.payload;
|
||||
// Defensive: successful logins always carry tokens — a malformed payload
|
||||
// is treated as terminal (null → clear storage + redirect).
|
||||
if (!payload?.tokens?.access_token) return null;
|
||||
return {
|
||||
token: payload.tokens.access_token,
|
||||
refresh: payload.tokens.refresh_token,
|
||||
session_id: payload.tokens.session_id,
|
||||
user: payload.user,
|
||||
permissions: payload.permissions,
|
||||
ttl: (payload.access_ttl || 900) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'logout') {
|
||||
// Intentional logout: fresh all-nulls object so model.data ends up in
|
||||
// the canonical "not authenticated" state (isAuthenticated() → false).
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onSuccess(name, data, param) {
|
||||
if (!data || !data.token) {
|
||||
// Logout, failed check, or failed refresh — all terminal:
|
||||
// clear storage, cancel timer, redirect if needed.
|
||||
clearStorage();
|
||||
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
|
||||
// location.hash includes the '#' — compare against '#/login', not '/login'.
|
||||
if (name === 'auth' && document.location.hash !== '#/login') {
|
||||
document.location.hash = '/login';
|
||||
}
|
||||
// Terminal transition — notify the app to tear down session-scoped resources
|
||||
// (the WS socket; the daemon validates it only at handshake, so it would
|
||||
// otherwise stay open and be reused by a same-tab relogin). The model never
|
||||
// imports websocket.js (would cycle), so teardown is event-driven — the
|
||||
// listener lives in app.js (see Phases 4 and 6).
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
return;
|
||||
}
|
||||
writeStorage(data);
|
||||
scheduleRefresh(data.ttl);
|
||||
// Transition event — fires ONLY for the 'login' action. Rationale:
|
||||
// * 'check' (bootstrap): initApp() already calls fetchInitialData()
|
||||
// and connect() on the authenticated branch — firing the event too
|
||||
// would double the work.
|
||||
// * 'refresh' (TTL timer, apiFetch 401, WS fail×3): the session is
|
||||
// already established; re-firing would re-run fetchInitialData()
|
||||
// on every ~14-minute silent refresh.
|
||||
// The app.js listener defers its path check to a macrotask (see
|
||||
// Phase 6), so it runs after doLogin's hashchange has landed.
|
||||
if (param?.action === 'login') {
|
||||
window.dispatchEvent(new CustomEvent('auth:login', {
|
||||
detail: { permissions: data.permissions },
|
||||
}));
|
||||
}
|
||||
},
|
||||
onFailure(name, error) {
|
||||
// Silent — data stays as-is. Only reachable on a real throw (network error
|
||||
// inside fetch()). The no-token branch above covers the normal failure path.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the token pair via POST /api/auth/refresh using the stored refresh
|
||||
* token. Shared by the 'refresh' action and the 'check' 401 fallback.
|
||||
* @returns {Promise<{token, refresh, session_id, user, permissions, ttl}|null>}
|
||||
* The rotated token state, or null when the refresh token is missing,
|
||||
* invalid, expired, or blacklisted.
|
||||
*/
|
||||
async function _doRefresh() {
|
||||
const stored = readStorage();
|
||||
if (!stored.refresh) return null;
|
||||
const r = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
refresh_token: stored.refresh,
|
||||
session_id: stored.session_id,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const json = await r.json();
|
||||
if (!json.ok || !json.data?.tokens) return null;
|
||||
const t = json.data.tokens;
|
||||
const prev = getModel('auth').data; // fallback for any field the server omits
|
||||
// NOTE: the server mints a NEW session_id on every refresh — the rotated
|
||||
// binding must win over `prev`.
|
||||
return {
|
||||
token: t.access_token,
|
||||
refresh: t.refresh_token,
|
||||
session_id: t.session_id,
|
||||
user: json.data.user ?? prev?.user,
|
||||
permissions: json.data.permissions ?? prev?.permissions,
|
||||
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read stored token state from sessionStorage.
|
||||
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
|
||||
*/
|
||||
function readStorage() {
|
||||
return {
|
||||
access: sessionStorage.getItem('vw:access'),
|
||||
refresh: sessionStorage.getItem('vw:refresh'),
|
||||
session_id: sessionStorage.getItem('vw:session_id'),
|
||||
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist token state to sessionStorage. @param {object} data - auth model data */
|
||||
function writeStorage(data) {
|
||||
sessionStorage.setItem('vw:access', data.token);
|
||||
sessionStorage.setItem('vw:refresh', data.refresh);
|
||||
sessionStorage.setItem('vw:session_id', data.session_id);
|
||||
sessionStorage.setItem('vw:access_ttl', String(data.ttl));
|
||||
sessionStorage.setItem('vw:user', JSON.stringify(data.user));
|
||||
sessionStorage.setItem('vw:permissions', JSON.stringify(data.permissions));
|
||||
}
|
||||
|
||||
/** Remove all stored token state. */
|
||||
function clearStorage() {
|
||||
sessionStorage.removeItem('vw:access');
|
||||
sessionStorage.removeItem('vw:refresh');
|
||||
sessionStorage.removeItem('vw:session_id');
|
||||
sessionStorage.removeItem('vw:access_ttl');
|
||||
sessionStorage.removeItem('vw:user');
|
||||
sessionStorage.removeItem('vw:permissions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a silent refresh TTL − 60s out (min 30s). Resets any pending timer.
|
||||
* @param {number} [ttl] - access token TTL in ms
|
||||
*/
|
||||
function scheduleRefresh(ttl) {
|
||||
if (_refreshTimer) clearTimeout(_refreshTimer);
|
||||
const delay = Math.max((ttl || 900 * 1000) - 60000, 30000);
|
||||
_refreshTimer = setTimeout(() => {
|
||||
if (!_refreshing) {
|
||||
_refreshing = true;
|
||||
modelFetch('auth', { action: 'refresh' }).finally(() => { _refreshing = false; });
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current access token from the auth model.
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function getAuthToken() {
|
||||
return getModel('auth').data?.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated only when the model carries both a token and a user.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAuthenticated() {
|
||||
const d = getModel('auth').data;
|
||||
return !!(d && d.token && d.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a token refresh via the model.
|
||||
* Resolves (never rejects) — callers branch on getAuthToken() afterwards.
|
||||
* modelFetch() returns undefined for unregistered models — normalize to a
|
||||
* resolved promise so the result is always a thenable (defensive: the auth
|
||||
* model is registered at app bootstrap before any consumer can run).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function refreshAuth() {
|
||||
return Promise.resolve(modelFetch('auth', { action: 'refresh' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole auth model data object.
|
||||
* @returns {{token, refresh, session_id, user, permissions, ttl}|null}
|
||||
*/
|
||||
export function getAuthData() {
|
||||
return getModel('auth').data;
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { _compExpandedCache } from './render.js?v=7';
|
||||
import { reactive } from './reactivity.js';
|
||||
import { h } from './vdom.js';
|
||||
import { _compExpandedCache } from './render.js';
|
||||
|
||||
/** Registry of mounted components: key → { state } */
|
||||
const _mounted = new Map();
|
||||
@@ -70,17 +70,19 @@ export function mountComponent(key, renderer) {
|
||||
|
||||
if (entry) {
|
||||
// Re-mount: component already exists with its state.
|
||||
// Don't re-run load — that re-render was triggered by a reactive update.
|
||||
return;
|
||||
// Abort previous in-flight load and re-run.
|
||||
if (entry.abortController) entry.abortController.abort();
|
||||
entry.abortController = null;
|
||||
} else {
|
||||
entry = { state: pd.state };
|
||||
_mounted.set(key, entry);
|
||||
pd.state.error = null;
|
||||
}
|
||||
|
||||
entry = { state: pd.state };
|
||||
_mounted.set(key, entry);
|
||||
|
||||
pd.state.error = null;
|
||||
|
||||
if (pd.load) {
|
||||
Promise.resolve().then(() => pd.load(pd.state));
|
||||
const abortController = new AbortController();
|
||||
entry.abortController = abortController;
|
||||
Promise.resolve().then(() => pd.load(pd.state, abortController));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +96,9 @@ export function unmountComponent(key, renderer) {
|
||||
|
||||
const pd = renderer._pageDef;
|
||||
|
||||
// Abort in-flight load requests so they don't mutate unmounted state
|
||||
if (entry.abortController) entry.abortController.abort();
|
||||
|
||||
if (pd.onUnmount) {
|
||||
try { pd.onUnmount(entry.state); } catch (_) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Hoover — components/applyconfirm.js
|
||||
*
|
||||
* Apply button with cross-subsystem confirmation modal.
|
||||
* Fetches pending changes from /api/status/pending, shows them in an
|
||||
* expandable modal, then applies all via /api/status/apply-all.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { html } from '../html.js';
|
||||
import { reactive } from '../reactivity.js';
|
||||
import { apiFetch, toast } from '../api.js';
|
||||
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
|
||||
|
||||
export const SUBSYSTEM_LIST = [
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
{ key: 'dnsmasq', label: 'DHCP/DNS' },
|
||||
{ key: 'nginx', label: 'Nginx' },
|
||||
{ key: 'wireguard', label: 'WireGuard' },
|
||||
{ key: 'networkd', label: 'Network' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract pending state from a subsystem result.
|
||||
* Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`.
|
||||
*/
|
||||
export function isPending(ss) {
|
||||
return (ss.needs_apply || ss.pending_changes || false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the VNode array for modal rows given pending data and expanded state.
|
||||
*/
|
||||
export function buildRows(pendingData, expanded) {
|
||||
const vnodeList = [];
|
||||
|
||||
for (const sub of SUBSYSTEM_LIST) {
|
||||
const ss = pendingData[sub.key] || {};
|
||||
const changes = ss.changes || [];
|
||||
const hasPending = isPending(ss) && changes.length > 0;
|
||||
const isExpanded = !!expanded[sub.key];
|
||||
|
||||
vnodeList.push(html`<div class="apply-subsystem-row${hasPending ? ' pending' : ''}">
|
||||
<span class="apply-subsystem-name">${sub.label}</span>
|
||||
<span class="apply-subsystem-status${hasPending ? ' pending' : ''}">${hasPending ? changes.length + ' pending changes' : 'Up to date'}</span>
|
||||
${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : ''}">\u25B6</span>` : ''}
|
||||
</div>`);
|
||||
|
||||
if (hasPending && isExpanded) {
|
||||
vnodeList.push(html`<div class="apply-detail-section">${changes.map(c => html`<div class="apply-detail-item">${c.summary || c.detail || c}</div>`)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
return vnodeList;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST apply-all, toast result, close modal. State-store models update from
|
||||
* the daemon's WS delta — no explicit refresh.
|
||||
*/
|
||||
async function doApply(successMsg) {
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast(successMsg, 'success');
|
||||
closeModal();
|
||||
// No modelFetch — WS delta updates all affected subsystems.
|
||||
} else {
|
||||
toast(resp.error || 'Apply failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending state, then open the confirmation modal.
|
||||
*/
|
||||
async function openApplyModal(successMsg) {
|
||||
const pendingResp = await apiFetch('/api/status/pending');
|
||||
if (!pendingResp.ok) {
|
||||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingData = pendingResp.data || {};
|
||||
const totalChanges = pendingData.total_changes || 0;
|
||||
|
||||
const expanded = reactive({});
|
||||
|
||||
openModal((inner) => {
|
||||
const rows = buildRows(pendingData, expanded);
|
||||
|
||||
if (totalChanges === 0) {
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||||
<div class="apply-no-changes">No pending changes to apply.</div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button></div>
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||||
<div class="modal-body">${rows}</div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
|
||||
</div>`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply button with cross-subsystem confirmation modal.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {boolean} props.pending - Whether any subsystem has pending changes
|
||||
* @param {string} [props.label] - Apply button text (default: 'Apply')
|
||||
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
|
||||
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
*/
|
||||
export function ApplyConfirm(props = {}) {
|
||||
const label = props.label || 'Apply';
|
||||
const syncedLabel = props.syncedLabel || 'Synced';
|
||||
const successMsg = props.successMsg || 'All changes applied';
|
||||
|
||||
return h('button', {
|
||||
class: props.cls !== undefined
|
||||
? props.cls
|
||||
: (props.pending ? 'btn btn-primary' : 'btn btn-outline'),
|
||||
'on:click': () => {
|
||||
if (!props.pending) {
|
||||
toast(successMsg || 'All synced', 'info');
|
||||
return;
|
||||
}
|
||||
openApplyModal(successMsg);
|
||||
},
|
||||
}, props.pending ? label : syncedLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST cancel-all, toast result, close modal. State-store models update from
|
||||
* the daemon's WS delta — no explicit refresh.
|
||||
*/
|
||||
async function doCancelAll() {
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const resp = await apiFetch('/api/status/cancel-all', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
const data = resp.data || {};
|
||||
let msg = 'Pending changes cancelled';
|
||||
const nSkipped = Object.keys(data.skipped || {}).length;
|
||||
if (nSkipped) {
|
||||
msg += ` (${nSkipped} skipped: ` +
|
||||
Object.entries(data.skipped).map(([k, v]) => `${k} — ${v}`).join('; ') + ')';
|
||||
}
|
||||
toast(msg, nSkipped ? 'warning' : 'success', nSkipped ? 8000 : undefined);
|
||||
const errs = data.errors || {};
|
||||
const nErrs = Object.keys(errs).length;
|
||||
if (nErrs) {
|
||||
toast('Cancel failed for: ' +
|
||||
Object.entries(errs).map(([k, v]) => `${k} — ${v}`).join('; '),
|
||||
'error', 8000);
|
||||
}
|
||||
closeModal();
|
||||
// No modelFetch — WS delta updates all affected subsystems.
|
||||
} else {
|
||||
toast(resp.error || 'Cancel failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending state, then open the cancel confirmation modal.
|
||||
*/
|
||||
async function openCancelModal() {
|
||||
const pendingResp = await apiFetch('/api/status/pending');
|
||||
if (!pendingResp.ok) {
|
||||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingData = pendingResp.data || {};
|
||||
const totalChanges = pendingData.total_changes || 0;
|
||||
|
||||
const expanded = reactive({});
|
||||
|
||||
openModal((inner) => {
|
||||
if (totalChanges === 0) {
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
|
||||
<div class="apply-no-changes">No pending changes to cancel.</div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Close</button></div>
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = buildRows(pendingData, expanded);
|
||||
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
|
||||
<p>Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.</p>
|
||||
<div class="modal-body">${rows}</div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Keep Changes</button><button class="btn btn-danger" onClick="${() => doCancelAll()}">Cancel All Changes</button></div>
|
||||
</div>`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel button with cross-subsystem confirmation modal.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.label] - Button text (default: 'Cancel All Changes')
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-danger')
|
||||
*/
|
||||
export function CancelConfirm(props = {}) {
|
||||
const label = props.label || 'Cancel All Changes';
|
||||
|
||||
return h('button', {
|
||||
class: props.cls !== undefined ? props.cls : 'btn btn-danger',
|
||||
'on:click': () => openCancelModal(),
|
||||
}, label);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Hoover — auth.js (components)
|
||||
*
|
||||
* Thin ceremony layer over the auth model (hoover/auth_model.js), which is
|
||||
* the single source of truth for token storage, refresh scheduling, session
|
||||
* validation, and login/logout state transitions.
|
||||
*
|
||||
* This file only contains:
|
||||
* - logout() — POST /api/auth/logout then drive the model to terminal
|
||||
* - doLogin() — drive the model through the 'login' action, then navigate
|
||||
* - WebAuthn (passkey) ceremony helpers — not state management
|
||||
*/
|
||||
|
||||
import { modelFetch } from '../model.js';
|
||||
import { getAuthData } from '../auth_model.js';
|
||||
|
||||
/**
|
||||
* Logout: blacklist the current tokens server-side, then drive the model to
|
||||
* the terminal all-nulls state. The model's onSuccess clears storage,
|
||||
* redirects to #/login, and dispatches auth:logout (app.js closes the WS
|
||||
* socket via disconnect() — no explicit WS close here). The server reads
|
||||
* jti/username from the request context; refresh_token is sent in the body
|
||||
* per the blueprint contract (webui/api/auth.py:75).
|
||||
*/
|
||||
export async function logout() {
|
||||
const auth = getAuthData();
|
||||
if (auth?.token) {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer ' + auth.token,
|
||||
'X-Session-Id': auth.session_id || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ refresh_token: auth.refresh || '' }),
|
||||
});
|
||||
} catch { /* ignore — we're clearing everything anyway */ }
|
||||
}
|
||||
modelFetch('auth', { action: 'logout' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a successful login: drive the auth model through the 'login'
|
||||
* action (onSuccess persists the session, schedules the TTL refresh, and
|
||||
* fires auth:login), then navigate.
|
||||
*
|
||||
* @param {object} data — login response data ({ tokens, user, permissions, access_ttl })
|
||||
* @param {string} [redirectPath] — where to navigate after login
|
||||
*/
|
||||
export function doLogin(data, redirectPath = '/dashboard') {
|
||||
modelFetch('auth', { action: 'login', payload: data });
|
||||
window.location.hash = redirectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebAuthn (passkeys) is supported in this browser.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function webauthnSupported() {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
||||
}
|
||||
|
||||
/* ─── Base64url helpers ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Convert base64url string to ArrayBuffer.
|
||||
* @param {string} b64url
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function b64urlToArrayBuffer(b64url) {
|
||||
const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
arr[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return arr.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64url string.
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
function arrayBufferToB64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 0x8000)));
|
||||
}
|
||||
const bin = chunks.join('');
|
||||
return btoa(bin)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/* ─── WebAuthn navigator wrappers ────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start a WebAuthn registration ceremony.
|
||||
*
|
||||
* Calls ``navigator.credentials.create()`` with the provided options,
|
||||
* then returns the credential response as a JSON-serializable dict
|
||||
* suitable for sending to the server.
|
||||
*
|
||||
* @param {object} registrationOptions — options from /webauthn/register-begin
|
||||
* @returns {Promise<object>} credential response (id, rawId, type, response)
|
||||
*/
|
||||
export async function startRegistration(registrationOptions) {
|
||||
if (!webauthnSupported()) {
|
||||
throw new Error('WebAuthn is not supported in this browser');
|
||||
}
|
||||
|
||||
const publicKey = {
|
||||
challenge: b64urlToArrayBuffer(registrationOptions.challenge),
|
||||
rp: registrationOptions.rp,
|
||||
user: {
|
||||
id: b64urlToArrayBuffer(registrationOptions.user.id),
|
||||
name: registrationOptions.user.name,
|
||||
displayName: registrationOptions.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: registrationOptions.pubKeyCredParams,
|
||||
timeout: registrationOptions.timeout,
|
||||
};
|
||||
|
||||
if (registrationOptions.excludeCredentials) {
|
||||
publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({
|
||||
...c,
|
||||
id: b64urlToArrayBuffer(c.id),
|
||||
}));
|
||||
}
|
||||
if (registrationOptions.authenticatorSelection) {
|
||||
publicKey.authenticatorSelection = registrationOptions.authenticatorSelection;
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
|
||||
const { id, rawId, type, response } = credential;
|
||||
|
||||
return {
|
||||
id: arrayBufferToB64url(rawId),
|
||||
rawId: arrayBufferToB64url(rawId),
|
||||
type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
|
||||
attestationObject: arrayBufferToB64url(response.attestationObject),
|
||||
transports: response.getTransports ? response.getTransports() : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a WebAuthn authentication ceremony.
|
||||
*
|
||||
* Calls ``navigator.credentials.get()`` with the provided options,
|
||||
* then returns the assertion response as a JSON-serializable dict.
|
||||
*
|
||||
* @param {object} authenticationOptions — options from /webauthn/authenticate-begin
|
||||
* @returns {Promise<object>} assertion response (id, rawId, type, response)
|
||||
*/
|
||||
export async function startAuthentication(authenticationOptions) {
|
||||
if (!webauthnSupported()) {
|
||||
throw new Error('WebAuthn is not supported in this browser');
|
||||
}
|
||||
|
||||
const publicKey = {
|
||||
challenge: b64urlToArrayBuffer(authenticationOptions.challenge),
|
||||
timeout: authenticationOptions.timeout,
|
||||
userVerification: authenticationOptions.userVerification || 'preferred',
|
||||
};
|
||||
|
||||
if (authenticationOptions.allowCredentials) {
|
||||
publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({
|
||||
...c,
|
||||
id: b64urlToArrayBuffer(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
|
||||
const { id, rawId, type, response } = credential;
|
||||
|
||||
return {
|
||||
id: arrayBufferToB64url(rawId),
|
||||
rawId: arrayBufferToB64url(rawId),
|
||||
type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToB64url(response.authenticatorData),
|
||||
signature: arrayBufferToB64url(response.signature),
|
||||
userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { esc } from '../helpers.js?v=7';
|
||||
import { apiFetch, toast } from '../api.js?v=7';
|
||||
import { modelFetch } from '../model.js?v=7';
|
||||
import { h } from '../vdom.js';
|
||||
import { esc } from '../helpers.js';
|
||||
import { apiFetch, toast } from '../api.js';
|
||||
import { requestUpdate } from '../reactivity.js';
|
||||
|
||||
const _actionPending = new Map();
|
||||
const _confirmPending = new Map();
|
||||
|
||||
export const _deleting = new Set();
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
@@ -63,38 +68,72 @@ 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
|
||||
* models update from the daemon's WS delta — no explicit refresh. When the
|
||||
* response includes a ``synced`` array (list of subsystem names that were
|
||||
* auto-updated), appends them to the success toast.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API DELETE URL
|
||||
* @param {string} props.message - Confirmation prompt text
|
||||
* @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 {object} [props.body] - Optional JSON body to send with DELETE
|
||||
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
|
||||
* @param {function} [props.onComplete] - Callback after successful deletion
|
||||
*/
|
||||
export function ConfirmDelete(props = {}) {
|
||||
const opts = { method: 'DELETE' };
|
||||
if (props.body) opts.body = props.body;
|
||||
return h('button', { class: 'btn btn-sm btn-danger',
|
||||
const deleteKey = props.url + (props.body ? '::' + JSON.stringify(props.body) : '');
|
||||
const pending = _confirmPending.get(deleteKey) || false;
|
||||
|
||||
return h('button', {
|
||||
class: 'btn btn-sm btn-danger',
|
||||
disabled: pending,
|
||||
'on:click': async () => {
|
||||
if (!confirm(props.message)) return;
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
toast(props.success || 'Removed', 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
_confirmPending.set(deleteKey, true);
|
||||
requestUpdate();
|
||||
try {
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
const synced = r.data?.synced;
|
||||
let msg = props.success || 'Removed';
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
}
|
||||
toast(msg, 'success');
|
||||
|
||||
if (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.onComplete) props.onComplete();
|
||||
// No modelFetch — WS delta updates state store models.
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
} finally {
|
||||
_confirmPending.delete(deleteKey);
|
||||
requestUpdate();
|
||||
}
|
||||
}}, props.label || 'Remove');
|
||||
}
|
||||
}, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove'));
|
||||
}
|
||||
|
||||
/**
|
||||
* An action button that POSTs to an API endpoint, toasts on result,
|
||||
* and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||
* An action button that POSTs to an API endpoint and toasts on result.
|
||||
* Supports toggle labels for on/off buttons. State-store models update from
|
||||
* the daemon's WS delta — no explicit refresh. When the response includes a
|
||||
* ``synced`` array (list of subsystem names that were auto-updated), appends
|
||||
* them to the success toast.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API URL
|
||||
@@ -106,7 +145,8 @@ export function ConfirmDelete(props = {}) {
|
||||
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @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 {boolean} [props.disabled] - Disabled state
|
||||
*/
|
||||
@@ -116,25 +156,39 @@ export function ActionButton(props = {}) {
|
||||
? (props.condition ? props.labelOn : props.labelOff)
|
||||
: 'Action');
|
||||
const cls = props.cls || 'btn btn-outline';
|
||||
const pending = _actionPending.get(props.url) || false;
|
||||
|
||||
return h('button', {
|
||||
class: cls,
|
||||
disabled: props.disabled,
|
||||
disabled: !!props.disabled || pending,
|
||||
'on:click': async () => {
|
||||
const body = props.body ? props.body() : undefined;
|
||||
const opts = { method: props.method || 'POST' };
|
||||
if (body !== undefined) opts.body = body;
|
||||
const resp = await apiFetch(props.url, opts);
|
||||
if (resp.ok) {
|
||||
if (props.successMsg) toast(props.successMsg, 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
if (pending) return;
|
||||
_actionPending.set(props.url, true);
|
||||
requestUpdate();
|
||||
try {
|
||||
const body = props.body ? props.body() : undefined;
|
||||
const opts = { method: props.method || 'POST' };
|
||||
if (body !== undefined) opts.body = body;
|
||||
const resp = await apiFetch(props.url, opts);
|
||||
if (resp.ok) {
|
||||
const synced = resp.data?.synced;
|
||||
let msg = props.successMsg || '';
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
if (props.onSuccess) props.onSuccess();
|
||||
// No modelFetch — WS delta updates state store models.
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
} finally {
|
||||
_actionPending.delete(props.url);
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}, label);
|
||||
}, pending ? h('span', { class: 'btn-spinner' }) : label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,18 +310,22 @@ export function ServiceStatus(props = {}) {
|
||||
* @param {string} props.removeUrl - API DELETE URL
|
||||
* @param {string} props.removeMessage - Confirmation prompt text
|
||||
* @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 {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 {boolean} [props.busy] - When true the action button is disabled (in-flight operation)
|
||||
* @param {string} [props.busyLabel] - Label shown while busy (default: editLabel + '…')
|
||||
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
|
||||
*/
|
||||
export function ActionCell(props = {}) {
|
||||
return h('td', null,
|
||||
h('button', {
|
||||
class: props.editCls || 'btn btn-sm btn-outline',
|
||||
style: 'margin-right:4px;',
|
||||
'on:click': props.editClick,
|
||||
}, props.editLabel),
|
||||
disabled: !!props.busy,
|
||||
'on:click': props.busy ? undefined : props.editClick,
|
||||
}, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel),
|
||||
ConfirmDelete({
|
||||
url: props.removeUrl,
|
||||
message: props.removeMessage,
|
||||
@@ -275,6 +333,7 @@ export function ActionCell(props = {}) {
|
||||
refresh: props.removeRefresh,
|
||||
label: props.removeLabel || 'Remove',
|
||||
body: props.removeBody,
|
||||
deleteKey: props.deleteKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { Table } from './data.js?v=7';
|
||||
import { collectLoadingModels } from '../model.js?v=7';
|
||||
import { h } from '../vdom.js';
|
||||
import { Table } from './data.js';
|
||||
import { collectLoadingModels } from '../model.js';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user