Compare commits
16 Commits
bd98830638
...
b673e87c9b
| Author | SHA1 | Date | |
|---|---|---|---|
| b673e87c9b | |||
| 633505e7dc | |||
| b8f20e99d9 | |||
| 687fa8f52f | |||
| 318d7169f7 | |||
| 2f78102090 | |||
| 7abe7700e9 | |||
| 6e814d2827 | |||
| 708b8b5d15 | |||
| 4fc0fb3f72 | |||
| c5813d68b3 | |||
| 593dece92b | |||
| 2874680ffa | |||
| b8c2fa2f24 | |||
| bc72db903c | |||
| 2f215793e9 |
@@ -15,10 +15,16 @@ __pycache__/
|
||||
# Local AI tool config (contains internal hostnames)
|
||||
opencode.json
|
||||
opencode.json.pwenv
|
||||
PLAN.md
|
||||
|
||||
# Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
|
||||
# Node dependencies (debugging only, never committed)
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Runtime artifacts
|
||||
build/
|
||||
config/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
|
||||
SSL proxy / firewall appliance. Python 3.13+ Flask SPA behind nginx reverse proxy.
|
||||
Deploys on Debian 13 (trixie). Serves from repo root by default.
|
||||
|
||||
## Architecture
|
||||
@@ -15,39 +15,65 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ syste
|
||||
|
||||
### 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 project directory and socket. Primary group is the WebUI user's primary 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`.
|
||||
- **WebUI user** (default: repo owner in `--dev` mode): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket.
|
||||
- **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:<group>` with mode `0660`. Project dir is owned by the WebUI user with group-read+execute.
|
||||
- **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:<group>` with mode `0660`.
|
||||
|
||||
### Code Layout
|
||||
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
|
||||
- `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/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, cache engine, batch routing, handler registry.
|
||||
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh.
|
||||
- `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/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`. All `lib/` modules use these instead of defining local helpers.
|
||||
- `lib/*.py` — Backend modules (parsing, config, shared logic). All have full type hints and `__all__` exports. No sudo calls — privilege escalation is handled by `daemon/handlers/*.py`.
|
||||
- `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.
|
||||
- `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`).
|
||||
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
||||
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
|
||||
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||
- `webui/static/` — Vendored frontend libraries (JS + CSS). Flask auto-serves at `/static/`.
|
||||
- `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/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty.
|
||||
|
||||
**No CDN packages.** All frontend libraries (JS and CSS) must be vendored in `webui/static/`. Never reference `unpkg.com`, `cdn.jsdelivr.net`, or similar. To add/update a library, edit the version in `scripts/update-vendor.sh` and run it.
|
||||
### Frontend (hoover)
|
||||
|
||||
| Library | Version | Vendor file | Symlink (active) | CDN source |
|
||||
| ------- | ------- | ------------------------------------|--------------------------------| ---------- |
|
||||
| htmx | 2.0.4 | `vendor/htmx-2.0.4.min.js` | `webui/static/htmx.min.js` | `npm:htmx.org@2.0.4` |
|
||||
| htmx-ext-json-enc | 2.0.0 | `vendor/json-enc-2.0.0.js` | `webui/static/json-enc.js` | `npm:htmx-ext-json-enc@2.0.0` |
|
||||
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.
|
||||
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
|
||||
- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
|
||||
- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object.
|
||||
- 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.
|
||||
|
||||
### 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 also be started as `python -m daemon` 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_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. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user (non-dev mode requires `--user`).
|
||||
`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 live at `PROJECT_DIR/data/acme/`.
|
||||
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`
|
||||
|
||||
## Local Dev
|
||||
|
||||
@@ -55,11 +81,11 @@ All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR
|
||||
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
|
||||
```
|
||||
|
||||
In production the systemd unit runs as the configured service user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking).
|
||||
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
|
||||
|
||||
When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system service commands (`nginx -t`, `nginx -s reload`, `firewall-cmd`, `wg`, `systemctl reload dnsmasq`, etc.). This allows invoking those commands directly in bash to inspect or test live system state during debugging, without relying on the mocked test suite.
|
||||
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking.
|
||||
|
||||
## Blueprint ↔ lib Mapping (Naming Is Not 1:1)
|
||||
## Blueprint ↔ lib Mapping
|
||||
|
||||
| Blueprint | URL prefix | Backend module |
|
||||
|-----------------------|-------------------|------------------|
|
||||
@@ -68,6 +94,8 @@ When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system se
|
||||
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
|
||||
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
||||
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
||||
| `webui/api/network` | `/api/network/` | `lib.network` |
|
||||
| `webui/api/logs` | `/api/logs/` | `lib.logging` |
|
||||
|
||||
## Privileged Operations
|
||||
|
||||
@@ -84,13 +112,10 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common`
|
||||
- `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
|
||||
## Page Routes vs API
|
||||
|
||||
`server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead.
|
||||
|
||||
## Deploy
|
||||
|
||||
`install.sh` is the single deploy script. Run as root, requires `MGMT_DOMAIN`, `MGMT_PASS`, `ACME_EMAIL` env vars.
|
||||
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname.
|
||||
|
||||
## Lint and Tests
|
||||
|
||||
@@ -101,14 +126,14 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
|
||||
```bash
|
||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||
.venv/bin/ruff format lib/ webui/ tests/ # format
|
||||
.venv/bin/python -m pytest tests/ -v # test (212 tests)
|
||||
.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.
|
||||
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below.
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
@@ -120,7 +145,7 @@ Install dev tooling with `pip install -e ".[dev]"`.
|
||||
| `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. Never make silent assumptions about intent, architecture, or requirements.
|
||||
2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
|
||||
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
|
||||
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.
|
||||
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.
|
||||
|
||||
@@ -8,7 +8,6 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
|
||||
- Python 3.13+, Flask 3.x web UI
|
||||
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
|
||||
- acme.sh for ACME certificates (ZeroSSL)
|
||||
- HTMX + Jinja2 templates
|
||||
|
||||
---
|
||||
|
||||
|
||||
+108
-20
@@ -5,14 +5,19 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
|
||||
from daemon.iface import PathLike
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_param_re = re.compile(r"<(\w+)>")
|
||||
|
||||
|
||||
class NotFound(Exception):
|
||||
"""Raised when the daemon returns HTTP 404."""
|
||||
@@ -21,7 +26,11 @@ class NotFound(Exception):
|
||||
|
||||
|
||||
class BadRequest(Exception):
|
||||
"""Raised when the daemon returns HTTP 400."""
|
||||
"""Exception raised when the daemon returns HTTP 400.
|
||||
|
||||
Used to signal client-side input or formatting errors from the
|
||||
daemon for distinction from general failures.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -30,6 +39,15 @@ _DEFAULT_SOCKET = None
|
||||
|
||||
|
||||
def _get_socket_path() -> str:
|
||||
"""Return the daemon Unix socket path.
|
||||
|
||||
Lazily resolves the path from the VACUUM_WALLD_SOCKET environment
|
||||
variable or falls back to data/daemon.sock under the project
|
||||
directory. The result is cached in _DEFAULT_SOCKET.
|
||||
|
||||
Returns:
|
||||
Absolute path string for the daemon socket.
|
||||
"""
|
||||
global _DEFAULT_SOCKET
|
||||
if _DEFAULT_SOCKET is None:
|
||||
import os
|
||||
@@ -44,13 +62,52 @@ def _get_socket_path() -> str:
|
||||
|
||||
|
||||
def set_socket_path(path: str) -> None:
|
||||
"""Override the default daemon socket path.
|
||||
|
||||
Useful for tests that need an alternate socket.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the Unix socket file.
|
||||
"""
|
||||
global _DEFAULT_SOCKET
|
||||
_DEFAULT_SOCKET = path
|
||||
|
||||
|
||||
def _format_path(path: str, params: dict[str, Any] | None) -> str:
|
||||
"""Replace ``<param>`` path segments with URL-encoded values from *params*.
|
||||
|
||||
Args:
|
||||
path: URL path that may contain ``<key>`` placeholders.
|
||||
params: Dict of parameter values to substitute.
|
||||
|
||||
Returns:
|
||||
Path with all ``<key>`` segments replaced by their URL-encoded
|
||||
values. Unmatched placeholders are left unchanged.
|
||||
"""
|
||||
if params is None:
|
||||
return path
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
key = m.group(1)
|
||||
if key in params:
|
||||
return urllib.parse.quote(str(params[key]), safe="")
|
||||
return m.group(0)
|
||||
|
||||
return _param_re.sub(_replace, path)
|
||||
|
||||
|
||||
def _resolve_path(method_or_ep: PathLike, path: str | None = None) -> tuple[str, str]:
|
||||
"""Resolve method/path from an Endpoint tuple or two separate arguments."""
|
||||
if isinstance(method_or_ep, tuple):
|
||||
return (method_or_ep[0], method_or_ep[1])
|
||||
if path is None:
|
||||
raise ValueError("path is required when method is a string")
|
||||
return (method_or_ep, path)
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
method: PathLike,
|
||||
path: str | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
query_params: dict[str, Any] | None = None,
|
||||
socket_path: str | None = None,
|
||||
@@ -58,27 +115,40 @@ def request(
|
||||
) -> dict[str, Any]:
|
||||
"""Make a request to the daemon and return the parsed response body.
|
||||
|
||||
*method* can be an :class:`Endpoint` tuple from :mod:`daemon.iface`,
|
||||
in which case *path* should be omitted.
|
||||
|
||||
For GET requests, query_params are sent as URL query parameters instead
|
||||
of a JSON body. For other methods, json_body is sent as JSON.
|
||||
|
||||
Raises RuntimeError on non-2xx responses or connection errors.
|
||||
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
|
||||
"""
|
||||
resolved_method, resolved_path = _resolve_path(method, path)
|
||||
|
||||
# Substitute <param> segments from body/query params so the daemon
|
||||
# receives a concrete path instead of a template.
|
||||
# Merge body and query params for <param> substitution. query_params
|
||||
# takes precedence on key conflicts, so callers should avoid passing
|
||||
# the same key in both dicts.
|
||||
combined = {**(json_body or {}), **(query_params or {})}
|
||||
formatted_path = _format_path(resolved_path, combined)
|
||||
|
||||
sp = socket_path or _get_socket_path()
|
||||
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{path}"
|
||||
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{formatted_path}"
|
||||
sess = requests_unixsocket.Session()
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"timeout": timeout,
|
||||
}
|
||||
if method == "GET":
|
||||
if resolved_method == "GET":
|
||||
if query_params:
|
||||
kwargs["params"] = query_params
|
||||
else:
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
resp = sess.request(
|
||||
method,
|
||||
resolved_method,
|
||||
url,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -92,7 +162,9 @@ def request(
|
||||
except requests.ConnectionError as exc:
|
||||
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
|
||||
except requests.Timeout as exc:
|
||||
raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc
|
||||
raise RuntimeError(
|
||||
f"Daemon request timed out: {resolved_method} {resolved_path}"
|
||||
) from exc
|
||||
except requests.HTTPError as exc:
|
||||
try:
|
||||
data = resp.json()
|
||||
@@ -109,24 +181,40 @@ def request(
|
||||
return data.get("data")
|
||||
|
||||
|
||||
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""GET request to daemon. Params are sent as URL query parameters."""
|
||||
return request("GET", path, query_params=params, **kwargs)
|
||||
def get(path: PathLike, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""Send a GET request to the daemon.
|
||||
|
||||
*path* can be a :class:`Endpoint` tuple from :mod:`daemon.iface`
|
||||
(e.g., ``GET_FIREWALL_ZONES``), or a plain string path.
|
||||
"""
|
||||
return request(path, query_params=params, **kwargs)
|
||||
|
||||
|
||||
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""POST request to daemon."""
|
||||
return request("POST", path, json_body=body, **kwargs)
|
||||
def post(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""Send a POST request to the daemon.
|
||||
|
||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||
"""
|
||||
return request(path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""PATCH request to daemon."""
|
||||
return request("PATCH", path, json_body=body, **kwargs)
|
||||
def patch(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""Send a PATCH request to the daemon.
|
||||
|
||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||
"""
|
||||
return request(path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""DELETE request to daemon."""
|
||||
return request("DELETE", path, json_body=body, **kwargs)
|
||||
def delete(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""Send a DELETE request to the daemon.
|
||||
|
||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||
"""
|
||||
return request(path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
||||
@@ -135,4 +223,4 @@ def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
||||
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
|
||||
Returns a dict mapping each id to its result.
|
||||
"""
|
||||
return post("/batch", {"ops": ops}, **kwargs)
|
||||
return request("POST", "/batch", json_body={"ops": ops}, **kwargs)
|
||||
|
||||
+188
-58
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
@@ -13,7 +12,21 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_REMOVE,
|
||||
GET_ACME_EMAIL,
|
||||
GET_ACME_INFO,
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_PATHS,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
POST_ACME_RENEW,
|
||||
POST_ACME_SELF_SIGNED,
|
||||
POST_ACME_VALIDATE,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.state import _run_acme
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +50,15 @@ _ISSUANCE_TTL = 300 # seconds to keep completed requests
|
||||
|
||||
@dataclass
|
||||
class IssueStep:
|
||||
"""Single step in a certificate issuance workflow.
|
||||
|
||||
Attributes:
|
||||
name: Machine-readable step identifier (e.g. "issue").
|
||||
label: Human-readable description shown to the user.
|
||||
status: Current state: "pending", "running", "done", or "error".
|
||||
message: Optional detail or error message for the step.
|
||||
"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
status: str = "pending"
|
||||
@@ -45,6 +67,19 @@ class IssueStep:
|
||||
|
||||
@dataclass
|
||||
class IssueRequest:
|
||||
"""Tracked certificate issuance request.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique hex identifier for polling.
|
||||
domain: Target domain for the certificate.
|
||||
email: Optional ACME contact email.
|
||||
webroot: Optional custom webroot path.
|
||||
steps: Ordered list of issuance steps.
|
||||
status: Overall status: "running", "completed", or "failed".
|
||||
created_at: Unix timestamp when request was created.
|
||||
expires_at: Unix timestamp when entry expires from store.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
domain: str
|
||||
email: str | None = None
|
||||
@@ -55,6 +90,7 @@ class IssueRequest:
|
||||
expires_at: float | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize request to a JSON-compatible dictionary."""
|
||||
return {
|
||||
"request_id": self.request_id,
|
||||
"domain": self.domain,
|
||||
@@ -77,57 +113,29 @@ class IssueRequest:
|
||||
# Internal helpers
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
from lib.state import _find_acme
|
||||
|
||||
acme_bin = _find_acme()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, **_ACME_ENVIRON},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output = output + result.stderr if output else result.stderr
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
|
||||
return output
|
||||
|
||||
|
||||
def _find_acme_bin() -> str:
|
||||
"""Return the path to the acme.sh binary."""
|
||||
from lib.state import _find_acme
|
||||
|
||||
return _find_acme()
|
||||
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
"""Read registered contact email from ACME account config."""
|
||||
from lib.acme import _read_acme_email
|
||||
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
"""Return the raw ACME entry from the shared state store."""
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("acme")
|
||||
|
||||
|
||||
def _get_acme_state() -> dict[str, Any]:
|
||||
"""Return the ACME state or empty dict when missing."""
|
||||
ac = _get_state()
|
||||
if ac is None:
|
||||
return {}
|
||||
@@ -213,6 +221,7 @@ def _check_dns_resolves(domain: str) -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_acme_installed() -> tuple[bool, str]:
|
||||
"""Verify acme.sh binary is installed and executable."""
|
||||
try:
|
||||
_find_acme_bin()
|
||||
return True, "acme.sh found"
|
||||
@@ -221,6 +230,7 @@ def _check_acme_installed() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_email_configured() -> tuple[bool, str]:
|
||||
"""Check whether an ACME contact email has been configured."""
|
||||
email = _get_acme_email() or ""
|
||||
if email:
|
||||
return True, f"Contact email configured: {email}"
|
||||
@@ -228,12 +238,14 @@ def _check_email_configured() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_webroot() -> tuple[bool, str]:
|
||||
"""Verify the ACME webroot directory exists and is writable."""
|
||||
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
|
||||
return True, "ACME webroot ready"
|
||||
return False, "ACME webroot not ready or not writable"
|
||||
|
||||
|
||||
def _check_challenge_config() -> tuple[bool, str]:
|
||||
"""Check for the ACME HTTP-01 challenge nginx config file."""
|
||||
from lib.nginx import SITES_DIR
|
||||
|
||||
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
|
||||
@@ -296,14 +308,21 @@ def _validate(domain: str) -> dict[str, Any]:
|
||||
# Routes — status reads from state, mutations call refresh_state
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/list")
|
||||
@registry.register(GET_ACME_LIST)
|
||||
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
||||
"""GET /acme/list — return managed certificates."""
|
||||
ac = _get_acme_state()
|
||||
return ac.get("certs", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/info")
|
||||
@registry.register(GET_ACME_INFO)
|
||||
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
"""GET /acme/info — return details for a single domain certificate.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
NotFoundError: When no certificate exists for domain.
|
||||
"""
|
||||
if not body or "domain" not in body:
|
||||
raise ValueError("'domain' is required")
|
||||
domain = body["domain"]
|
||||
@@ -314,8 +333,13 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
raise NotFoundError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/validate")
|
||||
@registry.register(POST_ACME_VALIDATE)
|
||||
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/validate — run pre-flight checks for a domain.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -324,8 +348,16 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return _validate(domain)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/issue")
|
||||
@registry.register(POST_ACME_ISSUE)
|
||||
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/issue — create a new certificate issuance request.
|
||||
|
||||
Deduplicates in-progress requests. Spawns background task for actual issuance.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
RuntimeError: When pre-flight checks fail.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -379,8 +411,14 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/issue/status")
|
||||
@registry.register(GET_ACME_ISSUE_STATUS)
|
||||
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /acme/issue/status — poll status of an issuance 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")
|
||||
@@ -442,8 +480,16 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/renew")
|
||||
@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.
|
||||
|
||||
Args:
|
||||
force: Force renewal regardless of expiry.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -460,8 +506,13 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/acme/remove")
|
||||
@registry.register(DELETE_ACME_REMOVE)
|
||||
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /acme/remove — remove a certificate from ACME management.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -473,39 +524,53 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/email")
|
||||
@registry.register(POST_ACME_EMAIL)
|
||||
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/email — set the ACME contact email via account registration.
|
||||
|
||||
Raises:
|
||||
ValueError: When email is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
# Persist to declarative ACME config
|
||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
|
||||
_acme_data: dict[str, str] = {}
|
||||
if acme_cfg.is_file():
|
||||
_acme_data = _json.loads(acme_cfg.read_text())
|
||||
_acme_data["email"] = email
|
||||
acme_cfg.write_text(_json.dumps(_acme_data, indent=4) + "\n")
|
||||
logger.info("ACME email set to %s", email)
|
||||
refresh_state(["acme"])
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/email")
|
||||
@registry.register(GET_ACME_EMAIL)
|
||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /acme/email — return the currently configured ACME contact email."""
|
||||
ac = _get_acme_state()
|
||||
email = ""
|
||||
if ac:
|
||||
return {"email": ac.get("email", "")}
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return {"email": match.group(1).strip().strip("'\"")}
|
||||
except OSError:
|
||||
pass
|
||||
return {"email": ""}
|
||||
email = ac.get("email", "")
|
||||
if not email:
|
||||
email = _get_acme_email()
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/paths")
|
||||
@registry.register(GET_ACME_PATHS)
|
||||
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body or "domain" not in body:
|
||||
raise ValueError("'domain' is required")
|
||||
domain = body["domain"]
|
||||
@@ -517,3 +582,68 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
|
||||
"ca": f"{acme_home}/ca.cer",
|
||||
"fullchain": f"{acme_home}/fullchain.cer",
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_ACME_SELF_SIGNED)
|
||||
def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/self-signed — generate a self-signed certificate for a domain.
|
||||
|
||||
Idempotent: skips generation if cert and key already exist.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
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"
|
||||
|
||||
if cert_file.is_file() and key_file.is_file():
|
||||
logger.info("Self-signed cert for %s already exists, skipping", domain)
|
||||
return {
|
||||
"domain": domain,
|
||||
"cert": str(cert_file),
|
||||
"key": str(key_file),
|
||||
"generated": False,
|
||||
}
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
str(key_file),
|
||||
"-out",
|
||||
str(cert_file),
|
||||
"-days",
|
||||
str(days),
|
||||
"-nodes",
|
||||
"-subj",
|
||||
f"/CN={domain}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
cert_file.chmod(0o644)
|
||||
key_file.chmod(0o600)
|
||||
|
||||
logger.info("Self-signed cert for %s generated (%d days)", domain, days)
|
||||
return {
|
||||
"domain": domain,
|
||||
"cert": str(cert_file),
|
||||
"key": str(key_file),
|
||||
"generated": True,
|
||||
}
|
||||
|
||||
+122
-15
@@ -8,6 +8,22 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
|
||||
GET_DNSMASQ_CONFIG,
|
||||
GET_DNSMASQ_LEASES,
|
||||
GET_DNSMASQ_STATUS,
|
||||
PATCH_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_APPLY,
|
||||
POST_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
POST_DNSMASQ_DOMAIN,
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
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
|
||||
|
||||
@@ -35,12 +51,14 @@ DEFAULT_CFG: dict[str, Any] = {
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
"""Retrieve cached dnsmasq state from the state store."""
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("dnsmasq")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
"""Load dnsmasq config from JSON, merging with defaults."""
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
raw = load_json(CONFIG_PATH)
|
||||
if not raw:
|
||||
@@ -49,21 +67,39 @@ def _get_config() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist dnsmasq config to JSON after merging with defaults."""
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||
save_json(CONFIG_PATH, merged)
|
||||
|
||||
|
||||
def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
"""Render dnsmasq.conf from Jinja template and 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,
|
||||
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,
|
||||
@@ -71,6 +107,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _get_dnsmasq_state() -> dict[str, Any]:
|
||||
"""Return cached dnsmasq state, or empty dict if unset."""
|
||||
dm = _get_state()
|
||||
if dm is None:
|
||||
return {}
|
||||
@@ -81,16 +118,26 @@ def _get_dnsmasq_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/config")
|
||||
@registry.register(GET_DNSMASQ_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/config
|
||||
|
||||
Returns cached config if available, otherwise loads from disk.
|
||||
"""
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm:
|
||||
return dm.get("config", {})
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/config")
|
||||
@registry.register(POST_DNSMASQ_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/config
|
||||
|
||||
Save full config. Raises ValueError on missing body.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
@@ -98,8 +145,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/dnsmasq/config")
|
||||
@registry.register(PATCH_DNSMASQ_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: PATCH /dnsmasq/config
|
||||
|
||||
Merge partial update into existing config. Raises ValueError on missing body.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
@@ -109,8 +161,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/apply")
|
||||
@registry.register(POST_DNSMASQ_APPLY)
|
||||
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/apply
|
||||
|
||||
Render config to dnsmasq.conf, write to disk, and reload dnsmasq service via sudo.
|
||||
"""
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
@@ -127,16 +184,26 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/status")
|
||||
@registry.register(GET_DNSMASQ_STATUS)
|
||||
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/status
|
||||
|
||||
Returns cached dnsmasq status object, or empty dict if unavailable.
|
||||
"""
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm and "status" in dm:
|
||||
return dm["status"]
|
||||
return {}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/ranges/add")
|
||||
@registry.register(POST_DNSMASQ_RANGES_ADD)
|
||||
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.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
@@ -179,8 +246,13 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/ranges/remove")
|
||||
@registry.register(DELETE_DNSMASQ_RANGES_REMOVE)
|
||||
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/ranges/remove
|
||||
|
||||
Remove DHCP range matching interface + start + end. Raises NotFoundError if missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
@@ -209,16 +281,26 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/leases")
|
||||
@registry.register(GET_DNSMASQ_LEASES)
|
||||
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/leases
|
||||
|
||||
Returns cached DHCP lease list from state, or empty list if unavailable.
|
||||
"""
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm:
|
||||
return dm.get("leases", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/static-lease/add")
|
||||
@registry.register(POST_DNSMASQ_STATIC_LEASE_ADD)
|
||||
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/static-lease/add
|
||||
|
||||
Add or update a static DHCP lease by MAC address. Raises ValueError on invalid input.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
mac = body.get("mac", "").strip()
|
||||
@@ -245,8 +327,13 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/static-lease/remove")
|
||||
@registry.register(DELETE_DNSMASQ_STATIC_LEASE_REMOVE)
|
||||
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/static-lease/remove
|
||||
|
||||
Remove static lease by MAC. Raises NotFoundError if no match.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
mac = body.get("mac", "").strip()
|
||||
@@ -265,8 +352,13 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/dns-record/add")
|
||||
@registry.register(POST_DNSMASQ_DNS_RECORD_ADD)
|
||||
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/dns-record/add
|
||||
|
||||
Add or update a custom DNS record by name. Raises ValueError on invalid input.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
@@ -293,8 +385,13 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/dns-record/remove")
|
||||
@registry.register(DELETE_DNSMASQ_DNS_RECORD_REMOVE)
|
||||
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/dns-record/remove
|
||||
|
||||
Remove custom DNS record by name. Raises NotFoundError if no match.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
@@ -311,8 +408,13 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/upstreams")
|
||||
@registry.register(POST_DNSMASQ_UPSTREAMS)
|
||||
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/upstreams
|
||||
|
||||
Replace DNS upstream servers list. Raises ValueError if servers field missing.
|
||||
"""
|
||||
if not body or "servers" not in body:
|
||||
raise ValueError("'servers' is required")
|
||||
cfg = _get_config()
|
||||
@@ -322,8 +424,13 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/domain")
|
||||
@registry.register(POST_DNSMASQ_DOMAIN)
|
||||
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/domain
|
||||
|
||||
Set or clear the local DNS domain. Raises ValueError on missing body.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain")
|
||||
|
||||
+89
-30
@@ -10,10 +10,34 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
DELETE_FIREWALL_ZONES_DELETE,
|
||||
GET_FIREWALL_CONFIG,
|
||||
GET_FIREWALL_CONFIG_PENDING,
|
||||
GET_FIREWALL_INTERFACES,
|
||||
GET_FIREWALL_RICH_RULES,
|
||||
GET_FIREWALL_SERVICES,
|
||||
GET_FIREWALL_STATE,
|
||||
GET_FIREWALL_ZONES,
|
||||
GET_FIREWALL_ZONES_ALL,
|
||||
GET_FIREWALL_ZONES_INFO,
|
||||
PATCH_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG_APPLY,
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
POST_FIREWALL_MASQUERADE,
|
||||
POST_FIREWALL_RICH_RULES_ADD,
|
||||
POST_FIREWALL_ZONES_CREATE,
|
||||
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 lib.firewall import (
|
||||
_normalize_target,
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
)
|
||||
from lib.firewall import (
|
||||
@@ -36,26 +60,31 @@ def _get_state() -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def _ensure_config_file() -> None:
|
||||
"""Initialize config file with defaults if missing."""
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
"""Load the firewall config file."""
|
||||
_ensure_config_file()
|
||||
return load_json(CONFIG_FILE)
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist firewall config to disk."""
|
||||
_ensure_config_file()
|
||||
save_json(CONFIG_FILE, cfg, indent=2)
|
||||
|
||||
|
||||
def _reload() -> None:
|
||||
"""Reload firewalld to apply permanent changes."""
|
||||
run(["firewall-cmd", "--reload"], sudo=True)
|
||||
|
||||
|
||||
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']}"]
|
||||
if "toaddr" in fp:
|
||||
parts.append(f"toaddr={fp['toaddr']}")
|
||||
@@ -65,6 +94,7 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
"""Return forward-port entries for a zone as CLI-style strings."""
|
||||
with suppress(Exception):
|
||||
fps = _parse_zone_output(
|
||||
zone_name,
|
||||
@@ -255,19 +285,21 @@ def _config_apply() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _get_fw_state() -> dict[str, Any]:
|
||||
"""Return firewall state from the state store, or empty dict if absent."""
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
return {}
|
||||
return fw
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/interfaces")
|
||||
@registry.register(GET_FIREWALL_INTERFACES)
|
||||
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /firewall/interfaces — return active interfaces from state."""
|
||||
fw = _get_fw_state()
|
||||
return fw.get("interfaces", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones")
|
||||
@registry.register(GET_FIREWALL_ZONES)
|
||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
@@ -275,7 +307,7 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"active": active, "available": list(zones.keys())}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/info")
|
||||
@registry.register(GET_FIREWALL_ZONES_INFO)
|
||||
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -287,7 +319,7 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return zones[zone]
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/all")
|
||||
@registry.register(GET_FIREWALL_ZONES_ALL)
|
||||
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
@@ -299,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/services")
|
||||
@registry.register(GET_FIREWALL_SERVICES)
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
fw = _get_fw_state()
|
||||
return fw.get("available_services", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config")
|
||||
@registry.register(GET_FIREWALL_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config")
|
||||
@registry.register(POST_FIREWALL_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
@@ -322,7 +354,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/firewall/config")
|
||||
@registry.register(PATCH_FIREWALL_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
@@ -336,13 +368,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config/pending")
|
||||
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_fw_state()
|
||||
return fw.get("pending", {})
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config/apply")
|
||||
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
result = _config_apply()
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
@@ -350,7 +382,7 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/create")
|
||||
@registry.register(POST_FIREWALL_ZONES_CREATE)
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -376,7 +408,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/zones/delete")
|
||||
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
|
||||
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -391,7 +423,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/interfaces")
|
||||
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -401,24 +433,31 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
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")
|
||||
try:
|
||||
current = _parse_zone_output(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("interfaces", [])
|
||||
except Exception:
|
||||
current = []
|
||||
for iface in current:
|
||||
|
||||
# Determine old zone for each interface being reassigned
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
|
||||
for iface in interfaces:
|
||||
# Find which zone currently owns this interface
|
||||
old_zone = None
|
||||
for az, az_ifaces in active.items():
|
||||
if iface in az_ifaces:
|
||||
old_zone = az
|
||||
break
|
||||
# Remove from old zone (if different from target)
|
||||
if old_zone and old_zone != zone:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--zone={old_zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in interfaces:
|
||||
# Add to target zone
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
@@ -428,13 +467,33 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
_reload()
|
||||
|
||||
# Update config
|
||||
cfg = _get_config()
|
||||
cfg.setdefault("zones", {})
|
||||
cfg["zones"].setdefault(zone, {})
|
||||
cfg["zones"][zone]["interfaces"] = list(interfaces)
|
||||
# Remove interface from any old zone in config
|
||||
for old_zone_name, old_zone_cfg in cfg["zones"].items():
|
||||
if old_zone_name == zone:
|
||||
continue
|
||||
old_ifaces = old_zone_cfg.get("interfaces", [])
|
||||
new_ifaces = [i for i in old_ifaces if i not in interfaces]
|
||||
if len(new_ifaces) < len(old_ifaces):
|
||||
if new_ifaces:
|
||||
old_zone_cfg["interfaces"] = new_ifaces
|
||||
elif "interfaces" in old_zone_cfg:
|
||||
del old_zone_cfg["interfaces"]
|
||||
_save_config(cfg)
|
||||
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/services")
|
||||
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -473,7 +532,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/rich-rules/add")
|
||||
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -504,7 +563,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/rich-rules/remove")
|
||||
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -542,7 +601,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/rich-rules")
|
||||
@registry.register(GET_FIREWALL_RICH_RULES)
|
||||
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -562,7 +621,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/masquerade")
|
||||
@registry.register(POST_FIREWALL_MASQUERADE)
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -577,7 +636,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/forward-port/add")
|
||||
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -620,7 +679,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/forward-port/remove")
|
||||
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -667,7 +726,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/state")
|
||||
@registry.register(GET_FIREWALL_STATE)
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
|
||||
+40
-9
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from daemon.server import registry
|
||||
from daemon.iface import (
|
||||
GET_LOGS_APP,
|
||||
GET_LOGS_DNSMASQ,
|
||||
GET_LOGS_JOURNAL,
|
||||
GET_LOGS_NGINX_ACCESS,
|
||||
GET_LOGS_NGINX_ERROR,
|
||||
)
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run_proc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,6 +24,16 @@ _MAX_LINES = 200
|
||||
|
||||
|
||||
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
"""Return the last N lines of a file, optionally via sudo.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the file to read.
|
||||
n: Number of trailing lines to return.
|
||||
sudo: Whether to use sudo to access the file.
|
||||
|
||||
Returns:
|
||||
Truncated file content or an error message string.
|
||||
"""
|
||||
try:
|
||||
if sudo:
|
||||
result = run_proc(["cat", path], sudo=True)
|
||||
@@ -26,12 +43,21 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
lines = f.readlines()
|
||||
return "".join(lines[-n:])
|
||||
except FileNotFoundError:
|
||||
return "(log file not found)\n"
|
||||
raise NotFoundError("log file not found") from None
|
||||
except PermissionError:
|
||||
return "(permission denied)\n"
|
||||
raise RuntimeError("permission denied") from None
|
||||
|
||||
|
||||
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
"""Return recent journalctl output for a systemd unit via sudo.
|
||||
|
||||
Args:
|
||||
unit: Systemd unit name to query.
|
||||
n: Number of journal lines to return.
|
||||
|
||||
Returns:
|
||||
Journal output or an error message string.
|
||||
"""
|
||||
try:
|
||||
result = run_proc(
|
||||
["journalctl", "--unit=" + unit, "-n", str(n)],
|
||||
@@ -42,29 +68,34 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
output = result.stdout.strip()
|
||||
return output if output else f"(no journal entries for {unit})\n"
|
||||
except Exception as exc:
|
||||
return f"(error reading journal: {exc})\n"
|
||||
raise RuntimeError(f"error reading journal: {exc}") from exc
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/journal")
|
||||
@registry.register(GET_LOGS_JOURNAL)
|
||||
def journal(_request, _body) -> str:
|
||||
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
|
||||
return _sudo_journalctl("vacuum-wall")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/access")
|
||||
@registry.register(GET_LOGS_NGINX_ACCESS)
|
||||
def nginx_access(_request, _body) -> str:
|
||||
"""GET /logs/nginx/access — return recent nginx access log lines."""
|
||||
return _tail_file("/var/log/nginx/access.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/error")
|
||||
@registry.register(GET_LOGS_NGINX_ERROR)
|
||||
def nginx_error(_request, _body) -> str:
|
||||
"""GET /logs/nginx/error — return recent nginx error log lines."""
|
||||
return _tail_file("/var/log/nginx/error.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/dnsmasq")
|
||||
@registry.register(GET_LOGS_DNSMASQ)
|
||||
def dnsmasq_log(_request, _body) -> str:
|
||||
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
|
||||
return _sudo_journalctl("dnsmasq")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/app")
|
||||
@registry.register(GET_LOGS_APP)
|
||||
def app_log(_request, _body) -> str:
|
||||
"""GET /logs/app — return recent application log lines."""
|
||||
return _tail_file(str(_APP_LOG_FILE))
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Networkd daemon handler.
|
||||
|
||||
Registers routes for managing systemd-networkd interface configuration
|
||||
via config/network/config.json and generated .network files.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.iface import (
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
GET_NETWORK_INFER_ZONES,
|
||||
GET_NETWORK_INTERFACE_NAME,
|
||||
GET_NETWORK_INTERFACES,
|
||||
POST_NETWORK_APPLY,
|
||||
POST_NETWORK_INTERFACE_NAME,
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
POST_NETWORK_SYSCTL_SET,
|
||||
)
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run, validate_interface_name
|
||||
from lib.dnsmasq import set_upstreams
|
||||
from lib.network import (
|
||||
KNOWN_INTERFACE_KEYS,
|
||||
collect_upstream_dns,
|
||||
generate_network_files,
|
||||
get_config,
|
||||
infer_dhcp_ranges,
|
||||
infer_zones,
|
||||
parse_networkctl_status,
|
||||
render_network_file,
|
||||
save_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "network"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
||||
|
||||
|
||||
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"
|
||||
dst_dir = Path("/etc/systemd/network")
|
||||
run(["mkdir", "-p", str(dst_dir)], sudo=True)
|
||||
dst = dst_dir / f"99-{iface_name}.network"
|
||||
run(["cp", str(src), str(dst)], sudo=True)
|
||||
|
||||
# Remove lower-priority .network files that match this interface
|
||||
# (they would override our config due to higher systemd priority)
|
||||
if dst_dir.exists():
|
||||
for f in dst_dir.iterdir():
|
||||
if (
|
||||
f.name.endswith(".network")
|
||||
and f.name != dst.name
|
||||
and _matches_interface(f.name, iface_name)
|
||||
):
|
||||
with contextlib.suppress(Exception):
|
||||
run(["rm", str(f)], sudo=True)
|
||||
logger.info("Removed conflicting file: %s", f.name)
|
||||
|
||||
run(["networkctl", "reload"], sudo=True)
|
||||
run(["networkctl", "reconfigure", iface_name], sudo=True)
|
||||
|
||||
|
||||
def _matches_interface(filename: str, iface_name: str) -> bool:
|
||||
"""Check if a .network filename would match the given interface."""
|
||||
base = filename.replace(".network", "")
|
||||
# Strip numeric priority prefix (e.g. "50-eth1" → "eth1")
|
||||
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||
base = base.split("-", 1)[1]
|
||||
return base == iface_name
|
||||
|
||||
|
||||
def _extract_iface_from_filename(filename: str) -> str | None:
|
||||
"""Extract interface name from a .network filename (e.g. '50-eth1.network' → 'eth1')."""
|
||||
base = filename.replace(".network", "")
|
||||
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||
return base.split("-", 1)[1]
|
||||
return base if base else None
|
||||
|
||||
|
||||
def _full_reload() -> None:
|
||||
"""Reload networkd for all interfaces."""
|
||||
run(["networkctl", "reload"], sudo=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@registry.register(GET_NETWORK_INTERFACES)
|
||||
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/interfaces — return all interface config + runtime state."""
|
||||
cfg = get_config()
|
||||
ifaces_cfg = cfg.get("interfaces", {})
|
||||
|
||||
runtime: dict[str, Any] = {}
|
||||
with contextlib.suppress(Exception):
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
runtime = parse_networkctl_status(raw)
|
||||
|
||||
merged: dict[str, Any] = {}
|
||||
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
|
||||
for name in sorted(all_names):
|
||||
merged[name] = {
|
||||
"config": ifaces_cfg.get(name, {}),
|
||||
"runtime": runtime.get(name, {}),
|
||||
}
|
||||
|
||||
from lib.state import _now_iso
|
||||
|
||||
return {"interfaces": merged, "timestamp": _now_iso()}
|
||||
|
||||
|
||||
@registry.register(GET_NETWORK_INTERFACE_NAME)
|
||||
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /network/interfaces/<name> — return config for one interface."""
|
||||
if not body or "name" not in body:
|
||||
raise ValueError("Interface name is required")
|
||||
name = validate_interface_name(body["name"])
|
||||
cfg = get_config()
|
||||
ifaces = cfg.get("interfaces", {})
|
||||
if name not in ifaces:
|
||||
raise NotFoundError(f"Interface '{name}' not found in config")
|
||||
|
||||
runtime: dict[str, Any] = {}
|
||||
with contextlib.suppress(Exception):
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
runtime = parse_networkctl_status(raw)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"config": ifaces[name],
|
||||
"runtime": runtime.get(name, {}),
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_INTERFACE_NAME)
|
||||
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name> — save config, render, apply."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = validate_interface_name(body.get("name", ""))
|
||||
|
||||
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
|
||||
|
||||
unknown = set(iface_cfg.keys()) - KNOWN_INTERFACE_KEYS
|
||||
if unknown:
|
||||
logger.warning(
|
||||
"Interface '%s': unexpected config keys %s — these will be "
|
||||
"saved but not rendered to .network files",
|
||||
name,
|
||||
sorted(unknown),
|
||||
)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
runtime = parse_networkctl_status(raw)
|
||||
if name not in runtime:
|
||||
logger.warning(
|
||||
"Interface '%s' not found in networkctl "
|
||||
"(config saved but networkd will ignore it)",
|
||||
name,
|
||||
)
|
||||
|
||||
cfg = get_config()
|
||||
cfg.setdefault("interfaces", {})
|
||||
cfg["interfaces"][name] = iface_cfg
|
||||
save_config(cfg)
|
||||
|
||||
content = render_network_file(name, iface_cfg)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(DATA_DIR / f"99-{name}.network").write_text(content)
|
||||
|
||||
# Deploy to system. In containerized environments this may fail
|
||||
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
|
||||
deployed = True
|
||||
try:
|
||||
_copy_and_reload(name)
|
||||
except Exception:
|
||||
deployed = False
|
||||
logger.warning(
|
||||
"Interface '%s' config saved but failed to deploy to "
|
||||
"systemd-networkd (sudo/system unavailable)",
|
||||
name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
|
||||
return {"name": name, "applied": deployed}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
|
||||
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
|
||||
if not body or "name" not in body:
|
||||
raise ValueError("'name' is required in request body")
|
||||
name = validate_interface_name(body["name"])
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
run(["networkctl", "reconfigure", name], sudo=True)
|
||||
|
||||
logger.info("Interface '%s' reloaded", name)
|
||||
return {"name": name, "reloaded": True}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_APPLY)
|
||||
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /network/apply — apply ALL interfaces (full sync)."""
|
||||
cfg = get_config()
|
||||
result = generate_network_files(cfg)
|
||||
generated = result.get("generated", [])
|
||||
cleaned = result.get("cleaned", [])
|
||||
|
||||
# Remove stale/conflicting files from system dir
|
||||
expected_names = {f.name for f in generated}
|
||||
managed_ifaces = {
|
||||
f.name.replace("99-", "").replace(".network", "") for f in generated
|
||||
}
|
||||
sys_dir = Path("/etc/systemd/network")
|
||||
if sys_dir.exists():
|
||||
for f in sys_dir.iterdir():
|
||||
if f.name.endswith(".network") and f.name not in expected_names:
|
||||
iface_from_file = _extract_iface_from_filename(f.name)
|
||||
if iface_from_file and iface_from_file in managed_ifaces:
|
||||
# Remove conflicting external configs for managed interfaces
|
||||
with contextlib.suppress(Exception):
|
||||
run(["rm", str(f)], sudo=True)
|
||||
cleaned.append(f)
|
||||
|
||||
for f in generated:
|
||||
dst = sys_dir / f.name
|
||||
run(["mkdir", "-p", str(sys_dir)], sudo=True)
|
||||
run(["cp", str(f), str(dst)], sudo=True)
|
||||
|
||||
_full_reload()
|
||||
|
||||
# TF-8: sync DNS upstreams to dnsmasq
|
||||
try:
|
||||
upstreams = collect_upstream_dns(cfg)
|
||||
if upstreams:
|
||||
set_upstreams(upstreams)
|
||||
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
|
||||
except Exception:
|
||||
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"Network config applied: %d interfaces, %d stale cleaned",
|
||||
len(generated),
|
||||
len(cleaned),
|
||||
)
|
||||
return {
|
||||
"applied": len(generated),
|
||||
"files": [str(p) for p in generated],
|
||||
"cleaned": [str(p) for p in cleaned],
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_NETWORK_INFER_DHCP_RANGES)
|
||||
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
|
||||
cfg = get_config()
|
||||
ranges = infer_dhcp_ranges(cfg)
|
||||
return {"ranges": ranges}
|
||||
|
||||
|
||||
@registry.register(GET_NETWORK_INFER_ZONES)
|
||||
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
|
||||
cfg = get_config()
|
||||
zones = infer_zones(cfg)
|
||||
return {"zones": zones}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_SYSCTL_SET)
|
||||
def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /sysctl/set — set a sysctl kernel parameter value.
|
||||
|
||||
Writes the value via `sysctl -w`, then verifies by reading it back.
|
||||
|
||||
Raises:
|
||||
ValueError: When name or value is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
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")
|
||||
value = str(body.get("value", "")).strip()
|
||||
if not value:
|
||||
raise ValueError("'value' is required")
|
||||
|
||||
run(["sysctl", "-w", f"{name}={value}"], sudo=True)
|
||||
|
||||
# Verify by reading back via /proc/sys (no sudo needed for reads, avoid
|
||||
# triggering sudoers for read-only sysctl which is not whitelisted)
|
||||
proc_path = Path(f"/proc/sys/{name.replace('.', '/')}")
|
||||
read_value = proc_path.read_text().strip()
|
||||
if read_value != value:
|
||||
raise RuntimeError(
|
||||
f"sysctl verify failed: set {name}={value} but read back {read_value}"
|
||||
)
|
||||
|
||||
logger.info("sysctl %s set to %s", name, value)
|
||||
return {"name": name, "value": value}
|
||||
+146
-19
@@ -8,6 +8,20 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
POST_NGINX_MANAGEMENT,
|
||||
POST_NGINX_RELOAD,
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
@@ -50,12 +64,18 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
"""Retrieve cached nginx state from the state store."""
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("nginx")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
"""Load the nginx config JSON, applying defaults for missing fields.
|
||||
|
||||
Returns:
|
||||
The parsed config dict with ssl defaults filled in.
|
||||
"""
|
||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||
raw = load_json(CONFIG_FILE)
|
||||
if not raw:
|
||||
@@ -66,10 +86,23 @@ def _get_config() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist the nginx config dict to disk.
|
||||
|
||||
Args:
|
||||
cfg: The config dictionary to save.
|
||||
"""
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
"""Render an nginx server block config from a domain entry via Jinja.
|
||||
|
||||
Args:
|
||||
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
|
||||
|
||||
Returns:
|
||||
Rendered server block as a string.
|
||||
"""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
return tmpl.render(
|
||||
domain=domain_cfg["domain"],
|
||||
@@ -86,6 +119,12 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _write_site(domain: str, conf_text: str) -> None:
|
||||
"""Atomically write a single site config file into sites-enabled.
|
||||
|
||||
Args:
|
||||
domain: Site name used as the filename.
|
||||
conf_text: Rendered nginx server block content.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
path = SITES_DIR / f"{domain}.conf"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
@@ -97,9 +136,10 @@ def _write_site(domain: str, conf_text: str) -> None:
|
||||
|
||||
|
||||
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 = INCLUDE_FILE.with_suffix(".tmp")
|
||||
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
@@ -109,6 +149,7 @@ def _write_include_file() -> None:
|
||||
|
||||
|
||||
def _write_ssl_snippet() -> None:
|
||||
"""Render and install the shared SSL snippet to /etc/nginx/snippets/."""
|
||||
cfg = _get_config()
|
||||
ssl_cfg = cfg.get("ssl", {})
|
||||
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
|
||||
@@ -116,7 +157,7 @@ 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 = SSL_SNIPPET.with_suffix(".tmp")
|
||||
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
@@ -126,6 +167,11 @@ def _write_ssl_snippet() -> None:
|
||||
|
||||
|
||||
def _test_config() -> tuple[bool, str]:
|
||||
"""Run `nginx -t` to validate the current config.
|
||||
|
||||
Returns:
|
||||
Tuple of (passed, message).
|
||||
"""
|
||||
result = run_proc(["nginx", "-t"], sudo=True, check=False)
|
||||
ok = result.returncode == 0
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
@@ -135,6 +181,10 @@ def _test_config() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _reload_nginx() -> None:
|
||||
"""Send SIGHUP to nginx to reload its configuration.
|
||||
|
||||
Logs an error if the reload fails.
|
||||
"""
|
||||
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
|
||||
if result.returncode != 0:
|
||||
logger.error("nginx reload failed: %s", result.stderr.strip())
|
||||
@@ -143,6 +193,10 @@ def _reload_nginx() -> None:
|
||||
|
||||
|
||||
def _write_all_sites() -> None:
|
||||
"""Regenerate all site configs, management proxy, and ACME challenge site.
|
||||
|
||||
Removes stale .conf files that are no longer in config.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = _get_config()
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||
@@ -185,12 +239,29 @@ def _write_all_sites() -> None:
|
||||
os.replace(tmp, site)
|
||||
|
||||
|
||||
def _write_htpasswd(user: str, password: str) -> None:
|
||||
ensure_dirs(DATA_DIR)
|
||||
import crypt
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt via passlib.
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
hashed = crypt.crypt(password, f"$5${salt}")
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd``.
|
||||
"""
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
user: The username to add or update.
|
||||
password: Plain-text password to hash.
|
||||
"""
|
||||
ensure_dirs(DATA_DIR)
|
||||
hashed = _hash_password(password)
|
||||
existing: dict[str, str] = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
@@ -211,6 +282,7 @@ def _write_htpasswd(user: str, password: str) -> None:
|
||||
|
||||
|
||||
def _get_nginx_state() -> dict[str, Any]:
|
||||
"""Return a shallow copy of the cached nginx state, or empty dict if unset."""
|
||||
ng = _get_state()
|
||||
if ng is None:
|
||||
return {}
|
||||
@@ -221,16 +293,26 @@ def _get_nginx_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config")
|
||||
@registry.register(GET_NGINX_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /nginx/config — return current nginx config.
|
||||
|
||||
Returns:
|
||||
Full config dict from state cache, or fallback to file.
|
||||
"""
|
||||
ng = _get_nginx_state()
|
||||
if ng:
|
||||
return ng.get("config", {})
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/config")
|
||||
@registry.register(POST_NGINX_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/config — replace the entire nginx config and refresh state.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
@@ -238,8 +320,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/nginx/config")
|
||||
@registry.register(PATCH_NGINX_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/config — deep-merge partial updates into current config.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
from lib.common import deep_merge
|
||||
@@ -251,16 +338,27 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/domains")
|
||||
@registry.register(GET_NGINX_DOMAINS)
|
||||
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /nginx/domains — return the list of configured proxy domains.
|
||||
|
||||
Returns:
|
||||
Domains list from state cache, or empty list.
|
||||
"""
|
||||
ng = _get_nginx_state()
|
||||
if ng:
|
||||
return ng.get("domains", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/add")
|
||||
@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.
|
||||
|
||||
Raises:
|
||||
ValueError: When required fields (domain, backend_host, backend_port) are missing.
|
||||
ValueError: When the domain already exists.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -296,8 +394,14 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/nginx/domains/remove")
|
||||
@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
|
||||
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body or domain field is missing.
|
||||
NotFoundError: When the domain is not configured.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -315,8 +419,14 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/update")
|
||||
@registry.register(POST_NGINX_DOMAINS_UPDATE)
|
||||
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/domains/update — patch fields of an existing domain entry.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body or domain field is missing.
|
||||
NotFoundError: When the domain is not configured.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -337,8 +447,13 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/apply")
|
||||
@registry.register(POST_NGINX_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/apply — render all configs, test, and reload nginx.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When the nginx config test fails.
|
||||
"""
|
||||
_write_ssl_snippet()
|
||||
_write_all_sites()
|
||||
_write_include_file()
|
||||
@@ -350,21 +465,32 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/test")
|
||||
@registry.register(POST_NGINX_TEST)
|
||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/test — dry-run validate the live nginx config without applying.
|
||||
|
||||
Returns:
|
||||
Dict with valid (bool) and output (str) from `nginx -t`.
|
||||
"""
|
||||
valid, output = _test_config()
|
||||
return {"valid": valid, "output": output}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/ssl-apply")
|
||||
@registry.register(POST_NGINX_SSL_APPLY)
|
||||
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
|
||||
_write_ssl_snippet()
|
||||
refresh_state(["nginx"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/management")
|
||||
@registry.register(POST_NGINX_MANAGEMENT)
|
||||
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/management — configure the management UI reverse proxy.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body or domain field is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
@@ -389,7 +515,8 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/reload")
|
||||
@registry.register(POST_NGINX_RELOAD)
|
||||
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
|
||||
_reload_nginx()
|
||||
return {"reloaded": True}
|
||||
|
||||
@@ -9,6 +9,20 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_PEERS_REMOVE,
|
||||
GET_WIREGUARD_CONFIG,
|
||||
GET_WIREGUARD_PEER_STATUS,
|
||||
GET_WIREGUARD_PEERS,
|
||||
GET_WIREGUARD_STATUS,
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_APPLY,
|
||||
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
|
||||
|
||||
@@ -42,20 +56,24 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
|
||||
|
||||
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"),
|
||||
@@ -65,6 +83,7 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
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 {}
|
||||
@@ -75,8 +94,9 @@ def _get_wg_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/config")
|
||||
@registry.register(GET_WIREGUARD_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /wireguard/config — return WireGuard config with private key stripped."""
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("config", {})
|
||||
@@ -88,8 +108,13 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/config")
|
||||
@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.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
@@ -105,8 +130,13 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/wireguard/config")
|
||||
@registry.register(PATCH_WIREGUARD_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /wireguard/config — deep-merge patch into existing config.
|
||||
|
||||
Raises:
|
||||
ValueError: When request body is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
if "interface" in body:
|
||||
@@ -120,8 +150,9 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/apply")
|
||||
@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)
|
||||
@@ -140,8 +171,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/down")
|
||||
@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)
|
||||
@@ -150,16 +182,18 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/status")
|
||||
@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": []}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/initialize")
|
||||
@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()
|
||||
if cfg["interface"].get("private_key"):
|
||||
return {"initialized": False, "reason": "already initialized"}
|
||||
@@ -178,8 +212,13 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"initialized": True, "config": safe}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/peers/add")
|
||||
@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.
|
||||
|
||||
Raises:
|
||||
ValueError: When body is missing or name is empty.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
@@ -217,8 +256,14 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return peer_out
|
||||
|
||||
|
||||
@registry.register("DELETE", "/wireguard/peers/remove")
|
||||
@registry.register(DELETE_WIREGUARD_PEERS_REMOVE)
|
||||
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /wireguard/peers/remove — remove a peer by name.
|
||||
|
||||
Raises:
|
||||
ValueError: When body is missing or name is empty.
|
||||
NotFoundError: When peer does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
@@ -235,8 +280,9 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peers")
|
||||
@registry.register(GET_WIREGUARD_PEERS)
|
||||
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /wireguard/peers — return configured peers with private keys stripped."""
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("peers", [])
|
||||
@@ -250,16 +296,23 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peer-status")
|
||||
@registry.register(GET_WIREGUARD_PEER_STATUS)
|
||||
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /wireguard/peer-status — return runtime peer status from cache."""
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("status", {}).get("peers", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/generate-client")
|
||||
@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.
|
||||
|
||||
Raises:
|
||||
ValueError: When body, name, or server_endpoint is missing.
|
||||
NotFoundError: When peer does not exist or has no private key.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
"""Shared walld interface definitions.
|
||||
|
||||
This module is the **single source of truth** for all daemon API endpoints.
|
||||
Every endpoint is a frozen tuple of (method, path). Both the server's
|
||||
registry.register() and the client's request/get/post/patch/delete() accept
|
||||
an Endpoint in addition to a plain string path, so renaming an endpoint here
|
||||
automatically updates both sides.
|
||||
|
||||
Usage:
|
||||
|
||||
# Server (daemon/handlers/)
|
||||
from daemon.iface import GET_FIREWALL_ZONES
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES)
|
||||
def get_zones(_request, _body):
|
||||
...
|
||||
|
||||
# Client (webui/api/)
|
||||
from daemon.iface import GET_FIREWALL_ZONES
|
||||
from daemon.client import get
|
||||
|
||||
data = get(GET_FIREWALL_ZONES)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
Endpoint = tuple[str, str]
|
||||
PathLike = str | Endpoint
|
||||
|
||||
|
||||
def _ep(method: str, path: str) -> Endpoint:
|
||||
return (method, path)
|
||||
|
||||
|
||||
# ---- Nginx / Proxy ----
|
||||
GET_NGINX_CONFIG: Endpoint = _ep("GET", "/nginx/config")
|
||||
POST_NGINX_CONFIG: Endpoint = _ep("POST", "/nginx/config")
|
||||
PATCH_NGINX_CONFIG: Endpoint = _ep("PATCH", "/nginx/config")
|
||||
GET_NGINX_DOMAINS: Endpoint = _ep("GET", "/nginx/domains")
|
||||
POST_NGINX_DOMAINS_ADD: Endpoint = _ep("POST", "/nginx/domains/add")
|
||||
DELETE_NGINX_DOMAINS_REMOVE: Endpoint = _ep("DELETE", "/nginx/domains/remove")
|
||||
POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
|
||||
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
|
||||
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
|
||||
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
|
||||
POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
|
||||
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
|
||||
|
||||
# ---- Firewall ----
|
||||
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
|
||||
GET_FIREWALL_ZONES: Endpoint = _ep("GET", "/firewall/zones")
|
||||
GET_FIREWALL_ZONES_INFO: Endpoint = _ep("GET", "/firewall/zones/info")
|
||||
GET_FIREWALL_ZONES_ALL: Endpoint = _ep("GET", "/firewall/zones/all")
|
||||
GET_FIREWALL_SERVICES: Endpoint = _ep("GET", "/firewall/services")
|
||||
GET_FIREWALL_CONFIG: Endpoint = _ep("GET", "/firewall/config")
|
||||
POST_FIREWALL_CONFIG: Endpoint = _ep("POST", "/firewall/config")
|
||||
PATCH_FIREWALL_CONFIG: Endpoint = _ep("PATCH", "/firewall/config")
|
||||
GET_FIREWALL_CONFIG_PENDING: Endpoint = _ep("GET", "/firewall/config/pending")
|
||||
POST_FIREWALL_CONFIG_APPLY: Endpoint = _ep("POST", "/firewall/config/apply")
|
||||
POST_FIREWALL_ZONES_CREATE: Endpoint = _ep("POST", "/firewall/zones/create")
|
||||
DELETE_FIREWALL_ZONES_DELETE: Endpoint = _ep("DELETE", "/firewall/zones/delete")
|
||||
POST_FIREWALL_ZONES_INTERFACES: Endpoint = _ep("POST", "/firewall/zones/interfaces")
|
||||
POST_FIREWALL_ZONES_SERVICES: Endpoint = _ep("POST", "/firewall/zones/services")
|
||||
POST_FIREWALL_RICH_RULES_ADD: Endpoint = _ep("POST", "/firewall/rich-rules/add")
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE: Endpoint = _ep(
|
||||
"DELETE", "/firewall/rich-rules/remove"
|
||||
)
|
||||
GET_FIREWALL_RICH_RULES: Endpoint = _ep("GET", "/firewall/rich-rules")
|
||||
POST_FIREWALL_MASQUERADE: Endpoint = _ep("POST", "/firewall/masquerade")
|
||||
POST_FIREWALL_FORWARD_PORT_ADD: Endpoint = _ep("POST", "/firewall/forward-port/add")
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE: Endpoint = _ep(
|
||||
"DELETE", "/firewall/forward-port/remove"
|
||||
)
|
||||
GET_FIREWALL_STATE: Endpoint = _ep("GET", "/firewall/state")
|
||||
|
||||
# ---- WireGuard ----
|
||||
GET_WIREGUARD_CONFIG: Endpoint = _ep("GET", "/wireguard/config")
|
||||
POST_WIREGUARD_CONFIG: Endpoint = _ep("POST", "/wireguard/config")
|
||||
PATCH_WIREGUARD_CONFIG: Endpoint = _ep("PATCH", "/wireguard/config")
|
||||
POST_WIREGUARD_APPLY: Endpoint = _ep("POST", "/wireguard/apply")
|
||||
POST_WIREGUARD_DOWN: Endpoint = _ep("POST", "/wireguard/down")
|
||||
GET_WIREGUARD_STATUS: Endpoint = _ep("GET", "/wireguard/status")
|
||||
POST_WIREGUARD_INITIALIZE: Endpoint = _ep("POST", "/wireguard/initialize")
|
||||
POST_WIREGUARD_PEERS_ADD: Endpoint = _ep("POST", "/wireguard/peers/add")
|
||||
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")
|
||||
|
||||
# ---- ACME / Certs ----
|
||||
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
|
||||
GET_ACME_INFO: Endpoint = _ep("GET", "/acme/info")
|
||||
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")
|
||||
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
|
||||
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
||||
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
|
||||
GET_ACME_PATHS: Endpoint = _ep("GET", "/acme/paths")
|
||||
POST_ACME_SELF_SIGNED: Endpoint = _ep("POST", "/acme/self-signed")
|
||||
|
||||
# ---- Dnsmasq / DHCP ----
|
||||
GET_DNSMASQ_CONFIG: Endpoint = _ep("GET", "/dnsmasq/config")
|
||||
POST_DNSMASQ_CONFIG: Endpoint = _ep("POST", "/dnsmasq/config")
|
||||
PATCH_DNSMASQ_CONFIG: Endpoint = _ep("PATCH", "/dnsmasq/config")
|
||||
POST_DNSMASQ_APPLY: Endpoint = _ep("POST", "/dnsmasq/apply")
|
||||
GET_DNSMASQ_STATUS: Endpoint = _ep("GET", "/dnsmasq/status")
|
||||
POST_DNSMASQ_RANGES_ADD: Endpoint = _ep("POST", "/dnsmasq/ranges/add")
|
||||
DELETE_DNSMASQ_RANGES_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/ranges/remove")
|
||||
GET_DNSMASQ_LEASES: Endpoint = _ep("GET", "/dnsmasq/leases")
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD: Endpoint = _ep("POST", "/dnsmasq/static-lease/add")
|
||||
DELETE_DNSMASQ_STATIC_LEASE_REMOVE: Endpoint = _ep(
|
||||
"DELETE", "/dnsmasq/static-lease/remove"
|
||||
)
|
||||
POST_DNSMASQ_DNS_RECORD_ADD: Endpoint = _ep("POST", "/dnsmasq/dns-record/add")
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/dns-record/remove")
|
||||
POST_DNSMASQ_UPSTREAMS: Endpoint = _ep("POST", "/dnsmasq/upstreams")
|
||||
POST_DNSMASQ_DOMAIN: Endpoint = _ep("POST", "/dnsmasq/domain")
|
||||
|
||||
# ---- Network ----
|
||||
GET_NETWORK_INTERFACES: Endpoint = _ep("GET", "/network/interfaces")
|
||||
GET_NETWORK_INTERFACE_NAME: Endpoint = _ep("GET", "/network/interfaces/<name>")
|
||||
POST_NETWORK_INTERFACE_NAME: Endpoint = _ep("POST", "/network/interfaces/<name>")
|
||||
POST_NETWORK_INTERFACE_RELOAD: Endpoint = _ep(
|
||||
"POST", "/network/interfaces/<name>/reload"
|
||||
)
|
||||
POST_NETWORK_APPLY: Endpoint = _ep("POST", "/network/apply")
|
||||
GET_NETWORK_INFER_DHCP_RANGES: Endpoint = _ep("GET", "/network/infer-dhcp-ranges")
|
||||
GET_NETWORK_INFER_ZONES: Endpoint = _ep("GET", "/network/infer-zones")
|
||||
POST_NETWORK_SYSCTL_SET: Endpoint = _ep("POST", "/network/sysctl/set")
|
||||
|
||||
# ---- Logs ----
|
||||
GET_LOGS_JOURNAL: Endpoint = _ep("GET", "/logs/journal")
|
||||
GET_LOGS_NGINX_ACCESS: Endpoint = _ep("GET", "/logs/nginx/access")
|
||||
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")
|
||||
|
||||
# ---- 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")
|
||||
|
||||
# Collect all endpoint module-level constants for __all__ verification
|
||||
_all_endpoints = [
|
||||
name
|
||||
for name, val in globals().items()
|
||||
if isinstance(val, tuple) and len(val) == 2 and all(isinstance(x, str) for x in val)
|
||||
]
|
||||
__all__ = ["Endpoint", "PathLike", *sorted(_all_endpoints)]
|
||||
+242
-24
@@ -15,46 +15,123 @@ from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from daemon.iface import PathLike
|
||||
from lib.state import state as state_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
|
||||
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
|
||||
|
||||
|
||||
class Handler:
|
||||
"""Wrapper for a daemon handler function."""
|
||||
"""Wrapper for a daemon handler function.
|
||||
|
||||
Attributes:
|
||||
method: HTTP method (e.g. "GET", "POST").
|
||||
path: URL path pattern.
|
||||
"""
|
||||
|
||||
def __init__(self, method: str, path: str) -> None:
|
||||
"""Initialize the handler metadata.
|
||||
|
||||
Args:
|
||||
method: HTTP method.
|
||||
path: URL path pattern.
|
||||
"""
|
||||
self.method = method.upper()
|
||||
self.path = path
|
||||
|
||||
|
||||
class Registry:
|
||||
"""Route registry for daemon handlers."""
|
||||
"""Route registry for daemon handlers.
|
||||
|
||||
Attributes:
|
||||
_routes: Mapping of (method, path) tuples to handler callables.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty route registry."""
|
||||
self._routes: dict[tuple[str, str], Callable] = {}
|
||||
|
||||
def register(self, method: str, path: str):
|
||||
def register(self, method: PathLike, path: str | None = None):
|
||||
"""Decorator that registers a handler for the given method and path.
|
||||
|
||||
Accepts either two separate arguments (``method``, ``path``) or a
|
||||
single :class:`daemon.iface.Endpoint` tuple.
|
||||
|
||||
Args:
|
||||
method: HTTP method string, or an :class:`Endpoint` tuple.
|
||||
path: URL path (omit when passing an :class:`Endpoint`).
|
||||
|
||||
Returns:
|
||||
Decorator function wrapping the handler.
|
||||
"""
|
||||
|
||||
if isinstance(method, tuple):
|
||||
ep_method, ep_path = method
|
||||
path = ep_path
|
||||
method = ep_method
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
self._routes[(method.upper(), path)] = fn
|
||||
fn._handler = Handler(method, path) # type: ignore[attr-defined]
|
||||
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
|
||||
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def get(self, method: str, path: str) -> Callable | None:
|
||||
"""Look up a registered handler by method and path.
|
||||
|
||||
Args:
|
||||
method: HTTP method to match.
|
||||
path: URL path to match.
|
||||
|
||||
Returns:
|
||||
The handler callable, or None if not registered.
|
||||
"""
|
||||
return self._routes.get((method.upper(), path))
|
||||
|
||||
def match(self, method: str, path: str):
|
||||
"""Match *path* against registered patterns, returning handler + params.
|
||||
|
||||
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
|
||||
Matching segments are captured into a dict and merged into *body*.
|
||||
|
||||
Returns:
|
||||
Tuple of (handler_fn, params_dict) or (None, None) if no match.
|
||||
"""
|
||||
for (reg_method, reg_path), fn in self._routes.items():
|
||||
if reg_method != method.upper():
|
||||
continue
|
||||
pat_params = _match_path(reg_path, path)
|
||||
if pat_params is not None:
|
||||
return fn, pat_params
|
||||
return None, None
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
||||
|
||||
def refresh_state(subsystems: list[str] | None = None) -> None:
|
||||
"""Refresh the pre-computed state for the given subsystems (or all)."""
|
||||
"""Refresh the pre-computed state for the given subsystems (or all).
|
||||
|
||||
Args:
|
||||
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
||||
"""
|
||||
state_store.populate(subsystems)
|
||||
targets = subsystems or state_store.SUBSYSTEMS
|
||||
for name in targets:
|
||||
state_store.bump(name)
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
task = asyncio.create_task(broadcast_versions())
|
||||
task.add_done_callback(_ws_tasks.discard)
|
||||
_ws_tasks.add(task)
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
@@ -64,36 +141,81 @@ class NotFoundError(Exception):
|
||||
|
||||
|
||||
def ok(data: Any = None) -> web.Response:
|
||||
"""Create a success JSON response.
|
||||
|
||||
Args:
|
||||
data: Response payload. Defaults to None.
|
||||
|
||||
Returns:
|
||||
A JSON Response with `ok` set to True.
|
||||
"""
|
||||
return web.json_response({"ok": True, "data": data})
|
||||
|
||||
|
||||
def error(msg: str, code: int = 400) -> web.Response:
|
||||
"""Create an error JSON response.
|
||||
|
||||
Args:
|
||||
msg: Error message.
|
||||
code: HTTP status code. Defaults to 400.
|
||||
|
||||
Returns:
|
||||
A JSON Response with `ok` set to False.
|
||||
"""
|
||||
return web.json_response({"ok": False, "error": msg}, status=code)
|
||||
|
||||
|
||||
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
|
||||
"""Match *path* against a URL pattern containing ``<param>`` segments.
|
||||
|
||||
Args:
|
||||
pattern: URL pattern like ``/network/interfaces/<name>``.
|
||||
path: Actual request path like ``/network/interfaces/eth1``.
|
||||
|
||||
Returns:
|
||||
Dict mapping param names to their matched values, or ``None`` if no match.
|
||||
"""
|
||||
p_parts = pattern.strip("/").split("/")
|
||||
r_parts = path.strip("/").split("/")
|
||||
if len(p_parts) != len(r_parts):
|
||||
return None
|
||||
params: dict[str, str] = {}
|
||||
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
|
||||
if p_seg.startswith("<") and p_seg.endswith(">"):
|
||||
params[p_seg[1:-1]] = r_seg
|
||||
elif p_seg != r_seg:
|
||||
return None
|
||||
return params
|
||||
|
||||
|
||||
async def _handle_request(request: web.Request) -> web.Response:
|
||||
"""Dispatch a request to the appropriate handler."""
|
||||
handler_fn = registry.get(request.method, request.path)
|
||||
"""Dispatch a request to the appropriate handler.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
The handler's response.
|
||||
"""
|
||||
handler_fn, pat_params = registry.match(request.method, request.path)
|
||||
if handler_fn is None:
|
||||
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
||||
|
||||
# Build body from JSON and merge query params. GET requests send params
|
||||
# as URL query string, so they need to be treated as body for handlers.
|
||||
body: dict[str, Any] | None = None
|
||||
# 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.
|
||||
body: dict[str, Any] | None = pat_params if pat_params else None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
json_body = await request.json()
|
||||
body = {**json_body, **body} if body is not None else json_body
|
||||
except json.JSONDecodeError:
|
||||
return error("Invalid JSON body", 400)
|
||||
|
||||
query_dict = dict(request.query)
|
||||
if query_dict:
|
||||
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
||||
if body is not None:
|
||||
merged = {**query_body, **body}
|
||||
body = merged
|
||||
else:
|
||||
body = query_body
|
||||
body = {**query_body, **body} if body is not None else query_body
|
||||
|
||||
try:
|
||||
if body is not None:
|
||||
@@ -126,7 +248,14 @@ async def _handle_request(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def _handle_batch(request: web.Request) -> web.Response:
|
||||
"""Handle batch requests: execute operations in order, return keyed results."""
|
||||
"""Handle batch requests: execute operations in order, return keyed results.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request containing operations.
|
||||
|
||||
Returns:
|
||||
A JSON response keyed by operation IDs.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
@@ -146,7 +275,7 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
||||
continue
|
||||
|
||||
handler_fn = registry.get(method, path)
|
||||
handler_fn, pat_params = registry.match(method, path)
|
||||
if handler_fn is None:
|
||||
results[op_id] = {
|
||||
"ok": False,
|
||||
@@ -155,6 +284,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
continue
|
||||
|
||||
op_body = op.get("body")
|
||||
if pat_params:
|
||||
op_body = {**(op_body or {}), **pat_params}
|
||||
|
||||
try:
|
||||
result = handler_fn(None, op_body)
|
||||
@@ -175,26 +306,94 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
"""Create and configure the aiohttp application.
|
||||
|
||||
Returns:
|
||||
A configured web.Application instance.
|
||||
"""
|
||||
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)
|
||||
app.router.add_route("*", "/{tail:.*}", _catch_all)
|
||||
return app
|
||||
|
||||
|
||||
# WebSocket subscribers
|
||||
_ws_subscribers: set[web.WebSocketResponse] = set()
|
||||
_ws_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _handle_ws(request: web.Request) -> web.Response:
|
||||
"""WebSocket endpoint for real-time state change notifications.
|
||||
|
||||
On connect: sends current versions. On state change: broadcasts
|
||||
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||
"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
_ws_subscribers.add(ws)
|
||||
|
||||
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.ERROR:
|
||||
break
|
||||
if msg.type == web.WSMsgType.CLOSE:
|
||||
break
|
||||
finally:
|
||||
_ws_subscribers.discard(ws)
|
||||
|
||||
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:
|
||||
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))
|
||||
|
||||
|
||||
async def _health(_request: web.Request) -> web.Response:
|
||||
"""Return the health check response.
|
||||
|
||||
Returns:
|
||||
JSON response with process ID and socket path.
|
||||
"""
|
||||
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."""
|
||||
"""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."""
|
||||
"""Re-collect all state from system.
|
||||
|
||||
Args:
|
||||
_request: The incoming request. JSON body may contain a "subsystems" list.
|
||||
|
||||
Returns:
|
||||
JSON response with the updated state snapshot.
|
||||
"""
|
||||
try:
|
||||
body = await _request.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
@@ -207,24 +406,40 @@ async def refresh_status(_request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def _catch_all(request: web.Request) -> web.Response:
|
||||
"""Catch-all for registered routes."""
|
||||
"""Catch-all for registered routes.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
The dispatched handler's response.
|
||||
"""
|
||||
return await _handle_request(request)
|
||||
|
||||
|
||||
def _register_routes() -> None:
|
||||
"""Import all handler modules to register routes."""
|
||||
"""Import all handler modules to register routes.
|
||||
|
||||
Side effect: each imported module's `@registry.register` calls populate the
|
||||
route registry with their handler endpoints.
|
||||
"""
|
||||
from daemon.handlers import (
|
||||
acme, # noqa: F401
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
network, # noqa: F401
|
||||
nginx, # noqa: F401
|
||||
wireguard, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for vacuum-walld."""
|
||||
"""Entry point for vacuum-walld.
|
||||
|
||||
Sets up logging, registers routes, creates the aiohttp application,
|
||||
and starts listening on the Unix socket.
|
||||
"""
|
||||
from lib.logging import setup_logging
|
||||
|
||||
setup_logging()
|
||||
@@ -252,6 +467,8 @@ def main() -> None:
|
||||
loop.run_until_complete(runner.setup())
|
||||
site = web.UnixSite(runner, socket_path)
|
||||
loop.run_until_complete(site.start())
|
||||
tcp_site = web.TCPSite(runner, "127.0.0.1", _WS_PORT)
|
||||
loop.run_until_complete(tcp_site.start())
|
||||
|
||||
os.chmod(socket_path, 0o660)
|
||||
|
||||
@@ -259,6 +476,7 @@ def main() -> None:
|
||||
logger.info("Populating system state...")
|
||||
state_store.populate()
|
||||
logger.info("vacuum-walld listening on %s", socket_path)
|
||||
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
|
||||
|
||||
try:
|
||||
loop.run_forever()
|
||||
|
||||
+187
-2
@@ -965,7 +965,29 @@ Set or update the ACME account contact email.
|
||||
|
||||
**Response (`data`):** Returns the set `email` field.
|
||||
|
||||
---
|
||||
#### Generate Self-Signed Certificate
|
||||
|
||||
```
|
||||
POST /api/certs/self-signed
|
||||
```
|
||||
|
||||
Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `<domain>.key` already exist at `data/acme/<domain>/`.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Domain name for the certificate CN |
|
||||
| `days` | `number` | No | Validity in days; defaults to `365` |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `domain` | `string` | Domain name |
|
||||
| `cert` | `string` | Path to `fullchain.cer` |
|
||||
| `key` | `string` | Path to `<domain>.key` |
|
||||
| `generated` | `boolean` | `true` if a new cert was created, `false` if existing cert was reused |
|
||||
|
||||
## WireGuard API
|
||||
|
||||
@@ -1190,9 +1212,172 @@ Returns HTTP `404` if the peer is not found.
|
||||
|
||||
---
|
||||
|
||||
## Network API
|
||||
|
||||
Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters.
|
||||
|
||||
### Interface Management
|
||||
|
||||
#### List All Interfaces
|
||||
|
||||
```
|
||||
GET /api/network/interfaces
|
||||
```
|
||||
|
||||
Return all configured interfaces with their network config and runtime state from `networkctl`.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data.interfaces` | `object` | Map of interface name to `{config, runtime}` |
|
||||
| `data.timestamp` | `string` | Timestamp of runtime data collection |
|
||||
|
||||
---
|
||||
|
||||
#### Get Interface Details
|
||||
|
||||
```
|
||||
GET /api/network/interfaces/<name>
|
||||
```
|
||||
|
||||
Return config and runtime state for a specific interface.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Interface name |
|
||||
| `config` | `object` | Full networkd config entry for this interface |
|
||||
| `runtime` | `object` | Runtime state from `networkctl` (addresses, gateway, DNS, state) |
|
||||
|
||||
Returns HTTP `400` if the interface name is invalid (contains path components, spaces, or characters outside `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`). Returns HTTP `404` if the interface is not found in config.
|
||||
|
||||
---
|
||||
|
||||
#### Save and Apply Interface
|
||||
|
||||
```
|
||||
POST /api/network/interfaces/<name>
|
||||
```
|
||||
|
||||
Save network config for an interface, render the `.network` file, copy it to `/etc/systemd/network/`, and reload networkd for that interface.
|
||||
|
||||
**Request Body:** Any networkd config keys (e.g., `addresses`, `gateway`, `dns`, `routes`, `dhcp`, `link`, `dhcp_client`).
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Interface name |
|
||||
| `applied` | `boolean` | Always `true` on success |
|
||||
|
||||
Returns HTTP `400` if the interface name is invalid.
|
||||
|
||||
---
|
||||
|
||||
#### Reload Interface
|
||||
|
||||
```
|
||||
POST /api/network/interfaces/<name>/reload
|
||||
```
|
||||
|
||||
Reload networkd for a single interface (runs `networkctl reload <name>`).
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Interface name |
|
||||
| `reloaded` | `boolean` | Always `true` on success |
|
||||
|
||||
Returns HTTP `400` if the interface name is invalid.
|
||||
|
||||
### Full Sync
|
||||
|
||||
#### Apply All Interfaces
|
||||
|
||||
```
|
||||
POST /api/network/apply
|
||||
```
|
||||
|
||||
Full sync: generate all `.network` files, remove stale files, copy to `/etc/systemd/network/`, reload all interfaces, and sync DNS upstreams to dnsmasq.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `applied` | `number` | Number of interfaces applied |
|
||||
| `files` | `[string, ...]` | Paths of generated files |
|
||||
| `cleaned` | `[string, ...]` | Paths of removed stale files |
|
||||
|
||||
### Helpers
|
||||
|
||||
#### Infer DHCP Ranges
|
||||
|
||||
```
|
||||
GET /api/network/infer-dhcp-ranges
|
||||
```
|
||||
|
||||
Suggest candidate DHCP ranges based on static interface IPs. For each interface with a static IPv4 address, calculates a usable address range in the subnet.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data.ranges` | `object` | Map of interface name to `{subnet, prefix, start, end}` |
|
||||
|
||||
---
|
||||
|
||||
#### Infer Firewall Zones
|
||||
|
||||
```
|
||||
GET /api/network/infer-zones
|
||||
```
|
||||
|
||||
Suggest firewalld zone assignments for configured interfaces based on heuristics:
|
||||
- Interface name contains `wg` → `wan`
|
||||
- DHCP-enabled or public-facing IP → `wan`
|
||||
- Has explicit routes → `management`
|
||||
- Everything else → `lan`
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
|
||||
|
||||
### Sysctl
|
||||
|
||||
#### Set Kernel Parameter
|
||||
|
||||
```
|
||||
POST /api/sysctl/set
|
||||
```
|
||||
|
||||
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) |
|
||||
| `value` | `string` | Yes | Value to set |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Parameter name |
|
||||
| `value` | `string` | Value set |
|
||||
|
||||
Returns HTTP `500` if the value cannot be verified after write.
|
||||
|
||||
---
|
||||
|
||||
## Logs API
|
||||
|
||||
Endpoints prefixed with `/api/logs/...`. Serve rendered HTML log line fragments for HTMX consumption. 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/...`. 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.
|
||||
|
||||
### System Journal
|
||||
|
||||
|
||||
+34
-1
@@ -34,12 +34,13 @@ The following diagram summarizes how the Flask WebUI communicates with each mana
|
||||
|
||||
```
|
||||
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
|
||||
Flask WebUI ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp server)
|
||||
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
|
||||
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||
vacuum-walld ──→ daemon/handlers/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/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
|
||||
```
|
||||
|
||||
@@ -77,6 +78,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
|
||||
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||
| 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. |
|
||||
|
||||
## Directory Structure
|
||||
@@ -95,6 +97,8 @@ config/
|
||||
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
||||
└── wireguard/
|
||||
└── config.json # WireGuard interface and peer configuration
|
||||
├── network/
|
||||
│ └── config.json # Per-interface static IP, routes, DNS, DHCP settings
|
||||
```
|
||||
|
||||
### Data — Runtime Artifacts
|
||||
@@ -114,6 +118,7 @@ data/
|
||||
├── logs/
|
||||
│ └── vacuum-wall.log # Application log file
|
||||
└── wireguard/ # WireGuard runtime artifacts
|
||||
├── 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.
|
||||
@@ -128,10 +133,38 @@ The following file system locations are used for integration with system service
|
||||
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
|
||||
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
||||
| `/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) |
|
||||
|
||||
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.
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
The web UI is a single-page application built on **Hoover**, a custom lightweight VDOM framework. See [Hoover Framework Reference](hoover.md) for the complete API.
|
||||
|
||||
### 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)
|
||||
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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### WebSocket Broadcast
|
||||
|
||||
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.
|
||||
|
||||
## Zone Model
|
||||
|
||||
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
|
||||
|
||||
@@ -292,3 +292,93 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
||||
### Applying Firewall Configuration
|
||||
|
||||
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`.
|
||||
|
||||
## Networkd (IP Configuration)
|
||||
|
||||
**File**: `config/network/config.json`
|
||||
|
||||
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-<name>.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`.
|
||||
|
||||
```json
|
||||
{
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"addresses": ["192.168.1.1/24"],
|
||||
"gateway": "192.168.1.254",
|
||||
"dns": ["8.8.8.8", "1.1.1.1"],
|
||||
"dhcp": "no"
|
||||
},
|
||||
"eth1": {
|
||||
"dhcp": "ipv4",
|
||||
"dns_default_route": true,
|
||||
"dhcp_client": {
|
||||
"hostname": "router",
|
||||
"use_dns": true
|
||||
}
|
||||
},
|
||||
"wg0": {
|
||||
"addresses": [{"address": "10.137.0.1/24"}],
|
||||
"routes": [
|
||||
{
|
||||
"destination": "10.0.0.0/8",
|
||||
"gateway": "10.137.0.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interface Entry Fields
|
||||
|
||||
Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`, `wg0`). The value is a dict with the following keys:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `addresses` | `array` | IPv4 addresses. Each item is either a bare CIDR string (`"192.168.1.1/24"`) or a dict with `address`, `label`, `scope`, `route_metric`, `duplicate_address_detection`, `manage_temporary_address`, `add_prefix_route`. Renders to `[Address]` sections. |
|
||||
| `ipv6_addresses` | `array` | Same as `addresses`, but for IPv6. |
|
||||
| `gateway` | `string` | Default IPv4 gateway (`[Network] Gateway=`). |
|
||||
| `ipv6_gateway` | `string` | Default IPv6 gateway (`[Network] IPv6Gateway=`). |
|
||||
| `dns` | `array` | IPv4 DNS servers (`[Network] DNS=`, one per line). |
|
||||
| `ipv6_dns` | `array` | IPv6 DNS servers (`[Network] IPv6DNS=`). |
|
||||
| `domains` | `array` | Search domains (`[Network] Domains=`). |
|
||||
| `ipv6_domains` | `array` | IPv6 search domains (`[Network] IPv6Domains=`). |
|
||||
| `dns_default_route` | `boolean` | Whether DNS is the default route for resolution (`[Network] DNSDefaultRoute=`). |
|
||||
| `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. |
|
||||
| `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. |
|
||||
| `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. |
|
||||
| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. |
|
||||
| `bind_carrier` | `array` | Carrier interfaces to bind to. |
|
||||
| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. |
|
||||
| `keep_configuration` | `boolean` | Keep configuration on stop. |
|
||||
| `configure_without_carrier` | `boolean` | Configure even without carrier. |
|
||||
| `link_local_addressing` | `string` | Link-local addressing mode. |
|
||||
| `ipv6_link_local_address_generation_mode` | `string` | IPv6 link-local address generation mode. |
|
||||
| `ipv6_stable_secret_address` | `string` | Stable secret for IPv6 address generation. |
|
||||
| `ipv4_ll_start_address` | `string` | Link-local IPv4 start address. |
|
||||
| `ipv4_ll_route` | `boolean` | Add route to link-local IPv4 address. |
|
||||
| `default_route_on_device` | `boolean` | Always add default route via this device. |
|
||||
| `ipv6_hop_limit` | `int` | IPv6 hop limit. |
|
||||
| `ipv6_retransmission_time_sec` | `string` | IPv6 retransmission timeout. |
|
||||
| `ipv4_duplicate_address_detection_timeout_sec` | `string` | IPv4 DAD timeout. |
|
||||
| `ipv4_reverse_path_filter` | `string` | IPv4 reverse path filtering mode. |
|
||||
| `ipv4_accept_local` | `boolean` | Accept packets to local addresses as non-local. |
|
||||
| `ipv4_route_localnet` | `boolean` | Route local network traffic. |
|
||||
| `ipv4_proxy_arp` | `boolean` | Enable proxy ARP. |
|
||||
| `ipv4_proxy_arp_private_vlan` | `boolean` | Private VLAN proxy ARP. |
|
||||
| `ipv6_proxy_ndp` | `boolean` | Enable IPv6 proxy NDP. |
|
||||
| `ipv6_proxy_ndp_address` | `string` | IPv6 proxy NDP address. |
|
||||
| `ipv6_send_ra` | `boolean` | Send IPv6 Router Advertisements. |
|
||||
| `m_pls_routing` | `boolean` | Enable MPLS routing. |
|
||||
| `keep_master` | `boolean` | Keep master on stop. |
|
||||
| `ip_family` | `string` | IP family to use. |
|
||||
|
||||
Keys not in the recognized set will be saved to `config.json` but won't be rendered to `.network` files. A warning is logged identifying any unrecognized keys.
|
||||
|
||||
### DNS Upstream Sync
|
||||
|
||||
When `POST /api/network/apply` is called, the handler automatically collects public DNS servers from all networkd interface configs (via `collect_upstream_dns()`), filters out local/private-range addresses, and syncs the deduplicated list to dnsmasq's upstream DNS configuration. This keeps dnsmasq's upstream resolvers in sync with whatever DNS the WAN interface receives (whether statically configured or via DHCP).
|
||||
|
||||
### 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]`).
|
||||
+1115
File diff suppressed because it is too large
Load Diff
+30
-8
@@ -2,11 +2,11 @@
|
||||
|
||||
## What is Vacuum Wall?
|
||||
|
||||
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
|
||||
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, systemd-networkd for static IP management, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Vacuum Wall is built around four 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 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 with basic HTTP authentication.
|
||||
|
||||
## Subsystems
|
||||
|
||||
@@ -22,6 +22,10 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
|
||||
|
||||
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (ZeroSSL by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
|
||||
|
||||
### Network (systemd-networkd)
|
||||
|
||||
The networkd subsystem manages static IP configuration for network interfaces via systemd-networkd. It renders declarative JSON configuration into per-interface `.network` INI files (`50-<name>.network`), supporting static addresses, routes, DNS, DHCP clients, link settings, and all `[Address]`, `[Route]`, `[DHCPv4]`, `[DHCPv6]`, and `[Link]` section keys. When the full apply runs, public DNS servers from networkd configs are auto-synced to dnsmasq's upstream resolvers. Helper endpoints can infer candidate DHCP ranges from static IPs and suggest firewalld zone assignments based on interface role.
|
||||
|
||||
### WireGuard
|
||||
|
||||
WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage.
|
||||
@@ -31,12 +35,11 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
|
||||
- Debian 13 (trixie) target platform
|
||||
- Python 3.13+, Flask 3.x for web management
|
||||
- firewalld (nftables backend)
|
||||
- systemd-networkd (ip-lladdr, networkctl)
|
||||
- nginx 1.26+
|
||||
- dnsmasq
|
||||
- WireGuard tools (wireguard-tools)
|
||||
- acme.sh for ACME certificate management (ZeroSSL by default)
|
||||
- HTMX for dynamic UI updates
|
||||
- Jinja2 for server-side templating
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -60,6 +63,7 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
├── .venv/ # Python virtual environment
|
||||
├── config/ # Declarative JSON configuration (source of truth)
|
||||
│ ├── dnsmasq/ # DHCP/DNS config
|
||||
│ ├── network/ # systemd-networkd per-interface config
|
||||
│ ├── firewall/ # Firewall zone & rule config
|
||||
│ ├── nginx/ # Proxy domain & SSL config
|
||||
│ └── wireguard/ # VPN interface & peer config
|
||||
@@ -69,11 +73,13 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── acme/ # ACME certificates
|
||||
│ ├── firewall/ # Firewall rule backup
|
||||
│ ├── logs/ # Application logs
|
||||
│ ├── networkd/ # Generated 50-<name>.network files
|
||||
│ └── wireguard/ # Generated WireGuard configs
|
||||
├── daemon/ # Privileged background daemon
|
||||
│ ├── server.py # aiohttp server, cache, batch routing, handler registry
|
||||
│ ├── client.py # Sync HTTP client over Unix socket
|
||||
│ └── handlers/ # Privileged operation handlers (all sudo calls)
|
||||
│ ├── handlers/ # Privileged operation handlers (all sudo calls)
|
||||
│ │ └── network.py # networkd handler (generate + apply)
|
||||
├── system/ # System file templates (all Jinja2)
|
||||
│ ├── systemd/ # Service and timer unit files
|
||||
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||
@@ -87,8 +93,10 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||
│ ├── 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
|
||||
├── webui/ # Flask web application
|
||||
@@ -100,16 +108,29 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ │ ├── proxy.py # Nginx proxy API
|
||||
│ │ ├── certs.py # Certificate API
|
||||
│ │ ├── wireguard.py # WireGuard API
|
||||
│ │ ├── network.py # Networkd API
|
||||
│ │ └── logs.py # Logs API
|
||||
│ ├── templates/ # Jinja2/HTMX templates
|
||||
│ └── static/ # CSS and client-side JS
|
||||
│ └── static/ # SPA (index.html, app.js, style.css)
|
||||
│ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
|
||||
│ │ ├── index.js # Barrel export of all public APIs
|
||||
│ │ ├── reactivity.js
|
||||
│ │ ├── vdom.js
|
||||
│ │ ├── render.js
|
||||
│ │ ├── component.js
|
||||
│ │ ├── router.js
|
||||
│ │ ├── websocket.js
|
||||
│ │ ├── api.js
|
||||
│ │ ├── helpers.js
|
||||
│ │ └── components/ # Layout, data display, modal, toast
|
||||
│ └── pages/ # Page modules (each defines a route via definePage)
|
||||
├── docs/ # Documentation
|
||||
│ ├── overview.md # This file
|
||||
│ ├── deployment.md
|
||||
│ ├── api.md
|
||||
│ ├── security.md
|
||||
│ ├── architecture.md
|
||||
│ └── config.md
|
||||
│ ├── config.md
|
||||
│ └── hoover.md # Hoover SPA framework
|
||||
└── scripts/ # Utility scripts
|
||||
└── update-vendor.sh # Vendor frontend library updates
|
||||
```
|
||||
@@ -121,3 +142,4 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
- [Security Model](security.md) - Privilege model and sudo whitelist
|
||||
- [Architecture](architecture.md) - Detailed subsystem design
|
||||
- [Configuration](config.md) - Config file formats and locations
|
||||
- [Hoover Framework](hoover.md) - Frontend SPA framework reference
|
||||
|
||||
@@ -40,6 +40,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
|
||||
| Certificates | (none) | acme.sh runs as the non-root service 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 |
|
||||
| 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 |
|
||||
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
|
||||
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
|
||||
|
||||
@@ -50,6 +53,10 @@ Key safety properties:
|
||||
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement.
|
||||
- The sudoers file is rendered from a Jinja2 template at install time, substituting the configured user name.
|
||||
|
||||
## Daemon Client Path Resolution
|
||||
|
||||
The `daemon/client.py` module resolves `<param>` placeholders in URL paths before sending requests over the Unix socket. For example, a request to `/network/interfaces/<name>` with a body containing `{"name": "eth0"}` is rewritten to `/network/interfaces/eth0` before transmission. Parameter values are URL-encoded to handle special characters safely. This eliminates the need for the API layer to construct literal paths and ensures the daemon always receives concrete paths for routing.
|
||||
|
||||
## Web Security
|
||||
|
||||
### Management Interface
|
||||
@@ -132,6 +139,15 @@ The `lib/firewall` module is a generic firewalld parser with no hardcoded zone d
|
||||
|
||||
IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the `internal` zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.
|
||||
|
||||
## Input Validation
|
||||
|
||||
Interface names provided via the API are validated at two layers before any file system access or subprocess invocation:
|
||||
|
||||
- **API layer** (`webui/api/network.py`): The Flask route calls `validate_interface_name()` from `lib/common.py`, rejecting any name that doesn't match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`. Names containing `/`, `..`, spaces, or other disallowed characters return HTTP `400`.
|
||||
- **Daemon handler layer** (`daemon/handlers/network.py`): Each handler re-validates the name from the request body using the same function. An invalid name raises `ValueError`, which the daemon converts to an error response before any `sudo` call.
|
||||
|
||||
This defense-in-depth approach ensures that even if a request bypasses the API layer, the daemon will still reject malicious interface names.
|
||||
|
||||
## Certificate Security
|
||||
|
||||
### acme.sh Integration
|
||||
|
||||
+128
-290
@@ -46,21 +46,21 @@ while [[ $# -gt 0 ]]; do
|
||||
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||
" --acme-email EMAIL ACME registration email (required)" \
|
||||
" --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \
|
||||
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||
" -h, --help Show this help" \
|
||||
"" \
|
||||
"All options also have environment variable equivalents:" \
|
||||
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
|
||||
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
|
||||
" MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \
|
||||
" CLI flags take precedence over env vars." \
|
||||
"" \
|
||||
"Example (dev):" \
|
||||
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
|
||||
" ./install.sh --dev --mgmt-pass pass" \
|
||||
"" \
|
||||
"Example (prod):" \
|
||||
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall"
|
||||
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -74,6 +74,7 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Required settings (no defaults — must be provided)
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
# ACME_EMAIL is optional — will be configured from the WebUI
|
||||
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
|
||||
|
||||
# Optional settings with defaults
|
||||
@@ -111,17 +112,15 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
||||
# --- Validate required settings ---
|
||||
missing=()
|
||||
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
|
||||
|
||||
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';;
|
||||
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
|
||||
esac
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
|
||||
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
|
||||
exit 1
|
||||
fi
|
||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
||||
@@ -287,171 +286,13 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- 7. Enable IP forwarding ---
|
||||
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
|
||||
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
|
||||
sysctl -w net.ipv4.ip_forward=1 2>/dev/null || warn "Could not enable IP forwarding (may need kernel access)"
|
||||
|
||||
# --- 8. Start and configure firewalld ---
|
||||
log "Enabling firewalld..."
|
||||
systemctl enable firewalld >/dev/null 2>&1 || warn "Could not enable firewalld (already running?)"
|
||||
systemctl start firewalld >/dev/null 2>&1 || warn "Could not start firewalld (may need D-Bus)"
|
||||
|
||||
firewall-cmd --permanent --add-service=http >/dev/null 2>&1 && \
|
||||
log "Added service http to public zone" || \
|
||||
warn "Could not add service http to public zone (already exists?)"
|
||||
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 && \
|
||||
log "Added service https to public zone" || \
|
||||
warn "Could not add service https to public zone (already exists?)"
|
||||
firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 && \
|
||||
log "Added service ssh to public zone" || \
|
||||
warn "Could not add service ssh to public zone (already exists?)"
|
||||
firewall-cmd --reload >/dev/null 2>&1 && \
|
||||
log "Firewalld rules reloaded" || \
|
||||
warn "Could not reload firewalld rules"
|
||||
|
||||
# --- 9. Configure dnsmasq ---
|
||||
log "Configuring dnsmasq..."
|
||||
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
|
||||
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
|
||||
log "dnsmasq configured (will fully start after DHCP ranges are set)"
|
||||
|
||||
# --- 10. Setup nginx management proxy ---
|
||||
mkdir -p "$ACME_HOME/$DOMAIN"
|
||||
|
||||
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
|
||||
log "SSL certificate already exists for $DOMAIN, skipping."
|
||||
else
|
||||
log "Generating self-signed certificate for management domain..."
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
|
||||
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
|
||||
-subj "/CN=$DOMAIN" \
|
||||
-addext "subjectAltName=DNS:$DOMAIN"
|
||||
fi
|
||||
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
|
||||
|
||||
# Generate/update htpasswd directly in data/nginx/
|
||||
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||
if [[ -f "$HTPASSWD_FILE" ]]; then
|
||||
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
warn "Could not update htpasswd (install apache2-utils)"
|
||||
else
|
||||
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" python3 -c "
|
||||
import os, crypt, base64
|
||||
password = os.environ['MGMT_PASS']
|
||||
user = os.environ['MGMT_USER']
|
||||
salt = '\$6\$' + base64.b64encode(os.urandom(16)).decode().rstrip('=')[:16]
|
||||
hashed = crypt.crypt(password, salt)
|
||||
with open(os.environ['HTFILE'], 'w') as f:
|
||||
f.write(user + ':' + hashed + '\n')
|
||||
" 2>/dev/null || \
|
||||
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
||||
fi
|
||||
|
||||
chown "$USER_NAME:$USER_GROUP" "${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||
|
||||
# Remove default nginx site so vacuum-wall management config takes precedence
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Write initial management proxy config directly to /etc/nginx/conf.d/.
|
||||
# This bootstrap config is needed before the WebUI is running. Once the
|
||||
# WebUI is up, it manages proxy configs from config/nginx/config.json
|
||||
# and renders them to data/nginx/sites-enabled/.
|
||||
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
|
||||
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
|
||||
# Vacuum Wall - WebSocket upgrade map
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
MAPEOF
|
||||
|
||||
# Write the management site block (conf.d/ is inside http {}, no extra http {} needed)
|
||||
cat > /etc/nginx/conf.d/vacuum-wall-mgmt.conf <<MGMTSITEEOF
|
||||
# Vacuum Wall - Management Proxy
|
||||
# Auto-generated by install.sh
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $DOMAIN;
|
||||
|
||||
ssl_certificate $ACME_HOME/$DOMAIN/fullchain.cer;
|
||||
ssl_certificate_key $ACME_HOME/$DOMAIN/$DOMAIN.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
auth_basic "Vacuum Wall";
|
||||
auth_basic_user_file ${PROJECT_DIR}/data/nginx/.htpasswd;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection \$connection_upgrade;
|
||||
}
|
||||
}
|
||||
MGMTSITEEOF
|
||||
|
||||
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
|
||||
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
|
||||
if [[ -f "$NGINX_CFG" ]]; then
|
||||
log "Nginx config already exists, skipping initial write."
|
||||
else
|
||||
log "Writing initial nginx configuration..."
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import json, os
|
||||
d = os.environ['MGMT_DOMAIN']
|
||||
u = os.environ['MGMT_USER']
|
||||
p = os.environ['INSTALL_DIR']
|
||||
cfg = {
|
||||
'domains': {},
|
||||
'management': {
|
||||
'domain': d,
|
||||
'backend': {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9090,
|
||||
'proto': 'http'
|
||||
},
|
||||
'auth': {
|
||||
'user': u,
|
||||
'htpasswd': p + '/data/nginx/.htpasswd'
|
||||
}
|
||||
},
|
||||
'ssl': {
|
||||
'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
|
||||
}
|
||||
}
|
||||
with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
f.write('\n')
|
||||
"
|
||||
fi
|
||||
|
||||
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
|
||||
# --- 8. Detect network interfaces ---
|
||||
log "Detecting network interfaces..."
|
||||
|
||||
# Auto-detect WAN (interface with default gateway)
|
||||
@@ -480,134 +321,146 @@ if [[ -z "$LAN_IFACES" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate config/firewall/config.json (skip if user has customized it)
|
||||
FIREWALL_CFG="${PROJECT_DIR}/config/firewall/config.json"
|
||||
if [[ -f "$FIREWALL_CFG" ]]; then
|
||||
log "Firewall config already exists, skipping initial write."
|
||||
# --- 9. Enable and start core services ---
|
||||
log "Enabling services..."
|
||||
systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld"
|
||||
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
|
||||
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
|
||||
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
|
||||
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
|
||||
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
|
||||
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
|
||||
|
||||
# Clean up old nginx bootstrap configs (replaced by daemon-generated config)
|
||||
rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf
|
||||
|
||||
# Stop all services to ensure clean start order
|
||||
systemctl stop vacuum-wall >/dev/null 2>&1 || true
|
||||
systemctl stop vacuum-walld >/dev/null 2>&1 || true
|
||||
|
||||
# Start services in dependency order
|
||||
systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld"
|
||||
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
|
||||
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
|
||||
|
||||
# Start daemon and wait for socket
|
||||
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
|
||||
|
||||
_SOCKET="$PROJECT_DIR/data/daemon.sock"
|
||||
for _i in $(seq 1 30); do
|
||||
[[ -S "$_SOCKET" ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ ! -S "$_SOCKET" ]]; then
|
||||
warn "Daemon socket not found at $_SOCKET — skipping API configuration"
|
||||
else
|
||||
log "Writing initial firewall configuration..."
|
||||
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" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
MGMT_PASS="$MGMT_PASS" \
|
||||
ACME_EMAIL="$ACME_EMAIL" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import json, os
|
||||
import daemon.client as c
|
||||
from daemon.iface import (
|
||||
POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY,
|
||||
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
|
||||
POST_ACME_EMAIL, GET_NETWORK_INFER_DHCP_RANGES,
|
||||
)
|
||||
import sys
|
||||
|
||||
wan = os.environ.get('WAN_IFACE', '').strip() or None
|
||||
lan = os.environ.get('LAN_IFACES', '').strip() or None
|
||||
p = os.environ['INSTALL_DIR']
|
||||
domain = '${DOMAIN}'
|
||||
mgmt_user = '${MGMT_USER}'
|
||||
mgmt_pass = '${MGMT_PASS}'
|
||||
acme_email = '${ACME_EMAIL}'
|
||||
wan_iface = '${WAN_IFACE}'
|
||||
lan_ifaces = '${LAN_IFACES}'
|
||||
|
||||
cfg = {'zones': {}}
|
||||
# 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)
|
||||
|
||||
if wan:
|
||||
cfg['zones']['public'] = {
|
||||
# Management proxy + htpasswd
|
||||
try:
|
||||
c.post(POST_NGINX_MANAGEMENT, {
|
||||
'domain': domain,
|
||||
'flask_host': '127.0.0.1',
|
||||
'flask_port': 9090,
|
||||
'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)
|
||||
|
||||
# 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.split(',') if i],
|
||||
'interfaces': [i for i in wan_iface.split(',') if i],
|
||||
'services': ['http', 'https', 'ssh'],
|
||||
'masquerade': True,
|
||||
}
|
||||
|
||||
if lan:
|
||||
cfg['zones']['internal'] = {
|
||||
if lan_ifaces:
|
||||
zones['internal'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [i for i in lan.split(',') if i],
|
||||
'interfaces': [i for i in lan_ifaces.split(',') if i],
|
||||
'services': ['dhcp', 'dns', 'ntp'],
|
||||
'masquerade': False,
|
||||
}
|
||||
|
||||
# Always create vpn zone skeleton for later WireGuard setup
|
||||
cfg['zones']['vpn'] = {
|
||||
zones['vpn'] = {
|
||||
'target': 'ACCEPT',
|
||||
'interfaces': [],
|
||||
'services': [],
|
||||
'masquerade': False,
|
||||
}
|
||||
|
||||
with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write('\n')
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# ACME email (optional)
|
||||
if acme_email:
|
||||
try:
|
||||
c.post(POST_ACME_EMAIL, {'email': acme_email})
|
||||
print(f' [acme] Email set to {acme_email}')
|
||||
except Exception as e:
|
||||
print(f' [acme] Warning: {e}', file=sys.stderr)
|
||||
|
||||
# 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
|
||||
"
|
||||
fi
|
||||
|
||||
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
|
||||
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 && \
|
||||
log "Created firewalld zone: internal" || \
|
||||
warn "firewalld zone 'internal' may already exist"
|
||||
firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || \
|
||||
warn "Could not set target ACCEPT on internal zone"
|
||||
firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 && \
|
||||
log "Added service dhcp to internal zone" || \
|
||||
warn "Could not add service dhcp to internal zone"
|
||||
firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 && \
|
||||
log "Added service dns to internal zone" || \
|
||||
warn "Could not add service dns to internal zone"
|
||||
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 && \
|
||||
log "Added service ntp to internal zone" || \
|
||||
warn "Could not add service ntp to internal zone"
|
||||
|
||||
firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 && \
|
||||
log "Created firewalld zone: vpn" || \
|
||||
warn "firewalld zone 'vpn' may already exist"
|
||||
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || \
|
||||
warn "Could not set target ACCEPT on vpn zone"
|
||||
|
||||
# Apply masquerade on public/WAN
|
||||
if [[ -n "$WAN_IFACE" ]]; then
|
||||
firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 && \
|
||||
log "Enabled masquerade on public zone ($WAN_IFACE)" || \
|
||||
warn "Could not enable masquerade on public zone"
|
||||
firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 && \
|
||||
log "Assigned $WAN_IFACE to public zone" || \
|
||||
warn "Could not assign $WAN_IFACE to public zone"
|
||||
fi
|
||||
|
||||
# Assign LAN interfaces to internal zone
|
||||
if [[ -n "$LAN_IFACES" ]]; then
|
||||
IFS=',' read -ra LAN_ARRAY <<< "$LAN_IFACES"
|
||||
for iface in "${LAN_ARRAY[@]}"; do
|
||||
iface=$(echo "$iface" | xargs)
|
||||
[[ -z "$iface" ]] && continue
|
||||
firewall-cmd --permanent --zone=internal --add-interface="$iface" >/dev/null 2>&1 && \
|
||||
log "Assigned $iface to internal zone" || \
|
||||
warn "Could not assign $iface to internal zone"
|
||||
done
|
||||
fi
|
||||
|
||||
firewall-cmd --reload >/dev/null 2>&1 && \
|
||||
log "Firewalld rules reloaded" || \
|
||||
warn "Could not reload firewalld rules"
|
||||
|
||||
# --- 13. Enable and start services ---
|
||||
log "Enabling services..."
|
||||
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
|
||||
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
|
||||
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
|
||||
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
|
||||
|
||||
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
|
||||
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
|
||||
|
||||
# Start daemon first, then web UI
|
||||
# Stop vacuum-wall first. vacuum-wall.service Requires=vacuum-walld.service,
|
||||
# so stopping vacuum-wall triggers a cascade stop of vacuum-walld. The explicit
|
||||
# stop of vacuum-walld below is redundant but ensures clean teardown.
|
||||
systemctl stop vacuum-wall >/dev/null 2>&1 || true
|
||||
systemctl stop vacuum-walld >/dev/null 2>&1 || true
|
||||
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
|
||||
|
||||
# Wait for daemon socket
|
||||
_SOCKET="$PROJECT_DIR/data/daemon.sock"
|
||||
for _i in $(seq 1 10); do
|
||||
[[ -S "$_SOCKET" ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ ! -S "$_SOCKET" ]]; then
|
||||
warn "Daemon socket not found at $_SOCKET"
|
||||
fi
|
||||
|
||||
# Set socket ownership so web UI user can connect
|
||||
if [[ -S "$_SOCKET" ]]; then
|
||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
|
||||
chmod 0660 "$_SOCKET" 2>/dev/null || true
|
||||
log "Subsystem configuration complete"
|
||||
fi
|
||||
|
||||
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
|
||||
@@ -616,22 +469,6 @@ nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
|
||||
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
|
||||
warn "Could not restart nginx (check config)"
|
||||
|
||||
# --- 14. Configure acme.sh default email ---
|
||||
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
|
||||
log "acme.sh account already registered, skipping."
|
||||
else
|
||||
# acme.sh must never run as root — always as the service user via sudo -u.
|
||||
# This prevents acme.sh from running any command as root and limits its
|
||||
# ability to modify system files.
|
||||
log "Registering acme.sh account with email $ACME_EMAIL..."
|
||||
mkdir -p "$ACME_HOME/www"
|
||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
|
||||
sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
||||
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
|
||||
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
||||
fi
|
||||
|
||||
# --- Done ---
|
||||
echo ""
|
||||
echo "============================================"
|
||||
@@ -657,10 +494,11 @@ else
|
||||
fi
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Verify zone assignments at https://$DOMAIN/interfaces"
|
||||
echo " 2. Configure DHCP ranges for your LAN"
|
||||
echo " 3. Add proxy domains with ACME certificates"
|
||||
echo " 4. Set up WireGuard tunnel (optional)"
|
||||
echo " 1. Set ACME contact email at https://$DOMAIN/certs/settings"
|
||||
echo " 2. Verify zone assignments at https://$DOMAIN/interfaces"
|
||||
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"
|
||||
|
||||
+20
-1
@@ -135,7 +135,16 @@ def set_email(email: str) -> None:
|
||||
|
||||
|
||||
def get_email() -> str:
|
||||
"""Return the ACME contact email, or '' if none is configured."""
|
||||
"""Return the ACME contact email, or '' if none is configured.
|
||||
|
||||
Checks account.conf first (acme.sh registered account), then falls
|
||||
back to the declarative acme config.
|
||||
"""
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _read_acme_email() -> str:
|
||||
"""Read ACME email from account.conf, falling back to declarative config."""
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
@@ -146,6 +155,16 @@ def get_email() -> str:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read account.conf: %s", exc)
|
||||
# Fallback: read from declarative ACME config
|
||||
try:
|
||||
from lib.common import load_json
|
||||
|
||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||
conf = load_json(acme_cfg)
|
||||
if conf and "email" in conf:
|
||||
return conf["email"]
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,38 @@ deep merging, and directory creation used across all subsystem modules.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def validate_interface_name(name: str) -> str:
|
||||
"""Validate a Linux network interface name.
|
||||
|
||||
Args:
|
||||
name: Interface name to validate.
|
||||
|
||||
Returns:
|
||||
The validated (stripped) name.
|
||||
|
||||
Raises:
|
||||
ValueError: When the name is empty, contains path components,
|
||||
or does not match Linux interface naming rules.
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Interface name must be a non-empty string")
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("Interface name must not be blank")
|
||||
if "/" in name or ".." in name or " " in name:
|
||||
raise ValueError(f"Invalid interface name: {name!r}")
|
||||
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", name):
|
||||
raise ValueError(f"Invalid interface name: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def run(
|
||||
cmd: list[str],
|
||||
check: bool = True,
|
||||
@@ -134,4 +160,5 @@ __all__ = [
|
||||
"run",
|
||||
"run_proc",
|
||||
"save_json",
|
||||
"validate_interface_name",
|
||||
]
|
||||
|
||||
+20
-5
@@ -101,10 +101,25 @@ def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
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,
|
||||
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,
|
||||
@@ -182,8 +197,8 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
|
||||
for i, lease in enumerate(leases):
|
||||
if lease["mac"].lower() == mac.lower():
|
||||
leases[i] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
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)
|
||||
@@ -219,8 +234,8 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
|
||||
|
||||
for i, r in enumerate(records):
|
||||
if r["name"] == name:
|
||||
records[i] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
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)
|
||||
|
||||
+47
-2
@@ -10,6 +10,7 @@ Output:
|
||||
viewing via the WebUI ``/logs`` page.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -66,15 +67,59 @@ def setup_logging(level: str | None = None) -> None:
|
||||
sh.setFormatter(fmt)
|
||||
root.addHandler(sh)
|
||||
|
||||
# rotating file handler
|
||||
class GroupWriteHandler(RotatingFileHandler):
|
||||
"""RotatingFileHandler that always opens files with group-write mode.
|
||||
|
||||
Ensures the log file is group-writable so both the WebUI process
|
||||
(vacuum-wall user) and daemon process (vacuum-walld user) can write
|
||||
to it when they share a group.
|
||||
"""
|
||||
|
||||
def _open(self):
|
||||
# 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):
|
||||
os.chmod(self.baseFilename, 0o664)
|
||||
# Temporarily clear group-write umask bits so os.open's mode is
|
||||
# not masked away.
|
||||
old = os.umask(0o002)
|
||||
try:
|
||||
fd = os.open(
|
||||
self.baseFilename, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o664
|
||||
)
|
||||
finally:
|
||||
os.umask(old)
|
||||
return os.fdopen(fd, "a", errors="backslashreplace")
|
||||
|
||||
def doRollover(self):
|
||||
"""Override to enforce group-write on rotated files."""
|
||||
super().doRollover()
|
||||
# Set group-write on all log files (current + backups)
|
||||
base = Path(self.baseFilename)
|
||||
for suffix in ("", ".1", ".2", ".3"):
|
||||
fp = str(base.parent / base.name + suffix)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(fp, 0o664)
|
||||
|
||||
# rotating file handler with group-write permissions
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fh = RotatingFileHandler(
|
||||
try:
|
||||
fh = GroupWriteHandler(
|
||||
str(_LOG_FILE),
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
root.addHandler(fh)
|
||||
except PermissionError:
|
||||
# Log file exists but is not writable (e.g. stale file from the other
|
||||
# process created with a stricter umask). Fall back to stderr-only.
|
||||
print(
|
||||
f"WARNING: cannot open log file {_LOG_FILE}, "
|
||||
"logging to stderr only. Fix with: chmod g+w "
|
||||
f"{_LOG_FILE}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Silence noisy third-party loggers in production
|
||||
for name in ("werkzeug", "urllib3"):
|
||||
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
"""Networkd/IP configuration module.
|
||||
|
||||
Reads/writes config/network/config.json, renders .network INI files,
|
||||
and parses networkctl JSON output for runtime state.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.common import load_json, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "network"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
|
||||
|
||||
KNOWN_INTERFACE_KEYS: set[str] = {
|
||||
"addresses",
|
||||
"ipv6_addresses",
|
||||
"gateway",
|
||||
"ipv6_gateway",
|
||||
"dns",
|
||||
"ipv6_dns",
|
||||
"domains",
|
||||
"ipv6_domains",
|
||||
"dns_default_route",
|
||||
"dhcp",
|
||||
"routes",
|
||||
"bind_carrier",
|
||||
"ignore_carrier_loss",
|
||||
"keep_configuration",
|
||||
"configure_without_carrier",
|
||||
"link_local_addressing",
|
||||
"ipv6_link_local_address_generation_mode",
|
||||
"ipv6_stable_secret_address",
|
||||
"ipv4_ll_start_address",
|
||||
"ipv4_ll_route",
|
||||
"default_route_on_device",
|
||||
"ipv6_hop_limit",
|
||||
"ipv6_retransmission_time_sec",
|
||||
"ipv4_duplicate_address_detection_timeout_sec",
|
||||
"ipv4_reverse_path_filter",
|
||||
"ipv4_accept_local",
|
||||
"ipv4_route_localnet",
|
||||
"ipv4_proxy_arp",
|
||||
"ipv4_proxy_arp_private_vlan",
|
||||
"ipv6_proxy_ndp",
|
||||
"ipv6_proxy_ndp_address",
|
||||
"ipv6_send_ra",
|
||||
"m_pls_routing",
|
||||
"keep_master",
|
||||
"ip_family",
|
||||
"link",
|
||||
"dhcp_client",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"KNOWN_INTERFACE_KEYS",
|
||||
"collect_upstream_dns",
|
||||
"generate_network_files",
|
||||
"get_config",
|
||||
"infer_dhcp_ranges",
|
||||
"infer_zones",
|
||||
"parse_networkctl_status",
|
||||
"render_network_file",
|
||||
"save_config",
|
||||
]
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Read network config from config/network/config.json.
|
||||
|
||||
Returns:
|
||||
Dict with ``interfaces`` mapping interface names to config entries.
|
||||
"""
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
||||
return load_json(CONFIG_FILE)
|
||||
|
||||
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist network config to disk.
|
||||
|
||||
Args:
|
||||
cfg: Full config dict to write.
|
||||
"""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
save_json(CONFIG_FILE, cfg, indent=2)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Inline key-value emitters — append to a lines list
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
v = d.get(py_key)
|
||||
if v is not None:
|
||||
if isinstance(v, bool):
|
||||
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||
else:
|
||||
lines.append(f"{key}={v}")
|
||||
|
||||
|
||||
def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
|
||||
"""Render a .network INI file for an interface.
|
||||
|
||||
Args:
|
||||
iface_name: Interface name (e.g. "eth0").
|
||||
cfg_entry: Dict with networkd config keys per the schema in
|
||||
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
|
||||
|
||||
Returns:
|
||||
INI content string ready to write as 50-<name>.network file.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
d = cfg_entry
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Match]
|
||||
# ------------------------------------------------------------------
|
||||
lines.append("[Match]")
|
||||
lines.append(f"Name={iface_name}")
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Link]
|
||||
# ------------------------------------------------------------------
|
||||
link = d.get("link", {})
|
||||
if link:
|
||||
lines.append("[Link]")
|
||||
_emit_int(lines, "MTUBytes", "mtu_bytes", link)
|
||||
_emit_str(lines, "MACAddress", "mac_address", link)
|
||||
_emit_bool_opt(lines, "ARP", "arp", link)
|
||||
_emit_bool_opt(lines, "Multicast", "multicast", link)
|
||||
_emit_bool_opt(lines, "AllMulticast", "all_multicast", link)
|
||||
_emit_bool_opt(lines, "Promiscuous", "promiscuous", link)
|
||||
_emit_bool(lines, "Unmanaged", "unmanaged", link)
|
||||
_emit_str(lines, "ActivationPolicy", "activation_policy", link)
|
||||
_emit_any(lines, "RequiredForOnline", "required_for_online", link)
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Network]
|
||||
# ------------------------------------------------------------------
|
||||
lines.append("[Network]")
|
||||
|
||||
_emit_str(lines, "DHCP", "dhcp", d)
|
||||
_emit_str(lines, "Gateway", "gateway", d)
|
||||
_emit_str(lines, "IPv6Gateway", "ipv6_gateway", d)
|
||||
|
||||
for dns in d.get("dns", []):
|
||||
lines.append(f"DNS={dns}")
|
||||
for dns in d.get("ipv6_dns", []):
|
||||
lines.append(f"IPv6DNS={dns}")
|
||||
for dm in d.get("domains", []):
|
||||
lines.append(f"Domains={dm}")
|
||||
for dm in d.get("ipv6_domains", []):
|
||||
lines.append(f"IPv6Domains={dm}")
|
||||
|
||||
_emit_bool(lines, "DNSDefaultRoute", "dns_default_route", d)
|
||||
for bc in d.get("bind_carrier", []):
|
||||
lines.append(f"BindCarrier={bc}")
|
||||
_emit_any(lines, "IgnoreCarrierLoss", "ignore_carrier_loss", d)
|
||||
_emit_any(lines, "KeepConfiguration", "keep_configuration", d)
|
||||
_emit_bool(lines, "ConfigureWithoutCarrier", "configure_without_carrier", d)
|
||||
_emit_str(lines, "LinkLocalAddressing", "link_local_addressing", d)
|
||||
_emit_str(
|
||||
lines,
|
||||
"IPv6LinkLocalAddressGenerationMode",
|
||||
"ipv6_link_local_address_generation_mode",
|
||||
d,
|
||||
)
|
||||
_emit_str(lines, "IPv6StableSecretAddress", "ipv6_stable_secret_address", d)
|
||||
_emit_str(lines, "IPv4LLStartAddress", "ipv4_ll_start_address", d)
|
||||
_emit_bool(lines, "IPv4LLRoute", "ipv4_ll_route", d)
|
||||
_emit_bool(lines, "DefaultRouteOnDevice", "default_route_on_device", d)
|
||||
_emit_int(lines, "IPv6HopLimit", "ipv6_hop_limit", d)
|
||||
_emit_str(lines, "IPv6RetransmissionTimeSec", "ipv6_retransmission_time_sec", d)
|
||||
_emit_str(
|
||||
lines,
|
||||
"IPv4DuplicateAddressDetectionTimeoutSec",
|
||||
"ipv4_duplicate_address_detection_timeout_sec",
|
||||
d,
|
||||
)
|
||||
_emit_str(lines, "IPv4ReversePathFilter", "ipv4_reverse_path_filter", d)
|
||||
_emit_bool(lines, "IPv4AcceptLocal", "ipv4_accept_local", d)
|
||||
_emit_bool(lines, "IPv4RouteLocalnet", "ipv4_route_localnet", d)
|
||||
_emit_bool(lines, "IPv4ProxyARP", "ipv4_proxy_arp", d)
|
||||
_emit_bool(lines, "IPv4ProxyARPPrivateVLAN", "ipv4_proxy_arp_private_vlan", d)
|
||||
_emit_bool(lines, "IPv6ProxyNDP", "ipv6_proxy_ndp", d)
|
||||
_emit_str(lines, "IPv6ProxyNDPAddress", "ipv6_proxy_ndp_address", d)
|
||||
_emit_bool(lines, "IPv6SendRA", "ipv6_send_ra", d)
|
||||
_emit_bool(lines, "MPLSRouting", "m_pls_routing", d)
|
||||
_emit_bool(lines, "KeepMaster", "keep_master", d)
|
||||
_emit_str(lines, "IPFamily", "ip_family", d)
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Address] sections — one per entry
|
||||
# ------------------------------------------------------------------
|
||||
addresses = d.get("addresses", [])
|
||||
for i, addr in enumerate(addresses):
|
||||
if not isinstance(addr, dict):
|
||||
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
|
||||
lines.append(f"Address={addr}")
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
|
||||
_emit_str(lines, "Address", "address", addr)
|
||||
_emit_str(lines, "Label", "label", addr)
|
||||
_emit_str(lines, "Scope", "scope", addr)
|
||||
_emit_int(lines, "RouteMetric", "route_metric", addr)
|
||||
_emit_str(
|
||||
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
|
||||
)
|
||||
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
|
||||
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Address] sections — IPv6
|
||||
# ------------------------------------------------------------------
|
||||
ipv6_addrs = d.get("ipv6_addresses", [])
|
||||
offset = len(addresses)
|
||||
for i, addr in enumerate(ipv6_addrs):
|
||||
if not isinstance(addr, dict):
|
||||
lines.append(f"[Address#{offset + i}]")
|
||||
lines.append(f"Address={addr}")
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append(f"[Address#{offset + i}]")
|
||||
_emit_str(lines, "Address", "address", addr)
|
||||
_emit_str(lines, "Label", "label", addr)
|
||||
_emit_str(lines, "Scope", "scope", addr)
|
||||
_emit_int(lines, "RouteMetric", "route_metric", addr)
|
||||
_emit_str(
|
||||
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
|
||||
)
|
||||
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
|
||||
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [Route#N] sections
|
||||
# ------------------------------------------------------------------
|
||||
routes = d.get("routes", [])
|
||||
for i, route in enumerate(routes):
|
||||
if not isinstance(route, dict):
|
||||
continue
|
||||
lines.append("[Route]" if i == 0 else f"[Route#{i}]")
|
||||
_emit_str(lines, "Destination", "destination", route)
|
||||
_emit_str(lines, "Gateway", "gateway", route)
|
||||
_emit_int(lines, "Metric", "metric", route)
|
||||
_emit_any(lines, "Table", "table", route)
|
||||
_emit_str(lines, "Type", "type", route)
|
||||
_emit_str(lines, "Scope", "scope", route)
|
||||
_emit_bool(lines, "GatewayOnLink", "gateway_on_link", route)
|
||||
_emit_str(lines, "IPv6Preference", "ipv6_preference", route)
|
||||
_emit_int(lines, "InitialCongestionWindow", "initial_congestion_window", route)
|
||||
_emit_int(
|
||||
lines,
|
||||
"InitialAdvertisedReceiveWindow",
|
||||
"initial_advertised_receive_window",
|
||||
route,
|
||||
)
|
||||
_emit_bool(lines, "QuickAck", "quick_ack", route)
|
||||
_emit_bool(lines, "FastOpenNoCookie", "fast_open_no_cookie", route)
|
||||
_emit_int(lines, "MTUBytes", "mtu_bytes", route)
|
||||
_emit_any(lines, "Protocol", "protocol", route)
|
||||
_emit_int(lines, "NextHop", "next_hop", route)
|
||||
for mpr in route.get("multi_path_route", []):
|
||||
lines.append(f"MultiPathRoute={mpr}")
|
||||
lines.append("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# [DHCPv4] / [DHCPv6]
|
||||
# ------------------------------------------------------------------
|
||||
dhcp_client = d.get("dhcp_client", {})
|
||||
if dhcp_client:
|
||||
dhcp_mode = d.get("dhcp", "no")
|
||||
render_v4 = dhcp_mode in ("yes", "ipv4")
|
||||
render_v6 = dhcp_mode in ("yes", "ipv6")
|
||||
if not render_v4 and not render_v6:
|
||||
render_v4 = True
|
||||
render_v6 = True
|
||||
|
||||
if render_v4:
|
||||
lines.append("[DHCPv4]")
|
||||
_emit_str(lines, "Hostname", "hostname", dhcp_client)
|
||||
_emit_any(lines, "DUID", "duid", dhcp_client)
|
||||
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
|
||||
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
|
||||
_emit_str(lines, "IAID", "iaid", dhcp_client)
|
||||
_emit_any(lines, "ClientIdentifier", "client_identifier", dhcp_client)
|
||||
_emit_bool(lines, "RapidCommit", "rapid_commit", dhcp_client)
|
||||
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
|
||||
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
|
||||
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
|
||||
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
|
||||
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
|
||||
_emit_bool(lines, "UseMTU", "use_mtu", dhcp_client)
|
||||
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
|
||||
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
|
||||
_emit_bool(lines, "UseRoutes", "use_routes", dhcp_client)
|
||||
_emit_int(lines, "RouteMetric", "route_metric", dhcp_client)
|
||||
_emit_bool(lines, "SendDecline", "send_decline", dhcp_client)
|
||||
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
|
||||
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
|
||||
_emit_str(lines, "IPServiceType", "ip_service_type", dhcp_client)
|
||||
_emit_int(lines, "SocketPriority", "socket_priority", dhcp_client)
|
||||
_emit_bool(lines, "BOOTP", "bootp", dhcp_client)
|
||||
_emit_str(lines, "Label", "label", dhcp_client)
|
||||
_emit_int(lines, "MaxAttempts", "max_attempts", dhcp_client)
|
||||
_emit_int(lines, "ListenPort", "listen_port", dhcp_client)
|
||||
_emit_int(lines, "ServerPort", "server_port", dhcp_client)
|
||||
_emit_str(lines, "MUDURL", "mud_url", dhcp_client)
|
||||
_emit_str(lines, "BootFilename", "boot_filename", dhcp_client)
|
||||
|
||||
for opt in dhcp_client.get("send_option", []):
|
||||
if isinstance(opt, dict):
|
||||
lines.append(
|
||||
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"SendOption={opt}")
|
||||
for opt in dhcp_client.get("send_vendor_option", []):
|
||||
if isinstance(opt, dict):
|
||||
lines.append(
|
||||
f"SendVendorOption={opt.get('code', '-')}"
|
||||
f" {opt.get('vendor_code', '')}"
|
||||
f" {opt.get('value', '')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"SendVendorOption={opt}")
|
||||
for uc in dhcp_client.get("user_class", []):
|
||||
lines.append(f"UserClass={uc}")
|
||||
_emit_str(
|
||||
lines, "VendorClassIdentifier", "vendor_class_identifier", dhcp_client
|
||||
)
|
||||
_emit_str(lines, "RequestOptions", "request_options", dhcp_client)
|
||||
lines.append("")
|
||||
|
||||
if render_v6:
|
||||
lines.append("[DHCPv6]")
|
||||
_emit_bool(lines, "SendHostname", "send_hostname", dhcp_client)
|
||||
_emit_str(lines, "Hostname", "hostname", dhcp_client)
|
||||
_emit_any(lines, "DUID", "duid", dhcp_client)
|
||||
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
|
||||
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
|
||||
_emit_str(lines, "IAID", "iaid", dhcp_client)
|
||||
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
|
||||
_emit_str(lines, "RapidCommit", "rapid_commit", dhcp_client)
|
||||
_emit_str(
|
||||
lines, "PrefixDelegationHint", "prefix_delegation_hint", dhcp_client
|
||||
)
|
||||
_emit_str(
|
||||
lines, "UnassignedSubnetPolicy", "unassigned_subnet_policy", dhcp_client
|
||||
)
|
||||
_emit_bool(lines, "UseAddress", "use_address", dhcp_client)
|
||||
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
|
||||
_emit_bool(lines, "UseDelegatedPrefix", "use_delegated_prefix", dhcp_client)
|
||||
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
|
||||
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
|
||||
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
|
||||
_emit_bool(lines, "UseDNR", "use_dnr", dhcp_client)
|
||||
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
|
||||
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
|
||||
_emit_bool(lines, "SendRelease", "send_release", dhcp_client)
|
||||
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
|
||||
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
|
||||
_emit_str(lines, "WithoutRA", "without_ra", dhcp_client)
|
||||
|
||||
for opt in dhcp_client.get("send_option", []):
|
||||
if isinstance(opt, dict):
|
||||
lines.append(
|
||||
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"SendOption={opt}")
|
||||
for opt in dhcp_client.get("send_vendor_option", []):
|
||||
if isinstance(opt, dict):
|
||||
lines.append(
|
||||
f"SendVendorOption={opt.get('code', '-')}"
|
||||
f" {opt.get('vendor_code', '')}"
|
||||
f" {opt.get('value', '')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"SendVendorOption={opt}")
|
||||
for uc in dhcp_client.get("user_class", []):
|
||||
lines.append(f"UserClass={uc}")
|
||||
for vc in dhcp_client.get("vendor_class", []):
|
||||
lines.append(f"VendorClass={vc}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _bytes_to_ip(addr_bytes: list[int], family: int) -> str:
|
||||
"""Convert networkctl JSON address byte array to string."""
|
||||
if family == 2:
|
||||
return str(ipaddress.ip_address(bytes(addr_bytes)))
|
||||
return str(ipaddress.IPv6Address(bytes(addr_bytes)))
|
||||
|
||||
|
||||
def parse_networkctl_status(output: str) -> dict[str, Any]:
|
||||
"""Parse ``networkctl status --json=short --all`` JSON output into runtime state dict.
|
||||
|
||||
Args:
|
||||
output: JSON command output from networkctl status.
|
||||
|
||||
Returns:
|
||||
Dict mapping interface names to their runtime state including
|
||||
addresses, gateway, DNS, and link state.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
for iface in data.get("Interfaces", []):
|
||||
name = iface.get("Name")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Addresses
|
||||
addresses = []
|
||||
for a in iface.get("Addresses", []):
|
||||
try:
|
||||
ip = _bytes_to_ip(a["Address"], a["Family"])
|
||||
addresses.append(f"{ip}/{a['PrefixLength']}")
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Gateway — find default route (Destination 0.0.0.0/0)
|
||||
gateway = None
|
||||
for route in iface.get("Routes", []):
|
||||
if route.get("Family") != 2:
|
||||
continue
|
||||
dest = route.get("Destination", [])
|
||||
prefix = route.get("DestinationPrefixLength", 32)
|
||||
if len(dest) == 4 and all(d == 0 for d in dest) and prefix == 0:
|
||||
gw_bytes = route.get("Gateway")
|
||||
if gw_bytes:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
gateway = _bytes_to_ip(gw_bytes, 2)
|
||||
break
|
||||
|
||||
# DNS
|
||||
dns = []
|
||||
for d in iface.get("DNS", []):
|
||||
try:
|
||||
dns.append(_bytes_to_ip(d["Address"], d["Family"]))
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# MAC
|
||||
mac = None
|
||||
hw = iface.get("HardwareAddress")
|
||||
if hw:
|
||||
mac = ":".join(f"{b:02x}" for b in hw)
|
||||
|
||||
# State
|
||||
state = iface.get("OperationalState") or "unknown"
|
||||
|
||||
result[name] = {
|
||||
"addresses": addresses,
|
||||
"gateway": gateway,
|
||||
"dns": dns,
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"link": iface.get("Type", "unknown"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
|
||||
"""Walk config and write all 99-<name>.network files to data/networkd/.
|
||||
|
||||
Also removes stale .network files that no longer match config.
|
||||
|
||||
Args:
|
||||
cfg: Network config dict (from get_config).
|
||||
|
||||
Returns:
|
||||
Dict with ``generated`` (new/updated files) and ``cleaned``
|
||||
(removed stale files) path lists.
|
||||
"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
generated: list[Path] = []
|
||||
cleaned: list[Path] = []
|
||||
|
||||
# Build set of expected filenames
|
||||
expected_names: set[str] = set()
|
||||
interfaces_cfg = cfg.get("interfaces", {})
|
||||
for iface_name, entry in interfaces_cfg.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fname = f"99-{iface_name}.network"
|
||||
expected_names.add(fname)
|
||||
content = render_network_file(iface_name, entry)
|
||||
out_path = DATA_DIR / fname
|
||||
out_path.write_text(content)
|
||||
generated.append(out_path)
|
||||
|
||||
# Remove stale files from DATA_DIR
|
||||
existing_files: set[str] = {
|
||||
f.name for f in DATA_DIR.iterdir() if f.name.endswith(".network")
|
||||
}
|
||||
for fname in existing_files - expected_names:
|
||||
(DATA_DIR / fname).unlink()
|
||||
cleaned.append(DATA_DIR / fname)
|
||||
|
||||
if cleaned:
|
||||
logger.info("Cleaned %d stale .network files from %s", len(cleaned), DATA_DIR)
|
||||
logger.info("Generated %d .network files in %s", len(generated), DATA_DIR)
|
||||
return {"generated": generated, "cleaned": cleaned}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TF-8: upstream DNS collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IS_LOCAL = [
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
|
||||
def _is_local_dns(addr: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
for net in _IS_LOCAL:
|
||||
if ip.version == net.version and ip in net:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def collect_upstream_dns(cfg: dict[str, Any]) -> list[str]:
|
||||
"""Collect public DNS servers from networkd config, filtering local ranges.
|
||||
|
||||
Args:
|
||||
cfg: Network config dict (from get_config).
|
||||
|
||||
Returns:
|
||||
Deduplicated list of upstream DNS server addresses.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for entry in cfg.get("interfaces", {}).values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for dns in entry.get("dns", []):
|
||||
if not _is_local_dns(dns) and dns not in seen:
|
||||
seen.add(dns)
|
||||
result.append(dns)
|
||||
for dns in entry.get("ipv6_dns", []):
|
||||
if not _is_local_dns(dns) and dns not in seen:
|
||||
seen.add(dns)
|
||||
result.append(dns)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TF-9: DHCP range inference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Infer candidate DHCP ranges from static interface IPs.
|
||||
|
||||
For each interface with a static IPv4 address, calculates a candidate
|
||||
DHCP range covering the usable addresses in the subnet.
|
||||
|
||||
Args:
|
||||
cfg: Network config dict (from get_config).
|
||||
|
||||
Returns:
|
||||
Dict mapping interface name to dict with ``subnet``, ``prefix``,
|
||||
``start``, and ``end`` keys. Interfaces with no inferrable range
|
||||
are omitted.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for name, entry in cfg.get("interfaces", {}).items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for addr in entry.get("addresses", []):
|
||||
if isinstance(addr, dict):
|
||||
addr = addr.get("address", "")
|
||||
if not addr or "/" not in str(addr):
|
||||
continue
|
||||
try:
|
||||
net = ipaddress.ip_network(addr, strict=False)
|
||||
except ValueError:
|
||||
continue
|
||||
if net.version != 4:
|
||||
continue
|
||||
if net.num_addresses < 4:
|
||||
continue
|
||||
net_addr = net.network_address
|
||||
broadcast = net.broadcast_address
|
||||
_start = net_addr + 100
|
||||
_end = net_addr + 200
|
||||
_start = min(_start, broadcast - 1)
|
||||
_end = min(_end, broadcast - 1)
|
||||
if _start > _end:
|
||||
continue
|
||||
result[name] = {
|
||||
"subnet": str(net_addr),
|
||||
"prefix": net.prefixlen,
|
||||
"start": str(_start),
|
||||
"end": str(_end),
|
||||
}
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TF-10: firewalld zone inference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _looks_wan(entry: dict[str, Any]) -> bool:
|
||||
"""Heuristic: interface has DHCP or public-facing address."""
|
||||
if entry.get("dhcp") in ("yes", "ipv4"):
|
||||
return True
|
||||
for addr in entry.get("addresses", []):
|
||||
if isinstance(addr, dict):
|
||||
addr = addr.get("address", "")
|
||||
if not addr or "/" not in str(addr):
|
||||
continue
|
||||
try:
|
||||
net = ipaddress.ip_network(addr, strict=False)
|
||||
except ValueError:
|
||||
continue
|
||||
if net.version != 4:
|
||||
continue
|
||||
ga = list(net.hosts())
|
||||
if ga and not _is_local_dns(str(ga[0])):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _looks_management(entry: dict[str, Any]) -> bool:
|
||||
"""Heuristic: interface has management-subnet addresses."""
|
||||
return bool(entry.get("routes"))
|
||||
|
||||
|
||||
def infer_zones(
|
||||
cfg: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""Classify network interfaces into firewalld zones.
|
||||
|
||||
Heuristics:
|
||||
- Interface name contains ``wg`` → ``"wan"``
|
||||
- DHCP-enabled or public-facing IP → ``"wan"``
|
||||
- Has explicit routes configured → ``"management"``
|
||||
- Everything else → ``"lan"``
|
||||
|
||||
Args:
|
||||
cfg: Network config dict (from get_config).
|
||||
|
||||
Returns:
|
||||
Dict mapping interface name to suggested zone name.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for name, entry in cfg.get("interfaces", {}).items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if "wg" in name or _looks_wan(entry):
|
||||
result[name] = "wan"
|
||||
elif _looks_management(entry):
|
||||
result[name] = "management"
|
||||
else:
|
||||
result[name] = "lan"
|
||||
return result
|
||||
+114
-9
@@ -59,6 +59,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Load the current nginx config, initializing with defaults if needed.
|
||||
|
||||
Ensure config and sites directories exist, then return a copy of the
|
||||
JSON file. On missing file or missing keys, populate from defaults.
|
||||
|
||||
Returns:
|
||||
The complete config dict with ``domains``, ``ssl``, and ``management`` keys.
|
||||
"""
|
||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||
raw = load_json(CONFIG_FILE)
|
||||
if not raw:
|
||||
@@ -69,10 +77,19 @@ def get_config() -> dict[str, Any]:
|
||||
|
||||
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist *cfg* to the nginx config file atomically."""
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def get_domains() -> list[dict[str, Any]]:
|
||||
"""Return a list of all configured proxy domains with status.
|
||||
|
||||
Each entry includes the domain name, backend info, SSL flag, and
|
||||
whether a site config file currently exists on disk.
|
||||
|
||||
Returns:
|
||||
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``.
|
||||
"""
|
||||
cfg = get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
@@ -101,6 +118,19 @@ def add_domain(
|
||||
cert: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Add a new proxy domain with the given backend and optional settings.
|
||||
|
||||
Args:
|
||||
domain: Domain name to add.
|
||||
backend_host: Upstream host to proxy to.
|
||||
backend_port: Upstream port.
|
||||
backend_proto: Protocol (``http`` or ``https``).
|
||||
cert: Optional certificate type identifier.
|
||||
extra_headers: Optional dict of extra headers to forward.
|
||||
|
||||
Raises:
|
||||
ValueError: If the domain is already configured.
|
||||
"""
|
||||
cfg = get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
@@ -128,6 +158,7 @@ def add_domain(
|
||||
|
||||
|
||||
def remove_domain(domain: str) -> None:
|
||||
"""Remove *domain* from the config and delete its site file."""
|
||||
cfg = get_config()
|
||||
cfg["domains"].pop(domain, None)
|
||||
save_config(cfg)
|
||||
@@ -138,6 +169,15 @@ def remove_domain(domain: str) -> None:
|
||||
|
||||
|
||||
def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
"""Update fields of an existing domain entry in-place.
|
||||
|
||||
Args:
|
||||
domain: Domain name to update.
|
||||
**kwargs: Key-value pairs to merge into the domain config.
|
||||
|
||||
Raises:
|
||||
KeyError: If the domain is not configured.
|
||||
"""
|
||||
cfg = get_config()
|
||||
if domain not in cfg["domains"]:
|
||||
raise KeyError(f"Domain {domain!r} not configured")
|
||||
@@ -157,6 +197,14 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
|
||||
|
||||
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
"""Render the Jinja template for a standard domain server block.
|
||||
|
||||
Args:
|
||||
domain_cfg: Domain entry dict including the ``domain`` key.
|
||||
|
||||
Returns:
|
||||
The complete nginx server-block configuration as a string.
|
||||
"""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
return tmpl.render(
|
||||
domain=domain_cfg["domain"],
|
||||
@@ -173,6 +221,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _generate_management_conf(management: dict[str, Any]) -> str:
|
||||
"""Render the Jinja template for the management WebUI server block.
|
||||
|
||||
Args:
|
||||
management: Management proxy config dict containing ``domain`` and optional ``auth``.
|
||||
|
||||
Returns:
|
||||
The complete nginx server-block configuration for the management UI.
|
||||
"""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
return tmpl.render(
|
||||
domain=management.get("domain"),
|
||||
@@ -196,6 +252,12 @@ def _generate_management_conf(management: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def write_site(domain: str, conf_text: str) -> None:
|
||||
"""Atomically write *conf_text* to the site config file for *domain*.
|
||||
|
||||
Args:
|
||||
domain: Domain name (becomes the ``<domain>.conf`` file).
|
||||
conf_text: Nginx server-block configuration text.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
path = SITES_DIR / f"{domain}.conf"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
@@ -207,7 +269,7 @@ def write_site(domain: str, conf_text: str) -> None:
|
||||
|
||||
|
||||
def write_acme_challenge() -> None:
|
||||
"""Write the ACME HTTP-01 challenge catch-all nginx config.
|
||||
"""Write the catch-all nginx config for ACME HTTP-01 challenges.
|
||||
|
||||
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
|
||||
webroot for any domain not yet covered by a dedicated server block.
|
||||
@@ -226,6 +288,12 @@ 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 and the management
|
||||
proxy (if any), removes orphaned site files, and ensures the ACME
|
||||
challenge config is present.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = get_config()
|
||||
|
||||
@@ -252,6 +320,11 @@ def write_all_sites() -> None:
|
||||
|
||||
|
||||
def write_include_file() -> None:
|
||||
"""Write the nginx include file that pulls in managed site configs.
|
||||
|
||||
The include file is installed at ``/etc/nginx/conf.d/vacuum-wall.conf``
|
||||
and must be owned by root.
|
||||
"""
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = INCLUDE_FILE.with_suffix(".tmp")
|
||||
@@ -264,6 +337,11 @@ def write_include_file() -> None:
|
||||
|
||||
|
||||
def write_ssl_snippet() -> None:
|
||||
"""Write the shared SSL settings snippet to ``/etc/nginx/snippets/``.
|
||||
|
||||
The snippet is populated from the ``ssl`` section of the nginx config
|
||||
and installed with root ownership.
|
||||
"""
|
||||
cfg = get_config()
|
||||
ssl_cfg = cfg.get("ssl", {})
|
||||
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
|
||||
@@ -287,6 +365,12 @@ def write_ssl_snippet() -> None:
|
||||
|
||||
|
||||
def test_config() -> tuple[bool, str]:
|
||||
"""Run ``nginx -t`` and return the pass/fail result.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(ok, message)`` where ``ok`` is ``True`` if the
|
||||
config test passed and ``message`` contains output or a summary.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
|
||||
)
|
||||
@@ -302,6 +386,11 @@ def test_config() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
"""Generate all configs, test them, and reload nginx.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the nginx config test fails.
|
||||
"""
|
||||
write_ssl_snippet()
|
||||
write_all_sites()
|
||||
write_include_file()
|
||||
@@ -329,6 +418,15 @@ def set_management_proxy(
|
||||
auth_user: str | None = None,
|
||||
auth_pass: str | None = None,
|
||||
) -> None:
|
||||
"""Configure the management reverse proxy for the WebUI.
|
||||
|
||||
Args:
|
||||
domain: Management domain name.
|
||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
||||
flask_port: Upstream Flask port (default ``9090``).
|
||||
auth_user: Optional basic-auth username.
|
||||
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
|
||||
"""
|
||||
cfg = get_config()
|
||||
entry: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
@@ -356,7 +454,12 @@ def set_management_proxy(
|
||||
|
||||
|
||||
def write_htpasswd(user: str, password: str) -> None:
|
||||
"""Append (or create) an htpasswd entry for *user*."""
|
||||
"""Append (or create) an htpasswd entry for *user*.
|
||||
|
||||
Args:
|
||||
user: Username for the htpasswd entry.
|
||||
password: Plain-text password to hash and store.
|
||||
"""
|
||||
ensure_dirs(DATA_DIR)
|
||||
hashed = _hash_password(password)
|
||||
existing: dict[str, str] = {}
|
||||
@@ -381,15 +484,17 @@ def write_htpasswd(user: str, password: str) -> None:
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
try:
|
||||
from passlib.hash import apache_passwd
|
||||
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
|
||||
|
||||
return apache_passwd.using(rounds=12).hash(password)
|
||||
except Exception:
|
||||
import crypt as _crypt
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
return _crypt.crypt(password, f"$5${salt}")
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``).
|
||||
"""
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
+207
-31
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
@@ -23,6 +22,7 @@ from lib.firewall import (
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
)
|
||||
from lib.network import parse_networkctl_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,23 +40,91 @@ class State:
|
||||
Each subsystem's value is a dict collected from the corresponding
|
||||
``collect_*`` function. A value of ``None`` means the subsystem has
|
||||
not been populated yet or the last collection failed.
|
||||
|
||||
Attributes:
|
||||
SUBSYSTEMS: Ordered list of subsystem names.
|
||||
_data: Dict mapping subsystem names to their state data.
|
||||
"""
|
||||
|
||||
SUBSYSTEMS: ClassVar[list[str]] = ["firewall", "dnsmasq", "nginx", "acme", "wireguard"]
|
||||
SUBSYSTEMS: ClassVar[list[str]] = [
|
||||
"firewall",
|
||||
"dnsmasq",
|
||||
"nginx",
|
||||
"acme",
|
||||
"wireguard",
|
||||
"networkd",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the state store with empty subsystem slots."""
|
||||
self._data: dict[str, dict[str, Any] | None] = {
|
||||
name: None for name in self.SUBSYSTEMS
|
||||
}
|
||||
self._versions: dict[str, int] = {name: 0 for name in self.SUBSYSTEMS}
|
||||
self._last_broadcast: dict[str, int] | None = None
|
||||
|
||||
def bump(self, subsystem: str) -> None:
|
||||
"""Increment the version counter for *subsystem*.
|
||||
|
||||
Args:
|
||||
subsystem: Subsystem name.
|
||||
"""
|
||||
if subsystem in self._versions:
|
||||
self._versions[subsystem] += 1
|
||||
|
||||
def get_versions(self) -> dict[str, int]:
|
||||
"""Return a shallow copy of all subsystem versions.
|
||||
|
||||
Returns:
|
||||
Dict mapping subsystem names to their current version integers.
|
||||
"""
|
||||
return dict(self._versions)
|
||||
|
||||
def get_updated_versions(self) -> dict[str, int]:
|
||||
"""Return versions that changed since the last broadcast.
|
||||
|
||||
After calling, ``_last_broadcast`` is updated to match current versions.
|
||||
|
||||
Returns:
|
||||
Dict of subsystems whose versions changed, or empty dict.
|
||||
"""
|
||||
if self._last_broadcast is None:
|
||||
self._last_broadcast = dict(self._versions)
|
||||
return {}
|
||||
updated: dict[str, int] = {}
|
||||
for name, v in self._versions.items():
|
||||
if v != self._last_broadcast.get(name, 0):
|
||||
updated[name] = v
|
||||
self._last_broadcast[name] = v
|
||||
return updated
|
||||
|
||||
def get(self, subsystem: str) -> dict[str, Any] | None:
|
||||
"""Get state data for *subsystem*.
|
||||
|
||||
Args:
|
||||
subsystem: Subsystem name.
|
||||
|
||||
Returns:
|
||||
State dict, or ``None`` if not populated.
|
||||
"""
|
||||
return self._data.get(subsystem)
|
||||
|
||||
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
|
||||
"""Set state data for *subsystem*.
|
||||
|
||||
Args:
|
||||
subsystem: Subsystem name.
|
||||
data: State data, or ``None`` to clear.
|
||||
"""
|
||||
self._data[subsystem] = data
|
||||
|
||||
def populate(self, subsystems: list[str] | None = None) -> None:
|
||||
"""Collect state for *subsystems* (all if None)."""
|
||||
"""Collect state for *subsystems* (all if ``None``).
|
||||
|
||||
Args:
|
||||
subsystems: List of subsystem names to collect. Collects all
|
||||
subsystems when ``None``.
|
||||
"""
|
||||
targets = subsystems or self.SUBSYSTEMS
|
||||
for name in targets:
|
||||
collector = _COLLECTORS.get(name)
|
||||
@@ -73,6 +141,11 @@ class State:
|
||||
self._data[name] = None
|
||||
|
||||
def is_populated(self) -> bool:
|
||||
"""Check whether all subsystem states have been populated.
|
||||
|
||||
Returns:
|
||||
``True`` if every subsystem has non-``None`` state data.
|
||||
"""
|
||||
return all(v is not None for v in self._data.values())
|
||||
|
||||
|
||||
@@ -88,6 +161,15 @@ _COLLECTORS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def register_collector(subsystem: str, fn: Any) -> Any:
|
||||
"""Register *fn* as the state collector for *subsystem*.
|
||||
|
||||
Args:
|
||||
subsystem: Subsystem name to register for.
|
||||
fn: Collector function to register.
|
||||
|
||||
Returns:
|
||||
The *fn* function (for decorator usage).
|
||||
"""
|
||||
_COLLECTORS[subsystem] = fn
|
||||
return fn
|
||||
|
||||
@@ -98,10 +180,19 @@ def register_collector(subsystem: str, fn: Any) -> Any:
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO 8601 string."""
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
"""Convert a port-forward dict to a compact string representation.
|
||||
|
||||
Args:
|
||||
fp: Port-forward entry containing port and proto keys.
|
||||
|
||||
Returns:
|
||||
Comma-separated string of key=value pairs (e.g. ``port=443,proto=tcp``).
|
||||
"""
|
||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||
if "toaddr" in fp:
|
||||
parts.append(f"toaddr={fp['toaddr']}")
|
||||
@@ -111,7 +202,12 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _collect_firewall() -> dict[str, Any]:
|
||||
"""Return the complete current state of firewalld."""
|
||||
"""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)
|
||||
@@ -126,7 +222,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
raw_name = parts[1].rstrip(":")
|
||||
raw_name = parts[1].rstrip(":").split("@")[0]
|
||||
iface_state = "UNKNOWN"
|
||||
mtu = None
|
||||
mac = None
|
||||
@@ -139,7 +235,6 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
mac = parts[i + 1]
|
||||
iface_map[raw_name] = {
|
||||
"name": raw_name,
|
||||
"display_name": raw_name.partition("@")[0],
|
||||
"mac": mac,
|
||||
"state": iface_state,
|
||||
"mtu": mtu,
|
||||
@@ -154,18 +249,17 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
addr_name = parts[1]
|
||||
addr_name = parts[1].split("@")[0]
|
||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == addr_name:
|
||||
if entry["name"] == addr_name:
|
||||
entry[addr_key].append(parts[3])
|
||||
break
|
||||
|
||||
for zone_name, ifaces in active.items():
|
||||
for raw_if in ifaces:
|
||||
clean = raw_if.partition("@")[0]
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == clean or entry["name"] == raw_if:
|
||||
if entry["name"] == raw_if:
|
||||
entry["zone"] = zone_name
|
||||
break
|
||||
|
||||
@@ -221,7 +315,11 @@ register_collector("firewall", _collect_firewall)
|
||||
|
||||
|
||||
def _collect_dnsmasq() -> dict[str, Any]:
|
||||
"""Collect dnsmasq status, config, and leases."""
|
||||
"""Collect dnsmasq status, config, and leases.
|
||||
|
||||
Returns:
|
||||
Dict containing config, service status, leases, and timestamp.
|
||||
"""
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
@@ -311,7 +409,11 @@ register_collector("dnsmasq", _collect_dnsmasq)
|
||||
|
||||
|
||||
def _collect_nginx() -> dict[str, Any]:
|
||||
"""Collect nginx config and domains list."""
|
||||
"""Collect nginx config and domains list.
|
||||
|
||||
Returns:
|
||||
Dict containing config, domains, and timestamp.
|
||||
"""
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
|
||||
@@ -341,11 +443,6 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
if raw:
|
||||
from lib.common import deep_merge
|
||||
|
||||
default_cfg: dict[str, Any] = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": deepcopy(DEFAULT_SSL),
|
||||
}
|
||||
cfg = deep_merge(default_cfg, raw)
|
||||
if "ssl" not in cfg:
|
||||
cfg["ssl"] = deepcopy(DEFAULT_SSL)
|
||||
@@ -381,6 +478,14 @@ register_collector("nginx", _collect_nginx)
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
"""Locate the ``acme.sh`` binary on the filesystem.
|
||||
|
||||
Returns:
|
||||
Absolute path to the ``acme.sh`` executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If acme.sh cannot be found.
|
||||
"""
|
||||
acme_home = PROJECT_DIR / "data" / "acme"
|
||||
candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")]
|
||||
for path in candidates:
|
||||
@@ -393,6 +498,17 @@ def _find_acme() -> str:
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
"""Run ``acme.sh`` with *args* and return combined output.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments to pass after the home/config flags.
|
||||
|
||||
Returns:
|
||||
Combined stdout/stderr output.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If acme.sh exits non-zero or times out.
|
||||
"""
|
||||
acme_bin = _find_acme()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
||||
@@ -421,6 +537,14 @@ def _run_acme(args: list[str]) -> str:
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
"""Parse a date string and return days until *date_str* from now.
|
||||
|
||||
Args:
|
||||
date_str: Date string in common ACME formats.
|
||||
|
||||
Returns:
|
||||
Number of days remaining, or ``None`` if empty or unparseable.
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
|
||||
@@ -438,6 +562,14 @@ def _days_until(date_str: str) -> int | None:
|
||||
|
||||
|
||||
def _parse_acme_list_output(raw: str) -> list[dict]:
|
||||
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
|
||||
|
||||
Args:
|
||||
raw: Raw output string from ``acme.sh --list``.
|
||||
|
||||
Returns:
|
||||
List of dicts with certificate entry fields.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
for line in raw.strip().splitlines():
|
||||
line = line.strip()
|
||||
@@ -455,27 +587,35 @@ def _parse_acme_list_output(raw: str) -> list[dict]:
|
||||
|
||||
|
||||
def _has_auto_renew(domain: str) -> bool:
|
||||
"""Check whether *domain* has an auto-renew configuration file.
|
||||
|
||||
Args:
|
||||
domain: Domain name to check.
|
||||
|
||||
Returns:
|
||||
``True`` if a corresponding ``acme.sh`` config file exists.
|
||||
"""
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
return bool(Path(acme_home_env) / f"{domain}.conf")
|
||||
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
acme_home_default = str(PROJECT_DIR / "data" / "acme")
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||
|
||||
Falls back to the declarative ACME config (config/acme/config.json)
|
||||
if acme.sh account has not been registered yet.
|
||||
"""
|
||||
from lib.acme import _read_acme_email
|
||||
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _collect_acme() -> dict[str, Any]:
|
||||
"""Collect ACME certificate list and email."""
|
||||
"""Collect ACME certificate list and email.
|
||||
|
||||
Returns:
|
||||
Dict containing certificate details and registered email.
|
||||
"""
|
||||
email = _get_acme_email()
|
||||
|
||||
certs: list[dict[str, Any]] = []
|
||||
@@ -529,7 +669,11 @@ register_collector("acme", _collect_acme)
|
||||
|
||||
|
||||
def _collect_wireguard() -> dict[str, Any]:
|
||||
"""Collect WireGuard config, status, and peers."""
|
||||
"""Collect WireGuard config, status, and peers.
|
||||
|
||||
Returns:
|
||||
Dict containing interface config, runtime status, and peers.
|
||||
"""
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
@@ -648,6 +792,38 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
|
||||
register_collector("wireguard", _collect_wireguard)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Networkd collector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_networkd() -> dict[str, Any]:
|
||||
"""Collect networkd interface state from networkctl.
|
||||
|
||||
Returns:
|
||||
Dict with interface runtime state parsed from networkctl output.
|
||||
Returns empty data if networkctl is not available.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
try:
|
||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||
result = parse_networkctl_status(raw)
|
||||
if not result:
|
||||
return {"interfaces": {}, "timestamp": _now_iso()}
|
||||
except Exception:
|
||||
return {
|
||||
"interfaces": {},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
return {
|
||||
"interfaces": result,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("networkd", _collect_networkd)
|
||||
|
||||
__all__ = [
|
||||
"State",
|
||||
|
||||
@@ -10,6 +10,7 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"Flask>=3.0,<4.0",
|
||||
"aiohttp>=3.9,<4.0",
|
||||
"passlib>=1.7.4,<2.0",
|
||||
"requests-unixsocket>=0.2,<1.0",
|
||||
]
|
||||
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
systemctl restart nginx
|
||||
sleep 1
|
||||
systemctl restart vacuum-walld
|
||||
sleep 1
|
||||
systemctl restart vacuum-wall
|
||||
|
||||
# Verify services are running
|
||||
failed=0
|
||||
for svc in nginx vacuum-walld vacuum-wall; do
|
||||
if ! systemctl is-active --quiet "$svc"; then
|
||||
echo "ERROR: $svc is not running" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failed" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Library versions ----
|
||||
HTMX_VERSION="2.0.4"
|
||||
HTMX_JSON_ENC_VERSION="2.0.0"
|
||||
ACME_VERSION="3.1.3"
|
||||
|
||||
VENDOR="vendor"
|
||||
STATIC="webui/static"
|
||||
|
||||
download() {
|
||||
local name="$1" url="$2" dest="$3"
|
||||
@@ -21,22 +18,10 @@ download() {
|
||||
curl -sfL -o "$dest" "$url"
|
||||
}
|
||||
|
||||
download "htmx@${HTMX_VERSION}" \
|
||||
"https://unpkg.com/htmx.org@${HTMX_VERSION}/dist/htmx.min.js" \
|
||||
"${VENDOR}/htmx-${HTMX_VERSION}.min.js"
|
||||
|
||||
download "htmx-ext-json-enc@${HTMX_JSON_ENC_VERSION}" \
|
||||
"https://unpkg.com/htmx-ext-json-enc@${HTMX_JSON_ENC_VERSION}/json-enc.js" \
|
||||
"${VENDOR}/json-enc-${HTMX_JSON_ENC_VERSION}.js"
|
||||
|
||||
download "acme.sh@${ACME_VERSION}" \
|
||||
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
|
||||
"${VENDOR}/acme.sh"
|
||||
|
||||
chmod +x "${VENDOR}/acme.sh"
|
||||
|
||||
# Symlinks in webui/static/ select the active version
|
||||
ln -sf "../../vendor/htmx-${HTMX_VERSION}.min.js" "${STATIC}/htmx.min.js"
|
||||
ln -sf "../../vendor/json-enc-${HTMX_JSON_ENC_VERSION}.js" "${STATIC}/json-enc.js"
|
||||
|
||||
echo "[done] All libraries vendored."
|
||||
|
||||
@@ -13,6 +13,7 @@ try:
|
||||
import requests_unixsocket
|
||||
|
||||
from daemon.client import post
|
||||
from daemon.iface import POST_NGINX_RELOAD
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
project_dir = os.environ.get("INSTALL_DIR", os.path.dirname(os.path.dirname(__file__)))
|
||||
@@ -20,7 +21,7 @@ try:
|
||||
"VACUUM_WALLD_SOCKET",
|
||||
os.path.join(project_dir, "data", "daemon.sock"),
|
||||
)
|
||||
post("/nginx/reload", socket_path=socket_path)
|
||||
post(POST_NGINX_RELOAD, socket_path=socket_path)
|
||||
sys.exit(0)
|
||||
except Exception as exc:
|
||||
logging.error("acme-deploy hook failed: %s", exc)
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
# ---- vacuum-wall managed dnsmasq configuration ----
|
||||
# generated {{ timestamp }}
|
||||
|
||||
bind-interfaces
|
||||
{% if interfaces %}
|
||||
interface={{ interfaces | join(',') }}
|
||||
bind-interfaces
|
||||
{% elif listen_addresses %}
|
||||
{% for addr in listen_addresses %}
|
||||
listen-address={{ addr }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for srv in dns.upstreams %}
|
||||
server={{ srv }}
|
||||
|
||||
@@ -39,8 +39,8 @@ server {
|
||||
|
||||
{% endif %}
|
||||
{% elif is_management %}
|
||||
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
||||
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||
ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
||||
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
||||
|
||||
{% endif %}
|
||||
# Shared SSL settings
|
||||
@@ -59,12 +59,14 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
{% endif %}
|
||||
location / {
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
{% if not is_management %}
|
||||
{% for hname, hval in headers.items() %}
|
||||
proxy_set_header {{ hname }} {{ hval }};
|
||||
{% endfor %}
|
||||
@@ -73,32 +75,31 @@ server {
|
||||
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
|
||||
proxy_http_version 1.1;
|
||||
|
||||
{% if not is_management %}
|
||||
# Timeouts
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
{% if is_management %}
|
||||
location /ws {
|
||||
auth_basic off;
|
||||
proxy_pass http://127.0.0.1:9091;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if not is_management %}
|
||||
# Access / error logs
|
||||
access_log /var/log/nginx/{{ domain }}_access.log;
|
||||
error_log /var/log/nginx/{{ domain }}_error.log warn;
|
||||
{% else %}
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
|
||||
access_log /var/log/nginx/wall_mgmt_access.log;
|
||||
error_log /var/log/nginx/wall_mgmt_error.log warn;
|
||||
{% endif %}
|
||||
location / {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
||||
# Nginx management
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||
{{ 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 * /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/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/snippets/vacuum-wall-ssl.conf
|
||||
@@ -18,21 +18,32 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
||||
# Dnsmasq management
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
|
||||
|
||||
# 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 * /etc/wireguard/*
|
||||
{{ 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
|
||||
|
||||
# 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/rm /etc/systemd/network/*.network
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network
|
||||
|
||||
# Sysctl
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/sysctl -w *
|
||||
|
||||
# Misc
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
||||
|
||||
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
|
||||
|
||||
# Security hardening
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ PROJECT_DIR }} /tmp
|
||||
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { reactive, h, html, render, Router, Link } from '../webui/static/reactive-dom.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
console.log('Testing reactive-dom.js\n');
|
||||
|
||||
// === Reactive ===
|
||||
test('reactive() returns proxy', () => {
|
||||
const s = reactive({ x: 1 });
|
||||
assert(s.x === 1);
|
||||
});
|
||||
|
||||
test('reactive() mutation triggers render callback', () => {
|
||||
const s = reactive({ x: 1 });
|
||||
let fired = false;
|
||||
// We can't easily test the render callback without a DOM, but we can check the proxy works
|
||||
s.x = 2;
|
||||
assert(s.x === 2);
|
||||
});
|
||||
|
||||
// === h() ===
|
||||
test('h() creates element VNode', () => {
|
||||
const v = h('div', { class: 'foo' });
|
||||
assert(v.tag === 'div' && v.props.class === 'foo');
|
||||
});
|
||||
|
||||
test('h() flattens children array', () => {
|
||||
const v = h('div', null, h('span', null, 'hi'));
|
||||
assert(v.ch.length === 1 && v.ch[0].tag === 'span');
|
||||
});
|
||||
|
||||
test('h() converts strings to text nodes', () => {
|
||||
const v = h('div', null, 'hello', 42);
|
||||
assert(v.ch.length === 2 && v.ch[0].tag === '#text' && v.ch[0].text === 'hello');
|
||||
assert(v.ch[1].text === '42');
|
||||
});
|
||||
|
||||
test('h() drops null/boolean children', () => {
|
||||
const v = h('div', null, null, undefined, true, false, 'x');
|
||||
assert(v.ch.length === 1 && v.ch[0].text === 'x');
|
||||
});
|
||||
|
||||
// === html() ===
|
||||
test('html() parses static element', () => {
|
||||
const nodes = html`<div>hello</div>`;
|
||||
assert(nodes[0].tag === 'div' && nodes[0].ch[0].text === 'hello');
|
||||
});
|
||||
|
||||
test('html() interpolates text into element children', () => {
|
||||
const name = 'World';
|
||||
const nodes = html`<div>Hello ${name}</div>`;
|
||||
assert(nodes[0].tag === 'div');
|
||||
// Should have: text "Hello ", then text "World"
|
||||
assert(nodes[0].ch[0].text && nodes[0].ch[0].text === 'Hello ');
|
||||
assert(nodes[0].ch[1].tag === '#text' && nodes[0].ch[1].text === 'World');
|
||||
});
|
||||
|
||||
test('html() interpolates class attribute value', () => {
|
||||
const cls = 'active';
|
||||
const nodes = html`<div class="${cls}">x</div>`;
|
||||
assert(nodes[0].tag === 'div' && nodes[0].props.class === 'active');
|
||||
});
|
||||
|
||||
test('html() interpolates on:click attribute value', () => {
|
||||
const handler = function click() {};
|
||||
const nodes = html`<button on:click="${handler}">Go</button>`;
|
||||
assert(nodes[0].tag === 'button' && typeof nodes[0].props['on:click'] === 'function');
|
||||
});
|
||||
|
||||
test('html() handles multiple interpolations', () => {
|
||||
const a = 'first', b = 'second';
|
||||
const nodes = html`<div><span>${a}</span> <span>${b}</span></div>`;
|
||||
assert(nodes[0].tag === 'div');
|
||||
assert(nodes[0].ch[0].tag === 'span' && nodes[0].ch[0].ch[0].text === 'first');
|
||||
});
|
||||
|
||||
test('html() interpolates VNode into element children', () => {
|
||||
const nodes = html`<ul>${html`<li>item</li>`[0]}</ul>`;
|
||||
assert(nodes[0].tag === 'ul' && nodes[0].ch[0].tag === 'li');
|
||||
});
|
||||
|
||||
// === Router ===
|
||||
test('Router initializes with current hash', () => {
|
||||
globalThis.location = { hash: '' };
|
||||
globalThis.window = { addEventListener: () => {} };
|
||||
const router = Router({ '/home': () => {} });
|
||||
assert(router.state.path === '/');
|
||||
});
|
||||
|
||||
// === Link ===
|
||||
test('Link creates anchor with hash href', () => {
|
||||
const link = Link({ path: '/dashboard' });
|
||||
assert(link.tag === 'a' && link.props.href === '#/dashboard');
|
||||
});
|
||||
|
||||
// === DOM functions (basic, no actual DOM) ===
|
||||
test('createDom produces document.createElement call', () => {
|
||||
const v = h('div', { class: 'foo' }, 'hi', h('span', null, 'nested'));
|
||||
// Can't test actual DOM without jsdom, but we can verify the VNode structure
|
||||
assert(v.tag === 'div' && v.ch.length === 2);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -35,6 +35,11 @@ def _wg(func, **kw):
|
||||
return _patch(f"webui.api.wireguard.{func}", **kw)
|
||||
|
||||
|
||||
def _ne(func, **kw):
|
||||
"""Patch daemon.client.{func} in the network blueprint namespace."""
|
||||
return _patch(f"webui.api.network.{func}", **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
@@ -42,11 +47,13 @@ def client():
|
||||
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.network import bp as network_bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wg_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
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")
|
||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||
@@ -788,3 +795,106 @@ class TestProxyDomainUpdate:
|
||||
json={"backend_host": "10.0.0.2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Network
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestNetworkListInterfaces:
|
||||
@_ne("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = [
|
||||
{"name": "eth0", "config": {"addresses": ["10.0.0.1/24"]}}
|
||||
]
|
||||
resp = client.get("/api/network/interfaces")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("get")
|
||||
def test_runtime_error(self, mock_get, client):
|
||||
mock_get.side_effect = RuntimeError("networkctl not found")
|
||||
resp = client.get("/api/network/interfaces")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestNetworkGetInterface:
|
||||
@_ne("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"name": "eth0",
|
||||
"config": {"addresses": ["10.0.0.1/24"]},
|
||||
}
|
||||
resp = client.get("/api/network/interfaces/eth0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("get")
|
||||
def test_not_found(self, mock_get, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_get.side_effect = NotFound("interface not found")
|
||||
resp = client.get("/api/network/interfaces/nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestNetworkSaveInterface:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"name": "eth0", "applied": True}
|
||||
resp = client.post(
|
||||
"/api/network/interfaces/eth0",
|
||||
json={
|
||||
"addresses": ["10.0.0.1/24"],
|
||||
"gateway": "10.0.0.254",
|
||||
"dns": ["8.8.8.8"],
|
||||
"routes": [],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("post")
|
||||
def test_not_found(self, mock_post, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_post.side_effect = NotFound("interface not found")
|
||||
resp = client.post("/api/network/interfaces/missing", json={})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestNetworkReloadInterface:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"name": "eth0", "reloaded": True}
|
||||
resp = client.post("/api/network/interfaces/eth0/reload")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
@_ne("post")
|
||||
def test_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("reload failed")
|
||||
resp = client.post("/api/network/interfaces/eth0/reload")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestNetworkApplyAll:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"applied": 2, "interfaces": ["eth0", "eth1"]}
|
||||
resp = client.post("/api/network/apply")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["applied"] == 2
|
||||
|
||||
@_ne("post")
|
||||
def test_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("apply failed")
|
||||
resp = client.post("/api/network/apply")
|
||||
assert resp.status_code == 500
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for daemon client path parameter substitution."""
|
||||
|
||||
import contextlib
|
||||
from unittest.mock import patch
|
||||
|
||||
from daemon.client import _format_path, request
|
||||
|
||||
|
||||
class TestFormatPath:
|
||||
def test_simple_substitution(self):
|
||||
assert (
|
||||
_format_path("/network/interfaces/<name>", {"name": "eth0"})
|
||||
== "/network/interfaces/eth0"
|
||||
)
|
||||
|
||||
def test_multiple_params(self):
|
||||
assert _format_path("/a/<x>/b/<y>", {"x": "1", "y": "2"}) == "/a/1/b/2"
|
||||
|
||||
def test_no_params_unchanged(self):
|
||||
assert (
|
||||
_format_path("/network/interfaces/<name>", None)
|
||||
== "/network/interfaces/<name>"
|
||||
)
|
||||
|
||||
def test_empty_params_unchanged(self):
|
||||
assert (
|
||||
_format_path("/network/interfaces/<name>", {})
|
||||
== "/network/interfaces/<name>"
|
||||
)
|
||||
|
||||
def test_partial_substitution(self):
|
||||
assert _format_path("/a/<x>/b/<y>", {"x": "1"}) == "/a/1/b/<y>"
|
||||
|
||||
def test_url_encodes_special_chars(self):
|
||||
assert _format_path("/a/<x>", {"x": "foo bar"}) == "/a/foo%20bar"
|
||||
|
||||
def test_preserves_non_param_brackets(self):
|
||||
assert _format_path("/foo[bar]/<x>", {"x": "z"}) == "/foo[bar]/z"
|
||||
|
||||
def test_numeric_value(self):
|
||||
assert _format_path("/items/<id>", {"id": 42}) == "/items/42"
|
||||
|
||||
def test_path_without_params(self):
|
||||
assert _format_path("/health", {"foo": "bar"}) == "/health"
|
||||
|
||||
|
||||
class TestRequestPathSubstitution:
|
||||
@patch("daemon.client.requests_unixsocket.Session")
|
||||
def test_post_substitutes_name_from_body(self, mock_session_cls):
|
||||
mock_sess = mock_session_cls.return_value
|
||||
mock_resp = mock_sess.request.return_value
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True, "data": {"name": "eth0"}}
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
request("POST", "/network/interfaces/<name>", json_body={"name": "eth0"})
|
||||
|
||||
call_args = mock_sess.request.call_args
|
||||
url = call_args[0][1] if call_args else ""
|
||||
assert "/interfaces/eth0" in url
|
||||
|
||||
@patch("daemon.client.requests_unixsocket.Session")
|
||||
def test_get_substitutes_name_from_query(self, mock_session_cls):
|
||||
mock_sess = mock_session_cls.return_value
|
||||
mock_resp = mock_sess.request.return_value
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True, "data": {}}
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
request("GET", "/network/interfaces/<name>", query_params={"name": "eth0"})
|
||||
|
||||
call_args = mock_sess.request.call_args
|
||||
url = call_args[0][1] if call_args else ""
|
||||
assert "/interfaces/eth0" in url
|
||||
@@ -314,7 +314,6 @@ _FakeState = {
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "eth0",
|
||||
"display_name": "eth0",
|
||||
"mac": "aa:bb:cc:dd:ee:00",
|
||||
"state": "UP",
|
||||
"mtu": 1500,
|
||||
@@ -324,7 +323,6 @@ _FakeState = {
|
||||
},
|
||||
{
|
||||
"name": "eth1",
|
||||
"display_name": "eth1",
|
||||
"mac": "aa:bb:cc:dd:ee:01",
|
||||
"state": "UP",
|
||||
"mtu": 1500,
|
||||
@@ -437,7 +435,7 @@ class TestDaemonGetState:
|
||||
|
||||
class TestDaemonConfigApply:
|
||||
@patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
"lib.firewall.get_config",
|
||||
return_value={
|
||||
"zones": {
|
||||
"public": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.handlers.acme import generate_self_signed
|
||||
|
||||
|
||||
class TestGenerateSelfSigned:
|
||||
def test_generate_creates_files(self, tmp_path):
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
|
||||
):
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
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")
|
||||
# key missing -> should regenerate
|
||||
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
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("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()
|
||||
return Path("")
|
||||
|
||||
mock_run.side_effect = _create_files
|
||||
generate_self_signed(None, {"domain": "test.local", "days": 730})
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "-days" in args
|
||||
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()
|
||||
|
||||
def test_generate_creates_directory(self, tmp_path):
|
||||
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
|
||||
generate_self_signed(None, {"domain": "test.local"})
|
||||
|
||||
assert (tmp_path / "acme" / "test.local").is_dir()
|
||||
|
||||
def test_generate_requires_domain(self):
|
||||
with pytest.raises(ValueError, match="domain"):
|
||||
generate_self_signed(None, {"foo": "bar"})
|
||||
|
||||
def test_generate_requires_body(self):
|
||||
with pytest.raises(ValueError, match="body"):
|
||||
generate_self_signed(None, None)
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for daemon/handlers/network.py — handler endpoint logic."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.handlers.network import (
|
||||
apply_all,
|
||||
get_infer_dhcp_ranges,
|
||||
get_infer_zones,
|
||||
get_interface,
|
||||
get_interfaces,
|
||||
reload_interface,
|
||||
save_interface,
|
||||
set_sysctl,
|
||||
)
|
||||
from lib import network as _net
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_network(tmp_path):
|
||||
orig_config = _net.CONFIG_FILE
|
||||
orig_data = _net.DATA_DIR
|
||||
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
||||
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
||||
yield tmp_path
|
||||
_net.CONFIG_FILE = orig_config
|
||||
_net.DATA_DIR = orig_data
|
||||
|
||||
|
||||
# =================================================================
|
||||
# TF-12: Handler tests
|
||||
# =================================================================
|
||||
|
||||
|
||||
class TestSaveInterface:
|
||||
def test_save_interface_saves_config(self, tmp_network):
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch(
|
||||
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
|
||||
),
|
||||
):
|
||||
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
|
||||
save_interface(
|
||||
None,
|
||||
{"name": "eth0", "addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"},
|
||||
)
|
||||
|
||||
cfg = _net.get_config()
|
||||
assert "eth0" in cfg["interfaces"]
|
||||
assert cfg["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"]
|
||||
|
||||
def test_save_interface_renders_file(self, tmp_network):
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch(
|
||||
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
|
||||
),
|
||||
):
|
||||
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
|
||||
save_interface(
|
||||
None,
|
||||
{"name": "eth0", "addresses": ["10.0.0.1/24"]},
|
||||
)
|
||||
|
||||
data_dir = tmp_network / "data" / "networkd"
|
||||
assert (data_dir / "99-eth0.network").exists()
|
||||
content = (data_dir / "99-eth0.network").read_text()
|
||||
assert "Name=eth0" in content
|
||||
assert "Address=10.0.0.1/24" in content
|
||||
|
||||
def test_save_interface_requires_name(self, tmp_network):
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
save_interface(None, {"addresses": ["10.0.0.1/24"]})
|
||||
|
||||
def test_save_interface_requires_body(self):
|
||||
with pytest.raises(ValueError, match="body"):
|
||||
save_interface(None, None)
|
||||
|
||||
def test_save_interface_rejects_invalid_name(self, tmp_network):
|
||||
invalid_names = [
|
||||
"../../etc/passwd",
|
||||
"eth 0",
|
||||
"",
|
||||
"eth/0",
|
||||
"eth..0",
|
||||
]
|
||||
for invalid in invalid_names:
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch(
|
||||
"daemon.handlers.network.DATA_DIR",
|
||||
tmp_network / "data" / "networkd",
|
||||
),
|
||||
):
|
||||
mock_run.return_value = (
|
||||
"1: eth0 ethernet routable\n State: routable\n"
|
||||
)
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
save_interface(None, {"name": invalid})
|
||||
|
||||
|
||||
class TestReloadInterfaceValidation:
|
||||
def test_reload_interface_rejects_invalid_name(self):
|
||||
invalid_names = [
|
||||
"../../etc/passwd",
|
||||
"eth 0",
|
||||
"",
|
||||
"eth/0",
|
||||
]
|
||||
for invalid in invalid_names:
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
reload_interface(None, {"name": invalid})
|
||||
|
||||
|
||||
class TestGetInterfaceValidation:
|
||||
def test_get_interface_rejects_invalid_name(self, tmp_network):
|
||||
invalid_names = [
|
||||
"../../etc/passwd",
|
||||
"eth 0",
|
||||
"",
|
||||
"eth/0",
|
||||
]
|
||||
for invalid in invalid_names:
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
get_interface(None, {"name": invalid})
|
||||
|
||||
|
||||
class TestReloadInterface:
|
||||
def test_reload_interface(self):
|
||||
with patch("daemon.handlers.network.run") as mock_run:
|
||||
mock_run.return_value = "reloaded"
|
||||
result = reload_interface(None, {"name": "eth0"})
|
||||
|
||||
assert result["name"] == "eth0"
|
||||
assert result["reloaded"] is True
|
||||
mock_run.assert_called_with(
|
||||
["networkctl", "reconfigure", "eth0"], sudo=True
|
||||
)
|
||||
|
||||
def test_reload_interface_requires_name(self):
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
reload_interface(None, None)
|
||||
|
||||
def test_reload_interface_missing_name(self):
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
reload_interface(None, {})
|
||||
|
||||
|
||||
class TestApplyAll:
|
||||
def test_apply_all_generates_files(self, tmp_network):
|
||||
_net.save_config(
|
||||
{
|
||||
"interfaces": {
|
||||
"eth0": {"addresses": ["10.0.0.1/24"]},
|
||||
"eth1": {"addresses": ["192.168.1.1/24"]},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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=[]),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
"cleaned": [],
|
||||
}
|
||||
mock_run.return_value = ""
|
||||
|
||||
result = apply_all(None, None)
|
||||
|
||||
assert result["applied"] == 1
|
||||
assert len(result["files"]) == 1
|
||||
|
||||
def test_apply_all_syncs_dns(self, tmp_network):
|
||||
_net.save_config(
|
||||
{
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"dhcp": "ipv4",
|
||||
"dns": ["8.8.8.8", "192.168.1.1"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
"cleaned": [],
|
||||
}
|
||||
mock_run.return_value = ""
|
||||
mock_collect.return_value = ["8.8.8.8"]
|
||||
|
||||
apply_all(None, None)
|
||||
|
||||
mock_set_upstreams.assert_called_once_with(["8.8.8.8"])
|
||||
|
||||
def test_apply_all_handles_dns_sync_failure(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
|
||||
|
||||
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",
|
||||
side_effect=RuntimeError("fail"),
|
||||
),
|
||||
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
"cleaned": [],
|
||||
}
|
||||
mock_run.return_value = ""
|
||||
mock_collect.return_value = ["8.8.8.8"]
|
||||
|
||||
result = apply_all(None, None)
|
||||
assert "applied" in result
|
||||
|
||||
def test_apply_all_removes_stale_system_files(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
|
||||
sys_dir = tmp_network / "etc" / "systemd" / "network"
|
||||
sys_dir.mkdir(parents=True)
|
||||
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
|
||||
|
||||
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=[]),
|
||||
):
|
||||
mock_gen.return_value = {
|
||||
"generated": [_net.DATA_DIR / "99-eth0.network"],
|
||||
"cleaned": [],
|
||||
}
|
||||
mock_run.return_value = ""
|
||||
|
||||
class FakePath:
|
||||
def __init__(self, p="/etc/systemd/network") -> None:
|
||||
self._p = sys_dir if p == "/etc/systemd/network" else Path(p)
|
||||
|
||||
def exists(self):
|
||||
return True
|
||||
|
||||
def iterdir(self):
|
||||
return iter(self._p.iterdir())
|
||||
|
||||
def __truediv__(self, other):
|
||||
return self._p / other
|
||||
|
||||
def mkdir(self, *args, **kwargs) -> None:
|
||||
self._p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch("daemon.handlers.network.Path", FakePath):
|
||||
apply_all(None, None)
|
||||
|
||||
assert (sys_dir / "stale-file.network").exists()
|
||||
|
||||
|
||||
class TestGetInterfaces:
|
||||
def test_get_interfaces_returns_merged_data(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
|
||||
|
||||
with patch("daemon.handlers.network.run") as mock_run:
|
||||
mock_run.return_value = (
|
||||
"1: eth0 ethernet 10.0.0.0/24 routable\n"
|
||||
" State: routable\n"
|
||||
" Addresses: 10.0.0.1/24,\n"
|
||||
)
|
||||
result = get_interfaces(None, None)
|
||||
|
||||
assert "interfaces" in result
|
||||
assert "eth0" in result["interfaces"]
|
||||
assert "config" in result["interfaces"]["eth0"]
|
||||
assert "runtime" in result["interfaces"]["eth0"]
|
||||
|
||||
def test_get_interfaces_handles_networkctl_failure(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
|
||||
|
||||
with patch(
|
||||
"daemon.handlers.network.run", side_effect=RuntimeError("no networkctl")
|
||||
):
|
||||
result = get_interfaces(None, None)
|
||||
|
||||
assert "interfaces" in result
|
||||
assert "eth0" in result["interfaces"]
|
||||
|
||||
|
||||
class TestGetInterface:
|
||||
def test_get_single_interface(self, tmp_network):
|
||||
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
|
||||
|
||||
with patch("daemon.handlers.network.run") as mock_run:
|
||||
mock_run.return_value = "1: eth0 ethernet\n State: routable\n"
|
||||
result = get_interface(None, {"name": "eth0"})
|
||||
|
||||
assert result["name"] == "eth0"
|
||||
assert "config" in result
|
||||
assert result["config"]["addresses"] == ["10.0.0.1/24"]
|
||||
|
||||
def test_get_interface_not_found(self, tmp_network):
|
||||
_net.save_config({"interfaces": {}})
|
||||
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
get_interface(None, {"name": "eth0"})
|
||||
|
||||
def test_get_interface_requires_name(self):
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
get_interface(None, None)
|
||||
|
||||
|
||||
class TestInferEndpoints:
|
||||
def test_infer_dhcp_ranges_endpoint(self, tmp_network):
|
||||
_net.save_config(
|
||||
{
|
||||
"interfaces": {
|
||||
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
|
||||
}
|
||||
}
|
||||
)
|
||||
result = get_infer_dhcp_ranges(None, None)
|
||||
assert "ranges" in result
|
||||
assert "eth0" in result["ranges"]
|
||||
|
||||
def test_infer_zones_endpoint(self, tmp_network):
|
||||
_net.save_config(
|
||||
{
|
||||
"interfaces": {
|
||||
"wg0": {},
|
||||
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
|
||||
}
|
||||
}
|
||||
)
|
||||
result = get_infer_zones(None, None)
|
||||
assert "zones" in result
|
||||
assert result["zones"]["wg0"] == "wan"
|
||||
assert result["zones"]["eth0"] == "lan"
|
||||
|
||||
|
||||
class TestSetSysctl:
|
||||
def test_set_sysctl_success(self):
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch.object(Path, "read_text", return_value="1"),
|
||||
):
|
||||
mock_run.return_value = "" # sysctl -w call
|
||||
result = set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
|
||||
|
||||
assert result["name"] == "net.ipv4.ip_forward"
|
||||
assert result["value"] == "1"
|
||||
assert mock_run.call_count == 1
|
||||
assert mock_run.call_args_list[0].args == (
|
||||
["sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
)
|
||||
assert mock_run.call_args_list[0].kwargs == {"sudo": True}
|
||||
|
||||
def test_set_sysctl_rejects_slash_in_name(self):
|
||||
with pytest.raises(ValueError, match="valid sysctl key"):
|
||||
set_sysctl(None, {"name": "net.ipv4/ip_forward", "value": "1"})
|
||||
|
||||
def test_set_sysctl_rejects_double_dot(self):
|
||||
with pytest.raises(ValueError, match="valid sysctl key"):
|
||||
set_sysctl(None, {"name": "net..ipv4", "value": "1"})
|
||||
|
||||
def test_set_sysctl_requires_name(self):
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
set_sysctl(None, {"value": "1"})
|
||||
|
||||
def test_set_sysctl_requires_value(self):
|
||||
with pytest.raises(ValueError, match="value"):
|
||||
set_sysctl(None, {"name": "net.ipv4.ip_forward"})
|
||||
|
||||
def test_set_sysctl_requires_body(self):
|
||||
with pytest.raises(ValueError, match="body"):
|
||||
set_sysctl(None, None)
|
||||
|
||||
def test_set_sysctl_verify_failure(self):
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch.object(Path, "read_text", return_value="0"),
|
||||
):
|
||||
mock_run.return_value = "" # sysctl -w call succeeds
|
||||
with pytest.raises(RuntimeError, match="verify failed"):
|
||||
set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests that daemon.iface stays in sync with registered server routes.
|
||||
|
||||
Verifies a two-way contract:
|
||||
1. Every iface constant has a matching handler registered.
|
||||
2. Every registered handler has a matching iface constant.
|
||||
|
||||
Run with: pytest tests/test_iface_sync.py -v
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_handlers():
|
||||
"""Load all handler modules so registry is populated."""
|
||||
# Import server to get registry, then load handlers
|
||||
from daemon import server
|
||||
|
||||
# Force route registration
|
||||
server._register_routes()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def registry():
|
||||
from daemon import server
|
||||
|
||||
return server.registry
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iface_module():
|
||||
import daemon.iface as iface
|
||||
|
||||
return iface
|
||||
|
||||
|
||||
def _get_iface_pairs(iface_module):
|
||||
"""Extract all (method, path) pairs from iface module."""
|
||||
iface = iface_module
|
||||
return {
|
||||
name: val
|
||||
for name, val in iface.__dict__.items()
|
||||
if isinstance(val, tuple) and len(val) == 2 and isinstance(val[0], str)
|
||||
}
|
||||
|
||||
|
||||
def _get_registered_routes(registry):
|
||||
"""Extract all (METHOD, path) keys from the registry."""
|
||||
return {(method.upper(), path) for (method, path) in registry._routes}
|
||||
|
||||
|
||||
class TestIfaceSync:
|
||||
"""Verify iface constants match registered routes."""
|
||||
|
||||
def test_iface_constants_non_empty(self, iface_module):
|
||||
pairs = _get_iface_pairs(iface_module)
|
||||
assert len(pairs) >= 50, f"Expected many iface constants, got {len(pairs)}"
|
||||
|
||||
def test_iface_constants_have_registered_handlers(self, registry, iface_module):
|
||||
"""Every iface constant should map to a registered route."""
|
||||
registered = _get_registered_routes(registry)
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
|
||||
# These 5 routes go through add_route() in create_app(), not @registry.register
|
||||
add_route_paths = {
|
||||
"/health",
|
||||
"/status/all",
|
||||
"/status/refresh",
|
||||
"/ws",
|
||||
"/batch",
|
||||
}
|
||||
|
||||
missing = []
|
||||
for name, (method, path) in iface_pairs.items():
|
||||
key = (method.upper(), path)
|
||||
if path not in add_route_paths and key not in registered:
|
||||
missing.append(
|
||||
(
|
||||
name,
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if missing:
|
||||
detail = "\n".join(f" {name}: {pair}" for name, pair in missing)
|
||||
pytest.fail(
|
||||
f"{len(missing)} iface constant(s) have no matching handler:\n{detail}"
|
||||
)
|
||||
|
||||
def test_registered_routes_have_iface_constants(self, registry, iface_module):
|
||||
"""Every registered route should have a matching iface constant."""
|
||||
registered = _get_registered_routes(registry)
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
|
||||
iface_keys = set(iface_pairs.values())
|
||||
missing = registered - iface_keys
|
||||
|
||||
if missing:
|
||||
detail = "\n".join(f" {method} {path}" for method, path in sorted(missing))
|
||||
pytest.fail(
|
||||
f"{len(missing)} registered route(s) have no matching iface constant:\n{detail}"
|
||||
)
|
||||
|
||||
def test_no_duplicate_iface_constants(self, iface_module):
|
||||
"""All iface constants should have unique (method, path) pairs."""
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
seen = defaultdict(list)
|
||||
for name, val in iface_pairs.items():
|
||||
seen[val].append(name)
|
||||
|
||||
dupes = {pair: names for pair, names in seen.items() if len(names) > 1}
|
||||
assert not dupes, "Duplicate iface constants:\n" + "".join(
|
||||
f" {pair}: {names}\n" for pair, names in dupes.items()
|
||||
)
|
||||
|
||||
|
||||
class TestIfaceFormat:
|
||||
"""Verify iface constants follow the expected format."""
|
||||
|
||||
def test_all_constants_are_tuples_of_str(self, iface_module):
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
for name, val in iface_pairs.items():
|
||||
assert isinstance(val, tuple), f"{name} should be a tuple"
|
||||
assert len(val) == 2, f"{name} should have length 2"
|
||||
assert isinstance(val[0], str), f"{name} method should be a string"
|
||||
assert isinstance(val[1], str), f"{name} path should be a string"
|
||||
|
||||
def test_all_constants_have_uppercase_methods(self, iface_module):
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
for name, val in iface_pairs.items():
|
||||
assert val[0].upper() in valid_methods, (
|
||||
f"{name} has invalid method: {val[0]} — "
|
||||
f"should be one of {valid_methods}"
|
||||
)
|
||||
|
||||
def test_all_constants_have_leading_slash_path(self, iface_module):
|
||||
iface_pairs = _get_iface_pairs(iface_module)
|
||||
for name, val in iface_pairs.items():
|
||||
assert val[1].startswith("/"), f"{name} path should start with /: {val[1]}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
"""Integration tests for networkd interactions with other subsystems.
|
||||
|
||||
Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lib import network as _net
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_network(tmp_path):
|
||||
orig_config = _net.CONFIG_FILE
|
||||
orig_data = _net.DATA_DIR
|
||||
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
||||
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
||||
yield tmp_path
|
||||
_net.CONFIG_FILE = orig_config
|
||||
_net.DATA_DIR = orig_data
|
||||
|
||||
|
||||
# =================================================================
|
||||
# TF-13: Integration tests — DNS upstream sync
|
||||
# =================================================================
|
||||
|
||||
|
||||
class TestDnsUpstreamIntegration:
|
||||
"""collect_upstream_dns + set_upstreams integration."""
|
||||
|
||||
def test_wan_dns_becomes_dnsmasq_upstream(self, tmp_network):
|
||||
"""WAN interface with public DNS should produce upstream list."""
|
||||
cfg = _net.get_config()
|
||||
cfg["interfaces"] = {
|
||||
"eth0": {
|
||||
"dhcp": "ipv4",
|
||||
"dns": ["8.8.8.8", "1.1.1.1"],
|
||||
},
|
||||
"lan0": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
"dns": ["127.0.0.1"],
|
||||
},
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
|
||||
upstreams = _net.collect_upstream_dns(cfg)
|
||||
assert "8.8.8.8" in upstreams
|
||||
assert "1.1.1.1" in upstreams
|
||||
assert "127.0.0.1" not in upstreams
|
||||
|
||||
def test_all_dns_local_yields_empty(self, tmp_network):
|
||||
"""When all DNS servers are local, no upstreams."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"lan0": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
"dns": ["127.0.0.1", "192.168.1.1"],
|
||||
"ipv6_dns": ["fe80::1"],
|
||||
}
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
upstreams = _net.collect_upstream_dns(cfg)
|
||||
assert upstreams == []
|
||||
|
||||
def test_mixed_v4_v6_upstreams(self, tmp_network):
|
||||
"""Collects both IPv4 and IPv6 public DNS."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"dns": ["8.8.8.8"],
|
||||
"ipv6_dns": ["2001:4860:4860::8888"],
|
||||
}
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
upstreams = _net.collect_upstream_dns(cfg)
|
||||
assert "8.8.8.8" in upstreams
|
||||
assert "2001:4860:4860::8888" in upstreams
|
||||
|
||||
def test_upstream_dns_after_generate_files(self, tmp_network):
|
||||
"""Full flow: save config -> generate files -> collect upstreams."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"dhcp": "ipv4",
|
||||
"dns": ["8.8.8.8", "192.168.1.1"],
|
||||
"gateway": "10.0.0.254",
|
||||
}
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
result = _net.generate_network_files(cfg)
|
||||
|
||||
assert len(result["generated"]) == 1
|
||||
upstreams = _net.collect_upstream_dns(cfg)
|
||||
assert "8.8.8.8" in upstreams
|
||||
assert "192.168.1.1" not in upstreams
|
||||
|
||||
|
||||
# =================================================================
|
||||
# TF-13: Integration tests — DHCP range inference
|
||||
# =================================================================
|
||||
|
||||
|
||||
class TestDhcpRangesIntegration:
|
||||
"""infer_dhcp_ranges for multiple interface scenarios."""
|
||||
|
||||
def test_multi_interface_ranges(self, tmp_network):
|
||||
"""Each interface with static IP gets its own range."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"lan1": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
},
|
||||
"lan2": {
|
||||
"addresses": [{"address": "10.10.0.1/16"}],
|
||||
},
|
||||
"wan0": {
|
||||
"dhcp": "ipv4",
|
||||
},
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
ranges = _net.infer_dhcp_ranges(cfg)
|
||||
|
||||
assert "lan1" in ranges
|
||||
assert "lan2" in ranges
|
||||
assert "wan0" not in ranges
|
||||
|
||||
assert ranges["lan1"]["start"] == "192.168.1.100"
|
||||
assert ranges["lan1"]["end"] == "192.168.1.200"
|
||||
assert ranges["lan2"]["start"] == "10.10.0.100"
|
||||
assert ranges["lan2"]["end"] == "10.10.0.200"
|
||||
|
||||
def test_generate_then_infer(self, tmp_network):
|
||||
"""End-to-end: save, generate, infer ranges."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"addresses": [{"address": "172.16.0.1/24"}],
|
||||
"gateway": "172.16.0.254",
|
||||
"dns": ["8.8.8.8"],
|
||||
}
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
_net.generate_network_files(cfg)
|
||||
ranges = _net.infer_dhcp_ranges(cfg)
|
||||
|
||||
assert "eth0" in ranges
|
||||
assert ranges["eth0"]["subnet"] == "172.16.0.0"
|
||||
assert ranges["eth0"]["prefix"] == 24
|
||||
|
||||
def test_ranges_cross_reference_with_zones(self, tmp_network):
|
||||
"""DHCP ranges for LAN interfaces correlate with zone inference."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"lan0": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
},
|
||||
"wan0": {
|
||||
"dhcp": "ipv4",
|
||||
},
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
ranges = _net.infer_dhcp_ranges(cfg)
|
||||
zones = _net.infer_zones(cfg)
|
||||
|
||||
assert "lan0" in ranges
|
||||
assert zones["lan0"] == "lan"
|
||||
assert "wan0" not in ranges
|
||||
assert zones["wan0"] == "wan"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# TF-13: Integration tests — zone inference with networkd config
|
||||
# =================================================================
|
||||
|
||||
|
||||
class TestZonesIntegration:
|
||||
"""infer_zones with realistic networkd configurations."""
|
||||
|
||||
def test_typical_router_setup(self, tmp_network):
|
||||
"""WAN (DHCP), LAN (static), WG (WireGuard) zones."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"dhcp": "ipv4",
|
||||
},
|
||||
"eth1": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
"gateway": "192.168.1.254",
|
||||
},
|
||||
"wg0": {
|
||||
"addresses": [{"address": "10.137.0.1/24"}],
|
||||
},
|
||||
"br-mgmt": {
|
||||
"addresses": [{"address": "10.0.0.1/24"}],
|
||||
"routes": [
|
||||
{"destination": "0.0.0.0/0", "gateway": "10.0.0.254"},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
zones = _net.infer_zones(cfg)
|
||||
|
||||
assert zones["eth0"] == "wan"
|
||||
assert zones["eth1"] == "lan"
|
||||
assert zones["wg0"] == "wan"
|
||||
assert zones["br-mgmt"] == "management"
|
||||
|
||||
def test_zone_inference_after_generate(self, tmp_network):
|
||||
"""Zone inference works after generate_network_files."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"wan0": {"dhcp": "ipv4"},
|
||||
"lan0": {"addresses": [{"address": "192.168.10.1/24"}]},
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
_net.generate_network_files(cfg)
|
||||
zones = _net.infer_zones(cfg)
|
||||
|
||||
assert zones["wan0"] == "wan"
|
||||
assert zones["lan0"] == "lan"
|
||||
|
||||
def test_full_pipeline(self, tmp_network):
|
||||
"""Full pipeline: config -> generate -> DNS -> ranges -> zones."""
|
||||
cfg = {
|
||||
"interfaces": {
|
||||
"eth0": {
|
||||
"dhcp": "ipv4",
|
||||
"dns": ["8.8.8.8", "1.1.1.1", "192.168.1.1"],
|
||||
},
|
||||
"eth1": {
|
||||
"addresses": [{"address": "192.168.1.1/24"}],
|
||||
"dns": ["127.0.0.1"],
|
||||
},
|
||||
"wg0": {
|
||||
"addresses": [{"address": "10.137.0.1/24"}],
|
||||
},
|
||||
}
|
||||
}
|
||||
_net.save_config(cfg)
|
||||
|
||||
gen_result = _net.generate_network_files(cfg)
|
||||
assert len(gen_result["generated"]) == 3
|
||||
|
||||
upstreams = _net.collect_upstream_dns(cfg)
|
||||
assert "8.8.8.8" in upstreams
|
||||
assert "1.1.1.1" in upstreams
|
||||
assert "192.168.1.1" not in upstreams
|
||||
assert "127.0.0.1" not in upstreams
|
||||
|
||||
ranges = _net.infer_dhcp_ranges(cfg)
|
||||
assert "eth1" in ranges
|
||||
assert "eth0" not in ranges
|
||||
# wg0 has a static address so it also gets a candidate range
|
||||
assert "wg0" in ranges
|
||||
|
||||
zones = _net.infer_zones(cfg)
|
||||
assert zones["eth0"] == "wan"
|
||||
assert zones["eth1"] == "lan"
|
||||
assert zones["wg0"] == "wan"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# TF-11: state.py parser dedup verification
|
||||
# =================================================================
|
||||
|
||||
|
||||
class TestStateParserDedup:
|
||||
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
|
||||
|
||||
def test_state_uses_network_parser(self):
|
||||
"""The networkd collector in state.py should import from lib.network."""
|
||||
import lib.state as _state
|
||||
|
||||
source = Path(_state.__file__).read_text()
|
||||
assert "from lib.network import parse_networkctl_status" in source
|
||||
assert "parse_networkctl_status" in source
|
||||
|
||||
def test_networkd_collector_returns_correct_format(self):
|
||||
"""_collect_networkd should return interfaces dict + timestamp."""
|
||||
import lib.state as _state
|
||||
|
||||
with patch("lib.state.run") as mock_run:
|
||||
mock_run.return_value = json.dumps(
|
||||
{
|
||||
"Interfaces": [
|
||||
{
|
||||
"Name": "eth0",
|
||||
"Type": "ether",
|
||||
"OperationalState": "routable",
|
||||
"Addresses": [
|
||||
{
|
||||
"Family": 2,
|
||||
"Address": [10, 0, 0, 1],
|
||||
"PrefixLength": 24,
|
||||
}
|
||||
],
|
||||
"DNS": [
|
||||
{"Family": 2, "Address": [8, 8, 8, 8]},
|
||||
],
|
||||
"Routes": [
|
||||
{
|
||||
"Family": 2,
|
||||
"Destination": [0, 0, 0, 0],
|
||||
"DestinationPrefixLength": 0,
|
||||
"Gateway": [10, 0, 0, 254],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
result = _state._collect_networkd()
|
||||
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
assert "eth0" in result["interfaces"]
|
||||
assert "10.0.0.1/24" in result["interfaces"]["eth0"]["addresses"]
|
||||
|
||||
def test_networkd_collector_handles_failure(self):
|
||||
"""_collect_networkd returns empty interfaces on error."""
|
||||
import lib.state as _state
|
||||
|
||||
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
|
||||
result = _state._collect_networkd()
|
||||
|
||||
assert result["interfaces"] == {}
|
||||
assert "timestamp" in result
|
||||
+96
-81
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -5,93 +7,106 @@ import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with patch("lib.logging.setup_logging"):
|
||||
from webui.server import app
|
||||
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestTemplateFilters:
|
||||
@pytest.fixture
|
||||
def env(self):
|
||||
from webui.server import app
|
||||
class TestSPARoutes:
|
||||
def test_root_serves_index(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b'id="app"' in resp.data
|
||||
|
||||
return app.jinja_env
|
||||
|
||||
def test_timestamp_filter_valid(self, env):
|
||||
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
|
||||
assert "2026-04-01" in result
|
||||
|
||||
def test_timestamp_filter_empty(self, env):
|
||||
assert env.filters["timestamp"]("") == ""
|
||||
assert env.filters["timestamp"](None) == ""
|
||||
|
||||
def test_timestamp_filter_invalid(self, env):
|
||||
result = env.filters["timestamp"]("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
def test_bytes_filter_zero(self, env):
|
||||
assert env.filters["bytes"](0) == "0.0 B"
|
||||
|
||||
def test_bytes_filter_kb(self, env):
|
||||
result = env.filters["bytes"](1536)
|
||||
assert "KB" in result
|
||||
|
||||
def test_bytes_filter_mb(self, env):
|
||||
result = env.filters["bytes"](1500000)
|
||||
assert "MB" in result
|
||||
|
||||
def test_bytes_filter_negative(self, env):
|
||||
assert env.filters["bytes"](-1) == "0 B"
|
||||
|
||||
def test_bytes_filter_invalid(self, env):
|
||||
assert env.filters["bytes"]("not-a-number") == "not-a-number"
|
||||
|
||||
def test_duration_filter_zero(self, env):
|
||||
assert env.filters["duration"](0) == "0s"
|
||||
|
||||
def test_duration_filter_seconds(self, env):
|
||||
assert env.filters["duration"](65) == "1m 5s"
|
||||
|
||||
def test_duration_filter_hours(self, env):
|
||||
result = env.filters["duration"](3661)
|
||||
assert "1h" in result
|
||||
|
||||
def test_duration_filter_days(self, env):
|
||||
result = env.filters["duration"](90000)
|
||||
assert "1d" in result
|
||||
|
||||
def test_duration_filter_invalid(self, env):
|
||||
assert env.filters["duration"]("bad") == "bad"
|
||||
|
||||
def test_json_pretty_filter(self, env):
|
||||
result = env.filters["json_pretty"]({"key": "value"})
|
||||
assert '{"key": "value"}' in result or "key" in result
|
||||
|
||||
|
||||
class TestSafelyHelper:
|
||||
def test_returns_result(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 42)
|
||||
assert result == 42
|
||||
|
||||
def test_returns_default_on_exception(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default=None)
|
||||
assert result is None
|
||||
|
||||
def test_returns_custom_default(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default="fallback")
|
||||
assert result == "fallback"
|
||||
|
||||
|
||||
class TestPageRoutes:
|
||||
@patch("webui.server.get")
|
||||
def test_dashboard_no_crash(self, mock_get, client):
|
||||
mock_get.return_value = {}
|
||||
def test_spa_catch_all_serves_index(self, client):
|
||||
resp = client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
assert b"index.html" in resp.data or b'id="app"' in resp.data
|
||||
|
||||
def test_spa_catch_all_other_page(self, client):
|
||||
resp = client.get("/zones")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_routes_still_work(self, client):
|
||||
resp = client.get("/api/firewall/zones")
|
||||
assert resp.status_code in (200, 502, 503)
|
||||
|
||||
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "firewall" in data["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
|
||||
|
||||
|
||||
class TestBlueprintsRegistered:
|
||||
def test_all_blueprints_registered(self, client):
|
||||
from webui.server import BLUEPRINTS
|
||||
|
||||
assert len(BLUEPRINTS) == 7
|
||||
names = [name for name, _ in BLUEPRINTS]
|
||||
assert "firewall" in names
|
||||
assert "network" in names
|
||||
assert "dhcp" in names
|
||||
assert "proxy" in names
|
||||
assert "certs" in names
|
||||
assert "wireguard" in names
|
||||
assert "logs" in names
|
||||
|
||||
|
||||
class TestGroupWriteHandler:
|
||||
def test_creates_file_with_group_write(self, tmp_path: Path) -> None:
|
||||
"""GroupWriteHandler creates new log files with group-write (0o664)."""
|
||||
import contextlib
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
log_file = tmp_path / "test.log"
|
||||
old = os.umask(0o022)
|
||||
try:
|
||||
|
||||
class GroupWriteHandler(RotatingFileHandler):
|
||||
def _open(self):
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(self.baseFilename, 0o664)
|
||||
saved = os.umask(0o002)
|
||||
try:
|
||||
fd = os.open(
|
||||
self.baseFilename,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
||||
0o664,
|
||||
)
|
||||
finally:
|
||||
os.umask(saved)
|
||||
return os.fdopen(fd, "a", errors="backslashreplace")
|
||||
|
||||
fh = GroupWriteHandler(str(log_file))
|
||||
fh.close()
|
||||
finally:
|
||||
os.umask(old)
|
||||
|
||||
mode = os.stat(log_file).st_mode & 0o777
|
||||
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
|
||||
|
||||
@@ -56,6 +56,49 @@ class TestCollectAll:
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
|
||||
@patch("lib.state.run")
|
||||
def test_collect_firewall_vlan_ips_populated(self, mock_run):
|
||||
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
|
||||
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"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
if "link" in args:
|
||||
return (
|
||||
"1: lo: <LOOPBACK> mtu 65536\n"
|
||||
"2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
||||
"3: eth0.100@eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
||||
)
|
||||
if "addr" in args:
|
||||
return (
|
||||
"2: eth0 inet 192.168.1.1/24\n"
|
||||
"3: eth0.100@if100 inet 10.0.0.1/24\n"
|
||||
)
|
||||
return ""
|
||||
if "--list-all" in args:
|
||||
return (
|
||||
"target: default\ninterfaces: eth0\nsources: "
|
||||
"services: \nports: \nprotocols: \nforward-ports: "
|
||||
"masquerade: no\nics: no\nrich-rules: "
|
||||
"icmp-blocks: \nmodule: \n"
|
||||
)
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = _collect_firewall()
|
||||
vlan_iface = next(
|
||||
(i for i in result["interfaces"] if i["name"] == "eth0.100"), None
|
||||
)
|
||||
assert vlan_iface is not None, "VLAN interface should be present"
|
||||
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
||||
assert "10.0.0.1/24" in vlan_iface["ips"]
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
||||
from unittest.mock import Mock
|
||||
@@ -78,3 +121,57 @@ class TestCollectFailure:
|
||||
s.set("firewall", None) # simulates failure
|
||||
assert s.get("firewall") is None
|
||||
assert s.is_populated() is False
|
||||
|
||||
|
||||
class TestStateVersions:
|
||||
def test_version_starts_at_zero(self):
|
||||
s = State()
|
||||
versions = s.get_versions()
|
||||
assert versions["firewall"] == 0
|
||||
assert versions["dnsmasq"] == 0
|
||||
|
||||
def test_bump_increments_version(self):
|
||||
s = State()
|
||||
assert s.get_versions()["firewall"] == 0
|
||||
s.bump("firewall")
|
||||
assert s.get_versions()["firewall"] == 1
|
||||
|
||||
def test_bump_unknown_subsystem_noop(self):
|
||||
s = State()
|
||||
versions = s.get_versions()
|
||||
s.bump("nonexistent")
|
||||
assert versions == s.get_versions()
|
||||
|
||||
def test_get_updated_versions_first_call_empty(self):
|
||||
s = State()
|
||||
s.bump("firewall")
|
||||
updated = s.get_updated_versions()
|
||||
assert updated == {}
|
||||
assert s.get_updated_versions() == {}
|
||||
|
||||
def test_get_updated_versions_detects_change(self):
|
||||
s = State()
|
||||
_ = s.get_updated_versions() # snapshot
|
||||
s.bump("firewall")
|
||||
updated = s.get_updated_versions()
|
||||
assert updated["firewall"] == 1
|
||||
|
||||
def test_broadcast_maintains_snapshot(self):
|
||||
s = State()
|
||||
s.bump("firewall")
|
||||
s.bump("dnsmasq")
|
||||
_ = s.get_updated_versions() # snapshot at fw=1, dm=1
|
||||
s.bump("wireguard")
|
||||
updated = s.get_updated_versions()
|
||||
assert updated["wireguard"] == 1
|
||||
assert s.get_updated_versions() == {}
|
||||
|
||||
def test_multiple_bumps_aggregate(self):
|
||||
s = State()
|
||||
_ = s.get_updated_versions()
|
||||
s.bump("firewall")
|
||||
s.bump("firewall")
|
||||
s.bump("dnsmasq")
|
||||
updated = s.get_updated_versions()
|
||||
assert updated["firewall"] == 2
|
||||
assert updated["dnsmasq"] == 1
|
||||
|
||||
+76
-8
@@ -8,6 +8,16 @@ import logging
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, post
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_REMOVE,
|
||||
GET_ACME_INFO,
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
POST_ACME_RENEW,
|
||||
POST_ACME_VALIDATE,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,8 +26,13 @@ bp = Blueprint("certs", __name__)
|
||||
|
||||
@bp.route("/list", methods=["GET"])
|
||||
def list_certs_bp():
|
||||
"""GET /api/certs/list — list all managed ACME certificates.
|
||||
|
||||
Returns:
|
||||
Response containing the list of certificates or an error message.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/acme/list"))
|
||||
return _ok(get(GET_ACME_LIST))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list certificates: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -25,8 +40,16 @@ def list_certs_bp():
|
||||
|
||||
@bp.route("/<domain>", methods=["GET"])
|
||||
def cert_details(domain: str):
|
||||
"""GET /api/certs/<domain> — get details for a specific certificate.
|
||||
|
||||
Args:
|
||||
domain: Domain name to look up.
|
||||
|
||||
Returns:
|
||||
Response containing certificate info or an error message.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/acme/info", {"domain": domain}))
|
||||
return _ok(get(GET_ACME_INFO, {"domain": domain}))
|
||||
except NotFound as exc:
|
||||
logger.info("Cert for '%s' not found: %s", domain, exc)
|
||||
return _error(str(exc), 404)
|
||||
@@ -37,12 +60,19 @@ def cert_details(domain: str):
|
||||
|
||||
@bp.route("/validate", methods=["POST"])
|
||||
def validate():
|
||||
"""POST /api/certs/validate — run pre-flight checks for certificate issuance.
|
||||
|
||||
Expects JSON body with ``{``domain``}``.
|
||||
|
||||
Returns:
|
||||
Response containing validation results or an error message.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
try:
|
||||
result = post("/acme/validate", {"domain": domain})
|
||||
result = post(POST_ACME_VALIDATE, {"domain": domain})
|
||||
return _ok(result)
|
||||
except BadRequest as exc:
|
||||
logger.info("Validation rejected: %s", exc)
|
||||
@@ -54,6 +84,13 @@ def validate():
|
||||
|
||||
@bp.route("/issue/start", methods=["POST"])
|
||||
def issue_start():
|
||||
"""POST /api/certs/issue/start — create a new certificate issuance request.
|
||||
|
||||
Expects JSON body with ``{``domain``}``; optional ``email`` and ``webroot``.
|
||||
|
||||
Returns:
|
||||
Response containing an issuance request ID or an error message.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
@@ -63,7 +100,7 @@ def issue_start():
|
||||
try:
|
||||
logger.info("Certificate issuance requested for '%s' via API", domain)
|
||||
result = post(
|
||||
"/acme/issue", {"domain": domain, "webroot": webroot, "email": email}
|
||||
POST_ACME_ISSUE, {"domain": domain, "webroot": webroot, "email": email}
|
||||
)
|
||||
logger.info(
|
||||
"Certificate issuance started for '%s' (id=%s)",
|
||||
@@ -81,8 +118,16 @@ def issue_start():
|
||||
|
||||
@bp.route("/issue/<request_id>", methods=["GET"])
|
||||
def issue_status(request_id: str):
|
||||
"""GET /api/certs/issue/<request_id> — poll status of a certificate issuance request.
|
||||
|
||||
Args:
|
||||
request_id: Issuance request identifier returned by issue_start.
|
||||
|
||||
Returns:
|
||||
Response containing issuance status or an error message.
|
||||
"""
|
||||
try:
|
||||
result = get("/acme/issue/status", {"id": request_id})
|
||||
result = get(GET_ACME_ISSUE_STATUS, {"id": request_id})
|
||||
return _ok(result)
|
||||
except NotFound as exc:
|
||||
logger.info("Issuance request '%s' not found: %s", request_id, exc)
|
||||
@@ -94,9 +139,17 @@ 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.
|
||||
|
||||
Returns:
|
||||
Response confirming renewal or an error message.
|
||||
"""
|
||||
try:
|
||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||
post("/acme/renew", {"domain": domain})
|
||||
post(POST_ACME_RENEW, {"domain": domain})
|
||||
logger.info("Certificate renewed for '%s'", domain)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
@@ -109,8 +162,16 @@ def renew_bp(domain: str):
|
||||
|
||||
@bp.route("/<domain>", methods=["DELETE"])
|
||||
def remove_bp(domain: str):
|
||||
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
|
||||
|
||||
Args:
|
||||
domain: Domain name whose certificate should be removed.
|
||||
|
||||
Returns:
|
||||
Response confirming removal or an error message.
|
||||
"""
|
||||
try:
|
||||
delete("/acme/remove", {"domain": domain})
|
||||
delete(DELETE_ACME_REMOVE, {"domain": domain})
|
||||
logger.info("Certificate removed for '%s' via API", domain)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
@@ -123,12 +184,19 @@ def remove_bp(domain: str):
|
||||
|
||||
@bp.route("/email", methods=["POST"])
|
||||
def set_email_bp():
|
||||
"""POST /api/certs/email — set the ACME account email address.
|
||||
|
||||
Expects JSON body with ``{``email``}``.
|
||||
|
||||
Returns:
|
||||
Response confirming the email was set or an error message.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
return _error("'email' is required", 400)
|
||||
try:
|
||||
post("/acme/email", {"email": email})
|
||||
post(POST_ACME_EMAIL, {"email": email})
|
||||
logger.info("ACME email set via API: %s", email)
|
||||
return _ok({"email": email})
|
||||
except BadRequest as exc:
|
||||
|
||||
+104
-12
@@ -8,6 +8,20 @@ import logging
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
|
||||
GET_DNSMASQ_CONFIG,
|
||||
GET_DNSMASQ_LEASES,
|
||||
GET_DNSMASQ_STATUS,
|
||||
PATCH_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_APPLY,
|
||||
POST_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,8 +35,13 @@ bp = Blueprint("dhcp", __name__)
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
"""GET /api/dhcp/config — Retrieve the current dnsmasq configuration.
|
||||
|
||||
Returns:
|
||||
JSON response with the config or an error.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/dnsmasq/config"))
|
||||
return _ok(get(GET_DNSMASQ_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -30,11 +49,19 @@ def get_config_bp():
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def post_config():
|
||||
"""POST /api/dhcp/config — Save a full replacement dnsmasq configuration.
|
||||
|
||||
Args:
|
||||
request: JSON body containing the complete config object.
|
||||
|
||||
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("/dnsmasq/config", body)
|
||||
post(POST_DNSMASQ_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("DHCP config save rejected: %s", exc)
|
||||
@@ -46,11 +73,19 @@ def post_config():
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
def patch_config():
|
||||
"""PATCH /api/dhcp/config — Partially update the dnsmasq configuration.
|
||||
|
||||
Args:
|
||||
request: JSON body containing the fields to update.
|
||||
|
||||
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:
|
||||
patch("/dnsmasq/config", body)
|
||||
patch(PATCH_DNSMASQ_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("DHCP config patch rejected: %s", exc)
|
||||
@@ -62,8 +97,10 @@ def patch_config():
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
"""POST /api/dhcp/apply — Apply the current dnsmasq configuration to the running service."""
|
||||
|
||||
try:
|
||||
post("/dnsmasq/apply")
|
||||
post(POST_DNSMASQ_APPLY)
|
||||
logger.info("dnsmasq config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -78,8 +115,10 @@ def apply_bp():
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
def status_bp():
|
||||
"""GET /api/dhcp/status — Retrieve dnsmasq service status."""
|
||||
|
||||
try:
|
||||
return _ok(get("/dnsmasq/status"))
|
||||
return _ok(get(GET_DNSMASQ_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get DHCP status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -92,6 +131,14 @@ def status_bp():
|
||||
|
||||
@bp.route("/ranges", methods=["POST"])
|
||||
def add_range_bp():
|
||||
"""POST /api/dhcp/ranges — Add a DHCP address range for an interface.
|
||||
|
||||
Args:
|
||||
request: JSON body with `interface`, `start`, `end`, and optional `lease_time`.
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
iface = body.get("interface", "").strip() or None
|
||||
start = body.get("start", "").strip()
|
||||
@@ -101,7 +148,7 @@ def add_range_bp():
|
||||
return _error("'start' and 'end' are required", 400)
|
||||
try:
|
||||
post(
|
||||
"/dnsmasq/ranges/add",
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
{
|
||||
"interface": iface or "",
|
||||
"start": start,
|
||||
@@ -121,6 +168,14 @@ def add_range_bp():
|
||||
|
||||
@bp.route("/ranges", methods=["DELETE"])
|
||||
def remove_range_bp():
|
||||
"""DELETE /api/dhcp/ranges — Remove a DHCP address range.
|
||||
|
||||
Args:
|
||||
request: JSON body with `interface`, `start`, and `end`.
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
start = body.get("start", "").strip()
|
||||
@@ -129,7 +184,8 @@ def remove_range_bp():
|
||||
return _error("'start' and 'end' are required", 400)
|
||||
try:
|
||||
delete(
|
||||
"/dnsmasq/ranges/remove", {"interface": iface, "start": start, "end": end}
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
{"interface": iface, "start": start, "end": end},
|
||||
)
|
||||
logger.info("DHCP range removed via API: %s-%s", start, end)
|
||||
return _ok(None)
|
||||
@@ -148,8 +204,10 @@ def remove_range_bp():
|
||||
|
||||
@bp.route("/leases", methods=["GET"])
|
||||
def leases_bp():
|
||||
"""GET /api/dhcp/leases — Retrieve the current DHCP lease table."""
|
||||
|
||||
try:
|
||||
return _ok(get("/dnsmasq/leases"))
|
||||
return _ok(get(GET_DNSMASQ_LEASES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read lease table: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -162,6 +220,14 @@ def leases_bp():
|
||||
|
||||
@bp.route("/static-lease", methods=["POST"])
|
||||
def add_static_lease_bp():
|
||||
"""POST /api/dhcp/static-lease — Add a static DHCP lease by MAC address.
|
||||
|
||||
Args:
|
||||
request: JSON body with `mac`, `ip`, and optional `hostname`.
|
||||
|
||||
Returns:
|
||||
JSON response with lease details or an error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
mac = body.get("mac", "").strip()
|
||||
ip = body.get("ip", "").strip()
|
||||
@@ -169,7 +235,9 @@ def add_static_lease_bp():
|
||||
if not mac or not ip:
|
||||
return _error("'mac' and 'ip' are required", 400)
|
||||
try:
|
||||
post("/dnsmasq/static-lease/add", {"mac": mac, "ip": ip, "hostname": hostname})
|
||||
post(
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD, {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
)
|
||||
logger.info("Static lease added via API: %s -> %s", mac, ip)
|
||||
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
||||
except BadRequest as exc:
|
||||
@@ -182,8 +250,16 @@ def add_static_lease_bp():
|
||||
|
||||
@bp.route("/static-lease/<mac>", methods=["DELETE"])
|
||||
def remove_static_lease_bp(mac):
|
||||
"""DELETE /api/dhcp/static-lease/<mac> — Remove a static DHCP lease by MAC address.
|
||||
|
||||
Args:
|
||||
mac: MAC address of the static lease to remove.
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
try:
|
||||
delete("/dnsmasq/static-lease/remove", {"mac": mac})
|
||||
delete(DELETE_DNSMASQ_STATIC_LEASE_REMOVE, {"mac": mac})
|
||||
logger.info("Static lease removed via API: %s", mac)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
@@ -201,6 +277,14 @@ def remove_static_lease_bp(mac):
|
||||
|
||||
@bp.route("/dns-record", methods=["POST"])
|
||||
def add_dns_record_bp():
|
||||
"""POST /api/dhcp/dns-record — Add a DNS record.
|
||||
|
||||
Args:
|
||||
request: JSON body with `name`, `address`, and optional `hostname`.
|
||||
|
||||
Returns:
|
||||
JSON response with record details or an error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
address = body.get("address", "").strip()
|
||||
@@ -209,7 +293,7 @@ def add_dns_record_bp():
|
||||
return _error("'name' and 'address' are required", 400)
|
||||
try:
|
||||
post(
|
||||
"/dnsmasq/dns-record/add",
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
{"name": name, "address": address, "hostname": hostname},
|
||||
)
|
||||
logger.info("DNS record added via API: %s -> %s", name, address)
|
||||
@@ -224,8 +308,16 @@ def add_dns_record_bp():
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
name: Name of the DNS record to remove.
|
||||
|
||||
Returns:
|
||||
JSON response with success status or an error.
|
||||
"""
|
||||
try:
|
||||
delete("/dnsmasq/dns-record/remove", {"name": name})
|
||||
delete(DELETE_DNSMASQ_DNS_RECORD_REMOVE, {"name": name})
|
||||
logger.info("DNS record removed via API: %s", name)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
|
||||
+280
-21
@@ -8,6 +8,28 @@ import logging
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
DELETE_FIREWALL_ZONES_DELETE,
|
||||
GET_FIREWALL_CONFIG,
|
||||
GET_FIREWALL_CONFIG_PENDING,
|
||||
GET_FIREWALL_INTERFACES,
|
||||
GET_FIREWALL_RICH_RULES,
|
||||
GET_FIREWALL_SERVICES,
|
||||
GET_FIREWALL_STATE,
|
||||
GET_FIREWALL_ZONES,
|
||||
GET_FIREWALL_ZONES_INFO,
|
||||
PATCH_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG_APPLY,
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
POST_FIREWALL_MASQUERADE,
|
||||
POST_FIREWALL_RICH_RULES_ADD,
|
||||
POST_FIREWALL_ZONES_CREATE,
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,8 +43,18 @@ bp = Blueprint("firewall", __name__)
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def config_list():
|
||||
"""Retrieve the current firewall declarative configuration.
|
||||
|
||||
Returns JSON containing the full firewall config from the daemon.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/config
|
||||
|
||||
Returns:
|
||||
JSON response with the config data or an error message.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/firewall/config"))
|
||||
return _ok(get(GET_FIREWALL_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read firewall config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -30,15 +62,29 @@ def config_list():
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def config_save():
|
||||
"""Save a new firewall declarative configuration.
|
||||
|
||||
Validates that the request body contains a ``zones`` dict, forwards
|
||||
to the daemon, and returns the pending state including unmanaged zones.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/config
|
||||
|
||||
Args:
|
||||
body: JSON with ``zones`` dict mapping zone names to zone configs.
|
||||
|
||||
Returns:
|
||||
JSON with ``config_saved`` flag and pending apply information.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "zones" not in body:
|
||||
return _error("'zones' key is required", 400)
|
||||
if not isinstance(body["zones"], dict):
|
||||
return _error("'zones' must be a dict", 400)
|
||||
try:
|
||||
post("/firewall/config", body)
|
||||
post(POST_FIREWALL_CONFIG, body)
|
||||
try:
|
||||
pending = get("/firewall/config/pending")
|
||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||
pending_data = {
|
||||
"pending": pending.get("pending", []),
|
||||
"needs_apply": pending.get("needs_apply", False),
|
||||
@@ -64,13 +110,27 @@ def config_save():
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
def patch_config():
|
||||
"""Partially update the firewall declarative configuration.
|
||||
|
||||
Accepts a JSON body and forwards it as a patch to the daemon config
|
||||
endpoint, returning the updated pending state.
|
||||
|
||||
Endpoint:
|
||||
PATCH /api/firewall/config
|
||||
|
||||
Args:
|
||||
body: JSON object with configuration fields to patch.
|
||||
|
||||
Returns:
|
||||
JSON with ``config_saved`` flag and pending apply information.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
patch("/firewall/config", body)
|
||||
patch(PATCH_FIREWALL_CONFIG, body)
|
||||
try:
|
||||
pending = get("/firewall/config/pending")
|
||||
pending = get(GET_FIREWALL_CONFIG_PENDING)
|
||||
pending_data = {
|
||||
"pending": pending.get("pending", []),
|
||||
"needs_apply": pending.get("needs_apply", False),
|
||||
@@ -95,8 +155,19 @@ def patch_config():
|
||||
|
||||
@bp.route("/config/apply", methods=["POST"])
|
||||
def config_apply_bp():
|
||||
"""Apply any pending firewall configuration changes.
|
||||
|
||||
Triggers the daemon to apply saved declarative config to the live
|
||||
firewalld instance.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/config/apply
|
||||
|
||||
Returns:
|
||||
JSON with ``applied_zones`` list or an error message.
|
||||
"""
|
||||
try:
|
||||
result = post("/firewall/config/apply")
|
||||
result = post(POST_FIREWALL_CONFIG_APPLY)
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
@@ -106,13 +177,46 @@ def config_apply_bp():
|
||||
|
||||
@bp.route("/config/pending", methods=["GET"])
|
||||
def config_pending_bp():
|
||||
"""Check the pending firewall configuration state.
|
||||
|
||||
Returns information about unsaved changes, whether an apply is
|
||||
needed, and any unmanaged zones detected on the system.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/config/pending
|
||||
|
||||
Returns:
|
||||
JSON with pending changes and apply status.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/firewall/config/pending"))
|
||||
return _ok(get(GET_FIREWALL_CONFIG_PENDING))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to check pending config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/state", methods=["GET"])
|
||||
def get_state():
|
||||
"""Retrieve current firewall state from the state store.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/state
|
||||
|
||||
Returns:
|
||||
JSON with firewall state data or an error message.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_FIREWALL_STATE))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get firewall state: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zones
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,8 +224,16 @@ def config_pending_bp():
|
||||
|
||||
@bp.route("/zones", methods=["GET"])
|
||||
def list_zones():
|
||||
"""List all active and available firewall zones.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/zones
|
||||
|
||||
Returns:
|
||||
JSON with ``active`` zones dict and ``available`` zones list.
|
||||
"""
|
||||
try:
|
||||
data = get("/firewall/zones")
|
||||
data = get(GET_FIREWALL_ZONES)
|
||||
return _ok(
|
||||
{"active": data.get("active", {}), "available": data.get("available", [])}
|
||||
)
|
||||
@@ -132,8 +244,19 @@ def list_zones():
|
||||
|
||||
@bp.route("/zones/<name>", methods=["GET"])
|
||||
def zone_details(name: str):
|
||||
"""Retrieve details for a specific firewall zone.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/zones/<name>
|
||||
|
||||
Args:
|
||||
name: Name of the zone to look up.
|
||||
|
||||
Returns:
|
||||
JSON with zone configuration details or 404 error.
|
||||
"""
|
||||
try:
|
||||
info = get("/firewall/zones/info", {"zone": name})
|
||||
info = get(GET_FIREWALL_ZONES_INFO, {"zone": name})
|
||||
return _ok(info)
|
||||
except NotFound as exc:
|
||||
logger.info("Zone '%s' not found: %s", name, exc)
|
||||
@@ -145,13 +268,24 @@ def zone_details(name: str):
|
||||
|
||||
@bp.route("/zones", methods=["POST"])
|
||||
def create_zone_bp():
|
||||
"""Create a new firewall zone.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/zones
|
||||
|
||||
Args:
|
||||
body: JSON with ``name`` (required) and optional ``target`` string.
|
||||
|
||||
Returns:
|
||||
JSON confirmation or error if the zone already exists.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone_name = body.get("name", "").strip()
|
||||
target = body.get("target", "default").strip() or "default"
|
||||
if not zone_name:
|
||||
return _error("Zone name is required", 400)
|
||||
try:
|
||||
post("/firewall/zones/create", {"name": zone_name, "target": target})
|
||||
post(POST_FIREWALL_ZONES_CREATE, {"name": zone_name, "target": target})
|
||||
logger.info("Zone '%s' created via API", zone_name)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
@@ -164,8 +298,19 @@ def create_zone_bp():
|
||||
|
||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
||||
def delete_zone_bp(name: str):
|
||||
"""Delete a firewall zone by name.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/firewall/zones/<name>
|
||||
|
||||
Args:
|
||||
name: Name of the zone to delete.
|
||||
|
||||
Returns:
|
||||
JSON confirmation or 404 if the zone does not exist.
|
||||
"""
|
||||
try:
|
||||
delete("/firewall/zones/delete", {"zone": name})
|
||||
delete(DELETE_FIREWALL_ZONES_DELETE, {"zone": name})
|
||||
logger.info("Zone '%s' deleted via API", name)
|
||||
return _ok(None)
|
||||
except NotFound as exc:
|
||||
@@ -183,12 +328,26 @@ def delete_zone_bp(name: str):
|
||||
|
||||
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
||||
def set_zone_interfaces_bp(name: str):
|
||||
"""Set the network interfaces assigned to a firewall zone.
|
||||
|
||||
Replaces all existing interfaces for the zone with the provided list.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/zones/<name>/interfaces
|
||||
|
||||
Args:
|
||||
name: Zone name.
|
||||
body: JSON with ``interfaces`` list of interface names.
|
||||
|
||||
Returns:
|
||||
JSON confirmation with zone and updated interfaces list.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
interfaces = body.get("interfaces", [])
|
||||
if not isinstance(interfaces, list):
|
||||
return _error("'interfaces' must be a list", 400)
|
||||
try:
|
||||
post("/firewall/zones/interfaces", {"zone": name, "interfaces": interfaces})
|
||||
post(POST_FIREWALL_ZONES_INTERFACES, {"zone": name, "interfaces": interfaces})
|
||||
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
|
||||
return _ok({"zone": name, "interfaces": interfaces})
|
||||
except BadRequest as exc:
|
||||
@@ -209,12 +368,26 @@ def set_zone_interfaces_bp(name: str):
|
||||
|
||||
@bp.route("/zones/<name>/services", methods=["POST"])
|
||||
def set_zone_services_bp(name: str):
|
||||
"""Set the allowed services for a firewall zone.
|
||||
|
||||
Replaces all existing services for the zone with the provided list.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/zones/<name>/services
|
||||
|
||||
Args:
|
||||
name: Zone name.
|
||||
body: JSON with ``services`` list of service names.
|
||||
|
||||
Returns:
|
||||
JSON confirmation with zone and updated services list.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
services = body.get("services", [])
|
||||
if not isinstance(services, list):
|
||||
return _error("'services' must be a list", 400)
|
||||
try:
|
||||
post("/firewall/zones/services", {"zone": name, "services": services})
|
||||
post(POST_FIREWALL_ZONES_SERVICES, {"zone": name, "services": services})
|
||||
return _ok({"zone": name, "services": services})
|
||||
except BadRequest as exc:
|
||||
logger.info("Set services for zone '%s' rejected: %s", name, exc)
|
||||
@@ -234,8 +407,16 @@ def set_zone_services_bp(name: str):
|
||||
|
||||
@bp.route("/services", methods=["GET"])
|
||||
def list_services():
|
||||
"""List all available firewall services.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/services
|
||||
|
||||
Returns:
|
||||
JSON with the list of available service names.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/firewall/services"))
|
||||
return _ok(get(GET_FIREWALL_SERVICES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list services: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -243,8 +424,16 @@ def list_services():
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
def list_interfaces():
|
||||
"""List all available network interfaces.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/interfaces
|
||||
|
||||
Returns:
|
||||
JSON with the list of available interface names.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/firewall/interfaces"))
|
||||
return _ok(get(GET_FIREWALL_INTERFACES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list interfaces: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -257,13 +446,24 @@ def list_interfaces():
|
||||
|
||||
@bp.route("/rich-rules", methods=["POST"])
|
||||
def add_rich_rule_bp():
|
||||
"""Add a rich rule to a firewall zone.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/rich-rules
|
||||
|
||||
Args:
|
||||
body: JSON with ``zone`` (zone name) and ``rule`` (XML rule string).
|
||||
|
||||
Returns:
|
||||
JSON with zone, generated rule ID, and rule string.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
rule = body.get("rule", "").strip()
|
||||
if not zone or not rule:
|
||||
return _error("Both 'zone' and 'rule' are required", 400)
|
||||
try:
|
||||
entry = post("/firewall/rich-rules/add", {"zone": zone, "rule": rule})
|
||||
entry = post(POST_FIREWALL_RICH_RULES_ADD, {"zone": zone, "rule": rule})
|
||||
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
|
||||
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
|
||||
except BadRequest as exc:
|
||||
@@ -276,8 +476,19 @@ def add_rich_rule_bp():
|
||||
|
||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
||||
def list_rich_rules(zone: str):
|
||||
"""List rich rules for a specific firewall zone.
|
||||
|
||||
Endpoint:
|
||||
GET /api/firewall/rich-rules/<zone>
|
||||
|
||||
Args:
|
||||
zone: Zone name to list rules for.
|
||||
|
||||
Returns:
|
||||
JSON with list of rich rule entries for the zone.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/firewall/rich-rules", {"zone": zone}))
|
||||
return _ok(get(GET_FIREWALL_RICH_RULES, {"zone": zone}))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -285,8 +496,20 @@ def list_rich_rules(zone: str):
|
||||
|
||||
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
|
||||
def remove_rich_rule_bp(zone: str, rule_id: str):
|
||||
"""Remove a rich rule from a firewall zone by ID.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/firewall/rich-rules/<zone>/<rule_id>
|
||||
|
||||
Args:
|
||||
zone: Zone name.
|
||||
rule_id: Rule identifier.
|
||||
|
||||
Returns:
|
||||
JSON confirmation or 404 if the rule does not exist.
|
||||
"""
|
||||
try:
|
||||
delete("/firewall/rich-rules/remove", {"zone": zone, "id": rule_id})
|
||||
delete(DELETE_FIREWALL_RICH_RULES_REMOVE, {"zone": zone, "id": rule_id})
|
||||
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
|
||||
return _ok({"zone": zone, "id": rule_id})
|
||||
except NotFound as exc:
|
||||
@@ -304,13 +527,24 @@ def remove_rich_rule_bp(zone: str, rule_id: str):
|
||||
|
||||
@bp.route("/masquerade", methods=["POST"])
|
||||
def set_masquerade_bp():
|
||||
"""Enable or disable masquerade (NAT) on a firewall zone.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/masquerade
|
||||
|
||||
Args:
|
||||
body: JSON with ``zone`` (zone name) and ``enable`` (boolean).
|
||||
|
||||
Returns:
|
||||
JSON confirmation with zone and masquerade status.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||
try:
|
||||
post("/firewall/masquerade", {"zone": zone, "enable": bool(enable)})
|
||||
post(POST_FIREWALL_MASQUERADE, {"zone": zone, "enable": bool(enable)})
|
||||
logger.info(
|
||||
"Masquerade %s on zone '%s' via API",
|
||||
"enabled" if enable else "disabled",
|
||||
@@ -332,6 +566,18 @@ def set_masquerade_bp():
|
||||
|
||||
@bp.route("/forward-port", methods=["POST"])
|
||||
def add_forward_port_bp():
|
||||
"""Add a port forwarding rule to a firewall zone.
|
||||
|
||||
Endpoint:
|
||||
POST /api/firewall/forward-port
|
||||
|
||||
Args:
|
||||
body: JSON with ``zone`` (zone name), ``port`` (int), ``proto``
|
||||
(tcp/udp), optional ``toaddr`` and ``toport``.
|
||||
|
||||
Returns:
|
||||
JSON confirmation with zone, generated ID, port, and protocol.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
port = body.get("port")
|
||||
@@ -353,7 +599,7 @@ def add_forward_port_bp():
|
||||
toaddr_str = str(toaddr) if toaddr else None
|
||||
try:
|
||||
entry = post(
|
||||
"/firewall/forward-port/add",
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
{
|
||||
"zone": zone,
|
||||
"port": port_int,
|
||||
@@ -373,9 +619,22 @@ def add_forward_port_bp():
|
||||
|
||||
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
|
||||
def remove_forward_port_bp(zone: str, port: int, proto: str):
|
||||
"""Remove a port forwarding rule from a firewall zone.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
|
||||
|
||||
Args:
|
||||
zone: Zone name.
|
||||
port: Port number.
|
||||
proto: Protocol (tcp/udp).
|
||||
|
||||
Returns:
|
||||
JSON confirmation or 404 if the rule does not exist.
|
||||
"""
|
||||
try:
|
||||
delete(
|
||||
"/firewall/forward-port/remove",
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
{"zone": zone, "port": port, "proto": proto},
|
||||
)
|
||||
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
||||
|
||||
+32
-28
@@ -1,68 +1,72 @@
|
||||
"""Log viewing API blueprint.
|
||||
|
||||
Serves log content to the /logs page via HTMX endpoints through vacuum-walld.
|
||||
Wraps raw log text in the standard JSON response contract.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, render_template_string
|
||||
from flask import Blueprint
|
||||
|
||||
from daemon.client import get
|
||||
from daemon.client import NotFound, get
|
||||
from daemon.iface import (
|
||||
GET_LOGS_APP,
|
||||
GET_LOGS_DNSMASQ,
|
||||
GET_LOGS_JOURNAL,
|
||||
GET_LOGS_NGINX_ACCESS,
|
||||
GET_LOGS_NGINX_ERROR,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("logs", __name__)
|
||||
|
||||
_LOG_LINE_TEMPLATE = """\
|
||||
{% for line in lines %}
|
||||
<div class="log-line{% if 'ERROR' in line %} log-error{% elif 'WARN' in line %} log-warn{% endif %}">{{ line | e }}</div>
|
||||
{% endfor %}"""
|
||||
|
||||
|
||||
def _render_log_lines(text: str) -> str:
|
||||
lines = text.rstrip("\n").split("\n") if text.strip() else []
|
||||
return render_template_string(_LOG_LINE_TEMPLATE, lines=lines)
|
||||
|
||||
|
||||
@bp.route("/journal")
|
||||
def journal():
|
||||
"""GET /api/logs/journal — Return systemd journal log lines."""
|
||||
try:
|
||||
text = get("/logs/journal")
|
||||
return _render_log_lines(text)
|
||||
return _ok(get(GET_LOGS_JOURNAL))
|
||||
except RuntimeError:
|
||||
return _render_log_lines("(error reading journal)\n")
|
||||
return _error("error reading journal", 500)
|
||||
|
||||
|
||||
@bp.route("/nginx/access")
|
||||
def nginx_access():
|
||||
"""GET /api/logs/nginx/access — Return nginx access log lines."""
|
||||
try:
|
||||
text = get("/logs/nginx/access")
|
||||
return _render_log_lines(text)
|
||||
return _ok(get(GET_LOGS_NGINX_ACCESS))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _render_log_lines("(log file not found)\n")
|
||||
return _error("error reading log", 500)
|
||||
|
||||
|
||||
@bp.route("/nginx/error")
|
||||
def nginx_error():
|
||||
"""GET /api/logs/nginx/error — Return nginx error log lines."""
|
||||
try:
|
||||
text = get("/logs/nginx/error")
|
||||
return _render_log_lines(text)
|
||||
return _ok(get(GET_LOGS_NGINX_ERROR))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _render_log_lines("(log file not found)\n")
|
||||
return _error("error reading log", 500)
|
||||
|
||||
|
||||
@bp.route("/dnsmasq")
|
||||
def dnsmasq():
|
||||
"""GET /api/logs/dnsmasq — Return dnsmasq log lines."""
|
||||
try:
|
||||
text = get("/logs/dnsmasq")
|
||||
return _render_log_lines(text)
|
||||
return _ok(get(GET_LOGS_DNSMASQ))
|
||||
except RuntimeError:
|
||||
return _render_log_lines("(error reading journal)\n")
|
||||
return _error("error reading journal", 500)
|
||||
|
||||
|
||||
@bp.route("/app")
|
||||
def app_log():
|
||||
"""GET /api/logs/app — Return application log lines."""
|
||||
try:
|
||||
text = get("/logs/app")
|
||||
return _render_log_lines(text)
|
||||
return _ok(get(GET_LOGS_APP))
|
||||
except NotFound:
|
||||
return _error("log file not found", 404)
|
||||
except RuntimeError:
|
||||
return _render_log_lines("(log file not found)\n")
|
||||
return _error("error reading log", 500)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Network management API blueprint.
|
||||
|
||||
Exposes /api/network/* and delegates to vacuum-walld for interface
|
||||
IP configuration via systemd-networkd.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import NotFound, get, post
|
||||
from daemon.iface import (
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
GET_NETWORK_INFER_ZONES,
|
||||
GET_NETWORK_INTERFACE_NAME,
|
||||
GET_NETWORK_INTERFACES,
|
||||
POST_NETWORK_APPLY,
|
||||
POST_NETWORK_INTERFACE_NAME,
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
)
|
||||
from lib.common import validate_interface_name
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("network", __name__)
|
||||
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
def list_interfaces():
|
||||
"""List all interfaces with their network config and runtime state.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/interfaces
|
||||
|
||||
Returns:
|
||||
JSON with interface config + runtime state.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INTERFACES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list network interfaces: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>", methods=["GET"])
|
||||
def get_interface(name: str):
|
||||
"""Get config + runtime state for a specific interface.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/interfaces/<name>
|
||||
|
||||
Returns:
|
||||
JSON with interface config and runtime state.
|
||||
"""
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
return _ok(get(GET_NETWORK_INTERFACE_NAME, {"name": name}))
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
logger.info("Interface '%s' not found: %s", name, exc)
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>", methods=["POST"])
|
||||
def save_interface(name: str):
|
||||
"""Save and apply network config for an interface.
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/interfaces/<name>
|
||||
|
||||
Args:
|
||||
body: JSON with addresses, gateway, dns, routes.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
body = {**(request.get_json(silent=True) or {}), "name": name}
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
post(POST_NETWORK_INTERFACE_NAME, body)
|
||||
logger.info("Interface '%s' config saved", name)
|
||||
return _ok({"name": name, "applied": True})
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except NotFound as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/interfaces/<name>/reload", methods=["POST"])
|
||||
def reload_interface(name: str):
|
||||
"""Reload networkd for a single interface.
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/interfaces/<name>/reload
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
try:
|
||||
validate_interface_name(name)
|
||||
post(POST_NETWORK_INTERFACE_RELOAD, {"name": name})
|
||||
logger.info("Interface '%s' reloaded", name)
|
||||
return _ok({"name": name, "reloaded": True})
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to reload interface '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_all():
|
||||
"""Apply network config for ALL interfaces (full sync).
|
||||
|
||||
Endpoint:
|
||||
POST /api/network/apply
|
||||
|
||||
Returns:
|
||||
JSON with number of interfaces applied.
|
||||
"""
|
||||
try:
|
||||
result = post(POST_NETWORK_APPLY, {})
|
||||
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
|
||||
return _ok(result)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply network config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/infer-dhcp-ranges", methods=["GET"])
|
||||
def infer_dhcp_ranges():
|
||||
"""Suggest candidate DHCP ranges based on static interface IPs.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/infer-dhcp-ranges
|
||||
|
||||
Returns:
|
||||
JSON with per-interface suggested DHCP ranges.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INFER_DHCP_RANGES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to infer DHCP ranges: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/infer-zones", methods=["GET"])
|
||||
def infer_zones():
|
||||
"""Suggest firewalld zone assignments for configured interfaces.
|
||||
|
||||
Endpoint:
|
||||
GET /api/network/infer-zones
|
||||
|
||||
Returns:
|
||||
JSON with per-interface suggested zone names.
|
||||
"""
|
||||
try:
|
||||
return _ok(get(GET_NETWORK_INFER_ZONES))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to infer zones: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
+128
-11
@@ -8,6 +8,19 @@ import logging
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
POST_NGINX_MANAGEMENT,
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,8 +29,18 @@ bp = Blueprint("proxy", __name__)
|
||||
|
||||
@bp.route("/ssl-apply", methods=["POST"])
|
||||
def ssl_apply_bp():
|
||||
"""Apply SSL snippet config.
|
||||
|
||||
POST /api/proxy/ssl-apply
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` on success.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If nginx SSL snippet write fails.
|
||||
"""
|
||||
try:
|
||||
post("/nginx/ssl-apply")
|
||||
post(POST_NGINX_SSL_APPLY)
|
||||
logger.info("SSL snippet written via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -27,8 +50,15 @@ def ssl_apply_bp():
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
"""Get the current nginx proxy configuration.
|
||||
|
||||
GET /api/proxy/config
|
||||
|
||||
Returns:
|
||||
Current config dict from the daemon.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/nginx/config"))
|
||||
return _ok(get(GET_NGINX_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read proxy config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -36,11 +66,21 @@ def get_config_bp():
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def post_config():
|
||||
"""Save the nginx proxy configuration.
|
||||
|
||||
POST /api/proxy/config
|
||||
|
||||
Body:
|
||||
Any JSON object to merge into the config.
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` 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:
|
||||
post("/nginx/config", body)
|
||||
post(POST_NGINX_CONFIG, body)
|
||||
logger.info("Proxy config saved: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
@@ -53,11 +93,21 @@ def post_config():
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
def patch_config():
|
||||
"""Partially update the nginx proxy configuration.
|
||||
|
||||
PATCH /api/proxy/config
|
||||
|
||||
Body:
|
||||
JSON object with fields to patch.
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` 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("/nginx/config", body)
|
||||
patch(PATCH_NGINX_CONFIG, body)
|
||||
logger.info("Proxy config patched: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
@@ -70,8 +120,15 @@ def patch_config():
|
||||
|
||||
@bp.route("/domains", methods=["GET"])
|
||||
def list_domains():
|
||||
"""List all configured proxy domains.
|
||||
|
||||
GET /api/proxy/domains
|
||||
|
||||
Returns:
|
||||
List of domain dicts from the daemon.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/nginx/domains"))
|
||||
return _ok(get(GET_NGINX_DOMAINS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list proxy domains: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -79,6 +136,21 @@ def list_domains():
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
"""Add a new proxy domain.
|
||||
|
||||
POST /api/proxy/domains
|
||||
|
||||
Body fields:
|
||||
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.
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
@@ -94,7 +166,7 @@ def add_domain_bp():
|
||||
return _error("'backend_port' is required", 400)
|
||||
try:
|
||||
post(
|
||||
"/nginx/domains/add",
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
{
|
||||
"domain": domain,
|
||||
"backend_host": backend_host,
|
||||
@@ -116,11 +188,21 @@ def add_domain_bp():
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["PUT"])
|
||||
def update_domain_bp(domain):
|
||||
"""Update an existing proxy domain in-place.
|
||||
|
||||
PUT /api/proxy/domains/<domain>
|
||||
|
||||
Body fields:
|
||||
Fields to merge into the domain config.
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not body:
|
||||
return _error("Request body must be a JSON object with fields to update", 400)
|
||||
try:
|
||||
post("/nginx/domains/update", {"domain": domain, **body})
|
||||
post(POST_NGINX_DOMAINS_UPDATE, {"domain": domain, **body})
|
||||
logger.info("Proxy domain '%s' updated via API", domain)
|
||||
return _ok({"domain": domain})
|
||||
except BadRequest as exc:
|
||||
@@ -136,8 +218,15 @@ def update_domain_bp(domain):
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["DELETE"])
|
||||
def remove_domain_bp(domain):
|
||||
"""Remove a proxy domain.
|
||||
|
||||
DELETE /api/proxy/domains/<domain>
|
||||
|
||||
Returns:
|
||||
``{"domain": ...}`` on success.
|
||||
"""
|
||||
try:
|
||||
delete("/nginx/domains/remove", {"domain": domain})
|
||||
delete(DELETE_NGINX_DOMAINS_REMOVE, {"domain": domain})
|
||||
logger.info("Proxy domain removed via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except NotFound as exc:
|
||||
@@ -150,8 +239,15 @@ def remove_domain_bp(domain):
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
"""Generate all nginx configs and reload nginx.
|
||||
|
||||
POST /api/proxy/apply
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` on success.
|
||||
"""
|
||||
try:
|
||||
post("/nginx/apply")
|
||||
post(POST_NGINX_APPLY)
|
||||
logger.info("nginx config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -161,8 +257,15 @@ def apply_bp():
|
||||
|
||||
@bp.route("/test", methods=["POST"])
|
||||
def test_bp():
|
||||
"""Test nginx configuration without reloading.
|
||||
|
||||
POST /api/proxy/test
|
||||
|
||||
Returns:
|
||||
``{"valid": true, "output": ...}`` on success. Returns 400 if test fails.
|
||||
"""
|
||||
try:
|
||||
result = post("/nginx/test")
|
||||
result = post(POST_NGINX_TEST)
|
||||
if result.get("valid"):
|
||||
return _ok({"valid": True, "output": result.get("output", "")})
|
||||
return _error(result.get("output", "unknown error"), 400)
|
||||
@@ -173,6 +276,20 @@ def test_bp():
|
||||
|
||||
@bp.route("/management", methods=["POST"])
|
||||
def management_bp():
|
||||
"""Configure the management reverse proxy for the WebUI.
|
||||
|
||||
POST /api/proxy/management
|
||||
|
||||
Body fields:
|
||||
domain: Management domain name.
|
||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
||||
flask_port: Upstream Flask port (default 9090).
|
||||
auth_user: Optional basic-auth username.
|
||||
auth_pass: Optional basic-auth password.
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
@@ -183,7 +300,7 @@ def management_bp():
|
||||
auth_pass = body.get("auth_pass")
|
||||
try:
|
||||
post(
|
||||
"/nginx/management",
|
||||
POST_NGINX_MANAGEMENT,
|
||||
{
|
||||
"domain": domain,
|
||||
"flask_host": flask_host,
|
||||
|
||||
+149
-13
@@ -8,6 +8,20 @@ import logging
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import BadRequest, NotFound, delete, get, patch, post
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_PEERS_REMOVE,
|
||||
GET_WIREGUARD_CONFIG,
|
||||
GET_WIREGUARD_PEER_STATUS,
|
||||
GET_WIREGUARD_PEERS,
|
||||
GET_WIREGUARD_STATUS,
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_APPLY,
|
||||
POST_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_DOWN,
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
POST_WIREGUARD_INITIALIZE,
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,8 +30,16 @@ bp = Blueprint("wireguard", __name__)
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
"""Get the current WireGuard configuration.
|
||||
|
||||
Endpoint: GET /api/wireguard/config
|
||||
|
||||
Returns:
|
||||
JSON response with the WireGuard config on success, or an error
|
||||
response on failure.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/wireguard/config"))
|
||||
return _ok(get(GET_WIREGUARD_CONFIG))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -25,6 +47,18 @@ def get_config_bp():
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def post_config():
|
||||
"""Create or fully replace the WireGuard configuration.
|
||||
|
||||
Endpoint: POST /api/wireguard/config
|
||||
|
||||
Args:
|
||||
body: JSON body with the configuration. If an ``interface`` key
|
||||
is present, the private key will be stripped before forwarding.
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, 400 on validation failure, or 500
|
||||
on server error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
@@ -33,7 +67,7 @@ def post_config():
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
post("/wireguard/config", body)
|
||||
post(POST_WIREGUARD_CONFIG, body)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("WireGuard config save rejected: %s", exc)
|
||||
@@ -45,6 +79,18 @@ def post_config():
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
def patch_config():
|
||||
"""Partially update the WireGuard configuration.
|
||||
|
||||
Endpoint: PATCH /api/wireguard/config
|
||||
|
||||
Args:
|
||||
body: JSON body with the fields to update. If an ``interface``
|
||||
key is present, the private key will be stripped before forwarding.
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, 400 on validation failure, or 500
|
||||
on server error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
@@ -53,7 +99,7 @@ def patch_config():
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
patch("/wireguard/config", body)
|
||||
patch(PATCH_WIREGUARD_CONFIG, body)
|
||||
logger.info("WireGuard config patched: %s", sorted(body.keys()))
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
@@ -66,8 +112,15 @@ def patch_config():
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
"""Apply the current WireGuard configuration to the live tunnel.
|
||||
|
||||
Endpoint: POST /api/wireguard/apply
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, or 500 on server error.
|
||||
"""
|
||||
try:
|
||||
post("/wireguard/apply")
|
||||
post(POST_WIREGUARD_APPLY)
|
||||
logger.info("WireGuard tunnel applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -77,8 +130,15 @@ def apply_bp():
|
||||
|
||||
@bp.route("/up", methods=["POST"])
|
||||
def up_bp():
|
||||
"""Bring the WireGuard tunnel interface up.
|
||||
|
||||
Endpoint: POST /api/wireguard/up
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, or 500 on server error.
|
||||
"""
|
||||
try:
|
||||
post("/wireguard/apply")
|
||||
post(POST_WIREGUARD_APPLY)
|
||||
logger.info("WireGuard tunnel started via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -88,8 +148,15 @@ def up_bp():
|
||||
|
||||
@bp.route("/down", methods=["POST"])
|
||||
def down_bp():
|
||||
"""Bring the WireGuard tunnel interface down.
|
||||
|
||||
Endpoint: POST /api/wireguard/down
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, or 500 on server error.
|
||||
"""
|
||||
try:
|
||||
post("/wireguard/down")
|
||||
post(POST_WIREGUARD_DOWN)
|
||||
logger.info("WireGuard tunnel brought down via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -99,8 +166,16 @@ def down_bp():
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
def status_bp():
|
||||
"""Get the current WireGuard tunnel status.
|
||||
|
||||
Endpoint: GET /api/wireguard/status
|
||||
|
||||
Returns:
|
||||
JSON response with the tunnel status on success, or an error
|
||||
response on failure.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/wireguard/status"))
|
||||
return _ok(get(GET_WIREGUARD_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -108,8 +183,15 @@ def status_bp():
|
||||
|
||||
@bp.route("/initialize", methods=["POST"])
|
||||
def initialize_bp():
|
||||
"""Initialize WireGuard for first-time use.
|
||||
|
||||
Endpoint: POST /api/wireguard/initialize
|
||||
|
||||
Returns:
|
||||
Success response on acceptance, or 500 on server error.
|
||||
"""
|
||||
try:
|
||||
post("/wireguard/initialize")
|
||||
post(POST_WIREGUARD_INITIALIZE)
|
||||
logger.info("WireGuard initialized via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
@@ -119,13 +201,28 @@ def initialize_bp():
|
||||
|
||||
@bp.route("/peers", methods=["POST"])
|
||||
def add_peer_bp():
|
||||
"""Add a new peer to the WireGuard configuration.
|
||||
|
||||
Endpoint: POST /api/wireguard/peers
|
||||
|
||||
Args:
|
||||
name: Peer display name (required).
|
||||
endpoint: Optional peer endpoint address.
|
||||
allowed_ips: Optional list of allowed IP CIDRs.
|
||||
persistent_keepalive: Optional keepalive interval in seconds.
|
||||
preshared_key: Optional pre-shared key in hex.
|
||||
|
||||
Returns:
|
||||
JSON response with the created peer on success, 400 on validation
|
||||
failure, or 500 on server error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
peer = post(
|
||||
"/wireguard/peers/add",
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
{
|
||||
"name": name,
|
||||
"endpoint": body.get("endpoint"),
|
||||
@@ -146,8 +243,19 @@ def add_peer_bp():
|
||||
|
||||
@bp.route("/peers/<name>", methods=["DELETE"])
|
||||
def remove_peer_bp(name):
|
||||
"""Remove a peer from the WireGuard configuration.
|
||||
|
||||
Endpoint: DELETE /api/wireguard/peers/<name>
|
||||
|
||||
Args:
|
||||
name: Peer name to remove (from URL path).
|
||||
|
||||
Returns:
|
||||
Success response with peer name on removal, 404 if peer not found,
|
||||
or 500 on server error.
|
||||
"""
|
||||
try:
|
||||
delete("/wireguard/peers/remove", {"name": name})
|
||||
delete(DELETE_WIREGUARD_PEERS_REMOVE, {"name": name})
|
||||
logger.info("WireGuard peer '%s' removed via API", name)
|
||||
return _ok({"name": name})
|
||||
except NotFound as exc:
|
||||
@@ -160,8 +268,16 @@ def remove_peer_bp(name):
|
||||
|
||||
@bp.route("/peers", methods=["GET"])
|
||||
def peers_bp():
|
||||
"""List all configured WireGuard peers.
|
||||
|
||||
Endpoint: GET /api/wireguard/peers
|
||||
|
||||
Returns:
|
||||
JSON response with the peers list on success, or an error response
|
||||
on failure.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/wireguard/peers"))
|
||||
return _ok(get(GET_WIREGUARD_PEERS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list WireGuard peers: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -169,8 +285,16 @@ def peers_bp():
|
||||
|
||||
@bp.route("/peer-status", methods=["GET"])
|
||||
def peer_status_bp():
|
||||
"""Get real-time status information for all WireGuard peers.
|
||||
|
||||
Endpoint: GET /api/wireguard/peer-status
|
||||
|
||||
Returns:
|
||||
JSON response with peer status on success, or an error response
|
||||
on failure.
|
||||
"""
|
||||
try:
|
||||
return _ok(get("/wireguard/peer-status"))
|
||||
return _ok(get(GET_WIREGUARD_PEER_STATUS))
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard peer status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
@@ -178,6 +302,18 @@ def peer_status_bp():
|
||||
|
||||
@bp.route("/generate-client", methods=["POST"])
|
||||
def generate_client_bp():
|
||||
"""Generate a WireGuard client configuration file for a peer.
|
||||
|
||||
Endpoint: POST /api/wireguard/generate-client
|
||||
|
||||
Args:
|
||||
name: Peer name (required).
|
||||
server_endpoint: Server endpoint address for the client config (required).
|
||||
|
||||
Returns:
|
||||
JSON response with the generated config string on success, 404 if
|
||||
peer not found, 400 on validation failure, or 500 on server error.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -187,7 +323,7 @@ def generate_client_bp():
|
||||
return _error("Field 'server_endpoint' is required", 400)
|
||||
try:
|
||||
result = post(
|
||||
"/wireguard/generate-client",
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
{
|
||||
"name": name,
|
||||
"server_endpoint": server_endpoint,
|
||||
|
||||
+72
-223
@@ -12,18 +12,19 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
from flask import Flask, abort, request
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from daemon.client import get
|
||||
from daemon.iface import GET_STATUS_ALL
|
||||
from lib.logging import setup_logging
|
||||
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.wireguard import bp as wireguard_bp
|
||||
|
||||
@@ -49,6 +50,11 @@ _reloading = False
|
||||
|
||||
|
||||
def _sighup_handler(signum, frame):
|
||||
"""Handle SIGHUP by reloading modules then restarting via SIGTERM.
|
||||
|
||||
Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints,
|
||||
and requests systemd restart by sending SIGTERM with default handler.
|
||||
"""
|
||||
global _reloading
|
||||
if _reloading:
|
||||
return
|
||||
@@ -72,7 +78,17 @@ signal.signal(signal.SIGHUP, _sighup_handler)
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
# Cache-control: short TTL in dev, aggressive caching in prod (versioned assets)
|
||||
_DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False
|
||||
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)
|
||||
|
||||
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")
|
||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||
@@ -81,6 +97,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||
|
||||
BLUEPRINTS = [
|
||||
("firewall", firewall_bp),
|
||||
("network", network_bp),
|
||||
("dhcp", dhcp_bp),
|
||||
("proxy", proxy_bp),
|
||||
("certs", certs_bp),
|
||||
@@ -91,7 +108,6 @@ BLUEPRINTS = [
|
||||
for name, _ in BLUEPRINTS:
|
||||
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request logging
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -99,11 +115,20 @@ for name, _ in BLUEPRINTS:
|
||||
|
||||
@app.before_request
|
||||
def _log_request_start():
|
||||
"""Record request start time for duration tracking."""
|
||||
request._start_time = time.monotonic()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def _log_request_finish(response):
|
||||
"""Log request duration and status code after response generation.
|
||||
|
||||
Args:
|
||||
response: The HTTP response object.
|
||||
|
||||
Returns:
|
||||
The unchanged response object.
|
||||
"""
|
||||
elapsed_ms = (
|
||||
time.monotonic() - getattr(request, "_start_time", time.monotonic())
|
||||
) * 1000
|
||||
@@ -114,241 +139,65 @@ def _log_request_finish(response):
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
)
|
||||
|
||||
# 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
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
elif response.content_type.startswith(("text/javascript", "text/css")):
|
||||
if _DEV_MODE:
|
||||
response.headers["Cache-Control"] = "max-age=5"
|
||||
else:
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, stale-while-revalidate=86400"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 custom filters
|
||||
# API proxy routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.template_filter("timestamp")
|
||||
def timestamp_filter(value):
|
||||
if not value:
|
||||
return ""
|
||||
@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:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.template_filter("bytes")
|
||||
def bytes_filter(value):
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if num < 0:
|
||||
return "0 B"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(num) < 1024:
|
||||
return f"{num:.1f} {unit}"
|
||||
num /= 1024
|
||||
return f"{num:.1f} PB"
|
||||
|
||||
|
||||
@app.template_filter("duration")
|
||||
def duration_filter(value):
|
||||
try:
|
||||
total = int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if total < 0:
|
||||
return "0s"
|
||||
parts = []
|
||||
days, remainder = divmod(total, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if days:
|
||||
parts.append(f"{days}d")
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{seconds}s")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.template_filter("json_pretty")
|
||||
def json_pretty_filter(value):
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safely(fn, default=None):
|
||||
"""Call *fn* and return *default* on any exception."""
|
||||
try:
|
||||
return fn()
|
||||
return {"ok": True, "data": get(GET_STATUS_ALL)}
|
||||
except Exception as exc:
|
||||
logger.warning("WebUI data load failed: %s", exc)
|
||||
return default
|
||||
logger.warning("Status all failed: %s", exc)
|
||||
return {"ok": False, "error": str(exc)}, 500
|
||||
|
||||
|
||||
def _get_service_status(dnsmasq_info, wg_info):
|
||||
"""Build a service status dict for the dashboard template."""
|
||||
services = {}
|
||||
if dnsmasq_info:
|
||||
services["Dnsmasq"] = {
|
||||
"running": dnsmasq_info.get("service_active", False),
|
||||
}
|
||||
if wg_info:
|
||||
services["WireGuard"] = {
|
||||
"running": wg_info.get("up", False),
|
||||
}
|
||||
return services
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA catch-all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fw_config_get() -> dict[str, Any]:
|
||||
"""Read firewall config via daemon."""
|
||||
return get("/firewall/config")
|
||||
|
||||
|
||||
def _load_status_all() -> dict[str, Any]:
|
||||
"""Load all system state in one call."""
|
||||
return get("/status/all")
|
||||
SPA_DIR = STATIC_DIR
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def root_redirect():
|
||||
from flask import redirect, url_for
|
||||
@app.route("/<path:path>")
|
||||
def spa_page(path=""):
|
||||
"""Single-page application catch-all.
|
||||
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
|
||||
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
|
||||
interfaces = fw_state.get("interfaces", [])
|
||||
dnsmasq = dm_state.get("status", {})
|
||||
domains = ng_state.get("domains", [])
|
||||
certs = ac_state.get("certs", [])
|
||||
wg = wg_state.get("status", {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_zones=active_zones,
|
||||
interfaces=interfaces,
|
||||
dnsmasq=dnsmasq,
|
||||
domains=domains,
|
||||
certs=certs,
|
||||
wg_status=wg,
|
||||
services=_get_service_status(dnsmasq, wg),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
zones=fw_state.get("active_zones", {}).keys() or [],
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"zones.html",
|
||||
zones=list(fw_state.get("zones", {}).values()),
|
||||
services=fw_state.get("available_services", []),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
zones = list(fw_state.get("zones", {}).keys())
|
||||
rules: dict[str, list[str]] = {}
|
||||
for zname, zcfg in fw_state.get("zones", {}).items():
|
||||
rr = zcfg.get("rich-rules", [])
|
||||
if rr:
|
||||
rules[zname] = rr
|
||||
return render_template("rules.html", zones=zones, rules=rules or None)
|
||||
|
||||
|
||||
@app.route("/nat")
|
||||
def nat_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
|
||||
|
||||
|
||||
@app.route("/dhcp")
|
||||
def dhcp_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=dm_state.get("config", {}),
|
||||
status=dm_state.get("status", {}),
|
||||
leases=dm_state.get("leases", []),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/proxy")
|
||||
def proxy_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
return render_template(
|
||||
"proxy.html",
|
||||
domains=ng_state.get("domains", []),
|
||||
config=ng_state.get("config", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/certs")
|
||||
def certs_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
return render_template(
|
||||
"certs.html",
|
||||
certs=ac_state.get("certs", []),
|
||||
email=ac_state.get("email", ""),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/wireguard")
|
||||
def wireguard_page():
|
||||
all_status = _safely(_load_status_all, {})
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
return render_template(
|
||||
"wireguard.html",
|
||||
config=wg_state.get("config", {}),
|
||||
status=wg_state.get("status", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
def logs_page():
|
||||
return render_template("logs.html")
|
||||
Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
|
||||
non-static paths. The client-side router handles navigation and defaults
|
||||
to ``#dashboard``.
|
||||
"""
|
||||
if path.startswith("api/") or path.startswith("static/"):
|
||||
abort(404)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+193
-469
@@ -1,497 +1,221 @@
|
||||
// Toast notifications
|
||||
const showToast = (message, type, duration = 4000) => {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
};
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const showSuccessToast = (msg) => showToast(msg, 'success');
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=7';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=7';
|
||||
import ZonesPage from '/static/pages/zones.js?v=7';
|
||||
import RulesPage from '/static/pages/rules.js?v=7';
|
||||
import NatPage from '/static/pages/nat.js?v=7';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=7';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=7';
|
||||
import CertsPage from '/static/pages/certs.js?v=7';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=7';
|
||||
import LogsPage from '/static/pages/logs.js?v=7';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=7';
|
||||
|
||||
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/interfaces', label: 'Interfaces' },
|
||||
{ path: '/zones', label: 'Zones' },
|
||||
{ path: '/rules', label: 'Rules' },
|
||||
{ path: '/nat', label: 'NAT' },
|
||||
{ path: '/dhcp', label: 'DHCP' },
|
||||
{ path: '/proxy', label: 'Proxy' },
|
||||
{ path: '/certs', label: 'Certs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
// Modal helpers
|
||||
const openModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
};
|
||||
|
||||
const closeModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
let switchTab = (tabName) => {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
if (clickedTab) clickedTab.classList.add('active');
|
||||
};
|
||||
|
||||
// Refresh a container from a JSON GET endpoint using a renderer callback
|
||||
const refreshTable = (url, container, renderer) => {
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const json = data.ok ? data.data : data;
|
||||
container.innerHTML = renderer(json);
|
||||
htmx.process(container);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// HTMX event handlers
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
|
||||
if (toastHeader) {
|
||||
const parts = toastHeader.split(':');
|
||||
const msg = parts.slice(1).join(':').trim();
|
||||
showToast(msg, parts[0]?.trim() || 'info');
|
||||
}
|
||||
/* ── Model registration ────────────────────────────────────── */
|
||||
modelRegister('status', {
|
||||
subsystem: 'status',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/status/all');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data;
|
||||
},
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||
const status = evt.detail.xhr?.status || 0;
|
||||
const json = evt.detail.xhr?.response;
|
||||
let msg = 'Request failed (' + status + ')';
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (parsed.error) msg = parsed.error;
|
||||
} catch (e) {}
|
||||
showToast(msg, 'error');
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading...';
|
||||
}
|
||||
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: {} };
|
||||
},
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn && btn.dataset.originalText !== undefined) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
delete btn.dataset.originalText;
|
||||
}
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard: Escape closes all modals
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
modelRegister('nginx', {
|
||||
subsystem: 'nginx',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/proxy/domains');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- Renderer helpers for htmx-driven DOM updates --------
|
||||
modelRegister('acme', {
|
||||
subsystem: 'acme',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/certs/list');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const renderZones = (data) => {
|
||||
const active = Array.isArray(data) ? data : (data.active || []);
|
||||
if (!active.length) return '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
|
||||
return active.map(zone =>
|
||||
'<div class="card" style="position:relative;">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
|
||||
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
|
||||
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
|
||||
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
|
||||
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
|
||||
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
|
||||
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
|
||||
).join('');
|
||||
modelRegister('wireguard', {
|
||||
subsystem: 'wireguard',
|
||||
fetch: async () => {
|
||||
const [stR, pR, cfgR] = await Promise.allSettled([
|
||||
apiFetch('/api/wireguard/status'),
|
||||
apiFetch('/api/wireguard/peers'),
|
||||
apiFetch('/api/wireguard/config'),
|
||||
]);
|
||||
const result = {};
|
||||
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
||||
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
||||
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
||||
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
||||
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const LOG_TABS = {
|
||||
journal: '/api/logs/journal',
|
||||
'nginx-access': '/api/logs/nginx/access',
|
||||
'nginx-error': '/api/logs/nginx/error',
|
||||
dnsmasq: '/api/logs/dnsmasq',
|
||||
app: '/api/logs/app',
|
||||
};
|
||||
|
||||
const renderRules = (data) => {
|
||||
let html = '';
|
||||
let zoneRules = {};
|
||||
const cfgZones = data && data.zones ? data.zones : null;
|
||||
if (cfgZones) {
|
||||
Object.keys(cfgZones).forEach(zname => {
|
||||
const rr = cfgZones[zname].rich_rules || [];
|
||||
if (rr.length) zoneRules[zname] = rr;
|
||||
});
|
||||
} else {
|
||||
zoneRules = data || {};
|
||||
}
|
||||
Object.keys(zoneRules).forEach(zone => {
|
||||
let entries = zoneRules[zone];
|
||||
if (!Array.isArray(entries)) entries = [];
|
||||
html += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
|
||||
if (entries.length) {
|
||||
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
|
||||
entries.forEach((entry, i) => {
|
||||
let ruleId, ruleText;
|
||||
if (typeof entry === 'object' && entry.rule) {
|
||||
ruleId = entry.id;
|
||||
ruleText = entry.rule;
|
||||
} else {
|
||||
ruleId = null;
|
||||
ruleText = String(entry);
|
||||
}
|
||||
html += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
|
||||
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
|
||||
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
|
||||
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
|
||||
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
|
||||
modelRegister('logs', {
|
||||
subsystem: '*',
|
||||
fetch: async (signal, tab) => {
|
||||
const tabKey = tab || 'journal';
|
||||
const url = LOG_TABS[tabKey];
|
||||
if (!url) throw new Error('Unknown log tab: ' + tabKey);
|
||||
const r = await apiFetch(url, { signal });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Initial fetch ─────────────────────────────────────────── */
|
||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
|
||||
modelFetch(name);
|
||||
}
|
||||
modelFetch('logs', 'journal');
|
||||
|
||||
/* ── Page map ──────────────────────────────────────────────── */
|
||||
const Pages = {
|
||||
dashboard: DashboardPage,
|
||||
interfaces: InterfacesPage,
|
||||
zones: ZonesPage,
|
||||
rules: RulesPage,
|
||||
nat: NatPage,
|
||||
dhcp: DhcpPage,
|
||||
proxy: ProxyPage,
|
||||
certs: CertsPage,
|
||||
wireguard: WireguardPage,
|
||||
logs: LogsPage,
|
||||
};
|
||||
|
||||
const renderForwards = (forwards) => {
|
||||
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
|
||||
return forwards.map(fwd => {
|
||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
|
||||
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
|
||||
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
|
||||
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||
}).join('');
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
return hComp(page, this.state.path);
|
||||
},
|
||||
};
|
||||
|
||||
const renderForwardsFromConfig = (data) => {
|
||||
const zones = data.zones || {};
|
||||
const forwards = [];
|
||||
Object.keys(zones).forEach(name => {
|
||||
zones[name].forward_ports = zones[name].forward_ports || [];
|
||||
zones[name].forward_ports.forEach(fwd => {
|
||||
forwards.push({
|
||||
zone: name,
|
||||
'proxy-protocol': fwd['proxy-protocol'] || fwd.proto,
|
||||
port: fwd.port,
|
||||
'to-addr': fwd['to-addr'] || fwd.toaddr,
|
||||
'to-port': fwd['to-port'] || fwd.toport
|
||||
});
|
||||
});
|
||||
});
|
||||
return renderForwards(forwards);
|
||||
};
|
||||
window.location.hash || (window.location.hash = router.state.path);
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
const renderRanges = (ranges) => {
|
||||
if (!ranges.length) return '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
|
||||
return ranges.map(rng =>
|
||||
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
|
||||
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
|
||||
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderStaticLeases = (leases) => {
|
||||
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
|
||||
return leases.map(lease =>
|
||||
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
|
||||
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderDnsRecords = (records) => {
|
||||
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
|
||||
return records.map(rec =>
|
||||
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
|
||||
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
|
||||
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderDomains = (domains) => {
|
||||
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
|
||||
return domains.map(d => {
|
||||
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
|
||||
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
|
||||
else if (typeof d.days_remaining === 'number') {
|
||||
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
|
||||
else certHtml = '<span class="badge badge-success">Valid</span>';
|
||||
}
|
||||
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
|
||||
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
|
||||
'<td>' + (d.backend_port || '-') + '</td>' +
|
||||
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
|
||||
'<td>' + certHtml + '</td>' +
|
||||
'<td><div class="flex gap-2">' +
|
||||
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
|
||||
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const renderPeers = (peers) => {
|
||||
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
|
||||
return peers.map(peer =>
|
||||
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
|
||||
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
|
||||
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
|
||||
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
|
||||
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
|
||||
'<td><div class="flex gap-2">' +
|
||||
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
|
||||
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
|
||||
).join('');
|
||||
};
|
||||
|
||||
const renderCerts = (certs) => {
|
||||
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
|
||||
return certs.map(cert => {
|
||||
const days = cert.days_remaining;
|
||||
let badgeHtml;
|
||||
if (cert.expired || (days !== undefined && days <= 0)) {
|
||||
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
|
||||
} else if (days !== undefined && days <= 30) {
|
||||
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
|
||||
} else {
|
||||
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
|
||||
}
|
||||
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
|
||||
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
|
||||
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
|
||||
'<td>' + badgeHtml + '</td>' +
|
||||
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
|
||||
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const renderInterfaces = (interfaces) => {
|
||||
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
|
||||
return interfaces.map(iface => {
|
||||
const zoneOptions = (iface.zones || []).map(z =>
|
||||
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
|
||||
).join('');
|
||||
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
|
||||
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
|
||||
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
|
||||
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
|
||||
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
|
||||
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const assignZone = (ifaceName, selectEl) => {
|
||||
fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ interfaces: [ifaceName] })
|
||||
})
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
showSuccessToast(ifaceName + ' assigned to ' + selectEl.value);
|
||||
refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces);
|
||||
}
|
||||
else return r.json().then(j => { throw new Error(j.error || r.statusText); });
|
||||
})
|
||||
.catch(e => { showErrorToast(e.message); });
|
||||
};
|
||||
|
||||
const escHtml = (s) => {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(s));
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const escAttr = (s) => {
|
||||
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
};
|
||||
|
||||
// ─── Certificate Issue Wizard ────────────────────────────────────────
|
||||
|
||||
let _issuePollHandle = null;
|
||||
let _issueRequestId = null;
|
||||
|
||||
function closeIssueWizard() {
|
||||
if (_issuePollHandle) {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
}
|
||||
_issueRequestId = null;
|
||||
resetIssueWizard();
|
||||
closeModal('issue-cert-modal');
|
||||
/* ── Sidebar render root ───────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
const current = router.state.path;
|
||||
return h('div', { class: 'sidebar' },
|
||||
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
||||
h('nav', null,
|
||||
Nav.map(item =>
|
||||
Link({
|
||||
path: item.path,
|
||||
class: current === item.path ? 'active' : '',
|
||||
children: [item.label],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetIssueWizard() {
|
||||
document.getElementById('cert-wizard-input').style.display = '';
|
||||
document.getElementById('cert-wizard-progress').style.display = 'none';
|
||||
document.getElementById('cert-check-results').style.display = 'none';
|
||||
document.getElementById('cert-check-btn').style.display = '';
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
document.getElementById('cert-close-progress').style.display = 'none';
|
||||
/* ── Main content render root ──────────────────────────────── */
|
||||
function MainContent() {
|
||||
return [
|
||||
router.component(),
|
||||
ToastContainer(),
|
||||
];
|
||||
}
|
||||
|
||||
function validateCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
if (!domain) {
|
||||
showErrorToast('Domain is required');
|
||||
return;
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export function initApp() {
|
||||
const sidebarEl = document.getElementById('sidebar');
|
||||
const mainEl = document.getElementById('main');
|
||||
if (sidebarEl && mainEl) {
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
const email = document.getElementById('cert-email').value.trim() || undefined;
|
||||
|
||||
const checkBtn = document.getElementById('cert-check-btn');
|
||||
checkBtn.disabled = true;
|
||||
checkBtn.textContent = 'Checking...';
|
||||
|
||||
fetch('/api/certs/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ domain, email })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
|
||||
const result = data.ok ? data.data : data;
|
||||
renderChecks(result.checks);
|
||||
|
||||
if (result.ready) {
|
||||
document.getElementById('cert-check-btn').style.display = 'none';
|
||||
document.getElementById('cert-issue-btn').style.display = '';
|
||||
} else {
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
showErrorToast('Validation failed: ' + e.message);
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
function renderChecks(checks) {
|
||||
const container = document.getElementById('cert-checks-list');
|
||||
const resultsDiv = document.getElementById('cert-check-results');
|
||||
resultsDiv.style.display = '';
|
||||
|
||||
container.innerHTML = checks.map(c => {
|
||||
let icon, badge;
|
||||
if (c.passed) {
|
||||
icon = '✓';
|
||||
badge = c.blocking ? 'badge-success' : 'badge-info';
|
||||
} else {
|
||||
icon = '✗';
|
||||
badge = 'badge-danger';
|
||||
}
|
||||
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
|
||||
'<span class="badge ' + badge + '">' + icon + '</span>' +
|
||||
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
|
||||
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function startCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
const email = document.getElementById('cert-email').value.trim() || undefined;
|
||||
|
||||
document.getElementById('cert-wizard-input').style.display = 'none';
|
||||
document.getElementById('cert-wizard-progress').style.display = '';
|
||||
document.getElementById('cert-steps-list').innerHTML = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
|
||||
|
||||
fetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ domain, email })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const result = data.ok ? data.data : data;
|
||||
_issueRequestId = result.request_id;
|
||||
if (!result.request_id) throw new Error('No request_id returned');
|
||||
|
||||
// If issuance already exists for this domain, follow the existing request
|
||||
startIssuePoll(result.request_id);
|
||||
})
|
||||
.catch(e => {
|
||||
showErrorToast('Failed to start issuance: ' + e.message);
|
||||
// Fall back to input phase
|
||||
document.getElementById('cert-wizard-input').style.display = '';
|
||||
document.getElementById('cert-wizard-progress').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function startIssuePoll(requestId) {
|
||||
_issueRequestId = requestId;
|
||||
_issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000);
|
||||
// Also poll immediately
|
||||
pollIssueStatus(requestId);
|
||||
}
|
||||
|
||||
function pollIssueStatus(requestId) {
|
||||
fetch('/api/certs/issue/' + encodeURIComponent(requestId))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const result = data.ok ? data.data : data;
|
||||
renderIssueSteps(result.steps, result.status);
|
||||
|
||||
if (result.status === 'completed') {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
document.getElementById('cert-close-progress').style.display = '';
|
||||
showSuccessToast('Certificate issued for ' + result.domain);
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
// Show failed — user can see which step failed
|
||||
document.getElementById('cert-close-progress').style.display = '';
|
||||
showErrorToast('Certificate issuance failed for ' + result.domain);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// Don't poll on error — but keep trying since request might still be running
|
||||
});
|
||||
}
|
||||
|
||||
function renderIssueSteps(steps, status) {
|
||||
const container = document.getElementById('cert-steps-list');
|
||||
if (!steps || !steps.length) {
|
||||
container.innerHTML = '<div class="text-muted text-sm">Pending…</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = steps.map(s => {
|
||||
let icon;
|
||||
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
|
||||
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
|
||||
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
|
||||
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
|
||||
|
||||
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
|
||||
icon +
|
||||
'<span>' + escHtml(s.label) + '</span>' +
|
||||
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
|
||||
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
|
||||
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (status === 'completed') {
|
||||
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Hoover — api.js
|
||||
*
|
||||
* JSON-friendly fetch wrapper with automatic header management.
|
||||
* Toast notification system with auto-dismiss.
|
||||
* ToastContainer component for rendering queued toasts.
|
||||
*/
|
||||
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { modelFetch } from './model.js?v=7';
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
*
|
||||
* Automatically sets Content-Type for object bodies, parses JSON
|
||||
* responses, and normalises the result to { ok, data, error, status }.
|
||||
*
|
||||
* @param {string} url – Target URL
|
||||
* @param {object} [options] – Fetch options (method, body, headers, …)
|
||||
* @returns {Promise<{ok, data, error, status}>}
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { method = 'GET', body, ...opts } = options;
|
||||
const headers = { 'Accept': 'application/json', ...opts.headers };
|
||||
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
||||
if (opts.signal?.aborted) {
|
||||
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.location.reload();
|
||||
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||
}
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status };
|
||||
}
|
||||
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status };
|
||||
} catch (e) {
|
||||
return { ok: false, data: null, error: e.message || 'Network error', status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** ─── Toast notifications ────────────────────────────────── */
|
||||
|
||||
/** Toast notification queue. Exported for ToastContainer component. */
|
||||
export const _toasts = [];
|
||||
const _toastIds = { next: 1 };
|
||||
|
||||
/**
|
||||
* Show a toast notification. Auto-dismisses after `duration` ms.
|
||||
*
|
||||
* @param {string} message – Toast text
|
||||
* @param {string} [type] – 'info' | 'success' | 'error' | 'warning'
|
||||
* @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite)
|
||||
* @returns {number} id
|
||||
*/
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
const id = _toastIds.next++;
|
||||
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
|
||||
|
||||
if (duration > 0) setTimeout(() => dismissToast(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a toast by id.
|
||||
*/
|
||||
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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abort-checking function from an AbortController.
|
||||
*
|
||||
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||
* fetching with abort handling and loading state management.
|
||||
* @param {AbortController} ac
|
||||
* @returns {function} () => boolean
|
||||
*/
|
||||
export function checkAbort(ac) {
|
||||
return () => ac?.signal?.aborted || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard data loading wrapper with state management and abort handling.
|
||||
*
|
||||
* Sets loading=true before, loading=false after, tracks errors.
|
||||
*
|
||||
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||
* fetching with abort handling and loading state management.
|
||||
* @param {object} state - Reactive state object
|
||||
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
|
||||
* @param {function} fetchFn - (state, signal, isAborted) => Promise
|
||||
* @param {object} [opts] - Additional options
|
||||
* @param {object} [opts.entry] - Component entry for requestId tracking
|
||||
* @param {AbortController} [opts.abortController] - Fresh abort controller
|
||||
*/
|
||||
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
|
||||
const entry = opts.entry;
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const ab = opts.abortController;
|
||||
const isAborted = ab ? checkAbort(ab) : () => false;
|
||||
const signal = ab ? ab.signal : null;
|
||||
|
||||
if (entry) {
|
||||
if (dataKey(state) !== undefined) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
}
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
await fetchFn(state, signal, isAborted);
|
||||
} catch (e) {
|
||||
if (!isAborted()) state.error = e.message || 'Request failed';
|
||||
} finally {
|
||||
if (!isAborted()) {
|
||||
if (entry) {
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a URL until success or error condition is met.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.url - URL to poll
|
||||
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
|
||||
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
|
||||
* @param {function} [opts.onComplete] - (data) => void, called on success
|
||||
* @param {function} [opts.onError] - (data) => void, called on failure
|
||||
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
|
||||
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
|
||||
*/
|
||||
export async function poll(opts) {
|
||||
const {
|
||||
url,
|
||||
successKey,
|
||||
onErrorKey,
|
||||
onComplete,
|
||||
onError,
|
||||
interval = 3000,
|
||||
timeout = 60000,
|
||||
} = opts;
|
||||
|
||||
const start = Date.now();
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeout) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(null);
|
||||
return;
|
||||
}
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(res);
|
||||
return;
|
||||
}
|
||||
if (successKey(res.data)) {
|
||||
clearInterval(timer);
|
||||
if (onComplete) onComplete(res.data);
|
||||
} else if (onErrorKey(res.data)) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(res.data);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action button descriptors for modal form submission.
|
||||
*
|
||||
* Returns an array of action descriptors that can be spread into the
|
||||
* actions array passed to formModal. First item is the submit button.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.url - API URL to POST/PUT to
|
||||
* @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 {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
|
||||
*/
|
||||
export function apiSubmit(opts) {
|
||||
const {
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
validate,
|
||||
successMsg = 'Saved',
|
||||
refresh,
|
||||
submitText = 'Submit',
|
||||
closeModal,
|
||||
} = opts;
|
||||
|
||||
return [
|
||||
{
|
||||
label: submitText,
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
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)));
|
||||
}
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Hoover — component.js
|
||||
*
|
||||
* Component wrapper: definePage, lifecycle hooks, state caching.
|
||||
*
|
||||
* definePage wraps a page definition into a renderer function compatible
|
||||
* with hoover's render engine. Handles reactive state creation and
|
||||
* lifecycle management. Data loading is handled by the model layer.
|
||||
*
|
||||
* Usage:
|
||||
* export default definePage({
|
||||
* init() { return { firewall: getModel('firewall') }; },
|
||||
* async load(state) { ... }, // optional, for one-time setup
|
||||
* render(state) { return [vnodes],
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { _compExpandedCache } from './render.js?v=7';
|
||||
|
||||
/** Registry of mounted components: key → { state } */
|
||||
const _mounted = new Map();
|
||||
|
||||
/**
|
||||
* Define a page component.
|
||||
*
|
||||
* @param {object} def — Page definition
|
||||
* @param {function} def.init — Return initial state object
|
||||
* @param {function} [def.load] — Optional one-time setup called on mount
|
||||
* @param {function} def.render — Render function that returns vnodes
|
||||
* @returns {object} — Component renderer compatible with h('#comp', ...)
|
||||
*/
|
||||
export function definePage(def) {
|
||||
let state = null;
|
||||
let stateInitialized = false;
|
||||
|
||||
const renderer = () => {
|
||||
if (!stateInitialized) {
|
||||
state = reactive(def.init());
|
||||
stateInitialized = true;
|
||||
}
|
||||
return def.render(state);
|
||||
};
|
||||
|
||||
renderer._pageDef = {
|
||||
get state() {
|
||||
if (!stateInitialized) {
|
||||
state = reactive(def.init());
|
||||
stateInitialized = true;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
load: def.load || null,
|
||||
onUnmount: def.onUnmount || null,
|
||||
};
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a page component. Called by the render engine when a #comp vnode
|
||||
* enters the tree for the first time.
|
||||
*/
|
||||
export function mountComponent(key, renderer) {
|
||||
const pd = renderer._pageDef;
|
||||
if (!pd) return;
|
||||
|
||||
let entry = _mounted.get(key);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
entry = { state: pd.state };
|
||||
_mounted.set(key, entry);
|
||||
|
||||
pd.state.error = null;
|
||||
|
||||
if (pd.load) {
|
||||
Promise.resolve().then(() => pd.load(pd.state));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount a page component. Called by the render engine when a #comp vnode
|
||||
* is removed from the tree.
|
||||
*/
|
||||
export function unmountComponent(key, renderer) {
|
||||
const entry = _mounted.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
const pd = renderer._pageDef;
|
||||
|
||||
if (pd.onUnmount) {
|
||||
try { pd.onUnmount(entry.state); } catch (_) {}
|
||||
}
|
||||
|
||||
_compExpandedCache.delete(key);
|
||||
_mounted.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of a mounted component.
|
||||
*/
|
||||
export function getComponentState(key) {
|
||||
const entry = _mounted.get(key);
|
||||
return entry ? entry.state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a component vnode that the render engine will wire up to lifecycle.
|
||||
*/
|
||||
export function hComp(renderer, key) {
|
||||
return h('#comp', { component: renderer, key }, []);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Hoover — components/data.js
|
||||
*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.text – Badge text
|
||||
* @param {string} [props.variant] – 'info' | 'success' | 'warning' | 'danger'
|
||||
*/
|
||||
export function Badge(props = {}) {
|
||||
return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.status – 'success' | 'up' | 'danger' | 'down' | 'pending'
|
||||
*/
|
||||
export function StatusDot(props = {}) {
|
||||
const v = ['success', 'up'].includes(props.status) ? 'up' :
|
||||
['danger', 'down'].includes(props.status) ? 'down' : 'pending';
|
||||
return h('span', { class: `status-dot status-${v}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state placeholder card.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.text]
|
||||
*/
|
||||
export function Empty(props = {}) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card wrapper with optional header and body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.header]
|
||||
* @param {VNode[]} [props.children]
|
||||
*/
|
||||
export function Card(props = {}) {
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
if (props.header) {
|
||||
return h('div', { class: 'card', ...key },
|
||||
h('div', { class: 'card-header' }, props.header),
|
||||
h('div', { class: 'card-body' }, props.children || []),
|
||||
);
|
||||
}
|
||||
return h('div', { class: 'card', ...key }, props.children || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
|
||||
*
|
||||
* @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} [props.label] - Button text (default: 'Remove')
|
||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||
*/
|
||||
export function ConfirmDelete(props = {}) {
|
||||
const opts = { method: 'DELETE' };
|
||||
if (props.body) opts.body = props.body;
|
||||
return h('button', { class: 'btn btn-sm btn-danger',
|
||||
'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));
|
||||
}
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 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.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API URL
|
||||
* @param {string} [props.method] - HTTP method (default: 'POST')
|
||||
* @param {function} [props.body] - () => body, or undefined for no body
|
||||
* @param {string} [props.label] - Button text
|
||||
* @param {string} [props.labelOn] - Label when condition is true (toggle)
|
||||
* @param {string} [props.labelOff] - Label when condition is false (toggle)
|
||||
* @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} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||
* @param {boolean} [props.disabled] - Disabled state
|
||||
*/
|
||||
export function ActionButton(props = {}) {
|
||||
const label = props.label !== undefined ? props.label :
|
||||
(props.labelOn !== undefined && props.labelOff !== undefined
|
||||
? (props.condition ? props.labelOn : props.labelOff)
|
||||
: 'Action');
|
||||
const cls = props.cls || 'btn btn-outline';
|
||||
return h('button', {
|
||||
class: cls,
|
||||
disabled: props.disabled,
|
||||
'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));
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
}
|
||||
}, label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table wrapper with header, body, and empty-state row.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string[]} props.columns - Column header labels
|
||||
* @param {VNode[]} props.rows - Body row vnodes
|
||||
* @param {string} [props.emptyText] - Empty-state message
|
||||
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
|
||||
* @param {string} [props.key] - VNode key
|
||||
*/
|
||||
export function Table(props = {}) {
|
||||
const cols = props.columns || [];
|
||||
const ths = cols.map(c => h('th', null, c));
|
||||
const table = h('table', { class: 'table' },
|
||||
h('thead', null, h('tr', null, ...ths)),
|
||||
h('tbody', null,
|
||||
props.rows.length ? props.rows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: cols.length, class: 'text-muted text-sm' },
|
||||
props.emptyText || 'No data'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
if (props.wrapCard !== false) {
|
||||
return h('div', { class: 'card', ...key }, table);
|
||||
}
|
||||
return h('div', key, table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard stat card.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.label
|
||||
* @param {*} props.value
|
||||
* @param {*} [props.meta]
|
||||
*/
|
||||
export function StatCard(props = {}) {
|
||||
return h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, props.label),
|
||||
h('div', { class: 'value' }, props.value),
|
||||
props.meta ? h('div', { class: 'meta' }, props.meta) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot + human-readable label.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.status
|
||||
*/
|
||||
export function StatusText(props = {}) {
|
||||
const status = props.status || 'down';
|
||||
const label = status === 'up' ? 'Up' : status === 'pending' ? 'Pending' : 'Down';
|
||||
return [StatusDot({ status }), ' ', label];
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge for certificate status based on expiry data.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {number} [props.daysRemaining] - Days until expiry
|
||||
* @param {boolean} [props.expired] - Explicitly expired flag
|
||||
* @param {string} [props.certStatus] - Status string (e.g. 'valid', 'active', 'expired')
|
||||
*/
|
||||
export function certStatusBadge(props = {}) {
|
||||
const { daysRemaining, expired, certStatus } = props;
|
||||
if (certStatus === 'valid' || certStatus === 'active')
|
||||
return Badge({ text: 'Valid', variant: 'success' });
|
||||
if (expired || certStatus === 'expired' || (daysRemaining !== undefined && daysRemaining <= 0))
|
||||
return Badge({ text: 'Expired', variant: 'danger' });
|
||||
if (daysRemaining !== undefined && daysRemaining <= 30)
|
||||
return Badge({ text: daysRemaining + 'd left', variant: 'warning' });
|
||||
if (daysRemaining !== undefined)
|
||||
return Badge({ text: daysRemaining + 'd left', variant: 'success' });
|
||||
return Badge({ text: certStatus || 'N/A', variant: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot + Badge pair for a service state string.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.state - Service state (e.g. 'up', 'down')
|
||||
*/
|
||||
export function serviceStatusBadge(props = {}) {
|
||||
const state = props.state || 'down';
|
||||
const isUp = state === 'up';
|
||||
return [
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' ',
|
||||
Badge({ text: state, variant: isUp ? 'success' : 'danger' }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStatusBadge + label in a single vnode.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.state - Service state string
|
||||
* @param {string} [props.label] - Optional label text after the badge
|
||||
*/
|
||||
export function ServiceStatus(props = {}) {
|
||||
return h('span', { class: 'service-status' },
|
||||
...serviceStatusBadge({ state: props.state }),
|
||||
props.label ? ' ' + props.label : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionCell — standardizes "action button + ConfirmDelete" in a table cell.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.editLabel - First button text
|
||||
* @param {function} props.editClick - First button click handler
|
||||
* @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} [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')
|
||||
*/
|
||||
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),
|
||||
ConfirmDelete({
|
||||
url: props.removeUrl,
|
||||
message: props.removeMessage,
|
||||
success: props.removeSuccess,
|
||||
refresh: props.removeRefresh,
|
||||
label: props.removeLabel || 'Remove',
|
||||
body: props.removeBody,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monospace text with optional truncation.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.text
|
||||
* @param {number} [props.maxLength] - Truncate with "..." if longer
|
||||
*/
|
||||
export function MonoText(props = {}) {
|
||||
const text = String(props.text || '');
|
||||
const display = props.maxLength && text.length > props.maxLength
|
||||
? text.substring(0, props.maxLength) + '...'
|
||||
: text;
|
||||
return h('span', { class: 'mono-text' }, esc(display));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown to select a firewall zone.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string[]} props.zones - Available zone names
|
||||
* @param {string} [props.value] - Currently selected zone
|
||||
* @param {function} [props.onChange] - (zone) => void
|
||||
* @param {string} [props.placeholder]
|
||||
*/
|
||||
export function ZoneSelect(props = {}) {
|
||||
return h('select', {
|
||||
class: 'form-select',
|
||||
'on:change': (e) => props.onChange?.(e.target.value),
|
||||
},
|
||||
props.placeholder ? h('option', { value: '' }, props.placeholder) : null,
|
||||
props.zones.map(z =>
|
||||
h('option', { value: z, selected: z === props.value }, z),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Hoover — components/layout.js
|
||||
*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
* @param {string} [props.subtitle]
|
||||
* @param {VNode} [props.actions]
|
||||
*/
|
||||
export function PageHeader(props = {}) {
|
||||
return h('div', { class: 'page-header' },
|
||||
h('div', null,
|
||||
h('h1', null, props.title || ''),
|
||||
props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null,
|
||||
),
|
||||
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle loading/error/no-data states and return early if applicable.
|
||||
* Returns null when data is ready for the page to render its content.
|
||||
*
|
||||
* Accepts a model object (with loading/refreshing/error/data properties) as
|
||||
* the `data` parameter to check the model's data property directly.
|
||||
*
|
||||
* @param {object} state - Page state with loading/error flags
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {*} [data] - Data to check (or model object with .data property)
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuard(state, title, subtitle, data) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' },
|
||||
state.refreshing ? 'Refreshing...' : 'Loading...',
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isEmpty(data) && !state.loading) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
h('div', { class: 'card-body loading' }, 'No data available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for pages consuming multiple models.
|
||||
* Internally calls collectLoadingModels then delegates to renderGuard.
|
||||
*
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {...object} models - Model objects to combine
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuardMulti(title, subtitle, ...models) {
|
||||
const combined = collectLoadingModels(...models);
|
||||
return renderGuard(combined, title, subtitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is "empty" for renderGuard's no-data check.
|
||||
* @param {*} data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmpty(data) {
|
||||
if (data === null || data === undefined || data === '') return true;
|
||||
if (Array.isArray(data)) return data.length === 0;
|
||||
if (typeof data === 'object') return Object.keys(data).length === 0;
|
||||
if (typeof data === 'number') return false;
|
||||
return !data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar component. Writes to state[prop] on tab click.
|
||||
* The caller is responsible for rendering tab body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {object} props.state - Reactive state object
|
||||
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
|
||||
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
|
||||
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
|
||||
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
|
||||
*/
|
||||
export function Tabs(props = {}) {
|
||||
const tabKeys = props.tabs || [];
|
||||
const prop = props.prop || 'activeTab';
|
||||
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
|
||||
return h('div', { class: 'tabs' },
|
||||
tabKeys.map(t => h('span', {
|
||||
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
|
||||
'on:click': () => {
|
||||
props.state[prop] = t;
|
||||
if (props.onTabClick) props.onTabClick(t);
|
||||
},
|
||||
style: 'cursor:pointer;',
|
||||
}, formatLabel(t))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
*/
|
||||
export function SectionTitle(props = {}) {
|
||||
return h('h3', { class: 'section-title' }, props.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex button container with 8px gap.
|
||||
*
|
||||
* @param {VNode[]} children
|
||||
*/
|
||||
export function ActionGroup(...children) {
|
||||
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableSection — SectionTitle heading followed by a Table.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Section heading
|
||||
* @param {string[]} props.columns
|
||||
* @param {VNode[]} props.rows
|
||||
* @param {string} [props.emptyText]
|
||||
* @param {string} [props.key]
|
||||
*/
|
||||
export function DataTableSection(props = {}) {
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
return h('div', { class: 'data-table-section', ...key },
|
||||
SectionTitle({ title: props.title }),
|
||||
Table({
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
emptyText: props.emptyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Hoover — components/modal.js
|
||||
*
|
||||
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
|
||||
* Renders directly into #modal-root using DOM manipulation (not vdom) to
|
||||
* avoid fighting with the main render cycle.
|
||||
*/
|
||||
|
||||
import { esc } from '../helpers.js?v=7';
|
||||
import { att_esc } from '../helpers.js?v=7';
|
||||
import { apiSubmit } from '../api.js?v=7';
|
||||
|
||||
const _modalQueue = [];
|
||||
|
||||
function _renderModals() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
_modalQueue.forEach((m, idx) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'modal-overlay active';
|
||||
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal';
|
||||
content.onclick = (e) => e.stopPropagation();
|
||||
if (m.renderFn) {
|
||||
try { m.renderFn(content, idx); }
|
||||
catch (err) { content.textContent = err.message; }
|
||||
}
|
||||
wrap.appendChild(content);
|
||||
root.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal dialog.
|
||||
*
|
||||
* @param {function} renderFn – (contentEl, idx) => void, renders into contentEl
|
||||
*/
|
||||
export function openModal(renderFn) {
|
||||
_modalQueue.push({ renderFn, id: _modalQueue.length });
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a modal by index. Closes the topmost modal if index is omitted.
|
||||
*
|
||||
* @param {number} [idx]
|
||||
*/
|
||||
export function closeModal(idx) {
|
||||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||||
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all open modals.
|
||||
*/
|
||||
export function closeAllModals() {
|
||||
_modalQueue.length = 0;
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a standard modal layout: title, form fields, action buttons.
|
||||
*
|
||||
* @param {HTMLElement} inner – Modal content element to fill
|
||||
* @param {string} title – Modal title
|
||||
* @param {object[]} fields – Form field descriptors
|
||||
* @param {object[]} actions – Action button descriptors
|
||||
*
|
||||
* Field shape:
|
||||
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
|
||||
*
|
||||
* Action shape:
|
||||
* { label, cls, action, handler }
|
||||
*/
|
||||
export function formModal(inner, title, fields, actions) {
|
||||
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
|
||||
+ fields.map(f => {
|
||||
if (f.tag === 'select')
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
|
||||
+ (f.multiple ? ' multiple' : '') + '>'
|
||||
+ (f.options || []).map(o =>
|
||||
typeof o === 'string'
|
||||
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
|
||||
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
|
||||
).join('') + '</select></div>';
|
||||
|
||||
const tag = f.tag || 'input';
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
|
||||
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
|
||||
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
|
||||
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
|
||||
+ '></' + tag + '></div>';
|
||||
}).join('') + '</div><div class="modal-actions">'
|
||||
+ actions.map(a =>
|
||||
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
|
||||
).join('') + '</div>';
|
||||
|
||||
actions.forEach(a => {
|
||||
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
|
||||
if (btn) btn.addEventListener('click', a.handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that returns a function to open a multi-select modal.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Modal title
|
||||
* @param {string} props.url - API POST URL
|
||||
* @param {string[]} props.options - All selectable options
|
||||
* @param {string[]} props.selected - Currently selected values
|
||||
* @param {string} props.fieldKey - JSON key for the field
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @returns {function} () => void, calls openModal
|
||||
*/
|
||||
export function MultiSelectModal(props = {}) {
|
||||
return () => {
|
||||
const selectId = 'ms-' + props.fieldKey;
|
||||
openModal((inner) => {
|
||||
formModal(inner, props.title,
|
||||
[{
|
||||
label: props.fieldKey,
|
||||
id: selectId,
|
||||
tag: 'select',
|
||||
multiple: true,
|
||||
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
|
||||
}],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
...apiSubmit({
|
||||
url: props.url,
|
||||
body: () => ({
|
||||
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
|
||||
.map(o => o.value),
|
||||
}),
|
||||
successMsg: props.successMsg || 'Updated',
|
||||
refresh: props.refresh,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that returns a function to open a modal with form fields and apiSubmit.
|
||||
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string|function} props.title - Modal title or (data) => string
|
||||
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
|
||||
* @param {object} props.submit - Submit configuration
|
||||
* @param {string|function} props.submit.url - API URL or (data) => string
|
||||
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
|
||||
* @param {function} [props.submit.body] - (data) => object
|
||||
* @param {function} [props.submit.validate] - (body) => string|null
|
||||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||||
* @returns {function} (data) => void, calls openModal
|
||||
*/
|
||||
export function QuickModal(props = {}) {
|
||||
return (data) => {
|
||||
const title = typeof props.title === 'function' ? props.title(data) : props.title;
|
||||
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
|
||||
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
|
||||
|
||||
openModal((inner) => {
|
||||
let actions;
|
||||
if (props.handler) {
|
||||
actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
{
|
||||
label: props.submitLabel || 'Submit',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: () => props.handler(data, () => closeModal()),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
...apiSubmit({
|
||||
url,
|
||||
method: props.submit.method || 'POST',
|
||||
body: props.submit.body ? () => props.submit.body(data) : undefined,
|
||||
validate: props.submit.validate,
|
||||
successMsg: typeof props.submit.successMsg === 'function'
|
||||
? props.submit.successMsg(data)
|
||||
: (props.submit.successMsg || 'Done'),
|
||||
refresh: props.refresh || undefined,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
formModal(inner, title, fields, actions);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Hoover — components/toast.js
|
||||
*
|
||||
* ToastContainer component that renders queued toast notifications.
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { _toasts, dismissToast } from '../api.js?v=7';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
*
|
||||
* @returns {VNode}
|
||||
*/
|
||||
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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Hoover — helpers.js
|
||||
*
|
||||
* Shared utilities: text escaping, attribute escaping, DOM value helpers,
|
||||
* zone parsing, form utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape text for safe HTML output.
|
||||
* Appends the string to a temporary div and reads innerHTML,
|
||||
* which safely escapes all HTML special characters.
|
||||
*/
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.append(String(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use in HTML attributes.
|
||||
*/
|
||||
export function att_esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode a string.
|
||||
*/
|
||||
export const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* Get the value of a DOM element by ID.
|
||||
*/
|
||||
export function $val(id) {
|
||||
return document.getElementById(id)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zone data from various API response shapes into a flat string array.
|
||||
*/
|
||||
export function parseZones(data) {
|
||||
let z = data?.active || data?.zones || [];
|
||||
if (typeof z === 'object' && !Array.isArray(z))
|
||||
z = Object.values(z).map(i => i?.name || i);
|
||||
return Array.isArray(z) ? z : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser file download from a Blob.
|
||||
*
|
||||
* @param {Blob} blob
|
||||
* @param {string} filename
|
||||
*/
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Hoover — index.js
|
||||
*
|
||||
* Barrel export of all public Hoover APIs.
|
||||
*/
|
||||
|
||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=7';
|
||||
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js?v=7';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js?v=7';
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
export { definePage, hComp } from './component.js?v=7';
|
||||
|
||||
/* ── Router ──────────────────────────────────────────────────── */
|
||||
export { createRouter, Link } from './router.js?v=7';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js?v=7';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=7';
|
||||
|
||||
/* ── Model ───────────────────────────────────────────────────── */
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=7';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=7';
|
||||
|
||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=7';
|
||||
|
||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js?v=7';
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Hoover — model.js
|
||||
*
|
||||
* Central reactive store for subsystem models. Each subsystem gets one
|
||||
* reactive model with { data, loading, refreshing, error }. Hoover handles
|
||||
* fetching, WS invalidation, loading states, and abort management.
|
||||
*
|
||||
* API:
|
||||
* modelRegister(name, definition) — register at app bootstrap
|
||||
* getModel(name) — return reactive model object
|
||||
* modelFetch(name, signal?, param?) — trigger fetch with in-flight dedup
|
||||
* refreshByTopic(topic) — WS callback: refresh all models matching topic
|
||||
* collectLoadingModels(...models) — combine loading/refreshing/error
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
|
||||
/** Registered models: name → { model, subsystem, fetch } */
|
||||
const _models = new Map();
|
||||
|
||||
/** In-flight fetch promises for dedup: name → Promise */
|
||||
const _fetchPromises = new Map();
|
||||
|
||||
/**
|
||||
* Register a subsystem model.
|
||||
*
|
||||
* @param {string} name - Model name (e.g. 'firewall', 'dnsmasq')
|
||||
* @param {object} definition
|
||||
* @param {string} definition.subsystem - WS topic to listen for ('*' = all)
|
||||
* @param {function} definition.fetch - async (signal?, param?) => Promise<data>
|
||||
* @param {any} [definition.defaultData] - Initial data value (default: null)
|
||||
* @returns {object} reactive model
|
||||
*/
|
||||
export function modelRegister(name, definition) {
|
||||
const model = reactive({
|
||||
data: definition.defaultData ?? null,
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
_models.set(name, {
|
||||
model,
|
||||
subsystem: definition.subsystem,
|
||||
fetch: definition.fetch,
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reactive model by name. Throws if not registered.
|
||||
* @param {string} name
|
||||
* @returns {object} reactive model
|
||||
*/
|
||||
export function getModel(name) {
|
||||
const entry = _models.get(name);
|
||||
if (!entry) throw new Error('Model not registered: ' + name);
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
/** Build dedup key from model name and optional param. */
|
||||
function _dedupKey(name, param) {
|
||||
return param !== undefined ? `${name}:${String(param)}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a fetch for the named model.
|
||||
*
|
||||
* In-flight dedup: if a fetch is already running, returns the existing
|
||||
* promise. Models never abort in-progress fetches since other consumers
|
||||
* may still need the data.
|
||||
*
|
||||
* @param {string} name - Model name
|
||||
* @param {AbortSignal|*} [signalOrParam] - AbortSignal (backward compat) or param
|
||||
* @param {AbortSignal} [signal] - AbortSignal when a param was provided
|
||||
*/
|
||||
export function modelFetch(name, signalOrParam, signal) {
|
||||
const entry = _models.get(name);
|
||||
if (!entry) return;
|
||||
|
||||
const isSignal = signalOrParam instanceof AbortSignal || signalOrParam === undefined;
|
||||
const param = isSignal ? undefined : signalOrParam;
|
||||
const actualSignal = isSignal ? signalOrParam : signal;
|
||||
|
||||
const model = entry.model;
|
||||
const isInitial = model.loading && model.data === null;
|
||||
const key = _dedupKey(name, param);
|
||||
|
||||
if (_fetchPromises.has(key)) return _fetchPromises.get(key);
|
||||
|
||||
if (isInitial) model.loading = true;
|
||||
else model.refreshing = true;
|
||||
model.error = null;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const data = await entry.fetch(actualSignal, param);
|
||||
model.data = data;
|
||||
} catch (e) {
|
||||
model.error = e.message || 'Fetch failed';
|
||||
} finally {
|
||||
model.loading = false;
|
||||
model.refreshing = false;
|
||||
}
|
||||
})();
|
||||
|
||||
_fetchPromises.set(key, promise);
|
||||
promise.finally(() => _fetchPromises.delete(key));
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all models whose subsystem topic matches the given topic.
|
||||
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
||||
*/
|
||||
export function refreshByTopic(topic) {
|
||||
for (const [name, entry] of _models) {
|
||||
if (entry.subsystem === '*') {
|
||||
modelFetch(name);
|
||||
} else if (entry.subsystem === topic || topic === '*') {
|
||||
modelFetch(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine loading/refreshing/error from multiple models.
|
||||
* @param {...object} models
|
||||
* @returns {{loading: boolean, refreshing: boolean, error: string|null}}
|
||||
*/
|
||||
export function collectLoadingModels(...models) {
|
||||
return {
|
||||
loading: models.some(m => m.loading),
|
||||
refreshing: models.some(m => m.refreshing),
|
||||
error: models.find(m => m.error)?.error ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Hoover — reactivity.js
|
||||
*
|
||||
* Reactive Proxy state + batched render requests via queueMicrotask.
|
||||
* Multiple property mutations in the same microtask tick produce a single
|
||||
* render cycle across all registered render roots.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global flag to prevent duplicate microtask scheduling.
|
||||
*/
|
||||
let _scheduled = false;
|
||||
|
||||
/**
|
||||
* Callback invoked by render.js to perform the actual batched re-render.
|
||||
* Set via setCommitFn() during render engine initialization.
|
||||
*/
|
||||
let _commitFn = null;
|
||||
|
||||
/**
|
||||
* Register the commit callback that performs batched re-renders.
|
||||
* Called by render.js during initialization.
|
||||
*/
|
||||
export function setCommitFn(fn) {
|
||||
_commitFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a single batched re-render for all active render roots.
|
||||
* Multiple reactive property mutations in the same tick produce one diff pass.
|
||||
*/
|
||||
export function requestUpdate() {
|
||||
if (_scheduled) return;
|
||||
_scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
_scheduled = false;
|
||||
if (_commitFn) _commitFn();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an object in a reactive Proxy.
|
||||
* Any property *assignment* that changes the value automatically triggers
|
||||
* a batched re-render via requestUpdate().
|
||||
*/
|
||||
export function reactive(obj = {}) {
|
||||
return new Proxy(obj, {
|
||||
set(target, key, value, receiver) {
|
||||
const old = target[key];
|
||||
const ok = Reflect.set(target, key, value, receiver);
|
||||
if (ok && !Object.is(old, value)) {
|
||||
requestUpdate();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Hoover — render.js
|
||||
*
|
||||
* Render engine: render(container, fn), container-level diffing,
|
||||
* batched re-render loop integration with reactivity.js.
|
||||
*/
|
||||
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=7';
|
||||
import {
|
||||
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
||||
setMountFn, setUnmountFn,
|
||||
} from './vdom.js?v=7';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=7';
|
||||
|
||||
/** Container → previous root vnodes */
|
||||
export const _renderSlots = new Map();
|
||||
|
||||
/** Container → render function */
|
||||
export const _renderFns = new Map();
|
||||
|
||||
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
|
||||
export const _compExpandedCache = new Map();
|
||||
|
||||
/** Component key → renderer function (survives normalization that expands #comp) */
|
||||
const _compRegistry = new Map();
|
||||
|
||||
/**
|
||||
* Set up lifecycle callback hooks from vdom.js.
|
||||
* Called once during render initialization.
|
||||
*/
|
||||
setMountFn((el) => {
|
||||
// Reserved for future DOM-level mount hooks
|
||||
});
|
||||
|
||||
setUnmountFn((el) => {
|
||||
// Called during sweepDom for cleanup
|
||||
});
|
||||
|
||||
/**
|
||||
* Commit callback: re-renders all registered containers in batch.
|
||||
* Set as the callback for reactivity.js's requestUpdate().
|
||||
*/
|
||||
function commitAll() {
|
||||
for (const container of _renderFns.keys()) {
|
||||
commit(container);
|
||||
}
|
||||
}
|
||||
|
||||
setCommitFn(commitAll);
|
||||
|
||||
/**
|
||||
* Mount a render function onto a DOM container.
|
||||
* - First call: create DOM from scratch, append to container
|
||||
* - Subsequent calls: diff against previous VNodes, patch in place
|
||||
*/
|
||||
export function render(container, fn) {
|
||||
_renderFns.set(container, fn);
|
||||
commit(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate render function, diff vs previous, commit to _renderSlots.
|
||||
*/
|
||||
function commit(container) {
|
||||
const fn = _renderFns.get(container);
|
||||
if (!fn) return;
|
||||
|
||||
let result = fn();
|
||||
if (typeof result === 'function') result = result();
|
||||
const prev = _renderSlots.get(container);
|
||||
|
||||
// Normalize: expand #comp vnodes and track lifecycle
|
||||
const vnodes = normalizeVNodesWithLifecycle(result, prev);
|
||||
|
||||
if (!prev) {
|
||||
for (const v of vnodes) {
|
||||
const d = createDom(v);
|
||||
_vnodeDom.set(v, d);
|
||||
container.appendChild(d);
|
||||
}
|
||||
} else {
|
||||
diffContainer(container, prev, vnodes);
|
||||
}
|
||||
|
||||
_renderSlots.set(container, vnodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize render output: filter nulls, expand #comp vnodes,
|
||||
* and manage component lifecycle based on key changes.
|
||||
*/
|
||||
function normalizeVNodesWithLifecycle(result, prevVnodes) {
|
||||
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
|
||||
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
|
||||
const newEntries = [];
|
||||
|
||||
const normalized = normalizeRecursive(result, oldKeyMap, newEntries);
|
||||
|
||||
for (const entry of oldEntries) {
|
||||
if (!newEntries.some(e => e.key === entry.key)) {
|
||||
unmountComponent(entry.key, entry.renderer);
|
||||
}
|
||||
}
|
||||
for (const entry of newEntries) {
|
||||
if (!oldKeyMap.has(entry.key)) {
|
||||
mountComponent(entry.key, entry.renderer);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
|
||||
// so collectCompEntries always returns [] after the first render)
|
||||
const newKeySet = new Set(newEntries.map(e => e.key));
|
||||
for (const [key] of _compRegistry) {
|
||||
if (!newKeySet.has(key)) _compRegistry.delete(key);
|
||||
}
|
||||
for (const entry of newEntries) {
|
||||
_compRegistry.set(entry.key, entry.renderer);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively normalize a value to a flat VNode array, expanding
|
||||
* #comp vnodes into their rendered content while tracking lifecycle.
|
||||
*
|
||||
* When prevCh is provided, preserves _vnodeDom entries so that diff
|
||||
* can locate existing DOM after normalization creates new vnode objects.
|
||||
*/
|
||||
function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) {
|
||||
if (result == null) return [];
|
||||
if (Array.isArray(result)) {
|
||||
const flat = [];
|
||||
let idx = 0;
|
||||
for (const item of result) {
|
||||
flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx]));
|
||||
idx++;
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
const vnode = result;
|
||||
if (typeof vnode !== 'object') return [{ tag: '#text', text: String(vnode) }];
|
||||
if (vnode.tag === '#text') return [vnode];
|
||||
|
||||
if (vnode.tag === '#comp') {
|
||||
const renderer = vnode.props?.component;
|
||||
const key = vnode.props?.key;
|
||||
if (key !== undefined) {
|
||||
const existing = newEntries.find(e => e.key === key);
|
||||
if (!existing) newEntries.push({ key, renderer });
|
||||
}
|
||||
if (renderer && typeof renderer === 'function') {
|
||||
const content = renderer();
|
||||
const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null;
|
||||
const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded);
|
||||
if (key !== undefined) _compExpandedCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh);
|
||||
}
|
||||
|
||||
const rawChildren = vnode.ch || [];
|
||||
const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null;
|
||||
const children = [];
|
||||
for (let i = 0; i < rawChildren.length; i++) {
|
||||
const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]);
|
||||
children.push(...normalized);
|
||||
}
|
||||
|
||||
const newVNode = { tag: vnode.tag, props: vnode.props, ch: children };
|
||||
|
||||
// Preserve _vnodeDom entry: if the old vnode at this position had a
|
||||
// DOM association, transfer it to the new normalized vnode so diff
|
||||
// can locate existing DOM without creating duplicates.
|
||||
if (prevCh && _vnodeDom.has(prevCh)) {
|
||||
_vnodeDom.set(newVNode, _vnodeDom.get(prevCh));
|
||||
}
|
||||
|
||||
return [newVNode];
|
||||
}
|
||||
|
||||
/** Collect all #comp entries {key, renderer} from a vnode tree. */
|
||||
function collectCompEntries(vnodes, entries) {
|
||||
for (const v of vnodes || []) {
|
||||
if (!v) continue;
|
||||
if (v.tag === '#comp') {
|
||||
const key = v.props?.key;
|
||||
const renderer = v.props?.component;
|
||||
if (key !== undefined) entries.push({ key, renderer });
|
||||
}
|
||||
if (v.ch) collectCompEntries(v.ch, entries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two VNode arrays inside a container, patching in place.
|
||||
*
|
||||
* Fix: anchor tracking ensures correct DOM insertion order.
|
||||
* Fix: _vnodeDom updated after every patch.
|
||||
*/
|
||||
function diffContainer(container, prev, vnodes) {
|
||||
const maxLen = Math.max(vnodes.length, prev.length);
|
||||
let lastDom = null;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldV = prev[i], newV = vnodes[i];
|
||||
|
||||
if (!newV && oldV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (newV && !oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
container.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldDom = getDom(oldV);
|
||||
if (oldDom && oldV.tag === newV.tag) {
|
||||
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
} else if (oldDom && !oldDom.parentNode) {
|
||||
// oldDom exists in _vnodeDom but detached from the tree
|
||||
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = nd;
|
||||
} else if (oldDom && oldV.tag !== newV.tag) {
|
||||
// tag mismatch — replace old DOM with new
|
||||
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
oldDom.parentNode.replaceChild(nd, oldDom);
|
||||
lastDom = nd;
|
||||
} else {
|
||||
// oldDom is null — create and insert new DOM
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = nd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Hoover — router.js
|
||||
*
|
||||
* Hash-based SPA router with reactive state (triggers re-render on
|
||||
* navigation). Link component for client-side navigation.
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
import { h } from './vdom.js?v=7';
|
||||
|
||||
/**
|
||||
* Hash-based router.
|
||||
*
|
||||
* const router = createRouter({
|
||||
* '/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
|
||||
* '/interfaces': () => h('#comp', { component: InterfacesPage, key: '/interfaces' }, []),
|
||||
* '*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
|
||||
* });
|
||||
*
|
||||
* Reactive `router.state.path` updates trigger re-renders automatically.
|
||||
*/
|
||||
export function createRouter(routes) {
|
||||
const initialPath = location.hash.slice(1) || '/dashboard';
|
||||
if (!location.hash) location.hash = initialPath;
|
||||
|
||||
const state = reactive({ path: initialPath });
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
const component = () => {
|
||||
const handler = routes[state.path] || routes['*'];
|
||||
if (!handler) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted' }, `404 — Not found: ${state.path}`));
|
||||
}
|
||||
try {
|
||||
return handler();
|
||||
} catch (e) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted' }, `Error: ${e.message || String(e)}`));
|
||||
}
|
||||
};
|
||||
|
||||
return { state, navigate: (p) => { location.hash = p; }, component };
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side navigation link component.
|
||||
* Sets `location.hash` without full page navigation.
|
||||
*/
|
||||
export function Link(props) {
|
||||
const { path, class: cls, children, ...rest } = props || {};
|
||||
return h('a', {
|
||||
href: '#' + path,
|
||||
class: cls || '',
|
||||
'on:click': (e) => { e.preventDefault(); location.hash = path; },
|
||||
...rest,
|
||||
}, children || []);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Hoover — vdom.js
|
||||
*
|
||||
* Virtual DOM: h() factory, vnode creation, diffing, patching.
|
||||
* Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution.
|
||||
*
|
||||
* Critical fixes vs. reactive-dom.js:
|
||||
* - _vnodeDom updated after EVERY vnode→dom assignment
|
||||
* - Keyed diff with proper element reordering
|
||||
* - Unkeyed diff with anchor tracking
|
||||
* - Proper unmountTree for cleanup (fires registered onUnmount hooks)
|
||||
*/
|
||||
|
||||
// Exported so render.js can access it
|
||||
export const _vnodeDom = new WeakMap();
|
||||
|
||||
// Lifecycle hooks registry (component.js populates this)
|
||||
export const _mountFn = { fn: null };
|
||||
export const _unmountFn = { fn: null };
|
||||
|
||||
export function setMountFn(fn) { _mountFn.fn = fn; }
|
||||
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
|
||||
|
||||
/**
|
||||
* Build a VNode. Three forms:
|
||||
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
|
||||
* h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called)
|
||||
* h('#text', 'some text') — text node
|
||||
*/
|
||||
export function h(tag, props, ...children) {
|
||||
if (typeof tag === 'function') {
|
||||
const base = typeof props === 'object' && props !== null ? props : {};
|
||||
if (!base.children && children.length)
|
||||
base.children = flatten(children);
|
||||
return tag(base);
|
||||
}
|
||||
if (tag === '#text')
|
||||
return { tag: '#text', text: String(props) };
|
||||
if (tag === '#comp') {
|
||||
return { tag: '#comp', props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
return { tag, props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
|
||||
/** Flatten nested arrays / primitives → VNode array. */
|
||||
function flatten(arr) {
|
||||
const out = [];
|
||||
for (const c of arr.flat(Infinity)) {
|
||||
if (c == null || typeof c === 'boolean') continue;
|
||||
out.push(
|
||||
typeof c === 'string' || typeof c === 'number'
|
||||
? { tag: '#text', text: String(c) }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the real DOM element for a VNode via _vnodeDom.
|
||||
*/
|
||||
export function getDom(vnode) {
|
||||
return vnode ? _vnodeDom.get(vnode) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a real DOM element (or subtree) from a VNode.
|
||||
* Also registers _vnodeDom mapping for the created element and all descendants.
|
||||
*/
|
||||
export function createDom(vnode) {
|
||||
if (!vnode) return document.createTextNode('');
|
||||
if (vnode.tag === '#text') {
|
||||
const tn = document.createTextNode(vnode.text || '');
|
||||
_vnodeDom.set(vnode, tn);
|
||||
return tn;
|
||||
}
|
||||
const el = document.createElement(vnode.tag);
|
||||
applyProps(el, vnode.props);
|
||||
_vnodeDom.set(vnode, el);
|
||||
for (const c of vnode.ch || []) {
|
||||
el.appendChild(createDom(c));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Apply every prop on an element (initial mount). */
|
||||
export function applyProps(el, props) {
|
||||
for (const [k, v] of Object.entries(props)) setProp(el, k, v);
|
||||
}
|
||||
|
||||
/** Set a single prop (or event) on an element. */
|
||||
export function setProp(el, key, value) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key === 'html') { el.innerHTML = String(value); return; }
|
||||
if (key === 'innerHTML') { el.innerHTML = String(value); return; }
|
||||
if (key === 'textContent') { el.textContent = String(value); return; }
|
||||
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) el.removeEventListener(ev, map[ev]);
|
||||
if (typeof value === 'function') {
|
||||
el.addEventListener(ev, value);
|
||||
map[ev] = value;
|
||||
} else delete map[ev];
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'class' && typeof value === 'object' && value !== null) {
|
||||
el.className = Object.keys(value).filter(k => value[k]).join(' ');
|
||||
return;
|
||||
}
|
||||
if (key === 'style' && typeof value === 'object' && value !== null) {
|
||||
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) {
|
||||
el.value = value == null ? '' : String(value); return;
|
||||
}
|
||||
if (key === 'checked' && tag === 'input') { el.checked = !!value; return; }
|
||||
if (key === 'disabled') { el.disabled = !!value; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = !!value; return; }
|
||||
|
||||
if (value == null || value === false || value === undefined)
|
||||
el.removeAttribute(key);
|
||||
else
|
||||
el.setAttribute(key, value === true ? '' : String(value));
|
||||
}
|
||||
|
||||
/** Remove a single prop from an element. */
|
||||
export function unsetProp(el, key) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; }
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return;
|
||||
if (key === 'checked' && tag === 'input') { el.checked = false; return; }
|
||||
if (key === 'disabled') { el.disabled = false; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = false; return; }
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
|
||||
/** Diff two props objects and patch the element in place. */
|
||||
export function patchProps(el, oldP = {}, newP = {}) {
|
||||
for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) {
|
||||
const hasOld = k in oldP, hasNew = k in newP;
|
||||
if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue;
|
||||
if (hasNew) setProp(el, k, newP[k]);
|
||||
else unsetProp(el, k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively clean up event listeners and child nodes. */
|
||||
export function sweepDom(el) {
|
||||
if (_unmountFn.fn) _unmountFn.fn(el);
|
||||
for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]);
|
||||
while (el.firstChild) {
|
||||
const child = el.firstChild;
|
||||
if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child);
|
||||
el.removeChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch children of a parent element.
|
||||
* Dispatches to keyed or unkeyed patching based on whether any vnode has a key.
|
||||
*/
|
||||
export function patchChildren(parent, oldCh, newCh) {
|
||||
const hasKeys = (ch) => ch.some(v => v?.props?.key != null);
|
||||
if (hasKeys(newCh) && hasKeys(oldCh))
|
||||
patchKeyed(parent, oldCh, newCh);
|
||||
else
|
||||
patchUnkeyed(parent, oldCh, newCh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unkeyed (index-based) children diff.
|
||||
*
|
||||
* Fix: _vnodeDom updated after EVERY vnode→dom assignment.
|
||||
*/
|
||||
export function patchUnkeyed(parent, oldCh, newCh) {
|
||||
const maxLen = Math.max(oldCh.length, newCh.length);
|
||||
let lastDom = null;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldV = oldCh[i], newV = newCh[i];
|
||||
|
||||
if (!newV && oldV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
lastDom = i > 0 ? getDom(oldCh[i - 1]) : null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (newV && !oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
continue;
|
||||
}
|
||||
|
||||
patchNode(parent, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed children diff — preserves order, reuses DOM by key.
|
||||
*
|
||||
* Fix: proper element reordering using lastDom anchor tracking.
|
||||
*/
|
||||
export function patchKeyed(parent, oldCh, newCh) {
|
||||
const oldMap = new Map(
|
||||
oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v])
|
||||
);
|
||||
const toRemove = new Set(oldMap.keys());
|
||||
let lastDom = null;
|
||||
|
||||
for (const newV of newCh) {
|
||||
const key = newV.props?.key;
|
||||
toRemove.delete(key);
|
||||
const oldV = oldMap.get(key);
|
||||
|
||||
if (oldV) {
|
||||
patchNode(parent, oldV, newV, null);
|
||||
const d = getDom(newV);
|
||||
if (d) {
|
||||
if (lastDom && d !== lastDom.nextSibling) {
|
||||
parent.insertBefore(d, lastDom.nextSibling || null);
|
||||
}
|
||||
lastDom = d;
|
||||
}
|
||||
} else {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toRemove) {
|
||||
const oldV = oldMap.get(key);
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch one VNode against another inside parent.
|
||||
*
|
||||
* - no old → create + insert
|
||||
* - no new → sweep + remove
|
||||
* - tag match → patchProps + patchChildren
|
||||
* - tag mismatch → replace
|
||||
*
|
||||
* Fix: _vnodeDom always set to the correct dom after patch.
|
||||
*/
|
||||
export function patchNode(parent, oldV, newV, anchor) {
|
||||
if (!oldV && !newV) return;
|
||||
|
||||
if (!oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
if (!newV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dom = getDom(oldV);
|
||||
if (!dom || !dom.parentNode) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tag changed → full replace
|
||||
if (oldV.tag !== newV.tag) {
|
||||
if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
dom.parentNode.replaceChild(nd, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text node — fast path
|
||||
if (oldV.tag === '#text') {
|
||||
if (oldV.text !== newV.text) dom.nodeValue = newV.text;
|
||||
_vnodeDom.set(newV, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Element: patch in place
|
||||
patchProps(dom, oldV.props || {}, newV.props || {});
|
||||
patchChildren(dom, oldV.ch || [], newV.ch || []);
|
||||
_vnodeDom.set(newV, dom);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Hoover — websocket.js
|
||||
*
|
||||
* WebSocket connection manager with auto-reconnect. WS messages are routed
|
||||
* to model-based refresh and direct onMessage handlers.
|
||||
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
||||
*/
|
||||
|
||||
import { refreshByTopic } from './model.js?v=7';
|
||||
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
|
||||
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
* origin has no port (nginx fronting the WS on a different port).
|
||||
*/
|
||||
function _wsUrl() {
|
||||
if (window.__WS_URL__) return window.__WS_URL__;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return proto + '//' + location.host + '/ws';
|
||||
}
|
||||
|
||||
/** Attempt a WebSocket connection. */
|
||||
function _wsConnect() {
|
||||
if (_wsConn && _wsConn.readyState <= 1) return;
|
||||
|
||||
_wsConn = new WebSocket(_wsUrl());
|
||||
|
||||
_wsConn.onopen = () => {
|
||||
_wsReconnectMs = 0;
|
||||
};
|
||||
|
||||
_wsConn.onclose = () => {
|
||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
||||
setTimeout(_wsConnect, _wsReconnectMs);
|
||||
};
|
||||
|
||||
_wsConn.onerror = () => {
|
||||
_wsConn.close();
|
||||
};
|
||||
|
||||
_wsConn.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an incoming WS message to model refresh and direct handlers.
|
||||
*
|
||||
* Expected message shapes:
|
||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||
* { type: 'notify', topic: 'firewall' }
|
||||
* { type: 'status', topic: 'firewall', … }
|
||||
*/
|
||||
function handleMessage(msg) {
|
||||
const topics = [];
|
||||
|
||||
if (msg.type === 'versions' || msg.type === 'refresh') {
|
||||
topics.push(...(msg.updated || msg.topics || []));
|
||||
} else if (msg.type === 'notify') {
|
||||
topics.push(msg.topic);
|
||||
} else if (msg.type === 'status') {
|
||||
topics.push(msg.topic || '*');
|
||||
}
|
||||
|
||||
// Refresh models for each topic
|
||||
for (const topic of topics) {
|
||||
refreshByTopic(topic);
|
||||
}
|
||||
|
||||
// Notify direct onMessage handlers
|
||||
for (const h of _directHandlers) {
|
||||
if (h.unsubscribed) continue;
|
||||
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
||||
try { h.handler(msg); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
||||
* Handler receives the raw parsed message when a matching topic arrives.
|
||||
* @param {string|string[]} topics
|
||||
* @param {function} handler
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
export function onMessage(topics, handler) {
|
||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
||||
const entry = { topics: tArray, handler, unsubscribed: false };
|
||||
_directHandlers.push(entry);
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
const idx = _directHandlers.indexOf(entry);
|
||||
if (idx !== -1) _directHandlers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
export function connect() {
|
||||
_wsConnect();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vacuum Wall</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="layout">
|
||||
<div id="sidebar"></div>
|
||||
<div class="main" id="main"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||
<script type="module" src="/static/app.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
|
||||
|
||||
function issueCertModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Issue Certificate',
|
||||
[
|
||||
{ label: 'Domain', id: 'ic-domain', placeholder: 'example.com' },
|
||||
{ label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
|
||||
const resp = await apiFetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Issuance started for ' + domain, 'success');
|
||||
closeModal(idx);
|
||||
const rid = resp.data?.request_id;
|
||||
if (rid) pollCertIssue(rid, state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollCertIssue(rid, state) {
|
||||
poll({
|
||||
url: '/api/certs/issue/' + enc(rid),
|
||||
successKey: (d) => d.status === 'completed',
|
||||
onErrorKey: (d) => d.status === 'failed',
|
||||
onComplete: (d) => {
|
||||
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
||||
modelFetch('acme');
|
||||
},
|
||||
onError: (d) => {
|
||||
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
acme: getModel('acme'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
|
||||
if (guard) return guard;
|
||||
|
||||
const rows = (state.acme.data || []).map(c => {
|
||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||
|
||||
return h('tr', { key: c.domain },
|
||||
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
|
||||
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
|
||||
h('td', null, esc(c.expiry || 'N/A')),
|
||||
h('td', null, badge),
|
||||
ActionCell({
|
||||
editLabel: 'Renew',
|
||||
editClick: async () => {
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
},
|
||||
removeUrl: '/api/certs/' + enc(c.domain),
|
||||
removeMessage: 'Remove certificate for ' + c.domain + '?',
|
||||
removeSuccess: 'Certificate removed',
|
||||
removeRefresh: 'acme',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Certificates',
|
||||
subtitle: 'ACME certificate management',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
|
||||
}),
|
||||
rows.length
|
||||
? Table({
|
||||
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
status: getModel('status'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data);
|
||||
if (guard) return guard;
|
||||
|
||||
const d = state.status.data;
|
||||
const fwZones = (d.firewall?.zones) || {};
|
||||
const net = d.net || {};
|
||||
const nCount = Object.keys(net).length;
|
||||
const upI = Object.values(net).filter(i => i.state === 'up');
|
||||
const upC = upI.length;
|
||||
const certs = d.certs || [];
|
||||
const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
|
||||
const dmsk = d.dnsmasq?.status || {};
|
||||
const wP = (d.wg || {}).peers || [];
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'grid grid-4' },
|
||||
StatCard({
|
||||
label: 'Active Zones',
|
||||
value: Object.keys(fwZones).length,
|
||||
meta: Object.keys(fwZones).join(', ') || 'None',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'Interfaces Up',
|
||||
value: upC + '/' + nCount,
|
||||
meta: upI.map(i => i.name).join(', ') || 'None up',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'WireGuard',
|
||||
value: String(d.wg?.state || 'unknown'),
|
||||
meta: wP.length + ' peers',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'Certificates',
|
||||
value: certs.length,
|
||||
meta: certW.length + ' expiring/expired',
|
||||
}),
|
||||
),
|
||||
h('div', { class: 'grid grid-2' },
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' }, 'Services'),
|
||||
h('div', { class: 'card-body' },
|
||||
h('ul', { class: 'service-list' },
|
||||
h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })),
|
||||
h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addRange = QuickModal({
|
||||
title: 'Add DHCP Range',
|
||||
fields: [
|
||||
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
|
||||
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
|
||||
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
|
||||
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/ranges',
|
||||
body: () => ({
|
||||
interface: ($val('r-iface') || '').trim() || undefined,
|
||||
start: ($val('r-start') || '').trim(),
|
||||
end: ($val('r-end') || '').trim(),
|
||||
lease_time: ($val('r-lease') || '').trim() || '12h',
|
||||
}),
|
||||
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
||||
successMsg: 'Range added',
|
||||
},
|
||||
refresh: 'dnsmasq',
|
||||
});
|
||||
|
||||
const addLease = QuickModal({
|
||||
title: 'Add Static Lease',
|
||||
fields: [
|
||||
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
|
||||
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
|
||||
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/static-lease',
|
||||
body: () => ({
|
||||
mac: ($val('l-mac') || '').trim(),
|
||||
ip: ($val('l-ip') || '').trim(),
|
||||
hostname: ($val('l-host') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
||||
successMsg: 'Lease added',
|
||||
},
|
||||
refresh: 'dnsmasq',
|
||||
});
|
||||
|
||||
const addDns = QuickModal({
|
||||
title: 'Add DNS Record',
|
||||
fields: [
|
||||
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
|
||||
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/dns-record',
|
||||
body: () => ({ name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim() }),
|
||||
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
||||
successMsg: 'DNS record added',
|
||||
},
|
||||
refresh: 'dnsmasq',
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
dnsmasq: getModel('dnsmasq'),
|
||||
activeTab: 'ranges',
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.dnsmasq, 'DHCP & DNS', 'Dnsmasq management', state.dnsmasq.data);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.dnsmasq.data?.config || {};
|
||||
const ranges = cfg.ranges || [];
|
||||
const staticLeases = cfg.static_leases || [];
|
||||
const dnsRecords = cfg.dns_records || [];
|
||||
const status = state.dnsmasq.data?.status || {};
|
||||
|
||||
const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
|
||||
h('td', null, r.interface || '(global)'),
|
||||
h('td', null, esc(r.start)),
|
||||
h('td', null, esc(r.end)),
|
||||
h('td', null, esc(r.lease_time || '12h')),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/ranges',
|
||||
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
|
||||
body: { interface: r.interface || '', start: r.start, end: r.end },
|
||||
success: 'Range removed',
|
||||
refresh: 'dnsmasq',
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac },
|
||||
h('td', null, esc(l.mac)),
|
||||
h('td', null, esc(l.ip)),
|
||||
h('td', null, l.hostname || '-'),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/static-lease/' + enc(l.mac),
|
||||
message: 'Remove lease ' + l.mac + '?',
|
||||
success: 'Lease removed',
|
||||
refresh: 'dnsmasq',
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name },
|
||||
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
|
||||
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
|
||||
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
|
||||
success: 'Record removed',
|
||||
refresh: 'dnsmasq',
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
|
||||
ActionButton({
|
||||
url: '/api/dhcp/apply',
|
||||
successMsg: 'dnsmasq applied',
|
||||
label: 'Apply',
|
||||
refresh: 'dnsmasq',
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
||||
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
|
||||
Tabs({ state, tabs: tabNames }),
|
||||
state.activeTab === 'ranges'
|
||||
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
|
||||
state.activeTab === 'leases'
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
|
||||
state.activeTab === 'dns'
|
||||
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
|
||||
state.activeTab === 'active'
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip },
|
||||
h('td', null, esc(l.mac || '-')),
|
||||
h('td', null, esc(l.ip || '-')),
|
||||
h('td', null, esc(l.hostname || '-')),
|
||||
h('td', null, esc(l.expires || '-')),
|
||||
)), emptyText: 'No active leases' }) : null,
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
|
||||
|
||||
async function changeZone(name, zone, state) {
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||
method: 'POST',
|
||||
body: { interfaces: [name] },
|
||||
});
|
||||
if (r.ok) {
|
||||
toast(name + ' \u2192 ' + zone, 'success');
|
||||
modelFetch('firewall');
|
||||
modelFetch('network');
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const cfgModalFn = QuickModal({
|
||||
title: (d) => 'Config: ' + d.name,
|
||||
fields: (d) => {
|
||||
const cfg = d.config || {};
|
||||
return [
|
||||
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
|
||||
{ label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
|
||||
{ label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
|
||||
];
|
||||
},
|
||||
submit: {
|
||||
url: (d) => '/api/network/interfaces/' + enc(d.name),
|
||||
body: () => ({
|
||||
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
gateway: ($val('cfg-gw') || '').trim() || undefined,
|
||||
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
}),
|
||||
successMsg: 'Config saved',
|
||||
},
|
||||
refresh: ['firewall', 'network'],
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
network: getModel('network'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
|
||||
if (guard) return guard;
|
||||
|
||||
const fwZones = state.firewall.data?.zones || {};
|
||||
const netData = state.network.data?.interfaces || {};
|
||||
const zones = fwZones.available || [];
|
||||
const activeZones = fwZones.active || {};
|
||||
|
||||
const ifaces = Object.entries(netData).map(([name, entry]) => {
|
||||
let zone = null;
|
||||
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
|
||||
if ((ifaces || []).includes(name)) {
|
||||
zone = zoneName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
mac: entry?.runtime?.mac || null,
|
||||
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
|
||||
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
|
||||
zone,
|
||||
config: entry?.config || {},
|
||||
};
|
||||
});
|
||||
|
||||
const rows = ifaces.map(iface => {
|
||||
return h('tr', { key: iface.name },
|
||||
h('td', null, h('strong', null, iface.name)),
|
||||
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
|
||||
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
|
||||
h('td', null, StatusText({ status: iface.state })),
|
||||
h('td', null,
|
||||
ZoneSelect({
|
||||
zones,
|
||||
value: iface.zone,
|
||||
onChange: (z) => changeZone(iface.name, z, state),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'margin-left:8px',
|
||||
'on:click': () => cfgModalFn(iface),
|
||||
}, 'Config'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
||||
Table({
|
||||
columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
|
||||
rows,
|
||||
emptyText: 'No interfaces found',
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const logTabs = [
|
||||
{ key: 'journal', label: 'Journal' },
|
||||
{ key: 'nginx-access', label: 'Nginx Access' },
|
||||
{ key: 'nginx-error', label: 'Nginx Error' },
|
||||
{ key: 'dnsmasq', label: 'Dnsmasq' },
|
||||
{ key: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
logs: getModel('logs'),
|
||||
activeTab: 'journal',
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const logData = state.logs.data;
|
||||
const stale = logData?.tab !== state.activeTab;
|
||||
const guard = renderGuard(state.logs, 'Logs', 'System and service logs', stale ? undefined : logData?.data);
|
||||
if (guard) return guard;
|
||||
|
||||
const lines = logData.data || [];
|
||||
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
||||
const lineVnodes = lines.map((line, i) =>
|
||||
h('div', { class: 'log-line', key: i }, esc(line))
|
||||
);
|
||||
|
||||
const tabsBody = Tabs({
|
||||
state,
|
||||
tabs: logTabs.map(t => t.key),
|
||||
formatLabel: (k) => {
|
||||
const t = logTabs.find(t => t.key === k);
|
||||
return t ? t.label : k.charAt(0).toUpperCase() + k.slice(1);
|
||||
},
|
||||
onTabClick: (key) => modelFetch('logs', key),
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Logs', subtitle: 'System and service logs' }),
|
||||
h('div', { class: 'card', key: 'log-card' },
|
||||
tabsBody,
|
||||
h('div', { class: 'card-header' },
|
||||
h('span', null, tab.label),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'float:right;',
|
||||
'on:click': () => modelFetch('logs', state.activeTab),
|
||||
}, '\u21BB'),
|
||||
),
|
||||
h('div', { class: 'card-body log-body' },
|
||||
h('pre', null, lineVnodes),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addFwd = QuickModal({
|
||||
title: 'Add Port Forward',
|
||||
fields: (d) => [
|
||||
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
|
||||
{ label: 'Port', id: 'fwd-port', type: 'number' },
|
||||
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
|
||||
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
|
||||
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/forward-port',
|
||||
body: () => ({
|
||||
zone: $val('fwd-zone'),
|
||||
port: parseInt($val('fwd-port')),
|
||||
proto: ($val('fwd-proto') || 'tcp').trim(),
|
||||
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
|
||||
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
|
||||
}),
|
||||
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
||||
successMsg: 'Forward rule added',
|
||||
},
|
||||
refresh: 'firewall',
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.firewall.data?.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
|
||||
const sIface = (state.firewall.data?.state || {}).interfaces || [];
|
||||
const masqZones = new Set(
|
||||
Object.entries(zoneData)
|
||||
.filter(([, zcfg]) => !!zcfg.masquerade)
|
||||
.map(([z]) => z)
|
||||
);
|
||||
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
|
||||
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
|
||||
|
||||
const ifaceRows = (ifaces) =>
|
||||
ifaces.map((iface) =>
|
||||
h('tr', { key: 'ii-' + iface.name },
|
||||
h('td', null,
|
||||
h('div', { class: 'd-flex align-items-center gap-2' },
|
||||
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
|
||||
h('strong', null, iface.name),
|
||||
),
|
||||
),
|
||||
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
|
||||
)
|
||||
);
|
||||
|
||||
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
||||
const masq = !!zcfg.masquerade;
|
||||
return h('tr', { key: 'm-' + zone },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
|
||||
h('td', null,
|
||||
ActionButton({
|
||||
url: '/api/firewall/masquerade',
|
||||
cls: 'btn btn-sm btn-outline',
|
||||
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
|
||||
body: () => ({ zone, enable: !masq }),
|
||||
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
|
||||
refresh: 'firewall',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const fwRows = [];
|
||||
Object.entries(zoneData).forEach(([zone, zcfg]) => {
|
||||
const forwards = zcfg.forward_ports || [];
|
||||
forwards.forEach((fwd, i) => {
|
||||
const port = fwd.port;
|
||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
|
||||
h('td', null, port),
|
||||
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
|
||||
h('td', null, fwd['to-port'] || fwd.toport || '-'),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
|
||||
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
|
||||
success: 'Rule removed',
|
||||
refresh: 'firewall',
|
||||
}),
|
||||
),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
||||
DataTableSection({
|
||||
title: 'WAN / External',
|
||||
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
||||
rows: ifaceRows(wanIface),
|
||||
emptyText: 'No WAN interfaces with masquerade enabled',
|
||||
}),
|
||||
DataTableSection({
|
||||
title: 'Internal / LAN',
|
||||
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
||||
rows: ifaceRows(lanIface),
|
||||
emptyText: 'No internal interfaces',
|
||||
}),
|
||||
DataTableSection({
|
||||
title: 'Masquerade',
|
||||
columns: ['Zone', 'Status', 'Action'],
|
||||
rows: masqRows,
|
||||
emptyText: 'No zones',
|
||||
}),
|
||||
SectionTitle({ title: 'Port Forwarding' }),
|
||||
Card({ children: [
|
||||
ActionGroup(
|
||||
h('button', { class: 'btn btn-sm btn-primary',
|
||||
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
|
||||
}, 'Add Forward'),
|
||||
),
|
||||
Table({
|
||||
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
||||
rows: fwRows,
|
||||
emptyText: 'No port forwarding rules',
|
||||
wrapCard: false,
|
||||
}),
|
||||
]}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { path: location.hash.slice(1) || '' };
|
||||
},
|
||||
render(state) {
|
||||
return [
|
||||
PageHeader({ title: '404' }),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { h, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addDomain = QuickModal({
|
||||
title: 'Add Proxy Domain',
|
||||
fields: [
|
||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/proxy/domains',
|
||||
body: () => ({
|
||||
domain: ($val('p-domain') || '').trim(),
|
||||
backend_host: ($val('p-host') || '').trim(),
|
||||
backend_port: parseInt($val('p-port')),
|
||||
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||
cert: ($val('p-cert') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
|
||||
successMsg: 'Domain added',
|
||||
},
|
||||
refresh: ['nginx', 'acme'],
|
||||
});
|
||||
|
||||
const editDomain = QuickModal({
|
||||
title: (d) => 'Edit: ' + d.domain,
|
||||
fields: (d) => [
|
||||
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
|
||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
|
||||
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
|
||||
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
|
||||
],
|
||||
submit: {
|
||||
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
||||
method: 'PUT',
|
||||
body: () => ({
|
||||
backend_host: ($val('pe-host') || '').trim(),
|
||||
backend_port: parseInt($val('pe-port')),
|
||||
backend_proto: ($val('pe-proto') || 'http').trim(),
|
||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
|
||||
successMsg: 'Domain updated',
|
||||
},
|
||||
refresh: ['nginx', 'acme'],
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
nginx: getModel('nginx'),
|
||||
acme: getModel('acme'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
||||
if (guard) return guard;
|
||||
|
||||
const domains = state.nginx.data || [];
|
||||
const rows = domains.map(d => {
|
||||
const certBadge = certStatusBadge({
|
||||
certStatus: d.cert_status,
|
||||
daysRemaining: d.days_remaining,
|
||||
expired: d.cert_status === 'expired',
|
||||
});
|
||||
|
||||
return h('tr', { key: d.domain },
|
||||
h('td', null, h('strong', null, esc(d.domain))),
|
||||
h('td', null, esc(d.backend_host || '-')),
|
||||
h('td', null, d.backend_port || '-'),
|
||||
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
|
||||
h('td', null, certBadge),
|
||||
ActionCell({
|
||||
editLabel: 'Edit',
|
||||
editClick: () => editDomain(d),
|
||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||
removeSuccess: 'Domain removed',
|
||||
removeRefresh: ['nginx', 'acme'],
|
||||
removeLabel: 'Delete',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
|
||||
ActionButton({
|
||||
url: '/api/proxy/apply',
|
||||
successMsg: 'Nginx applied & reloaded',
|
||||
label: 'Apply',
|
||||
refresh: ['nginx', 'acme'],
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||
rows.length
|
||||
? Table({
|
||||
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addRule = QuickModal({
|
||||
title: 'Add Rich Rule',
|
||||
fields: (d) => [
|
||||
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
|
||||
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/rich-rules',
|
||||
body: () => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
|
||||
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
||||
successMsg: 'Rule added',
|
||||
},
|
||||
refresh: 'firewall',
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.firewall.data?.config || {};
|
||||
const zones = state.firewall.data?.zones?.available || [];
|
||||
const zoneData = cfg.zones || {};
|
||||
const zoneRules = {};
|
||||
Object.entries(zoneData).forEach(([zname, zcfg]) => {
|
||||
const rr = zcfg.rich_rules || [];
|
||||
if (rr.length) zoneRules[zname] = rr;
|
||||
});
|
||||
|
||||
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
||||
return Card({
|
||||
header: 'Zone: ' + esc(zone),
|
||||
key: zone,
|
||||
children: [Table({
|
||||
columns: ['#', 'Rule', 'Action'],
|
||||
rows: (Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||
return h('tr', { key: i },
|
||||
h('td', { class: 'text-muted' }, i + 1),
|
||||
h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
|
||||
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
|
||||
success: 'Rule removed',
|
||||
refresh: 'firewall',
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
emptyText: 'No rules',
|
||||
wrapCard: false,
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Rules',
|
||||
subtitle: 'Firewall rich rules',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addRule({ zones }), }, 'Add Rule'),
|
||||
}),
|
||||
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addPeer = QuickModal({
|
||||
title: 'Add WireGuard Peer',
|
||||
fields: [
|
||||
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
||||
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
||||
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
|
||||
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/wireguard/peers',
|
||||
body: () => ({
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
}),
|
||||
validate: (b) => !b.name ? 'Name is required' : null,
|
||||
successMsg: 'Peer added',
|
||||
},
|
||||
refresh: 'wireguard',
|
||||
});
|
||||
|
||||
function downloadConfigModal(peerName, config, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Download Config for ' + peerName,
|
||||
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const endpoint = ($val('wg-srv-endpoint') || '').trim();
|
||||
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/wireguard/generate-client', {
|
||||
method: 'POST',
|
||||
body: { name: peerName, server_endpoint: endpoint },
|
||||
});
|
||||
if (resp.ok && resp.data?.config) {
|
||||
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
wireguard: getModel('wireguard'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
|
||||
if (guard) return guard;
|
||||
|
||||
const st = state.wireguard.data?.status || {};
|
||||
const isUp = st.state === 'up';
|
||||
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
|
||||
|
||||
const peerRows = (state.wireguard.data?.peers || []).map(p => {
|
||||
const hasHandshake = !!p.latest_handshake;
|
||||
return h('tr', { key: p.name },
|
||||
h('td', null,
|
||||
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
|
||||
h('strong', null, esc(p.name || 'unnamed')),
|
||||
),
|
||||
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
|
||||
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
|
||||
h('td', { class: 'text-sm' },
|
||||
'Recv: ' + esc(p.transfer_recv || '0'),
|
||||
h('br'),
|
||||
'Sent: ' + esc(p.transfer_sent || '0'),
|
||||
),
|
||||
ActionCell({
|
||||
editLabel: 'Config',
|
||||
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
|
||||
removeUrl: '/api/wireguard/peers/' + enc(p.name),
|
||||
removeMessage: 'Remove peer ' + p.name + '?',
|
||||
removeSuccess: 'Peer removed',
|
||||
removeRefresh: 'wireguard',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
|
||||
ActionButton({
|
||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
|
||||
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
||||
refresh: 'wireguard',
|
||||
}),
|
||||
ActionButton({
|
||||
url: '/api/wireguard/apply',
|
||||
successMsg: 'Config applied',
|
||||
label: 'Apply',
|
||||
refresh: 'wireguard',
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'WireGuard',
|
||||
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
|
||||
actions,
|
||||
}),
|
||||
ServiceStatus({ state: st.state || 'down' }),
|
||||
peerRows.length
|
||||
? Table({
|
||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
|
||||
rows: peerRows,
|
||||
})
|
||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7';
|
||||
|
||||
const addZone = QuickModal({
|
||||
title: 'Add Zone',
|
||||
fields: [
|
||||
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
||||
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/zones',
|
||||
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
|
||||
validate: (b) => !b.name ? 'Zone name required' : null,
|
||||
successMsg: 'Zone created',
|
||||
},
|
||||
refresh: 'firewall',
|
||||
});
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
|
||||
const zones = state.firewall.data?.zones?.available || [];
|
||||
const activeZones = state.firewall.data?.zones?.active || {};
|
||||
const zoneDetails = {};
|
||||
for (const name of zones) {
|
||||
const activeIfaces = activeZones[name];
|
||||
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
|
||||
}
|
||||
|
||||
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
|
||||
const z = typeof zdata === 'object' ? zdata : {};
|
||||
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
||||
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
||||
return h('div', { class: 'card', key: name, style: 'position:relative;' },
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' },
|
||||
h('div', null,
|
||||
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name),
|
||||
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' },
|
||||
z.target ? 'Target: ' + esc(z.target) : '',
|
||||
),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'text-sm mb-4' },
|
||||
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'),
|
||||
ifacesArr.length
|
||||
? ifacesArr.map(i => Badge({ text: esc(i) }))
|
||||
: h('span', { class: 'text-muted' }, 'None'),
|
||||
),
|
||||
h('div', { class: 'text-sm mb-4' },
|
||||
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'),
|
||||
svcsArr.length
|
||||
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' }))
|
||||
: h('span', { class: 'text-muted' }, 'None'),
|
||||
),
|
||||
h('div', { style: 'display:flex;gap:6px;' },
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => MultiSelectModal({
|
||||
title: 'Interfaces: ' + name,
|
||||
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
||||
options: state.firewall.data?.interfaces || [],
|
||||
selected: ifacesArr,
|
||||
fieldKey: 'interfaces',
|
||||
successMsg: 'Interfaces updated',
|
||||
refresh: 'firewall',
|
||||
})(),
|
||||
}, 'Interfaces'),
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => MultiSelectModal({
|
||||
title: 'Services: ' + name,
|
||||
url: '/api/firewall/zones/' + enc(name) + '/services',
|
||||
options: state.firewall.data?.services || [],
|
||||
selected: svcsArr,
|
||||
fieldKey: 'services',
|
||||
successMsg: 'Services updated',
|
||||
refresh: 'firewall',
|
||||
})(),
|
||||
}, 'Services'),
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/zones/' + enc(name),
|
||||
message: 'Delete zone ' + name + '?',
|
||||
success: 'Zone ' + name + ' deleted',
|
||||
refresh: 'firewall',
|
||||
label: 'Delete',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Zones',
|
||||
subtitle: 'Firewall zones',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addZone(), }, 'Add Zone'),
|
||||
}),
|
||||
zoneCards.length
|
||||
? h('div', { class: 'card-grid' }, ...zoneCards)
|
||||
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
+229
-1
@@ -301,10 +301,40 @@ body {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
animation: toastSlideIn 0.3s ease forwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-message .toast-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toast-message .toast-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
.toast-message .toast-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toast-message .toast-btn:hover { opacity: 1; }
|
||||
|
||||
.toast-message.toast-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
@@ -464,6 +494,204 @@ body {
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Stat cards */
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-up { background: var(--success); }
|
||||
.status-down { background: var(--danger); }
|
||||
.status-pending { background: var(--warning); }
|
||||
|
||||
/* Badge info */
|
||||
.badge-info {
|
||||
background: rgba(0, 180, 216, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Modal actions */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Section title */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Card grid */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card .card-grid {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Service list */
|
||||
.service-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.service-list li {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-list .svc-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Logs area */
|
||||
.logs-area {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 1rem;
|
||||
background: #0d0d1a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.log-line.error { color: var(--danger); }
|
||||
.log-line.warn { color: var(--warning); }
|
||||
.log-line.info { color: var(--text); }
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.refresh-active::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
margin-right: 6px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--danger);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Vacuum Wall{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-card-hover: #0f3460d0;
|
||||
--accent: #00b4d8;
|
||||
--accent-hover: #0096c7;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--danger: #e63946;
|
||||
--danger-hover: #c62828;
|
||||
--success: #2ecc71;
|
||||
--warning: #f1c40f;
|
||||
--border: #1a1a3e;
|
||||
--input-bg: #0d1b2a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-header span {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: all 0.15s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover {
|
||||
color: var(--text);
|
||||
background: rgba(0, 180, 216, 0.05);
|
||||
}
|
||||
|
||||
.sidebar nav a.active {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
min-height: 100vh;
|
||||
width: calc(100vw - 220px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(0, 180, 216, 0.1);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(0, 180, 216, 0.03);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
|
||||
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
|
||||
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
|
||||
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
|
||||
|
||||
/* Status indicator */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-up { background: var(--success); }
|
||||
.status-down { background: var(--danger); }
|
||||
.status-pending { background: var(--warning); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
|
||||
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
|
||||
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text); }
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Scrollable log */
|
||||
.log-viewer {
|
||||
background: #0a0a14;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--border);
|
||||
border-radius: 22px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch .slider:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--text);
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Flex utils */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-right { text-align: right; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
/* Service status list */
|
||||
.service-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-list li {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.service-list .svc-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Inline form row */
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
margin-left: 0;
|
||||
width: 100vw;
|
||||
padding: 16px;
|
||||
}
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inline-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body hx-ext="json-enc">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
VACUUM WALL
|
||||
<span>Firewall Management</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
|
||||
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
|
||||
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
|
||||
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
|
||||
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
|
||||
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
|
||||
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
|
||||
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
|
||||
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
|
||||
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/json-enc.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,94 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Certificates - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Certificates</h1>
|
||||
<div class="subtitle">SSL/TLS certificate management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Issuer</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Days Left</th>
|
||||
<th style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cert-rows">
|
||||
{% for cert in (certs or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
||||
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
|
||||
<td>{{ cert.get('expiry', 'N/A') }}</td>
|
||||
<td>
|
||||
{% set days = cert.get('days_remaining') %}
|
||||
{% if cert.get('expired') or (days is not none and days <= 0) %}
|
||||
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
|
||||
{% elif days is not none and days <= 30 %}
|
||||
<span class="badge badge-warning">{{ days }} days</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">{{ days }} days</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/api/certs/{{ cert.get('domain', '') }}/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Renewal started for {{ cert.domain }}'); }">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (certs or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Issue Certificate Modal — Phase 1: Validate -->
|
||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
|
||||
<div class="modal" style="min-width:480px;">
|
||||
<h2>Issue New Certificate</h2>
|
||||
|
||||
<!-- Phase 1: Input + Pre-flight Checks -->
|
||||
<div id="cert-wizard-input">
|
||||
<div class="form-group">
|
||||
<label for="cert-domain">Domain</label>
|
||||
<input type="text" id="cert-domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cert-email">Contact Email</label>
|
||||
<input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
|
||||
</div>
|
||||
|
||||
<!-- Pre-flight validation results (shown after Check) -->
|
||||
<div id="cert-check-results" style="display:none;">
|
||||
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
|
||||
<div id="cert-checks-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
|
||||
<button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
|
||||
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Step progress -->
|
||||
<div id="cert-wizard-progress" style="display:none;">
|
||||
<div id="cert-steps-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,124 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="subtitle">System overview and status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Zones</div>
|
||||
<div class="value">{{ active_zones|default({})|length }}</div>
|
||||
<div class="meta">Firewalld zones configured</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Proxy Domains</div>
|
||||
<div class="value">{{ domains|default([])|length }}</div>
|
||||
<div class="meta">SSL-terminated backends</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Certificates</div>
|
||||
<div class="value">{{ certs|default([])|length }}</div>
|
||||
{% set expired = certs|selectattr('days_until_expiry','lt',0)|list|default([])|length %}
|
||||
{% set expiring = certs|rejectattr('days_until_expiry','lt',0)|selectattr('days_until_expiry','le',30)|list|default([])|length %}
|
||||
<div class="meta">
|
||||
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
|
||||
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
|
||||
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">WireGuard</div>
|
||||
<div class="value" style="font-size:20px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
|
||||
{{ 'UP' if (wg_status is defined and wg_status.get('up')) else 'DOWN' }}
|
||||
</div>
|
||||
<div class="meta">Tunnel state</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Leases</div>
|
||||
<div class="value">{{ dnsmasq.get('leases', [])|length }}</div>
|
||||
<div class="meta">DHCP clients connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Services</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for svc_name, svc in (services or {}).items() %}
|
||||
<div class="stat-card">
|
||||
<div class="label">{{ svc_name }}</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
|
||||
{{ 'Running' if svc.get('running') else 'Stopped' }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
|
||||
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (services or {}) %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Firewalld</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Dnsmasq</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Nginx</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">wg0</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
|
||||
{{ 'Up' if (wg_status is defined and wg_status.get('up')) else 'Down' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set warnings = [] %}
|
||||
{% if certs is defined %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('days_until_expiry') is not none and cert.days_until_expiry < 0 %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
|
||||
{% elif cert.get('days_until_expiry') is not none and cert.days_until_expiry <= 30 %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_until_expiry|string + " days") %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if warnings|length > 0 or (services is defined) %}
|
||||
<div class="section-title">Warnings & Activity</div>
|
||||
|
||||
<div class="card">
|
||||
{% if warnings|length > 0 %}
|
||||
<ul class="service-list">
|
||||
{% for w in warnings %}
|
||||
<li>
|
||||
<span class="status-dot status-pending"></span>
|
||||
<span class="svc-name">{{ w }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if not warnings and not (services or {}) %}
|
||||
<div class="text-muted text-sm">No warnings</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,214 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>DHCP & DNS</h1>
|
||||
<div class="subtitle">Dnsmasq configuration and lease management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DHCP Ranges -->
|
||||
<div class="section-title">DHCP Ranges</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/ranges" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="range-interface">Interface</label>
|
||||
<select id="range-interface" name="interface">
|
||||
<option value="">— Global —</option>
|
||||
{% for iface in (interfaces or []) %}
|
||||
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-start">Start IP</label>
|
||||
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-end">End IP</label>
|
||||
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-lease">Lease Time</label>
|
||||
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Range</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Lease Time</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="range-rows">
|
||||
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rng.get('interface', '(global)') }}</td>
|
||||
<td>{{ rng.get('start', '') }}</td>
|
||||
<td>{{ rng.get('end', '') }}</td>
|
||||
<td>{{ rng.get('lease_time', '1h') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Static Leases -->
|
||||
<div class="section-title">Static Leases</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/static-lease" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="lease-mac">MAC Address</label>
|
||||
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-ip">IP Address</label>
|
||||
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-host">Hostname</label>
|
||||
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Lease</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lease-rows">
|
||||
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('mac', '') }}</td>
|
||||
<td>{{ lease.get('ip', '') }}</td>
|
||||
<td>{{ lease.get('hostname', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom DNS Records -->
|
||||
<div class="section-title">Custom DNS Records</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/dns-record" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="dns-ip">IP Address</label>
|
||||
<input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dns-hostname">Hostname / Domain</label>
|
||||
<input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Record</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-rows">
|
||||
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
|
||||
<td class="text-sm">{{ rec.get('address', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current DHCP Leases -->
|
||||
<div class="section-title">Current DHCP Leases</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expires</th>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>Client ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in (leases or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('expires', 'N/A') }}</td>
|
||||
<td>{{ lease.get('mac', 'N/A') }}</td>
|
||||
<td>{{ lease.get('ip', 'N/A') }}</td>
|
||||
<td>{{ lease.get('hostname', '*') or '*' }}</td>
|
||||
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (leases or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-2 text-right">
|
||||
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Dnsmasq configuration reloaded'); }">Apply & Restart Dnsmasq</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,61 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interfaces - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Interfaces</h1>
|
||||
<div class="subtitle">Network interface to zone bindings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>MAC Address</th>
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
<th>Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interface-list">
|
||||
{% for iface in (interfaces or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ iface.get('display_name', iface.get('name', 'unknown')) }}</strong></td>
|
||||
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
||||
<td>
|
||||
{% for ip in iface.get('ips', []) %}
|
||||
{{ ip }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% if not iface.get('ips') %}N/A{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if iface.get('state') == 'UP' else 'status-down' }}"></span>
|
||||
{{ 'Up' if iface.get('state') == 'UP' else 'Down' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if zones %}
|
||||
<select
|
||||
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.display_name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
|
||||
>
|
||||
{% for zname in zones %}
|
||||
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<span class="text-muted">No zones configured</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (interfaces or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,151 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>System Logs</h1>
|
||||
<div class="subtitle">Service logs and journal output</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted">Auto-refresh</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
|
||||
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
|
||||
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
||||
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
||||
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-journal" class="tab-content active">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-journal"
|
||||
hx-get="/api/logs/journal"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading journal entries...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-access" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-access"
|
||||
hx-get="/api/logs/nginx/access"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading Nginx access log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-error" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-error"
|
||||
hx-get="/api/logs/nginx/error"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading Nginx error log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-dnsmasq" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-dnsmasq"
|
||||
hx-get="/api/logs/dnsmasq"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading dnsmasq log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-app" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-app"
|
||||
hx-get="/api/logs/app"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading app log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var refreshInterval = {{ (refresh_interval | default(15)) }};
|
||||
var currentTab = 'journal';
|
||||
|
||||
function loadTabEl(el) {
|
||||
var url = el.getAttribute('hx-get');
|
||||
if (!url) return;
|
||||
el.textContent = 'Loading...';
|
||||
fetch(url).then(function(r) { return r.text(); })
|
||||
.then(function(html) { el.innerHTML = html; })
|
||||
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
|
||||
}
|
||||
|
||||
function setActivePolling() {
|
||||
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||
if (typeof htmx !== 'undefined') htmx.abort(el);
|
||||
el.setAttribute('hx-trigger', 'none');
|
||||
});
|
||||
var activeEl = document.getElementById('log-' + currentTab);
|
||||
if (activeEl) {
|
||||
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
|
||||
}
|
||||
if (typeof htmx !== 'undefined') htmx.process(document.body);
|
||||
}
|
||||
|
||||
function loadActiveTab() {
|
||||
var activeEl = document.getElementById('log-' + currentTab);
|
||||
if (activeEl) {
|
||||
loadTabEl(activeEl);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var toggle = document.getElementById('auto-refresh-toggle');
|
||||
if (toggle.checked) {
|
||||
setActivePolling();
|
||||
loadActiveTab();
|
||||
} else {
|
||||
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||
if (typeof htmx !== 'undefined') htmx.abort(el);
|
||||
el.setAttribute('hx-trigger', 'none');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadActiveTab();
|
||||
});
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
|
||||
switchTab = function(tabName) {
|
||||
currentTab = tabName;
|
||||
if (origSwitchTab) {
|
||||
origSwitchTab(tabName);
|
||||
}
|
||||
if (document.getElementById('auto-refresh-toggle').checked) {
|
||||
setActivePolling();
|
||||
}
|
||||
loadActiveTab();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,131 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAT - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>NAT & Port Forwarding</h1>
|
||||
<div class="subtitle">Masquerading and destination NAT rules</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Masquerade (Source NAT)</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th style="width:120px;">Masquerade</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for zone in (zones or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
||||
<td>
|
||||
<form hx-post="/api/firewall/masquerade" hx-encoding="json" hx-vals='{"zone": "{{ zone.get('name', '') }}", "enable": JSON.stringify(this.checked)}' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Masquerade '+(this.checked?'enabled':'disabled')+' for {{ zone.get('name', '') }}') } else { this.checked=!this.checked; }">
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
{% if zone.get('masquerade') %}checked{% endif %}
|
||||
id="masq-{{ zone.get('name', '') }}"
|
||||
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
|
||||
disabled>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<button type="submit" style="display:none"></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted text-sm">No zones configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Port Forwarding (DNAT)</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Forward Rule</h3>
|
||||
<form hx-post="/api/firewall/forward-port" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="fw-zone">Zone</label>
|
||||
<select id="fw-zone" name="zone" required>
|
||||
<option value="">— Select —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-protocol">Protocol</label>
|
||||
<select id="fw-protocol" name="proto">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-port">Port</label>
|
||||
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target">Target Address</label>
|
||||
<input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target-port">Target Port</label>
|
||||
<input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Proto</th>
|
||||
<th>Port</th>
|
||||
<th>Target</th>
|
||||
<th>Tgt Port</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="forward-rows">
|
||||
{% set all_forwards = [] %}
|
||||
{% for zone in (zones or []) %}
|
||||
{% for fwd in zone.get('forward_ports', []) %}
|
||||
{% set _ = all_forwards.append({'zone': zone.get('name'), 'proto': fwd.get('proto'), 'port': fwd.get('port'), 'toaddr': fwd.get('toaddr'), 'toport': fwd.get('toport')}) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for fwd in all_forwards %}
|
||||
<tr>
|
||||
<td><strong>{{ fwd.zone }}</strong></td>
|
||||
<td><span class="badge badge-info">{{ fwd.proto }}</span></td>
|
||||
<td>{{ fwd.port }}</td>
|
||||
<td>{{ fwd.toaddr }}</td>
|
||||
<td>{{ fwd.toport }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd.proto }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Rule removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd.proto }} → {{ fwd.toaddr }}:{{ fwd.toport }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not all_forwards %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,151 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Proxy - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>SSL Proxy Domains</h1>
|
||||
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
||||
<button class="btn btn-outline" hx-post="/api/proxy/ssl-apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('SSL settings applied')">Apply SSL Settings</button>
|
||||
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Backend Host</th>
|
||||
<th>Backend Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Certificate</th>
|
||||
<th style="width:140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-rows">
|
||||
{% for domain in (domains or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
||||
<td>{{ domain.get('backend_host', '-') }}</td>
|
||||
<td>{{ domain.get('backend_port', '-') }}</td>
|
||||
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
|
||||
<td>
|
||||
{% set matched_cert = None %}
|
||||
{% if certs %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('domain') == domain.get('domain') %}
|
||||
{% set matched_cert = cert %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if matched_cert %}
|
||||
{% if matched_cert.get('expired') %}
|
||||
<span class="badge badge-danger">Expired</span>
|
||||
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
|
||||
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">Valid</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-danger">No cert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (domains or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Modal -->
|
||||
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add Proxy Domain</h2>
|
||||
<form hx-post="/api/proxy/domains" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }">
|
||||
<div class="form-group">
|
||||
<label for="new-domain">Domain</label>
|
||||
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-host">Backend Host</label>
|
||||
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-port">Backend Port</label>
|
||||
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-protocol">Backend Protocol</label>
|
||||
<select id="new-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Domain</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Domain Modal -->
|
||||
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Edit Proxy Domain</h2>
|
||||
<form id="edit-domain-form" hx-post="/api/proxy/domains" hx-swap="none" hx-encoding="json" hx-on::after-request="if(evt.detail.successful){ closeModal('edit-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain updated'); }">
|
||||
<input type="hidden" id="edit-original-domain" name="original_domain">
|
||||
<div class="form-group">
|
||||
<label for="edit-domain">Domain</label>
|
||||
<input type="text" id="edit-domain" name="domain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-host">Backend Host</label>
|
||||
<input type="text" id="edit-backend-host" name="backend_host" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-port">Backend Port</label>
|
||||
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-protocol">Backend Protocol</label>
|
||||
<select id="edit-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEditDomainModal(domainName, d) {
|
||||
document.getElementById('edit-original-domain').value = d.domain;
|
||||
document.getElementById('edit-domain').value = d.domain;
|
||||
document.getElementById('edit-backend-host').value = d.backend_host || '';
|
||||
document.getElementById('edit-backend-port').value = d.backend_port || '';
|
||||
document.getElementById('edit-protocol').value = d.protocol || 'http';
|
||||
openModal('edit-domain-modal');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,83 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rules - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Rich Rules</h1>
|
||||
<div class="subtitle">Firewalld rich firewall rules per zone</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Rule</h3>
|
||||
<form hx-post="/api/firewall/rich-rules" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="rule-zone">Zone</label>
|
||||
<select id="rule-zone" name="zone" required>
|
||||
<option value="">— Select zone —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone }}">{{ zone }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule-text">Rule Expression</label>
|
||||
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-muted text-sm mt-2">
|
||||
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rules-container">
|
||||
{% if rules or False %}
|
||||
{% for zone_name, zone_rules in rules.items() %}
|
||||
<div class="card">
|
||||
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
|
||||
{% if zone_rules %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Rule</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in zone_rules %}
|
||||
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ loop.index }}</td>
|
||||
<td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not (zones or []) %}
|
||||
<div class="card" style="border-color:var(--warning);">
|
||||
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,123 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}WireGuard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>WireGuard</h1>
|
||||
<div class="subtitle">VPN tunnel management</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/down"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
|
||||
Stop Tunnel
|
||||
</button>
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/apply"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
|
||||
Start Tunnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tunnel Status -->
|
||||
<div class="card mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3>
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
{% if config %}
|
||||
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> |
|
||||
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> |
|
||||
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Peer Form -->
|
||||
<div class="section-title">Peers</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Peer</h3>
|
||||
<form hx-post="/api/wireguard/peers" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer added'); }">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="peer-name">Name</label>
|
||||
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-pubkey">Public Key</label>
|
||||
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-allowed">Allowed IPs</label>
|
||||
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers or []|length + 2) %}{{ next }}/32" required style="width:160px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Peer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Peers Table -->
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Allowed IPs</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Latest Handshake</th>
|
||||
<th>Transfer</th>
|
||||
<th style="width:160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="peer-rows">
|
||||
{% for peer in (peers or []) %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
|
||||
<strong>{{ peer.get('name', 'unnamed') }}</strong>
|
||||
</td>
|
||||
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
|
||||
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
|
||||
<td class="text-sm">
|
||||
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
|
||||
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (peers or []) %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadPeerConfig(peerName) {
|
||||
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,90 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zones - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Zones</h1>
|
||||
<div class="subtitle">Firewalld zone management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
||||
</div>
|
||||
|
||||
<div id="zone-grid" class="card-grid">
|
||||
{% for zone in (zones or []) %}
|
||||
<div class="card" style="position:relative;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
|
||||
<div class="text-muted text-sm" style="margin-bottom:10px;">
|
||||
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
|
||||
{% if zone.get('interfaces') %}
|
||||
{% for iface in zone.interfaces %}
|
||||
<span class="badge badge-info">{{ iface }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Services</div>
|
||||
{% if zone.get('services') %}
|
||||
{% for svc in zone.services %}
|
||||
<span class="badge badge-success">{{ svc }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
|
||||
<form hx-delete="/api/firewall/zones/{{ zone.get('name', '') }}" hx-swap="none" hx-confirm="Delete zone {{ zone.name }}? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone deleted'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Create Zone Modal -->
|
||||
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
|
||||
<div class="modal">
|
||||
<h2>Create Zone</h2>
|
||||
<form hx-post="/api/firewall/zones" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }">
|
||||
<div class="form-group">
|
||||
<label for="zone-name">Zone Name</label>
|
||||
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-target">Target</label>
|
||||
<select id="zone-target" name="target">
|
||||
<option value="default">default</option>
|
||||
<option value="%%REJECT%%">%REJECT%</option>
|
||||
<option value="%%DROP%%">%DROP%</option>
|
||||
<option value="%%ACCEPT%%">%ACCEPT%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-services">Default Services (comma-separated)</label>
|
||||
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user