Compare commits

...

10 Commits

Author SHA1 Message Date
mteehan 5ac69dfa7e refactor: move JS vendors to vendor/ with versioned filenames
Vendor JS files to vendor/ with versioned names, symlinks in
webui/static/ select active version. Update update-vendor.sh and
AGENTS.md.
2026-05-25 01:20:37 +00:00
mteehan f81be96e59 add: track shared modules, vendor scripts, and update gitignore
- lib/common.py: shared run/load_json/save_json utilities
- webui/api/common.py: _ok/_error response helpers
- webui/static/htmx.min.js, json-enc.js: vendored frontend deps
- scripts/update-vendor.sh: frontend vendor updater
- vendor/acme.sh: bundled ACME client
- .gitignore: add .playwright-mcp/ and opencode.json.pwenv
2026-05-25 01:13:34 +00:00
mteehan ecb5a9a44d fix: escape colon in sudoers chown argument
The colon in root:root was interpreted as a sudoers runas
separator, causing a syntax error.
2026-05-25 01:03:30 +00:00
mteehan d1ab717c0f refactor: unify project structure, improve security, and enhance deployment
- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
2026-05-25 00:53:32 +00:00
mteehan 8829ac579d fix: strip private_key from WireGuard PATCH endpoint and add acme-challenge template 2026-05-24 13:12:40 +00:00
mteehan cf8115bb0d refactor(lib): fix acme.renew deploy call and hoist wireguard imports
- Add deploy(domain) to acme.renew() to register deploy hook after
  renewal, matching the pattern in issue()
- Hoist run and run_proc imports to module level in wireguard,
  removing 4 inline imports for consistency
2026-05-23 03:57:54 +00:00
mteehan 37039351be fix htmx refactor route mismatches and remaining TODO items
- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
2026-05-17 01:17:12 +00:00
mteehan 0e7090a2cb Update .gitignore, AGENTS.md, README.md for config/ split and deployment model 2026-05-14 03:32:06 +00:00
mteehan dcb581a359 Update documentation for config/ migration, new install model, and firewall config API
Reflect config/ vs data/ split, install-time Jinja2 templating, auto-discovered
paths, ZeroSSL/ACME support, and the new firewall save-then-apply API in all
reference docs.
2026-05-14 03:31:59 +00:00
mteehan 6106c1434d Add declarative firewall config with save-then-apply workflow
New two-step config flow: POST /config saves desired state to
config/firewall/config.json, GET /config/pending diffs against live
firewalld state, POST /config/apply synchronizes live state.  Adds target
normalization helpers and full test coverage for config CRUD and pending
diff logic.
2026-05-14 03:31:49 +00:00
52 changed files with 11853 additions and 1550 deletions
+8 -3
View File
@@ -14,7 +14,12 @@ __pycache__/
# Local AI tool config (contains internal hostnames) # Local AI tool config (contains internal hostnames)
opencode.json opencode.json
opencode.json.pwenv
# Runtime data configs (source-of-truth for services) # Playwright MCP artifacts
data/dnsmasq/config.json .playwright-mcp/
data/nginx/sites-enabled/
# Runtime artifacts
build/
config/
data/
+31 -9
View File
@@ -3,7 +3,7 @@
## What This Is ## What This Is
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy. SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
Deploys on Debian 13 (trixie). Target system: `/home/wall/vacuum-wall`. Deploys on Debian 13 (trixie). Serves from repo root by default.
## Architecture ## Architecture
@@ -14,15 +14,28 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
- `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`.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. - `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
- `lib/*.py` — Backend modules. Wrap system commands via `subprocess.run(["sudo", ...])`. - `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `data/` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. - `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. All have full type hints and `__all__` exports.
- `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/`. - `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/`.
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 `sys.path` boilerplate needed.
## Fixed Path **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.
Every module hardcodes `/home/wall/vacuum-wall`. Changing it requires updating `lib/*.py`, `system/systemd/*.service`, `install.sh`, and `system/sudoers.d/vacuum-wall`. | 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` |
## 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. The `vacuum-wall` system user has `HOME=$INSTALL_DIR` but no actual home directory (`--no-create-home`).
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`.
## Local Dev ## Local Dev
@@ -46,13 +59,16 @@ In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPriv
`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`. `lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`.
**acme.sh must never run as root** — always as the service user via `sudo -u`.
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply. Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
Adding a new privileged command requires a sudoers entry **and** the `lib/` code. Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
## API Response Contract ## API Response Contract
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` - Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common`
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` - 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 - HTTP codes: `400` bad request, `404` not found, `500` internal failure
- Full spec: `docs/api.md` - Full spec: `docs/api.md`
@@ -73,11 +89,17 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
```bash ```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint .venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format .venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (154 tests) .venv/bin/python -m pytest tests/ -v # test (192 tests)
``` ```
Install dev tooling with `pip install -e ".[dev]"`. Install dev tooling with `pip install -e ".[dev]"`.
## Docs ## Docs
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, and data directory layout in detail. `docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, data directory layout, and shared utility patterns in detail.
## 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.
+30 -12
View File
@@ -7,7 +7,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
- Debian 13 (trixie) target platform - Debian 13 (trixie) target platform
- Python 3.13+, Flask 3.x web UI - Python 3.13+, Flask 3.x web UI
- firewalld (nftables backend), dnsmasq, nginx, WireGuard - firewalld (nftables backend), dnsmasq, nginx, WireGuard
- acme.sh for Let's Encrypt - acme.sh for ACME certificates (ZeroSSL)
- HTMX + Jinja2 templates - HTMX + Jinja2 templates
--- ---
@@ -22,7 +22,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
- A DNS A record pointing to the appliance's public IP for the management domain - A DNS A record pointing to the appliance's public IP for the management domain
- Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk - Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk
### Install ### Install (Production)
```bash ```bash
MGMT_DOMAIN=wall.example.com \ MGMT_DOMAIN=wall.example.com \
@@ -32,12 +32,28 @@ ACME_EMAIL="admin@example.com" \
bash install.sh bash install.sh
``` ```
| Variable | Required | Description | ### Install (Development)
|---|---|---|
| `MGMT_DOMAIN` | Yes | Public domain for the management WebUI | ```bash
| `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI | ./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
| `MGMT_USER` | No | WebUI username (defaults to `admin`) | ```
| `ACME_EMAIL` | Yes | Let's Encrypt registration email |
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
| Flag | Env Var | Required | Description |
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
| `--mgmt-user` | `MGMT_USER` | No | WebUI username (defaults to `admin`) |
| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) |
| `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) |
| `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) |
| `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (alias for env var) |
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating. After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
@@ -45,7 +61,7 @@ After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial cer
1. **Assign interfaces** to zones from the Interfaces tab 1. **Assign interfaces** to zones from the Interfaces tab
2. **Configure DHCP** ranges for your LAN 2. **Configure DHCP** ranges for your LAN
3. **Add proxy domains** with Let's Encrypt certificates 3. **Add proxy domains** with ACME certificates
4. **Set up WireGuard** (optional) 4. **Set up WireGuard** (optional)
See [docs/deployment.md](docs/deployment.md) for the full guide, including troubleshooting. See [docs/deployment.md](docs/deployment.md) for the full guide, including troubleshooting.
@@ -75,13 +91,15 @@ Start the WebUI locally (binds to 127.0.0.1:9090):
.venv/bin/ruff format lib/ webui/ tests/ .venv/bin/ruff format lib/ webui/ tests/
``` ```
All `lib/` modules share `lib.common` utilities (`run`, `run_proc`, `load_json`, `save_json`, `deep_merge`, `ensure_dirs`) and have full type hints and `__all__` exports. API blueprints share `_ok`/`_error` from `webui.api.common`.
### Tests ### Tests
```bash ```bash
.venv/bin/python -m pytest tests/ -v .venv/bin/python -m pytest tests/ -v
``` ```
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 192 tests across 5 test modules.
### Documentation MCP Server ### Documentation MCP Server
@@ -109,7 +127,7 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
| `webui/api/certs` | `/api/certs/` | `lib.acme` | | `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` | | `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
See [docs/architecture.md](docs/architecture.md) for detailed request flow and zone model. See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone model, and shared utility patterns.
--- ---
@@ -117,7 +135,7 @@ See [docs/architecture.md](docs/architecture.md) for detailed request flow and z
- [Overview](docs/overview.md) — Feature summary and tech stack - [Overview](docs/overview.md) — Feature summary and tech stack
- [Deployment Guide](docs/deployment.md) — Full installation and post-install configuration - [Deployment Guide](docs/deployment.md) — Full installation and post-install configuration
- [Architecture](docs/architecture.md) — Request flow, subsystems, zone model - [Architecture](docs/architecture.md) — Request flow, subsystems, zone model, shared utilities
- [API Reference](docs/api.md) — REST API endpoints - [API Reference](docs/api.md) — REST API endpoints
- [Security Model](docs/security.md) — Privilege model and sudo whitelist - [Security Model](docs/security.md) — Privilege model and sudo whitelist
- [Configuration](docs/config.md) — Declarative config file formats and locations - [Configuration](docs/config.md) — Declarative config file formats and locations
+303 -205
View File
@@ -1,6 +1,6 @@
# REST API Reference # REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://wall.lan/api/...`). All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://<hostname>.local/api/...`).
Every request and response uses `Content-Type: application/json`. Every request and response uses `Content-Type: application/json`.
@@ -34,12 +34,86 @@ Error responses carry one of the following HTTP status codes:
| `404` | Not found — the requested resource does not exist | | `404` | Not found — the requested resource does not exist |
| `500` | Internal server error — unexpected failure in the backend | | `500` | Internal server error — unexpected failure in the backend |
### Route Patterns
Resource identification uses **path parameters** whenever possible. Exceptions occur only when the identifier is inherently long (e.g., a rich rule string), in which case the body carries the identifier.
--- ---
## Firewall API ## Firewall API
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade. Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
### Declarative Config
The firewall supports a two-step declarative workflow: save config to `config/firewall/config.json`, then apply it to live firewalld. The config tracks `rich_rules` and `forward_ports` with auto-generated `id` fields.
#### Get Config
```
GET /api/firewall/config
```
Return the current declarative firewall config.
**Response:** `data` contains the config object with a `zones` mapping.
#### Save Config
```
POST /api/firewall/config
```
Replace the declarative config. Returns pending changes summary.
**Request Body:** Request body must contain `zones`.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config_saved` | `boolean` | Always `true` |
| `pending` | `[object, ...]` | List of pending changes |
| `needs_apply` | `boolean` | Whether changes need to be applied |
| `unmanaged_zones` | `object` | Zones active on system but not in config |
#### Apply Config
```
POST /api/firewall/config/apply
```
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**Response:** `data` contains `applied_zones` list and backup path.
#### Check Pending Changes
```
GET /api/firewall/config/pending
```
Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
**Response:** Same structure as POST /config response.
#### Partial Update Config
```
PATCH /api/firewall/config
```
Deep-merge the provided fields into the existing config. Returns pending changes summary.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config_saved` | `boolean` | Always `true` |
| `pending` | `[object, ...]` | List of pending changes |
| `needs_apply` | `boolean` | Whether changes need to be applied |
| `unmanaged_zones` | `object` | Zones active on system but not in config |
### Zone Management ### Zone Management
#### List All Zones #### List All Zones
@@ -76,8 +150,8 @@ Return detailed configuration for a single zone.
| `services` | `[string, ...]` | Services allowed through the zone | | `services` | `[string, ...]` | Services allowed through the zone |
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) | | `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled | | `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules | | `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
| `rich_rules` | `[string, ...]` | Rich rule definitions | | `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
Returns HTTP `404` if the zone does not exist. Returns HTTP `404` if the zone does not exist.
@@ -135,7 +209,7 @@ Replace all interfaces assigned to the zone with the provided list.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned to the zone | | `interfaces` | `[string, ...]` | List of interface names now assigned |
--- ---
@@ -158,9 +232,9 @@ Replace all services allowed in the zone with the provided list.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `services` | `[string, ...]` | List of services now allowed in the zone | | `services` | `[string, ...]` | List of services now allowed |
### Firewall Rules ### Rich Rules
#### Add Rich Rule #### Add Rich Rule
@@ -168,7 +242,7 @@ Replace all services allowed in the zone with the provided list.
POST /api/firewall/rich-rules POST /api/firewall/rich-rules
``` ```
Add a firewalld rich rule to a zone. Add a firewalld rich rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
**Request Body:** **Request Body:**
@@ -182,6 +256,7 @@ Add a firewalld rich rule to a zone.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `id` | `string` | 8-character unique ID |
| `rule` | `string` | Full rich rule string | | `rule` | `string` | Full rich rule string |
--- ---
@@ -189,24 +264,19 @@ Add a firewalld rich rule to a zone.
#### Remove Rich Rule #### Remove Rich Rule
``` ```
DELETE /api/firewall/rich-rules DELETE /api/firewall/rich-rules/<zone>/<id>
``` ```
Remove an existing rich rule from a zone. The `rule` string must match exactly. Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `rule` | `string` | Yes | Exact rich rule string to remove |
**Response (`data`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `rule` | `string` | Exact rich rule string that was removed | | `id` | `string` | ID of the removed rule |
Returns HTTP `404` if the rule ID is not found.
--- ---
@@ -216,15 +286,64 @@ Remove an existing rich rule from a zone. The `rule` string must match exactly.
GET /api/firewall/rich-rules/<zone> GET /api/firewall/rich-rules/<zone>
``` ```
Return all rich rules for the specified zone. Return all rich rules for the specified zone, each with an `id` and `rule` string.
**Response:** **Response:**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data` | `[string, ...]` | Rich rule strings | | `data` | `[{id, rule}, ...]` | Rich rules with IDs |
### NAT ### Port Forwarding
#### Add Port Forward
```
POST /api/firewall/forward-port
```
Add a port forwarding rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `id` | `string` | 8-character unique ID |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
---
#### Remove Port Forward
```
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
```
Remove a port forwarding rule. Zone, port, and protocol are all path parameters.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
Returns HTTP `404` if the forward port is not found.
### Masquerade (NAT)
#### Enable / Disable Masquerade #### Enable / Disable Masquerade
@@ -246,63 +365,7 @@ Toggle masquerade (source NAT) for a zone.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled for the zone | | `masquerade` | `boolean` | Whether masquerade is now enabled |
---
#### Add Port Forward
```
POST /api/firewall/forward-port
```
Add a port forwarding rule to a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
---
#### Remove Port Forward
```
DELETE /api/firewall/forward-port
```
Remove a port forwarding rule. The body must match the original rule exactly.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
### Info ### Info
@@ -406,24 +469,74 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
### Leases ### Status
#### Get Live Leases #### Get Service Status
``` ```
GET /api/dhcp/leases GET /api/dhcp/status
``` ```
Return the current DHCP lease table from dnsmasq. Return the current service status, config summary, and active lease count.
**Response:** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects | | `service_active` | `boolean` | Whether dnsmasq is running |
| `config_file_exists` | `boolean` | Whether config file exists on disk |
| `config_in_sync` | `boolean` | Whether disk config matches expected |
| `dhcp_ranges` | `number` | Number of DHCP ranges |
| `static_leases` | `number` | Number of static leases |
| `custom_dns_records` | `number` | Number of custom DNS records |
| `upstreams` | `[string, ...]` | Upstream DNS servers |
| `domain` | `string` | Local DNS domain |
| `active_leases` | `number` | Number of active leases |
| `leases` | `[object, ...]` | Active lease objects |
### DHCP Ranges
#### Add Range
```
POST /api/dhcp/ranges
```
Add or replace the DHCP range for a given interface.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | No | Interface name (empty = all interfaces) |
| `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range |
| `lease_time` | `string` | No | Lease duration; defaults to `"12h"` |
**Response:** `data` is `null` on success.
--- ---
#### Remove Range
```
DELETE /api/dhcp/ranges
```
Remove a DHCP range. Body contains identifying fields.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | Yes | Interface name |
| `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range |
**Response:** `data` is `null` on success.
### Static Leases
#### Add Static Lease #### Add Static Lease
``` ```
@@ -446,28 +559,38 @@ Add a static (reserved) DHCP lease.
|-------|------|-------------| |-------|------|-------------|
| `mac` | `string` | MAC address | | `mac` | `string` | MAC address |
| `ip` | `string` | Reserved IP address | | `ip` | `string` | Reserved IP address |
| `hostname` | `string` | Hostname for the reservation | | `hostname` | `string` | Hostname |
--- ---
#### Remove Static Lease #### Remove Static Lease
``` ```
DELETE /api/dhcp/static-lease?mac=aa:bb:cc:dd:ee:ff DELETE /api/dhcp/static-lease/<mac>
``` ```
Remove a previously configured static lease. Remove a static lease by MAC address.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mac` | `string` | Yes | MAC address of the lease to remove |
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
Returns HTTP `404` if no matching lease is found. Returns HTTP `404` if no matching lease is found.
### Live Leases
#### Get Live Leases
```
GET /api/dhcp/leases
```
Return the current DHCP lease table from dnsmasq.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects |
### DNS Records ### DNS Records
#### Add DNS Record #### Add DNS Record
@@ -484,6 +607,7 @@ Add a custom DNS A record served by dnsmasq.
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name | | `name` | `string` | Yes | Fully qualified domain name |
| `address` | `string` | Yes | IP address to resolve to | | `address` | `string` | Yes | IP address to resolve to |
| `hostname` | `string` | No | Short hostname |
**Response (`data`):** **Response (`data`):**
@@ -498,16 +622,10 @@ Add a custom DNS A record served by dnsmasq.
#### Remove DNS Record #### Remove DNS Record
``` ```
DELETE /api/dhcp/dns-record?name=nas.lan DELETE /api/dhcp/dns-record/<name>
``` ```
Remove a custom DNS record. Remove a custom DNS record by domain name.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name to remove |
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
@@ -553,6 +671,8 @@ Add a new reverse proxy domain.
| `backend_host` | `string` | Yes | Backend server IP or hostname | | `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port | | `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` | | `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
| `cert` | `string` | No | Certificate domain |
| `extra_headers` | `object` | No | Extra proxy headers |
**Response (`data`):** **Response (`data`):**
@@ -572,14 +692,7 @@ GET /api/proxy/domains/<domain>
Return the configuration for a single proxy domain. Return the configuration for a single proxy domain.
**Response (`data`):** **Response (`data`):** Domain name plus backend configuration fields.
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
| `backend_host` | `string` | Backend server address |
| `backend_port` | `number` | Backend server port |
| `backend_proto` | `string` | Backend protocol |
Returns HTTP `404` if the domain is not configured. Returns HTTP `404` if the domain is not configured.
@@ -591,15 +704,9 @@ Returns HTTP `404` if the domain is not configured.
PUT /api/proxy/domains/<domain> PUT /api/proxy/domains/<domain>
``` ```
Update one or more fields of an existing domain entry. Only the fields present in the body are modified. Update one or more fields of an existing domain entry. Only fields present in the body are modified.
**Request Body:** **Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `backend_host` | `string` | No | Backend server IP or hostname |
| `backend_port` | `number` | No | Backend server port |
| `backend_proto` | `string` | No | Backend protocol |
**Response (`data`):** **Response (`data`):**
@@ -649,15 +756,17 @@ Returns HTTP `500` if nginx config generation fails or the reload fails.
POST /api/proxy/test POST /api/proxy/test
``` ```
Run `nginx -t` against the generated configuration without reloading. Useful for validating changes before applying. Run `nginx -t` against the generated configuration without reloading.
**Response:** **Response (valid):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data.valid` | `boolean` | Whether the configuration syntax is valid | | `data.valid` | `boolean` | Always `true` |
| `data.output` | `string` | Raw nginx test output | | `data.output` | `string` | Raw nginx test output |
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
### Management ### Management
#### Configure Management WebUI Proxy #### Configure Management WebUI Proxy
@@ -672,21 +781,19 @@ Configure the nginx proxy block for the management WebUI itself, including optio
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `domain` | `string` | Yes | Management domain (e.g., `"wall.lan"`) | | `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` | | `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` | | `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
| `auth_user` | `string` | No | Username for basic auth. An `.htpasswd` entry is created when this field is present. | | `auth_user` | `string` | No | Username for basic auth |
| `auth_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. | | `auth_pass` | `string` | No | Password for basic auth |
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
If `auth_user` and `auth_pass` are provided, the endpoint creates or updates the corresponding `.htpasswd` file entry.
--- ---
## Certificate API ## Certificate API
Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (Let's Encrypt / certbot). Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (ZeroSSL, Let's Encrypt, etc.).
### Listing & Details ### Listing & Details
@@ -704,15 +811,7 @@ Return all managed certificates with metadata.
|-------|------|-------------| |-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects | | `data` | `[object, ...]` | Array of certificate objects |
Each certificate object: Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain the certificate covers |
| `expires_at` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days until expiration |
| `cert_path` | `string` | Path to the certificate file |
| `key_path` | `string` | Path to the private key file |
--- ---
@@ -724,15 +823,7 @@ GET /api/certs/<domain>
Return details for a single certificate. Return details for a single certificate.
**Response (`data`):** **Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain |
| `expires_at` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days |
| `cert_path` | `string` | Certificate file path |
| `key_path` | `string` | Private key file path |
Returns HTTP `404` if no certificate is found for the domain. Returns HTTP `404` if no certificate is found for the domain.
@@ -755,7 +846,7 @@ Request a new certificate for a domain.
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
Returns HTTP `400` if the domain is missing or the request is malformed. Returns HTTP `500` if the ACME challenge or certificate issuance fails. Returns HTTP `400` if the domain is missing. Returns HTTP `500` if issuance fails.
--- ---
@@ -765,7 +856,7 @@ Returns HTTP `400` if the domain is missing or the request is malformed. Returns
POST /api/certs/<domain>/renew POST /api/certs/<domain>/renew
``` ```
Force-renew an existing certificate, regardless of its current expiry status. Force-renew an existing certificate.
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
@@ -793,7 +884,7 @@ Returns HTTP `404` if the certificate is not found.
POST /api/certs/email POST /api/certs/email
``` ```
Set or update the ACME account contact email (used by Let's Encrypt for expiration and security notices). Set or update the ACME account contact email.
**Request Body:** **Request Body:**
@@ -801,11 +892,7 @@ Set or update the ACME account contact email (used by Let's Encrypt for expirati
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `email` | `string` | Yes | Contact email address | | `email` | `string` | Yes | Contact email address |
**Response (`data`):** **Response (`data`):** Returns the set `email` field.
| Field | Type | Description |
|-------|------|-------------|
| `email` | `string` | Contact email address |
--- ---
@@ -821,13 +908,13 @@ Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, p
GET /api/wireguard/config GET /api/wireguard/config
``` ```
Return the current WireGuard server configuration. The `private_key` field is stripped from the response. Return the current WireGuard server configuration. The `private_key` field is stripped.
**Response:** **Response:**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data` | `object` | Full WireGuard configuration dictionary (`private_key` omitted) | | `data` | `object` | WireGuard config (`private_key` omitted) |
--- ---
@@ -845,11 +932,25 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|-------|------|----------|-------------| |-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object | | *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
**Response:** **Response:** `data` contains the updated configuration (`private_key` omitted).
| Field | Type | Description | ---
|-------|------|-------------|
| `data` | `object` | Updated configuration (`private_key` omitted) | #### Partial Update Configuration
```
PATCH /api/wireguard/config
```
Deep-merge the provided fields into the existing configuration.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(any subset)* | `any` | Yes | Fields to merge into the existing config |
**Response:** `data` is `null` on success.
### Tunnel Control ### Tunnel Control
@@ -859,11 +960,21 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
POST /api/wireguard/apply POST /api/wireguard/apply
``` ```
Write the current configuration to `wg0.conf` on disk and bring the WireGuard tunnel up. Write the current configuration to `wg0.conf` and bring the tunnel up.
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
Returns HTTP `500` if config write or interface bring-up fails. ---
#### Start Tunnel
```
POST /api/wireguard/up
```
Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
**Response:** `data` is `null` on success.
--- ---
@@ -885,15 +996,15 @@ Bring down the WireGuard tunnel interface (`wg0`).
GET /api/wireguard/status GET /api/wireguard/status
``` ```
Return live tunnel state, including interface metrics and per-peer connection statistics. Return live tunnel state with interface metrics and per-peer connection statistics.
**Response (`data`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `up` | `boolean` | Whether the tunnel interface is up | | `up` | `boolean` | Whether the tunnel interface is up |
| `interface` | `object` | Interface info (listen port, public key, etc.) | | `interface` | `object` | Interface info (listen port, public key) |
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) | | `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) |
--- ---
@@ -903,19 +1014,35 @@ Return live tunnel state, including interface metrics and per-peer connection st
POST /api/wireguard/initialize POST /api/wireguard/initialize
``` ```
Perform first-time setup: generate a server key pair, write an initial configuration, and prepare for peer enrollment. This endpoint is idempotent — calling it multiple times has no additional effect. First-time setup: generate server key pair, write initial config. Idempotent.
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
### Peer Management ### Peer Management
#### List Peers
```
GET /api/wireguard/peers
```
Return all configured peers. Private keys are stripped.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Peer objects (private keys omitted) |
---
#### Add Peer #### Add Peer
``` ```
POST /api/wireguard/add-peer POST /api/wireguard/peers
``` ```
Add a new WireGuard peer. A key pair is auto-generated for the peer. The response includes peer details with the private key stripped. Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response.
**Request Body:** **Request Body:**
@@ -924,33 +1051,20 @@ Add a new WireGuard peer. A key pair is auto-generated for the peer. The respons
| `name` | `string` | Yes | Peer identifier name | | `name` | `string` | Yes | Peer identifier name |
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) | | `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` | | `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
| `persistent_keepalive` | `number` | No | Persistent keepalive interval in seconds | | `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) |
| `preshared_key` | `string` | No | Preshared key |
**Response (`data`):** **Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
| `public_key` | `string` | Peer's public key |
| `allowed_ips` | `[string, ...]` | Allowed IPs |
| `endpoint` | `string` | Allowed endpoint |
| `persistent_keepalive` | `number` | Keepalive interval |
--- ---
#### Remove Peer #### Remove Peer
``` ```
DELETE /api/wireguard/remove-peer?name=alice DELETE /api/wireguard/peers/<name>
``` ```
Remove a configured peer. Remove a configured peer by name.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to remove |
**Response (`data`):** **Response (`data`):**
@@ -962,35 +1076,19 @@ Returns HTTP `404` if the peer is not found.
--- ---
#### List Peers
```
GET /api/wireguard/peers
```
Return all configured peers. Private keys are stripped from the response.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of peer objects (private keys omitted) |
---
#### Peer Connection Status #### Peer Connection Status
``` ```
GET /api/wireguard/peer-status GET /api/wireguard/peer-status
``` ```
Return live per-peer connection status from `wg show`, including last handshake time, transfer bytes, and current endpoint. Return live per-peer connection status from `wg show`.
**Response:** **Response:**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data` | `[object, ...]` | Array of live peer status objects | | `data` | `[object, ...]` | Live peer status (handshake time, bytes, endpoint) |
### Client Configuration ### Client Configuration
@@ -1000,21 +1098,21 @@ Return live per-peer connection status from `wg show`, including last handshake
POST /api/wireguard/generate-client POST /api/wireguard/generate-client
``` ```
Generate a complete WireGuard client configuration file for provisioning a device. The returned config includes the peer's private key for the client to use. Generate a complete WireGuard client configuration file. The returned config includes the peer's private key for provisioning.
**Request Body:** **Request Body:**
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to generate config for | | `name` | `string` | Yes | Peer name to generate config for |
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) for the client's `[Peer]` section | | `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) |
**Response (`data`):** **Response (`data`):**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `config` | `string` | Complete WireGuard client config text (`[Interface]` + `[Peer]` block) | | `config` | `string` | Complete client config text (`[Interface]` + `[Peer]`) |
The client config includes the generated private key so the client can be provisioned directly. Note that this is the only endpoint that returns a WireGuard private key — all other endpoints strip private keys from responses. This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
Returns HTTP `404` if the peer is not found. Returns HTTP `404` if the peer is not found.
+55 -27
View File
@@ -9,7 +9,7 @@ The following describes the path a request takes from an external client to a ba
1. An external client sends an HTTP request to `app.example.com`. 1. An external client sends an HTTP request to `app.example.com`.
2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS). 2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate. 3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `data/nginx/config.json`. 4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `config/nginx/config.json`.
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive. 5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
6. The backend service processes the request and returns an HTTP response. 6. The backend service processes the request and returns an HTTP response.
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response. 7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
@@ -17,7 +17,7 @@ The following describes the path a request takes from an external client to a ba
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs. For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
### Management WebUI Access (e.g., `wall.lan`) ### Management WebUI Access (e.g., `<hostname>.local`)
1. A client sends an HTTPS request to the management domain. 1. A client sends an HTTPS request to the management domain.
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file. 2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
@@ -36,42 +36,70 @@ External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt ACME Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
Flask WebUI ──→ lib/wireguard.py ──→ render /home/wall/vacuum-wall/data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 Flask WebUI ──→ lib/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
``` ```
Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged `vacuum-wall` user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges. Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `config/` and runtime artifacts from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged service user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
## Install-Time Templating
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
- **`sudoers.d/vacuum-wall`** — `{{ USER_NAME }}` is substituted to produce the sudoers whitelist.
- The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is.
Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data.
## State Management ## State Management
Vacuum Wall uses a declarative configuration model. The source of truth for each subsystem is a JSON file in the `data/` directory. The application renders these declarations into the format expected by the underlying system service. Vacuum Wall uses a declarative configuration model. Persistent user-facing configuration lives in `config/<subsystem>/config.json`. Runtime artifacts and generated files live in `data/<subsystem>/`. The application renders these declarations into the format expected by the underlying system service.
| Subsystem | Declarative Config | Rendered Target | State Persistence | | Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|---|---|---|---| |---|---|---|---|---|
| firewalld | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as a declarative backup and can be used to restore firewall rules. | | firewalld | N/A (firewalld manages own state) | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as an automated backup snapshot. |
| dnsmasq | `data/dnsmasq/config.json` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. | | 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 | `data/nginx/config.json` | `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. | | 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 | `data/wireguard/config.json` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is 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. |
| ACME | `~/.acme.sh/` (managed by acme.sh) | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. | | ACME | 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. |
## Data Directory Structure ## Directory Structure
### Config — Declarative Settings
Config files are persistent, user-editable JSON that defines the desired state for each subsystem:
``` ```
data/ config/
├── nginx/
│ ├── config.json # Proxy domain definitions, management domain, SSL settings
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/ ├── dnsmasq/
── config.json # DHCP ranges, static leases, DNS forwarding, custom records ── config.json # DHCP ranges, static leases, DNS forwarding, custom records
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim) ├── nginx/
├── firewall/ │ └── config.json # Proxy domain definitions, management domain, SSL settings
│ └── rules.json # Declarative firewall rule state backup
└── wireguard/ └── wireguard/
└── config.json # WireGuard interface and peer configuration └── config.json # WireGuard interface and peer configuration
``` ```
The `data/` directory resides within the `vacuum-wall` user's project directory (`/home/wall/vacuum-wall/data/`). The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to this directory, while keeping the rest of the filesystem read-only. ### Data — Runtime Artifacts
The `data/` directory holds generated files, credentials, and subsystem artifacts:
```
data/
├── nginx/
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
├── firewall/
│ └── rules.json # Auto-generated firewall rule state backup
├── acme/ # ACME certificate files (acme.sh home)
└── wireguard/ # WireGuard runtime artifacts
```
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.
## File System Layout ## File System Layout
@@ -81,9 +109,9 @@ The following file system locations are used for integration with system service
|---|---|---| |---|---|---|
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) | | `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) |
| `/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/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 `data/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.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 `data/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) | | `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the `vacuum-wall` user. Defines all permitted privilege escalations. | Install script (manual edits not required) | | `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the configured system 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. 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.
+11 -11
View File
@@ -1,10 +1,10 @@
# Configuration Reference # Configuration Reference
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All configuration is stored in the `data/` directory as declarative JSON. The application renders these declarations into the format expected by each underlying service. This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All persistent configuration is stored in the `config/` directory as declarative JSON. Runtime artifacts and generated files live in `data/`. The application renders these declarations into the format expected by each underlying service.
## DHCP/DNS Configuration ## DHCP/DNS Configuration
**File**: `data/dnsmasq/config.json` **File**: `config/dnsmasq/config.json`
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`. This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`.
@@ -74,7 +74,7 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
## Nginx Configuration ## Nginx Configuration
**File**: `data/nginx/config.json` **File**: `config/nginx/config.json`
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`. This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
@@ -99,7 +99,7 @@ This file defines reverse proxy domains, the management interface, and global SS
} }
}, },
"management": { "management": {
"domain": "wall.lan", "domain": "<hostname>.local",
"backend": { "backend": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 9090, "port": 9090,
@@ -107,7 +107,7 @@ This file defines reverse proxy domains, the management interface, and global SS
}, },
"auth": { "auth": {
"user": "admin", "user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" "htpasswd": "data/nginx/.htpasswd"
} }
}, },
"ssl": { "ssl": {
@@ -132,7 +132,7 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). | | `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. | | `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. |
| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. | | `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. |
| `cert.email` | string | Yes (if `acme`) | ACME account email used by Let's Encrypt. | | `cert.email` | string | Yes (if `acme`) | ACME account email used by the CA provider. |
| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). | | `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). | | `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). |
@@ -140,9 +140,9 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
| Type | Description | | Type | Description |
|---|---| |---|---|
| `acme` | Vacuum Wall uses acme.sh to request and renew a Let's Encrypt certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. | | `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
| `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. | | `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored alongside other acme-managed files in `~/.acme.sh/` with a `.selfsigned` marker. | | `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. |
### Management Domain ### Management Domain
@@ -150,7 +150,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `wall.lan`). | | `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. | | `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
| `auth` | object | Yes | HTTP Basic Authentication configuration. | | `auth` | object | Yes | HTTP Basic Authentication configuration. |
| `auth.user` | string | Yes | Username for the `.htpasswd` file. | | `auth.user` | string | Yes | Username for the `.htpasswd` file. |
@@ -159,7 +159,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
The `.htpasswd` file can be created with the `htpasswd` utility: The `.htpasswd` file can be created with the `htpasswd` utility:
```bash ```bash
htpasswd -bc /home/wall/vacuum-wall/data/nginx/.htpasswd admin yourpassword htpasswd -bc data/nginx/.htpasswd admin yourpassword
``` ```
### Global SSL Settings ### Global SSL Settings
@@ -174,7 +174,7 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th
## WireGuard Configuration ## WireGuard Configuration
**File**: `data/wireguard/config.json` **File**: `config/wireguard/config.json`
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
+81 -34
View File
@@ -1,4 +1,4 @@
# Vacuum Wall Deployment Guide # Deployment Guide
This guide walks through deploying Vacuum Wall on a real appliance or server. Vacuum Wall is an SSL proxy firewall appliance that combines edge proxying, firewall management, DHCP, DNS, and WireGuard in a single device. This guide walks through deploying Vacuum Wall on a real appliance or server. Vacuum Wall is an SSL proxy firewall appliance that combines edge proxying, firewall management, DHCP, DNS, and WireGuard in a single device.
@@ -19,24 +19,55 @@ This guide walks through deploying Vacuum Wall on a real appliance or server. Va
## Installation ## Installation
Download the Vacuum Wall repository onto the target machine, then run the installer with the required environment variables: Download the Vacuum Wall repository onto the target machine, then run the installer with required settings. All options accept both CLI flags and environment variables (CLI takes precedence).
```bash ```bash
# Production: all env vars
MGMT_DOMAIN=wall.example.com \ MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \ MGMT_PASS="strongpassword" \
MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \ ACME_EMAIL="admin@example.com" \
bash install.sh bash install.sh
# Dev mode: CLI flags, auto-detects repo owner
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
# mDNS (LAN-only, no DNS record needed)
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass --acme-email "me@example.com"
``` ```
### Environment Variables ### Options
| Variable | Required | Description | All settings that can be passed as an environment variable also have a CLI flag equivalent. CLI flags take precedence over environment variables.
|---|---|---|
| `MGMT_DOMAIN` | Yes | The public-facing domain for the management WebUI. A DNS A record must point to the appliance's IP. | | Flag | Env Var | Required | Description |
| `MGMT_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. | |---|---|---|---|
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. | | -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| `ACME_EMAIL` | Yes | The email address registered with Let's Encrypt for certificate issuance and expiry notifications. | | `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
| `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). |
| `--user, -u` | `USER_NAME` | No | System user for the WebUI service. Defaults to `vacuum-wall`. |
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
| `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. |
| `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
Run `./install.sh --help` for full usage.
---
## Container / Custom Deployment
You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (or `INSTALL_DIR`) for the mount or bind path, and `--user` (or `USER_NAME`) for whatever system user exists:
```bash
# Docker volume mount example
./install.sh --path /app/vacuum-wall --user ww-app \
--mgmt-domain proxy.internal --mgmt-pass strongpassword \
--acme-email "admin@example.com"
```
The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `INSTALL_DIR`. This means no hardcoded paths remain after installation.
--- ---
@@ -44,27 +75,43 @@ bash install.sh
The installer performs the following steps automatically: The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils. - **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for Let's Encrypt certificate management. - **System user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with a nologin shell that owns the project data and runs the WebUI service.
- **Flask installation**: Ensures the Flask Python package is available via pip for the WebUI backend. - **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
- **System user creation**: Creates a dedicated `vacuum-wall` system user (nologin shell) that owns the project data and runs the WebUI service. - **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
- **Directory setup**: Creates data directories under `/home/wall/vacuum-wall/data/` for nginx sites, dnsmasq config, firewall rules, and WireGuard config. Sets ownership to the `vacuum-wall` user. - **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config).
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the `vacuum-wall` user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`. - **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. - **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present.
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access. - **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces. - **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. - **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support. - **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Copies it to both `$USER_HOME/vacuum-wall/.htpasswd` (used by install.sh's initial nginx config) and `data/nginx/.htpasswd` (used by the running app). - **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Systemd units**: Installs three units: - **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
- **Systemd units**: Installs three units (rendered from Jinja2 templates):
- `vacuum-wall.service` — the Flask WebUI backend. - `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot. - `vacuum-wall-acme.service` — the certificate renewal oneshot.
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals. - `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
- **Firewalld zones**: Creates initial zones: - **Firewalld zones**: Creates initial zones:
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed. - `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
- `vpn` — WireGuard tunnel zone. - `vpn` — WireGuard tunnel zone.
- **Service startup**: Enables and starts nginx, the vacuum-wall WebUI, and the ACME renewal timer. - **Service startup**: Enables and starts/restarts nginx and the WebUI service, and enables the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes.
- **ACME registration**: Registers the Let's Encrypt account with the provided email via acme.sh. - **ACME registration**: Registers the ACME account with the provided email via acme.sh.
### Idempotent Re-Runs
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
- Rebuilds the Python venv and reinstalls dependencies
- Restarts `vacuum-wall` and reloads `nginx` to pick up changes
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
- Preserves existing `config.json` files (skips initial write if file exists)
- Safely updates `htpasswd` (uses update mode instead of create mode)
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation.
--- ---
@@ -92,7 +139,7 @@ Log in with the username and password you provided during installation.
### Certificate Note ### Certificate Note
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real Let's Encrypt certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert. The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real ACME certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
--- ---
@@ -207,7 +254,7 @@ journalctl -u nginx --no-pager -n 50
nginx -t nginx -t
``` ```
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `/home/wall/vacuum-wall/data/`. Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `data/`.
### Firewall Rules Not Applying ### Firewall Rules Not Applying
@@ -226,14 +273,14 @@ visudo -cf /etc/sudoers.d/vacuum-wall
### Certificate Issuance Fails ### Certificate Issuance Fails
Let's Encrypt ACME validation requires: ACME validation via the ACME provider requires:
- The domain's DNS A record points to the appliance's public IP. - The domain's DNS A record points to the appliance's public IP.
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface. - Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
- The ACME email was registered correctly. Check with: - The ACME email was registered correctly. Check with:
```bash ```bash
su -s /bin/bash vacuum-wall -c "~/.acme.sh/acme.sh --list" su -s /bin/bash "$USER_NAME" -c "~/.acme.sh/acme.sh --list"
``` ```
If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically. If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically.
@@ -244,16 +291,16 @@ Verify that:
- The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`). - The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`).
- Dnsmasq is running: `systemctl status dnsmasq`. - Dnsmasq is running: `systemctl status dnsmasq`.
- A DHCP range is configured for the correct interface. Check dnsmasq config at `/home/wall/vacuum-wall/data/dnsmasq/`. - A DHCP range is configured for the correct interface. Check dnsmasq config at `data/dnsmasq/`.
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`. - The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
### WebUI Not Accessible ### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`. 1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`. 2. Test nginx configuration: `nginx -t`.
2. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply). 3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
4. Ensure the `vacuum-wall` WebUI service is listening on port 9090: `ss -tlnp | grep 9090`. 4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real Let's Encrypt certificate. 5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
--- ---
@@ -261,10 +308,10 @@ Verify that:
| Component | Service | Config Location | | Component | Service | Config Location |
|---|---|---| |---|---|---|
| WebUI backend | `vacuum-wall.service` | `/home/wall/vacuum-wall/webui/` | | WebUI backend | `vacuum-wall.service` | `webui/` |
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` | | Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` | | Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
| DHCP/DNS | `dnsmasq` | `/home/wall/vacuum-wall/data/dnsmasq/` | | DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
| VPN | wireguard-tools | `/home/wall/vacuum-wall/data/wireguard/` | | VPN | wireguard-tools | `config/wireguard/` |
| Certificates | `vacuum-wall-acme.timer` | `/home/vacuum-wall/.acme.sh/` | | Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` | | Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
+31 -13
View File
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
## Architecture Overview ## Architecture Overview
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic Let's Encrypt certificates through acme.sh, handling SSL termination and reverse proxying for backend services. 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 four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic ACME certificates through acme.sh, handling SSL termination and reverse proxying for backend services. 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 ## Subsystems
@@ -20,7 +20,7 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
### SSL Proxy ### SSL Proxy
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and Let's Encrypt. 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. 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.
### WireGuard ### WireGuard
@@ -29,39 +29,56 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
## Tech Stack ## Tech Stack
- Debian 13 (trixie) target platform - Debian 13 (trixie) target platform
- Python 3, Flask 3.x for web management - Python 3.13+, Flask 3.x for web management
- firewalld (nftables backend) - firewalld (nftables backend)
- nginx 1.26+ - nginx 1.26+
- dnsmasq - dnsmasq
- WireGuard tools (wireguard-tools) - WireGuard tools (wireguard-tools)
- acme.sh for ACME/Let's Encrypt certificate management - acme.sh for ACME certificate management (ZeroSSL by default)
- HTMX for dynamic UI updates - HTMX for dynamic UI updates
- Jinja2 for server-side templating - Jinja2 for server-side templating
## Quick Start ## Quick Start
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with the required environment variables: To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
```bash ```bash
MGMT_DOMAIN=wall.lan MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \ # Production
bash install.sh ./install.sh --mgmt-pass yourpassword --acme-email "admin@example.com"
# Development (auto-detects your user)
./install.sh --dev --mgmt-pass yourpassword --acme-email "admin@example.com"
``` ```
After installation, access the management interface at `https://wall.lan` using the credentials you configured. The `install.sh` script provisions nginx, sets up authentication, obtains an initial Let's Encrypt certificate, and starts all services. After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options.
## Project Structure ## Project Structure
``` ```
├── install.sh # Deployment script ├── install.sh # Deployment script (renders Jinja2 templates)
├── pyproject.toml # Project metadata + dependencies ├── pyproject.toml # Project metadata + dependencies
├── .venv/ # Python virtual environment ├── .venv/ # Python virtual environment
├── system/ # System file templates ├── config/ # Declarative JSON configuration (source of truth)
│ ├── dnsmasq/ # DHCP/DNS config
│ ├── nginx/ # Proxy domain & SSL config
│ └── wireguard/ # VPN interface & peer config
├── data/ # Runtime artifacts & generated files
│ ├── nginx/sites-enabled/ # Generated server blocks
│ ├── dnsmasq/fragments/ # User config fragments
│ ├── acme/ # ACME certificates
│ └── firewall/ # Firewall rule backup
├── system/ # System file templates (all Jinja2)
│ ├── systemd/ # Service and timer unit files │ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service │ │ ├── vacuum-wall.service # Web UI service (rendered at install)
│ │ ├── vacuum-wall-acme.service # Certificate renewal service │ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
│ │ └── vacuum-wall-acme.timer # Renewal schedule │ │ └── vacuum-wall-acme.timer # Renewal schedule
── sudoers.d/ # Sudo whitelist for service account ── sudoers.d/ # Sudo whitelist (rendered at install)
│ ├── nginx/ # Nginx config templates (rendered at runtime)
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
├── lib/ # Subsystem abstraction layer ├── lib/ # Subsystem abstraction layer
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
│ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings │ ├── firewall.py # firewalld bindings
│ ├── dnsmasq.py # DHCP/DNS configuration │ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration │ ├── nginx.py # Reverse proxy configuration
@@ -70,6 +87,7 @@ After installation, access the management interface at `https://wall.lan` using
├── webui/ # Flask web application ├── webui/ # Flask web application
│ ├── server.py # Application entry point │ ├── server.py # Application entry point
│ ├── api/ # REST API route modules │ ├── api/ # REST API route modules
│ │ └── common.py # Shared API response helpers (_ok, _error)
│ ├── templates/ # Jinja2/HTMX templates │ ├── templates/ # Jinja2/HTMX templates
│ └── static/ # CSS and client-side JS │ └── static/ # CSS and client-side JS
└── docs/ # Documentation └── docs/ # Documentation
+11 -9
View File
@@ -2,13 +2,13 @@
## Privilege Model ## Privilege Model
The Vacuum Wall management WebUI (Flask application) runs as the unprivileged `vacuum-wall` system user. The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the `vacuum-wall` user without sudo escalation, using webroot validation that doesn't require binding to privileged ports. The Vacuum Wall management WebUI (Flask application) runs as an unprivileged system user (default name: `vacuum-wall`, configurable via the `USER_NAME` environment variable at install time). The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the application user without sudo escalation, using webroot validation that doesn't require binding to privileged ports.
This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the `vacuum-wall` user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access. This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the dedicated service user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
## Sudo Whitelist ## Sudo Whitelist
The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are: The file `/etc/sudoers.d/vacuum-wall` grants the configured system user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
| Category | Whitelisted Command | Purpose | | Category | Whitelisted Command | Purpose |
|---|---|---| |---|---|---|
@@ -19,7 +19,7 @@ The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status | | Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) | | WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management | | WireGuard | `wg *` | WireGuard status and peer management |
| Certificates | (none) | acme.sh runs as the unprivileged `vacuum-wall` user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) | | Certificates | (none) | acme.sh runs as the unprivileged service user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) |
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths | | File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration | | File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files | | File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
@@ -32,7 +32,8 @@ Key safety properties:
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`). - Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
- Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none grant shell access or arbitrary command execution. - Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none grant shell access or arbitrary command execution.
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:vacuum-wall` restricts the secure path and disables TTY requirement. - `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.
## Web Security ## Web Security
@@ -70,8 +71,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| Directive | Value | Effect | | Directive | Value | Effect |
|---|---|---| |---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths | | `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ProtectHome` | `read-only` | Makes `/home`, `/root`, and `/run/user` inaccessible | | `ReadWritePaths` | `$INSTALL_DIR`, `$INSTALL_DIR/config`, `$INSTALL_DIR/data`, and `/tmp` | The project directory, config directory, data directory, and `/tmp` are writable (required by `ProtectSystem=strict`). The project path is templated at install time. |
| `ReadWritePaths` | `/home/wall/vacuum-wall/data /tmp` | Only the application data directory and `/tmp` are writable |
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace | | `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` | | `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `IPAddressDeny` | `all` | Drops all network traffic | | `IPAddressDeny` | `all` | Drops all network traffic |
@@ -88,7 +88,9 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services | | `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities | | `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the data directory, and no ability to escalate privileges through kernel interfaces. The `User`, `Group`, `WorkingDirectory`, `ExecStart`, and `ReadWritePaths` directives in the service unit are rendered from a Jinja2 template at install time with the configured `USER_NAME` and `INSTALL_DIR`.
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the config and data directories, and no ability to escalate privileges through kernel interfaces.
## Network Security ## Network Security
@@ -114,7 +116,7 @@ IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routin
### acme.sh Integration ### acme.sh Integration
Certificate management is handled by acme.sh, which stores all certificates and private keys in the `vacuum-wall` user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the `vacuum-wall` user. Certificate management is handled by acme.sh, which stores all certificates and private keys in the service user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the service user.
### Private Key Protection ### Private Key Protection
+176 -68
View File
@@ -12,75 +12,146 @@ log() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; } warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; } err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
# --- Configurable via environment --- # --- CLI argument parsing ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)" _cli_user=""
USER_NAME="${USER_NAME:-vacuum-wall}" _cli_is_dev=false
_cli_path=""
# --- Validate required env vars --- _cli_mgmt_pass=""
missing=() _cli_mgmt_user=""
[[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS) _cli_mgmt_domain=""
[[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL) _cli_acme_email=""
_cli_force_venv=false
if (( ${#missing[@]} )); then _cli_wan_iface=""
echo -e "${RED}[!!]${NC} Missing required environment variables:" _cli_lan_ifaces=""
for v in "${missing[@]}"; do while [[ $# -gt 0 ]]; do
case "$v" in case "$1" in
MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';; --user|-u) _cli_user="$2"; shift 2 ;;
ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";; --path|-p) _cli_path="$2"; shift 2 ;;
--dev) _cli_is_dev=true; shift ;;
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
--acme-email) _cli_acme_email="$2"; shift 2 ;;
--force-venv) _cli_force_venv=true; shift ;;
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
-h|--help)
printf '%s\n' \
"Usage: install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER System user for service (default: vacuum-wall)" \
" --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --acme-email EMAIL ACME registration email (required)" \
" --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." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh"
exit 0
;;
*)
err "Unknown argument: $1 (use --help for usage)"
;;
esac esac
done done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi
# Auto-detect MGMT_DOMAIN from system hostname if not provided # --- Resolve config: CLI flag > env var > default ---
if [[ -z "${MGMT_DOMAIN:-}" ]]; then REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
# Optional settings with defaults
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
if [[ -n "$_cli_mgmt_domain" ]]; then
DOMAIN="$_cli_mgmt_domain"
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
DOMAIN="$MGMT_DOMAIN"
else
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true) HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
if [[ -z "$HOSTNAME_F" ]]; then if [[ -z "$HOSTNAME_F" ]]; then
err "Cannot determine system hostname — set MGMT_DOMAIN env var." err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
fi fi
DOMAIN="${HOSTNAME_F}.local" DOMAIN="${HOSTNAME_F}.local"
else
DOMAIN="$MGMT_DOMAIN"
fi fi
MGMT_USER="${MGMT_USER:-admin}"
# Install directory (CLI > env > repo root)
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
if [[ -n "$INSTALL_DIR" ]]; then
PROJECT_DIR="$INSTALL_DIR"
else
PROJECT_DIR="$REPO_DIR"
fi
# Network interfaces (CLI > env — auto-detect happens later if still unset)
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
# --- Pre-flight checks --- # --- Pre-flight checks ---
[[ $EUID -eq 0 ]] || err "This script must be run as root." [[ $EUID -eq 0 ]] || err "This script must be run as root."
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu." [[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
# --- Deploy to /opt/vacuum-wall --- # --- Validate required settings ---
INSTALL_DIR="/opt/vacuum-wall" missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
# Install rsync first if not available (needed for deploy) if (( ${#missing[@]} )); then
if ! command -v rsync &>/dev/null; then echo -e "${RED}[!!]${NC} Missing required settings:"
apt-get update -qq for v in "${missing[@]}"; do
apt-get install -y -qq rsync 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'
exit 1
fi fi
if [[ -d "$INSTALL_DIR" ]]; then
if [[ -L "$INSTALL_DIR" ]]; then
log "Symbolic link already exists at $INSTALL_DIR, skipping deploy."
elif [[ "$INSTALL_DIR" == "$REPO_DIR" ]]; then
log "Installed from repo location, skipping deploy."
else
err "Installation directory $INSTALL_DIR already exists."
fi
else
log "Deploying $REPO_DIR$INSTALL_DIR"
rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.git' \
--exclude='build' \
"$REPO_DIR/" "$INSTALL_DIR/"
chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR"
fi
PROJECT_DIR="$INSTALL_DIR"
ACME_HOME="$PROJECT_DIR/data/acme" ACME_HOME="$PROJECT_DIR/data/acme"
# Dev mode: auto-detect repo owner as service user
if [[ "$_cli_is_dev" == true ]]; then
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
_cli_user="$_repo_owner"
log "Dev mode: using repo owner '$_repo_owner' as service user"
else
err "Dev mode: cannot determine repo owner (root or unavailable)."
fi
fi
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# --- Safety check: running service as a non-system regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME")
_gid=$(id -g "$USER_NAME")
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
warn "This grants NOPASSWD sudo and runs the web service as your login account."
warn "Only use for development. For production, use --user vacuum-wall."
fi
fi
echo "============================================" echo "============================================"
echo " Vacuum Wall Appliance Installer" echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR" echo " Install dir: $PROJECT_DIR"
@@ -114,17 +185,23 @@ else
fi fi
# --- 2b. Setup Python venv --- # --- 2b. Setup Python venv ---
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
log "Python venv already exists, skipping (use --force-venv to recreate)."
else
log "Setting up Python virtual environment..." log "Setting up Python virtual environment..."
rm -rf "${PROJECT_DIR}/.venv"
python3 -m venv "${PROJECT_DIR}/.venv" python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}" "${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh --- # --- 2c. Install acme.sh (vendored) ---
if [[ ! -d "$ACME_HOME" ]]; then if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh..." log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME" mkdir -p "$ACME_HOME"
chown "$USER_NAME:$USER_NAME" "$ACME_HOME" cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ chmod +x "$ACME_HOME/acme.sh"
sh -c 'curl -sS https://get.acme.sh | sh' chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
else else
log "acme.sh already installed." log "acme.sh already installed."
fi fi
@@ -201,20 +278,29 @@ systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no int
log "dnsmasq configured (will fully start after DHCP ranges are set)" log "dnsmasq configured (will fully start after DHCP ranges are set)"
# --- 10. Setup nginx management proxy --- # --- 10. Setup nginx management proxy ---
log "Generating self-signed certificate for management domain..."
mkdir -p "$ACME_HOME/$DOMAIN" 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 \ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \ -keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \ -out "$ACME_HOME/$DOMAIN/fullchain.cer" \
-subj "/CN=$DOMAIN" \ -subj "/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN" -addext "subjectAltName=DNS:$DOMAIN"
fi
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME" chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
# Generate htpasswd directly in data/nginx/ # Generate/update htpasswd directly in data/nginx/
htpasswd -cb "${PROJECT_DIR}/data/nginx/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \ HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="${PROJECT_DIR}/data/nginx/.htpasswd" python3 -c " 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 import os, crypt, base64
password = os.environ['MGMT_PASS'] password = os.environ['MGMT_PASS']
user = os.environ['MGMT_USER'] user = os.environ['MGMT_USER']
@@ -224,6 +310,7 @@ with open(os.environ['HTFILE'], 'w') as f:
f.write(user + ':' + hashed + '\n') f.write(user + ':' + hashed + '\n')
" 2>/dev/null || \ " 2>/dev/null || \
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)" warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
fi
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd" chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
@@ -279,7 +366,11 @@ server {
} }
MGMTSITEEOF MGMTSITEEOF
# --- 11. Write initial nginx config.json --- # --- 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..." log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \ MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \ MGMT_USER="$MGMT_USER" \
@@ -313,6 +404,7 @@ with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
json.dump(cfg, f, indent=4) json.dump(cfg, f, indent=4)
f.write('\n') f.write('\n')
" "
fi
# --- 12. Auto-detect interfaces and setup initial firewalld zones --- # --- 12. Auto-detect interfaces and setup initial firewalld zones ---
log "Detecting network interfaces..." log "Detecting network interfaces..."
@@ -342,7 +434,11 @@ if [[ -z "$LAN_IFACES" ]]; then
fi fi
fi fi
# Generate config/firewall/config.json # 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."
else
log "Writing initial firewall configuration..." log "Writing initial firewall configuration..."
WAN_IFACE="$WAN_IFACE" \ WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \ LAN_IFACES="$LAN_IFACES" \
@@ -384,6 +480,7 @@ with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
json.dump(cfg, f, indent=2) json.dump(cfg, f, indent=2)
f.write('\n') f.write('\n')
" "
fi
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config) # Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true
@@ -435,14 +532,25 @@ systemctl enable avahi-daemon >/dev/null 2>&1 || true
log "Enabled avahi-daemon" log "Enabled avahi-daemon"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon" systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
systemctl start nginx >/dev/null 2>&1 && log "Started nginx" || warn "Could not start nginx (check config)" systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI" systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
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 --- # --- 14. Configure acme.sh default email ---
log "Configuring 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
log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www"
chown "$USER_NAME:$USER_NAME" "$ACME_HOME/www"
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
"$ACME_HOME/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \ "$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)" warn "Could not register acme.sh account (will be done from WebUI)"
fi
# --- Done --- # --- Done ---
echo "" echo ""
+44 -47
View File
@@ -27,6 +27,8 @@ _ACME_ENVIRON = {
), ),
} }
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def _find_acme() -> str: def _find_acme() -> str:
"""Locate the acme.sh binary on the system. """Locate the acme.sh binary on the system.
@@ -147,77 +149,52 @@ def get_email() -> str:
return "" return ""
def issue(domain: str, webroot: str | None = None) -> dict: def issue(domain: str, webroot: str | None = None, email: str | None = None) -> str:
"""Issue a new SSL certificate for a domain. """Issue a new SSL certificate for a domain.
Args: Args:
domain: The primary domain name. domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation. webroot: Path to the web root directory for HTTP-01 validation.
email: Contact email. Falls back to configured ACME email if not given.
Returns: Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'. Combined stdout from the acme.sh command.
Raises:
RuntimeError: If issuance fails.
""" """
args: list[str] = ["--issue", "-d", domain] args: list[str] = ["--issue", "-d", domain]
args.extend(["--webroot", webroot or str(_WEBROOT)])
if webroot: contact = email or get_email()
args.extend(["--webroot", webroot]) if contact:
args.extend(["-m", contact])
email = get_email()
if email:
args.extend(["-m", email])
args.append("--force") args.append("--force")
try:
output = _run_acme(args) output = _run_acme(args)
deploy(domain) deploy(domain)
return { logger.info("Certificate for %s issued successfully", domain)
"success": True, return output.strip()
"domain": domain,
"message": f"Certificate for {domain} issued successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to issue certificate for {domain}",
"output": "",
"error": str(exc),
}
def renew(domain: str, force: bool = False) -> dict: def renew(domain: str, force: bool = False) -> str:
"""Renew an existing SSL certificate. """Renew an existing SSL certificate.
Args: Args:
domain: The domain whose certificate should be renewed. domain: The domain whose certificate should be renewed.
force: If True, renew even if the certificate isn't close to expiry. force: If True, renew even if not close to expiry.
Returns: Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'. Combined stdout from the acme.sh command.
Raises:
RuntimeError: If renewal fails.
""" """
args: list[str] = ["--renew", "-d", domain] args: list[str] = ["--renew", "-d", domain]
if force: if force:
args.append("--force") args.append("--force")
try:
output = _run_acme(args) output = _run_acme(args)
return { deploy(domain)
"success": True, logger.info("Certificate for %s renewed successfully", domain)
"domain": domain, return output.strip()
"message": f"Certificate for {domain} renewed successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to renew certificate for {domain}",
"output": "",
"error": str(exc),
}
def remove(domain: str) -> str: def remove(domain: str) -> str:
@@ -274,7 +251,10 @@ def list_certs() -> list[dict]:
certs.append( certs.append(
{ {
"domain": main, "domain": main,
"ca": entry.get("CA", ""), "issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": cert_path, "cert_path": cert_path,
"key_path": key_path, "key_path": key_path,
"ca_path": ca_path, "ca_path": ca_path,
@@ -494,3 +474,20 @@ def _has_auto_renew(domain: str) -> bool:
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
domain_conf = Path(acme_home_env) / f"{domain}.conf" domain_conf = Path(acme_home_env) / f"{domain}.conf"
return bool(domain_conf.is_file()) return bool(domain_conf.is_file())
__all__ = [
"copy_cert",
"days_until_expiry",
"deploy",
"get_cert_info",
"get_cert_paths",
"get_email",
"get_expiry",
"is_expired",
"issue",
"list_certs",
"remove",
"renew",
"set_email",
]
+137
View File
@@ -0,0 +1,137 @@
"""Shared utilities for Vacuum Wall lib/ modules.
Provides common helpers for JSON persistence, subprocess execution,
deep merging, and directory creation used across all subsystem modules.
"""
import json
import os
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
def run(
cmd: list[str],
check: bool = True,
sudo: bool = False,
timeout: int | None = None,
) -> str:
"""Run a command and return stripped stdout.
Args:
cmd: Command arguments.
check: Raise RuntimeError on non-zero exit.
sudo: Prefix command with ``sudo``.
timeout: Timeout in seconds (``None`` no timeout).
Returns:
``stdout`` with trailing whitespace removed.
Raises:
RuntimeError: When ``check=True`` and the process exits non-zero.
"""
full_cmd = ["sudo", *cmd] if sudo else list(cmd)
try:
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
check=check,
timeout=timeout,
)
return result.stdout.strip()
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"Command failed: {' '.join(full_cmd)} (rc={exc.returncode}): "
f"{exc.stderr.strip()}"
) from exc
def run_proc(
cmd: list[str],
check: bool = True,
sudo: bool = False,
timeout: int | None = None,
input: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a command and return the full ``CompletedProcess``.
Args:
cmd: Command arguments.
check: Raise ``subprocess.CalledProcessError`` on non-zero exit.
sudo: Prefix command with ``sudo``.
timeout: Timeout in seconds.
input: String to pass as stdin to the subprocess.
Returns:
The completed process object.
"""
full_cmd = ["sudo", *cmd] if sudo else list(cmd)
return subprocess.run(
full_cmd,
capture_output=True,
text=True,
check=check,
timeout=timeout,
input=input,
)
def load_json(path: Path, default: dict[str, Any] | None = None) -> dict[str, Any]:
"""Load JSON from *path*.
Returns *default* (default ``{}``) if the file does not exist.
"""
if default is None:
default = {}
if not path.exists():
return deepcopy(default)
with open(path) as f:
return json.load(f)
def save_json(path: Path, data: dict[str, Any], indent: int = 4) -> None:
"""Atomically write *data* as JSON to *path*.
Writes to ``path.tmp`` first, then replaces *path* via ``os.replace()``
to avoid partial writes.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=indent)
f.write("\n")
os.replace(tmp, path)
def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge *overrides* into a deep copy of *base*.
For nested dicts the merge recurses; for all other values
*overrides* wins.
"""
result = deepcopy(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result
def ensure_dirs(*dirs: Path) -> None:
"""Create each directory (and parents) if it does not exist."""
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
__all__ = [
"deep_merge",
"ensure_dirs",
"load_json",
"run",
"run_proc",
"save_json",
]
+78 -64
View File
@@ -1,12 +1,10 @@
""" """Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range, Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo. static leases, and custom DNS records through sudo.
""" """
import json import logging
import os
import subprocess import subprocess
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -15,6 +13,10 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq" DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
@@ -43,64 +45,25 @@ DEFAULT_CFG: dict[str, Any] = {
}, },
} }
# ───────── helpers ───────────────────────────────────────────────────
def _ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["sudo", *list(cmd)],
capture_output=True,
text=True,
check=True,
)
def _load_json(path: Path) -> dict:
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
def _save_json(path: Path, data: dict) -> None:
_ensure_dirs()
with open(path, "w") as f:
json.dump(data, f, indent=4)
def _deep_merge(base: dict, overrides: dict) -> dict:
result = deepcopy(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result
# ───────── config lifecycle ────────────────────────────────────────── # ───────── config lifecycle ──────────────────────────────────────────
def get_config() -> dict: def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file.""" """Load current dnsmasq config from JSON state file."""
_ensure_dirs() ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = _load_json(CONFIG_PATH) raw = load_json(CONFIG_PATH)
if not raw: if not raw:
return deepcopy(DEFAULT_CFG) return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw) return deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> None: def save_config(cfg: dict[str, Any]) -> None:
"""Persist config to JSON (does NOT touch on-disk dnsmasq config).""" """Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
_ensure_dirs() ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg) merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged) save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
def apply_config() -> None: def apply_config() -> None:
@@ -108,8 +71,8 @@ def apply_config() -> None:
cfg = get_config() cfg = get_config()
conf_text = generate_conf(cfg) conf_text = generate_conf(cfg)
_ensure_dirs() ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
_sudo("mkdir", "-p", "/etc/dnsmasq.d") subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run( subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"], ["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text, input=conf_text,
@@ -117,13 +80,19 @@ def apply_config() -> None:
text=True, text=True,
check=True, check=True,
) )
_sudo("systemctl", "reload", "dnsmasq") subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ───────────────────────────────────────── # ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str: def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict.""" """Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {}) dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {}) dns_cfg = cfg.get("dns", {})
@@ -187,6 +156,23 @@ def set_dhcp_range(
ranges.append(entry) ranges.append(entry)
save_config(cfg) save_config(cfg)
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
"""Remove a DHCP range by interface + IP range."""
cfg = get_config()
cfg["dhcp"]["ranges"] = [
r
for r in cfg["dhcp"]["ranges"]
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
save_config(cfg)
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None: def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
@@ -200,6 +186,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
if hostname: if hostname:
leases[i]["hostname"] = hostname leases[i]["hostname"] = hostname
save_config(cfg) save_config(cfg)
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
return return
entry: dict[str, Any] = {"mac": mac, "ip": ip} entry: dict[str, Any] = {"mac": mac, "ip": ip}
@@ -207,6 +194,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
entry["hostname"] = hostname entry["hostname"] = hostname
leases.append(entry) leases.append(entry)
save_config(cfg) save_config(cfg)
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
def remove_static_lease(mac: str) -> None: def remove_static_lease(mac: str) -> None:
@@ -218,6 +206,7 @@ def remove_static_lease(mac: str) -> None:
if lease["mac"].lower() != mac.lower() if lease["mac"].lower() != mac.lower()
] ]
save_config(cfg) save_config(cfg)
logger.info("Static DHCP lease removed for MAC %s", mac)
# ───────── dns record management ───────────────────────────────────── # ───────── dns record management ─────────────────────────────────────
@@ -234,6 +223,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
if hostname: if hostname:
records[i]["hostname"] = hostname records[i]["hostname"] = hostname
save_config(cfg) save_config(cfg)
logger.info("DNS record updated: %s -> %s", name, address)
return return
entry: dict[str, Any] = {"name": name, "address": address} entry: dict[str, Any] = {"name": name, "address": address}
@@ -241,6 +231,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
entry["hostname"] = hostname entry["hostname"] = hostname
records.append(entry) records.append(entry)
save_config(cfg) save_config(cfg)
logger.info("DNS record added: %s -> %s", name, address)
def remove_dns_record(name: str) -> None: def remove_dns_record(name: str) -> None:
@@ -250,6 +241,7 @@ def remove_dns_record(name: str) -> None:
r for r in cfg["dns"]["custom_records"] if r["name"] != name r for r in cfg["dns"]["custom_records"] if r["name"] != name
] ]
save_config(cfg) save_config(cfg)
logger.info("DNS record removed: %s", name)
# ───────── lease table ─────────────────────────────────────────────── # ───────── lease table ───────────────────────────────────────────────
@@ -278,11 +270,16 @@ def _parse_lease_line(line: str) -> dict[str, Any] | None:
} }
def get_lease_table() -> list[dict]: def get_lease_table() -> list[dict[str, Any]]:
"""Read and parse the current dnsmasq lease file.""" """Read and parse the current dnsmasq lease file."""
leases: list[dict] = [] leases: list[dict[str, Any]] = []
try: try:
result = _sudo("cat", LEASE_FILE) result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()): for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None: if entry is not None:
leases.append(entry) leases.append(entry)
@@ -299,6 +296,7 @@ def set_upstreams(servers: list[str]) -> None:
cfg = get_config() cfg = get_config()
cfg["dns"]["upstreams"] = list(servers) cfg["dns"]["upstreams"] = list(servers)
save_config(cfg) save_config(cfg)
logger.info("DNS upstreams set to %s", servers)
def set_domain(domain: str | None) -> None: def set_domain(domain: str | None) -> None:
@@ -306,16 +304,16 @@ def set_domain(domain: str | None) -> None:
cfg = get_config() cfg = get_config()
cfg["dns"]["domain"] = domain if domain else None cfg["dns"]["domain"] = domain if domain else None
save_config(cfg) save_config(cfg)
logger.info("DNS domain set to '%s'", domain)
# ───────── status / info ───────────────────────────────────────────── # ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict: def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count.""" """Return service status, config summary, and current lease count."""
cfg = get_config() cfg = get_config()
# dnsmasq process check
try: try:
proc = subprocess.run( proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"], ["sudo", "systemctl", "is-active", "dnsmasq"],
@@ -326,8 +324,7 @@ def get_status() -> dict:
except Exception: except Exception:
active = False active = False
# config on disk conf_exists = Path(DNSMASQ_CONF).is_file()
conf_exists = os.path.isfile(DNSMASQ_CONF)
if conf_exists: if conf_exists:
try: try:
with open(DNSMASQ_CONF) as f: with open(DNSMASQ_CONF) as f:
@@ -337,7 +334,6 @@ def get_status() -> dict:
else: else:
conf_on_disk = "" conf_on_disk = ""
# current expected config
expected = generate_conf(cfg) expected = generate_conf(cfg)
leases = get_lease_table() leases = get_lease_table()
@@ -354,3 +350,21 @@ def get_status() -> dict:
"active_leases": len(leases), "active_leases": len(leases),
"leases": leases, "leases": leases,
} }
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]
+481 -184
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""
logging - Centralized logging configuration for Vacuum Wall.
Call :func:`setup_logging` once at application startup. All other
modules obtain a logger via ``logging.getLogger(__name__)``.
Output:
* **stderr** (StreamHandler) - captured by systemd journald
* **data/logs/vacuum-wall.log** (RotatingFileHandler) - persisted for
viewing via the WebUI ``/logs`` page.
"""
import logging
import os
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent.parent
_LOG_DIR = PROJECT_DIR / "data" / "logs"
_LOG_FILE = _LOG_DIR / "vacuum-wall.log"
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
_BACKUP_COUNT = 3
_LOG_FMT = "[%(asctime)s] %(levelname)-8s %(name)s %(message)s"
_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_initialized = False
def setup_logging(level: str | None = None) -> None:
"""Configure and enable root-level logging for the application.
Safe to call multiple times; subsequent calls are no-ops.
Args:
level: Override log level string (e.g. ``"DEBUG"``). If ``None``,
reads ``VACUUM_WALL_LOG_LEVEL`` from the environment, defaulting
to ``"INFO"``.
"""
global _initialized
if _initialized:
return
_initialized = True
if level is None:
level = os.environ.get("VACUUM_WALL_LOG_LEVEL", "INFO").upper()
valid_levels = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
numeric = valid_levels.get(level, logging.INFO)
root = logging.getLogger()
root.setLevel(numeric)
fmt = logging.Formatter(_LOG_FMT, datefmt=_DATE_FMT)
# stderr handler — feeds systemd journal
sh = logging.StreamHandler(sys.stderr)
sh.setFormatter(fmt)
root.addHandler(sh)
# rotating file handler
_LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
)
fh.setFormatter(fmt)
root.addHandler(fh)
# Silence noisy third-party loggers in production
for name in ("werkzeug", "urllib3"):
logging.getLogger(name).setLevel(logging.WARNING)
+122 -81
View File
@@ -1,17 +1,22 @@
""" """Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Manages per-domain SSL reverse proxy configurations, certificate Manages per-domain SSL reverse proxy configurations, certificate
bootstrap, basic-auth htpasswd files, and nginx reload cycles. bootstrap, basic-auth htpasswd files, and nginx reload cycles.
""" """
import json import logging
import os import os
import subprocess import subprocess
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from lib.common import ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "nginx" CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
DATA_DIR = PROJECT_DIR / "data" / "nginx" DATA_DIR = PROJECT_DIR / "data" / "nginx"
@@ -28,7 +33,7 @@ ENV = Environment(
trim_blocks=True, trim_blocks=True,
) )
DEFAULT_SSL = { DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3", "protocols": "TLSv1.2 TLSv1.3",
"ciphers": ( "ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:" "ECDHE-ECDSA-AES128-GCM-SHA256:"
@@ -41,63 +46,35 @@ DEFAULT_SSL = {
"prefer_server_ciphers": False, "prefer_server_ciphers": False,
} }
DEFAULT_CONFIG = { DEFAULT_CONFIG: dict[str, Any] = {
"domains": {}, "domains": {},
"management": None, "management": None,
"ssl": {**DEFAULT_SSL}, "ssl": {**DEFAULT_SSL},
} }
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_dirs():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SITES_DIR.mkdir(parents=True, exist_ok=True)
def _run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
def _json_load(path):
_ensure_dirs()
if not path.exists():
return DEFAULT_CONFIG.copy()
with open(path) as f:
data = json.load(f)
if "ssl" not in data:
data["ssl"] = DEFAULT_SSL.copy()
return data
def _json_dump(path, data):
_ensure_dirs()
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
f.write("\n")
os.replace(tmp, path)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def get_config() -> dict: def get_config() -> dict[str, Any]:
return _json_load(CONFIG_FILE) ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
return raw
def save_config(cfg: dict) -> None: def save_config(cfg: dict[str, Any]) -> None:
_json_dump(CONFIG_FILE, cfg) save_json(CONFIG_FILE, cfg)
def get_domains() -> list[dict]: def get_domains() -> list[dict[str, Any]]:
cfg = get_config() cfg = get_config()
result = [] result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items(): for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf" site = SITES_DIR / f"{name}.conf"
result.append( result.append(
@@ -117,17 +94,17 @@ def get_domains() -> list[dict]:
def add_domain( def add_domain(
domain, domain: str,
backend_host, backend_host: str,
backend_port, backend_port: int,
backend_proto="http", backend_proto: str = "http",
cert=None, cert: str | None = None,
extra_headers=None, extra_headers: dict[str, str] | None = None,
) -> None: ) -> None:
cfg = get_config() cfg = get_config()
if domain in cfg["domains"]: if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured") raise ValueError(f"Domain {domain!r} already configured")
entry = { entry: dict[str, Any] = {
"backend": { "backend": {
"host": backend_host, "host": backend_host,
"port": int(backend_port), "port": int(backend_port),
@@ -141,18 +118,26 @@ def add_domain(
entry["headers"] = extra_headers entry["headers"] = extra_headers
cfg["domains"][domain] = entry cfg["domains"][domain] = entry
save_config(cfg) save_config(cfg)
logger.info(
"Proxy domain '%s' added -> %s:%d (%s)",
domain,
backend_host,
backend_port,
backend_proto,
)
def remove_domain(domain) -> None: def remove_domain(domain: str) -> None:
cfg = get_config() cfg = get_config()
cfg["domains"].pop(domain, None) cfg["domains"].pop(domain, None)
save_config(cfg) save_config(cfg)
site = SITES_DIR / f"{domain}.conf" site = SITES_DIR / f"{domain}.conf"
if site.exists(): if site.exists():
site.unlink() site.unlink()
logger.info("Proxy domain '%s' removed", domain)
def update_domain(domain, **kwargs) -> None: def update_domain(domain: str, **kwargs: Any) -> None:
cfg = get_config() cfg = get_config()
if domain not in cfg["domains"]: if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured") raise KeyError(f"Domain {domain!r} not configured")
@@ -163,6 +148,7 @@ def update_domain(domain, **kwargs) -> None:
else: else:
entry[key] = val entry[key] = val
save_config(cfg) save_config(cfg)
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -170,7 +156,7 @@ def update_domain(domain, **kwargs) -> None:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict) -> str: def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf") tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render( return tmpl.render(
domain=domain_cfg["domain"], domain=domain_cfg["domain"],
@@ -182,10 +168,11 @@ def generate_server_conf(domain_cfg: dict) -> str:
is_management=False, is_management=False,
acme_home=str(PROJECT_DIR / "data" / "acme"), acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"), certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
) )
def _generate_management_conf(management: dict) -> str: def _generate_management_conf(management: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf") tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render( return tmpl.render(
domain=management.get("domain"), domain=management.get("domain"),
@@ -199,6 +186,7 @@ def _generate_management_conf(management: dict) -> str:
is_management=True, is_management=True,
acme_home=str(PROJECT_DIR / "data" / "acme"), acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"), certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
) )
@@ -207,8 +195,8 @@ def _generate_management_conf(management: dict) -> str:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def write_site(domain, conf_text) -> None: def write_site(domain: str, conf_text: str) -> None:
_ensure_dirs() ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf" path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp") tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f: with open(tmp, "w") as f:
@@ -218,13 +206,32 @@ def write_site(domain, conf_text) -> None:
os.replace(tmp, path) os.replace(tmp, path)
def write_acme_challenge() -> None:
"""Write the ACME HTTP-01 challenge catch-all nginx config.
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
webroot for any domain not yet covered by a dedicated server block.
"""
tmpl = ENV.get_template("nginx/acme-challenge.conf")
content = tmpl.render(
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
site = SITES_DIR / "_acme-challenge.conf"
tmp = site.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(content)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, site)
def write_all_sites() -> None: def write_all_sites() -> None:
_ensure_dirs() ensure_dirs(SITES_DIR)
cfg = get_config() cfg = get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set() existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written = set() written: set[str] = set()
for name, dom in cfg.get("domains", {}).items(): for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name) dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy) conf = generate_server_conf(dom_copy)
@@ -240,6 +247,9 @@ def write_all_sites() -> None:
if old.suffix == ".conf" and old.name not in written: if old.suffix == ".conf" and old.name not in written:
old.unlink() old.unlink()
write_acme_challenge()
logger.info("All nginx site configs written (%d sites)", len(written))
def write_include_file() -> None: def write_include_file() -> None:
tmpl = ENV.get_template("nginx/include.conf") tmpl = ENV.get_template("nginx/include.conf")
@@ -248,15 +258,15 @@ def write_include_file() -> None:
with open(tmp, "w") as f: with open(tmp, "w") as f:
f.write(content) f.write(content)
os.chmod(tmp, 0o644) os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True) subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True)
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True) subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True)
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
def write_ssl_snippet() -> None: def write_ssl_snippet() -> None:
cfg = get_config() cfg = get_config()
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy()) ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", False) ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"]) ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"]) ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
@@ -266,8 +276,8 @@ def write_ssl_snippet() -> None:
with open(tmp, "w") as f: with open(tmp, "w") as f:
f.write(content) f.write(content)
os.chmod(tmp, 0o644) os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True) subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True)
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True) subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True)
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
@@ -277,11 +287,17 @@ def write_ssl_snippet() -> None:
def test_config() -> tuple[bool, str]: def test_config() -> tuple[bool, str]:
result = _run(["sudo", "nginx", "-t"]) result = subprocess.run(
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
)
ok = result.returncode == 0 ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip() output = (result.stderr or result.stdout or "").strip()
if not output and ok: if not output and ok:
output = "nginx configuration test passed" output = "nginx configuration test passed"
if ok:
logger.info("nginx config test passed")
else:
logger.error("nginx config test failed: %s", output)
return ok, output return ok, output
@@ -292,7 +308,13 @@ def apply() -> None:
ok, msg = test_config() ok, msg = test_config()
if not ok: if not ok:
raise RuntimeError(f"nginx config test failed: {msg}") raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"]) result = subprocess.run(
["sudo", "nginx", "-s", "reload"], capture_output=True, text=True, check=False
)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
else:
logger.info("nginx configuration applied and reloaded")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -301,10 +323,14 @@ def apply() -> None:
def set_management_proxy( def set_management_proxy(
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None domain: str,
flask_host: str = "127.0.0.1",
flask_port: int = 9090,
auth_user: str | None = None,
auth_pass: str | None = None,
) -> None: ) -> None:
cfg = get_config() cfg = get_config()
entry = { entry: dict[str, Any] = {
"domain": domain, "domain": domain,
"backend": { "backend": {
"host": flask_host, "host": flask_host,
@@ -321,6 +347,7 @@ def set_management_proxy(
save_config(cfg) save_config(cfg)
if auth_user and auth_pass: if auth_user and auth_pass:
write_htpasswd(auth_user, auth_pass) write_htpasswd(auth_user, auth_pass)
logger.info("Management proxy set to '%s'", domain)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -328,17 +355,11 @@ def set_management_proxy(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def write_htpasswd(user, password) -> None: def write_htpasswd(user: str, password: str) -> None:
""" """Append (or create) an htpasswd entry for *user*."""
Append (or create) an htpasswd entry for *user*. ensure_dirs(DATA_DIR)
Uses passlib's apache_passwd hash so the file remains portable.
If passlib is unavailable falls back to Python's built-in crypt.
If the user already exists the line is replaced in-place.
"""
_ensure_dirs()
hashed = _hash_password(password) hashed = _hash_password(password)
existing = {} existing: dict[str, str] = {}
if HTPASSWD_FILE.exists(): if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f: with open(HTPASSWD_FILE) as f:
for line in f: for line in f:
@@ -359,7 +380,7 @@ def write_htpasswd(user, password) -> None:
os.replace(tmp, HTPASSWD_FILE) os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password): def _hash_password(password: str) -> str:
try: try:
from passlib.hash import apache_passwd from passlib.hash import apache_passwd
@@ -369,3 +390,23 @@ def _hash_password(password):
salt = os.urandom(16).hex()[:16] salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}") return _crypt.crypt(password, f"$5${salt}")
__all__ = [
"add_domain",
"apply",
"generate_server_conf",
"get_config",
"get_domains",
"remove_domain",
"save_config",
"set_management_proxy",
"test_config",
"update_domain",
"write_acme_challenge",
"write_all_sites",
"write_htpasswd",
"write_include_file",
"write_site",
"write_ssl_snippet",
]
+81 -168
View File
@@ -1,20 +1,24 @@
""" """WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface. the WireGuard tunnel interface.
""" """
import json import logging
import os import os
import subprocess from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json") CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf" WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick" WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg" WG_BIN = "wg"
@@ -26,28 +30,7 @@ ENV = Environment(
trim_blocks=True, trim_blocks=True,
) )
DEFAULT_CONFIG: dict[str, Any] = {
# --- Helpers ---
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a command via sudo and return the completed process."""
return subprocess.run(
["sudo", *cmd],
capture_output=True,
text=True,
check=check,
)
def _ensure_dir(path: str) -> None:
"""Create parent directories for *path* if they don't exist."""
Path(path).parent.mkdir(parents=True, exist_ok=True)
def _default_config() -> dict:
"""Return the skeleton config with no keys and no peers."""
return {
"interface": { "interface": {
"name": "wg0", "name": "wg0",
"listen_port": 51820, "listen_port": 51820,
@@ -64,57 +47,24 @@ def _default_config() -> dict:
# --- Core config persistence --- # --- Core config persistence ---
def get_config() -> dict: def get_config() -> dict[str, Any]:
"""Load the current WireGuard configuration from the JSON store. """Load the current WireGuard configuration from the JSON store."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
Returns the full config dict. If the file does not exist or is
unreadable, returns the default (empty) config skeleton.
"""
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
# Backfill keys that might be missing from older snapshots.
defaults = _default_config()
cfg.setdefault("interface", defaults["interface"])
cfg["interface"].setdefault("name", defaults["interface"]["name"])
cfg["interface"].setdefault("listen_port", defaults["interface"]["listen_port"])
cfg["interface"].setdefault("private_key", defaults["interface"]["private_key"])
cfg["interface"].setdefault("public_key", defaults["interface"]["public_key"])
cfg["interface"].setdefault("addresses", defaults["interface"]["addresses"])
cfg["interface"].setdefault("post_up", defaults["interface"]["post_up"])
cfg["interface"].setdefault("post_down", defaults["interface"]["post_down"])
cfg.setdefault("peers", {})
return cfg
except (FileNotFoundError, json.JSONDecodeError):
return _default_config()
def save_config(cfg: dict) -> None: def save_config(cfg: dict[str, Any]) -> None:
"""Persist *cfg* to the JSON store atomically. """Persist *cfg* to the JSON store atomically."""
save_json(CONFIG_PATH, cfg)
Writes to a temporary file in the same directory and then renames
to avoid partial reads on crash.
"""
_ensure_dir(CONFIG_PATH)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
os.replace(tmp, CONFIG_PATH)
# --- Key generation --- # --- Key generation ---
def generate_keypair() -> tuple[str, str]: def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI. """Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = run_proc([WG_BIN, "genkey"], sudo=True)
Returns:
``(private_key, public_key)`` as two 43-character base64 strings.
"""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip() private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key) res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
public_key = res2.stdout.strip() public_key = res2.stdout.strip()
return private_key, public_key return private_key, public_key
@@ -122,7 +72,7 @@ def generate_keypair() -> tuple[str, str]:
# --- wg0.conf generation --- # --- wg0.conf generation ---
def generate_conf(cfg: dict) -> str: def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2.""" """Render a valid wg-quick config file from *cfg* using Jinja2."""
tmpl = ENV.get_template("wireguard.conf") tmpl = ENV.get_template("wireguard.conf")
return tmpl.render( return tmpl.render(
@@ -139,7 +89,7 @@ def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick.""" """Write the current config to disk and bring the tunnel up with wg-quick."""
cfg = get_config() cfg = get_config()
conf_text = generate_conf(cfg) conf_text = generate_conf(cfg)
save_config(cfg) # ensure latest state persisted save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard" local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True) local_dir.mkdir(parents=True, exist_ok=True)
@@ -147,61 +97,46 @@ def apply() -> None:
with open(local_tmp, "w") as f: with open(local_tmp, "w") as f:
f.write(conf_text) f.write(conf_text)
os.chmod(local_tmp, 0o600) os.chmod(local_tmp, 0o600)
_run(["cp", "--", str(local_tmp), WG_CONF_PATH]) run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
_run(["chown", "root:root", WG_CONF_PATH], check=False) run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]]) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
def down() -> None: def down() -> None:
"""Bring the WireGuard tunnel interface down.""" """Bring the WireGuard tunnel interface down."""
cfg = get_config() cfg = get_config()
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name]) run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name)
# --- Status --- # --- Status ---
def status() -> dict: def status() -> dict[str, Any]:
"""Query the live tunnel state via ``wg show``. """Query the live tunnel state via ``wg show``."""
Returns a dict with keys:
- ``up`` (bool) - whether the interface is currently up.
- ``interface`` (dict) - name, public key, listen port, fwmark.
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
"""
cfg = get_config() cfg = get_config()
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
result = { result: dict[str, Any] = {
"up": False, "up": False,
"interface": {}, "interface": {},
"peers": [], "peers": [],
} }
try: try:
proc = _run([WG_BIN, "show", name], check=False) res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
if proc.returncode != 0: if res.returncode != 0:
return result return result
raw = proc.stdout.strip() raw = res.stdout.strip()
except Exception: except Exception:
return result return result
# Parse the wg show output. current_peer: dict[str, Any] | None = None
# Format (multi-section, separated by blank lines or interleaved): peers: list[dict[str, Any]] = []
# interface:
# public key: ...
# listening port: ...
# peer: <key>
# endpoint: ...
# allowed ips: ...
# latest handshake: ...
# transfer: ...
# persistent-keepalive: ...
current_peer = None
peers: list[dict] = []
for line in raw.splitlines(): for line in raw.splitlines():
line = line.strip() line = line.strip()
@@ -286,25 +221,8 @@ def add_peer(
allowed_ips: list[str] | None = None, allowed_ips: list[str] | None = None,
persistent_keepalive: int | None = None, persistent_keepalive: int | None = None,
preshared_key: str | None = None, preshared_key: str | None = None,
) -> dict: ) -> dict[str, Any]:
"""Add (or update) a peer in the configuration. """Add (or update) a peer in the configuration."""
If the peer has no public key yet, one will be generated
together with a matching private key (useful for client provi-
sioning). The returned dict mirrors the stored peer record
with an additional ``private_key`` field so the caller can
distribute the client credentials.
Args:
name: Human-readable identifier (dict key in config).
endpoint: e.g. ``203.0.113.1:51820``.
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
persistent_keepalive: Interval in seconds (or ``None``).
preshared_key: Optional PSK (base64 string).
Returns:
The peer dict as stored, plus ``private_key`` for client use.
"""
cfg = get_config() cfg = get_config()
peers = cfg.setdefault("peers", {}) peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or [] allowed_ips = allowed_ips or []
@@ -316,23 +234,23 @@ def add_peer(
peer["persistent_keepalive"] = persistent_keepalive peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None: if preshared_key is not None:
peer["preshared_key"] = preshared_key peer["preshared_key"] = preshared_key
logger.info("WireGuard peer '%s' updated", name)
else: else:
# Generate a key pair for the new peer.
priv, pub = generate_keypair() priv, pub = generate_keypair()
peer = { peer = {
"public_key": pub, "public_key": pub,
"private_key": priv, # stored so we can hand it to the client "private_key": priv,
"endpoint": endpoint, "endpoint": endpoint,
"allowed_ips": allowed_ips, "allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive, "persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key, "preshared_key": preshared_key,
} }
peers[name] = peer peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
save_config(cfg) save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer) peer_out = dict(peer)
peer_out.pop("private_key", None)
return peer_out return peer_out
@@ -341,33 +259,23 @@ def remove_peer(name: str) -> None:
cfg = get_config() cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None) cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg) save_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
def get_peers() -> list[dict]: def get_peers() -> list[dict[str, Any]]:
"""List all configured peers (from the JSON store, *not* live). """List all configured peers (from the JSON store, *not* live)."""
Returns a list of dicts. Each dict includes ``name`` and all
stored fields **except** ``private_key`` (not exposed here).
"""
cfg = get_config() cfg = get_config()
peers = [] peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items(): for name, info in cfg.get("peers", {}).items():
entry = dict(info) entry = dict(info)
entry["name"] = name entry["name"] = name
# Strip private key from the public listing.
entry.pop("private_key", None) entry.pop("private_key", None)
peers.append(entry) peers.append(entry)
return peers return peers
def get_peer_status() -> list[dict]: def get_peer_status() -> list[dict[str, Any]]:
"""Return live peer status from ``wg show``. """Return live peer status from ``wg show``."""
Each element contains:
- ``public_key``, ``endpoint``, ``allowed_ips``,
``latest_handshake``, ``transfer_received``,
``transfer_sent``, ``persistent_keepalive``.
"""
st = status() st = status()
return st.get("peers", []) return st.get("peers", [])
@@ -401,7 +309,7 @@ def generate_client_conf(
client_addr = f"{prefix_base}.{peer_index}/{prefix}" client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf") tmpl = ENV.get_template("wireguard-client.conf")
return tmpl.render( conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name, peer_name=peer_name,
client_priv=client_priv, client_priv=client_priv,
@@ -412,29 +320,25 @@ def generate_client_conf(
preshared_key=peer.get("preshared_key"), preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"), persistent_keepalive=peer.get("persistent_keepalive"),
) )
logger.info("Client config generated for peer '%s'", peer_name)
return conf
# --- Interface-level setters --- # --- Interface-level setters ---
def set_listen_port(port: int) -> None: def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration. """Update the server listen port in the stored configuration."""
Does **not** hot-reload; call :func:`apply` afterwards to
activate the change.
"""
if not (1 <= port <= 65535): if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535") raise ValueError("Listen port must be in range 1..65535")
cfg = get_config() cfg = get_config()
cfg["interface"]["listen_port"] = port cfg["interface"]["listen_port"] = port
save_config(cfg) save_config(cfg)
logger.info("WireGuard listen port set to %d", port)
def set_post_up(cmd: str | None) -> None: def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command. """Set (or clear) the PostUp hook command."""
The command is passed verbatim to the generated wg0.conf.
"""
cfg = get_config() cfg = get_config()
cfg["interface"]["post_up"] = cmd cfg["interface"]["post_up"] = cmd
save_config(cfg) save_config(cfg)
@@ -450,40 +354,28 @@ def set_post_down(cmd: str | None) -> None:
# --- Initialise --- # --- Initialise ---
def initialize() -> dict: def initialize() -> dict[str, Any]:
"""Perform first-time WireGuard setup. """Perform first-time WireGuard setup."""
Generates a fresh server key pair, writes the initial config
to disk, and returns the full config dict.
Call this once at appliance bootstrapping time. It will
**not** overwrite an existing config that already has a
non-empty private key.
"""
cfg = get_config() cfg = get_config()
if cfg["interface"].get("private_key"): if cfg["interface"].get("private_key"):
# Already initialised — return existing config.
return cfg return cfg
priv, pub = generate_keypair() priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub cfg["interface"]["public_key"] = pub
save_config(cfg) save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg return cfg
# --- Utility: parse wg show into structured peer map --- # --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict: def _parse_wg_show(output: str) -> dict[str, Any]:
"""Internal parser for ``wg show`` multiline output. """Internal parser for ``wg show`` multiline output."""
peers: dict[str, dict[str, Any]] = {}
Returns a dict keyed by peer public key with parsed values. current: dict[str, Any] | None = None
Used internally; ``status()`` is the public interface.
"""
peers: dict = {}
current = None
for line in output.splitlines(): for line in output.splitlines():
line = line.strip() line = line.strip()
@@ -509,3 +401,24 @@ def _parse_wg_show(output: str) -> dict:
current["persistent_keepalive"] = line.split(":", 1)[1].strip() current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers return peers
__all__ = [
"DEFAULT_CONFIG",
"add_peer",
"apply",
"down",
"generate_client_conf",
"generate_conf",
"generate_keypair",
"get_config",
"get_peer_status",
"get_peers",
"initialize",
"remove_peer",
"save_config",
"set_listen_port",
"set_post_down",
"set_post_up",
"status",
]
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Download and vendor libraries into vendor/.
# Run from the project root after updating the VERSION variables below.
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"
if [[ -n "${SKIP_DOWNLOAD:-}" ]]; then
echo "[skip] $name (SKIP_DOWNLOAD is set)"
return
fi
echo "[download] $name$dest"
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."
+17
View File
@@ -0,0 +1,17 @@
# Auto-generated by Vacuum Wall — do not edit manually
# Serve ACME HTTP-01 challenges on port 80 for any domain not yet
# configured with a dedicated server block (catch-all).
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root {{ acme_webroot }};
}
location / {
return 444;
}
}
+5
View File
@@ -7,6 +7,11 @@ server {
listen [::]:80; listen [::]:80;
server_name {{ domain }}; server_name {{ domain }};
# ACME HTTP-01 challenge
location /.well-known/acme-challenge/ {
root {{ acme_webroot }};
}
# Redirect all HTTP traffic to HTTPS # Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
+1
View File
@@ -13,6 +13,7 @@ Defaults:{{ USER_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/ {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management # Dnsmasq management
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
+1 -1
View File
@@ -7,4 +7,4 @@ User={{ USER_NAME }}
WorkingDirectory={{ PROJECT_DIR }} WorkingDirectory={{ PROJECT_DIR }}
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }} Environment=HOME={{ PROJECT_DIR }}
ExecStart=/usr/local/bin/acme.sh --cron --home {{ ACME_HOME }} ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
+2 -3
View File
@@ -20,7 +20,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening # Security hardening
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
PrivateTmp=yes PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
@@ -34,9 +34,8 @@ LockPersonality=yes
SystemCallFilter=@system-service SystemCallFilter=@system-service
PrivateDevices=yes PrivateDevices=yes
ProtectHome=read-only
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=all IPAddressDeny=any
IPAddressAllow=localhost IPAddressAllow=localhost
[Install] [Install]
+255 -19
View File
@@ -1,3 +1,7 @@
"""
API integration tests all blueprints tested via a single Flask app fixture.
"""
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -24,6 +28,11 @@ def client():
return app.test_client() return app.test_client()
# ============================================================================
# Firewall
# ============================================================================
class TestFirewallListZones: class TestFirewallListZones:
@patch("webui.api.firewall.get_active_zones") @patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones") @patch("webui.api.firewall.get_available_zones")
@@ -100,7 +109,7 @@ class TestFirewallDeleteZone:
class TestFirewallRichRules: class TestFirewallRichRules:
@patch("webui.api.firewall.add_rich_rule") @patch("webui.api.firewall.add_rich_rule")
def test_add(self, mock_add, client): def test_add(self, mock_add, client):
mock_add.return_value = None mock_add.return_value = {"id": "abc123", "rule": "rule accept"}
resp = client.post( resp = client.post(
"/api/firewall/rich-rules", "/api/firewall/rich-rules",
json={ json={
@@ -111,18 +120,37 @@ class TestFirewallRichRules:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert data["ok"] is True assert data["ok"] is True
assert data["data"]["id"] == "abc123"
def test_missing_fields(self, client): def test_missing_fields(self, client):
resp = client.post("/api/firewall/rich-rules", json={}) resp = client.post("/api/firewall/rich-rules", json={})
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules") @patch("webui.api.firewall.get_rich_rules")
def test_list(self, mock_list, client): @patch("webui.api.firewall.get_config")
mock_list.return_value = ["rule1", "rule2"] def test_list(self, mock_cfg, mock_list, client):
mock_list.return_value = ["rule1"]
mock_cfg.return_value = {
"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}
}
resp = client.get("/api/firewall/rich-rules/public") resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert data["data"] == ["rule1", "rule2"] assert isinstance(data["data"], list)
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 404
class TestFirewallServices: class TestFirewallServices:
@@ -161,12 +189,14 @@ class TestFirewallMasquerade:
class TestFirewallForwardPort: class TestFirewallForwardPort:
@patch("webui.api.firewall.add_forward_port") @patch("webui.api.firewall.add_forward_port")
def test_add(self, mock_add, client): def test_add(self, mock_add, client):
mock_add.return_value = None mock_add.return_value = {"id": "fp1", "port": 443, "proto": "tcp"}
resp = client.post( resp = client.post(
"/api/firewall/forward-port", "/api/firewall/forward-port",
json={"zone": "public", "port": 443, "proto": "tcp"}, json={"zone": "public", "port": 443, "proto": "tcp"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["id"] == "fp1"
def test_missing_fields(self, client): def test_missing_fields(self, client):
resp = client.post( resp = client.post(
@@ -175,6 +205,25 @@ class TestFirewallForwardPort:
) )
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/forward-port/public/443/tcp")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/forward-port/public/999/tcp")
assert resp.status_code == 404
# ============================================================================
# DHCP
# ============================================================================
class TestDhcpConfig: class TestDhcpConfig:
@patch("webui.api.dhcp.get_config") @patch("webui.api.dhcp.get_config")
@@ -192,6 +241,67 @@ class TestDhcpConfig:
assert data is not None assert data is not None
class TestDhcpApply:
@patch("webui.api.dhcp.apply_config")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/dhcp/apply")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
class TestDhcpStatus:
@patch("webui.api.dhcp.dnsmasq_status")
def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["service_active"] is True
class TestDhcpRanges:
@patch("webui.api.dhcp.set_dhcp_range")
def test_add_range(self, mock_set, client):
mock_set.return_value = None
resp = client.post(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
"lease_time": "2h",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_add_range_missing_fields(self, client):
resp = client.post("/api/dhcp/ranges", json={"start": "192.168.1.100"})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dhcp_range")
def test_remove_range(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_remove_range_missing_fields(self, client):
resp = client.delete("/api/dhcp/ranges", json={})
assert resp.status_code == 400
class TestDhcpStaticLease: class TestDhcpStaticLease:
@patch("webui.api.dhcp.add_static_lease") @patch("webui.api.dhcp.add_static_lease")
def test_add(self, mock_add, client): def test_add(self, mock_add, client):
@@ -213,19 +323,15 @@ class TestDhcpStaticLease:
"dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]} "dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]}
} }
mock_remove.return_value = None mock_remove.return_value = None
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC") resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
assert resp.status_code == 200 assert resp.status_code == 200
@patch("webui.api.dhcp.get_config") @patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client): def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dhcp": {"static_leases": []}} mock_get.return_value = {"dhcp": {"static_leases": []}}
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC") resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
assert resp.status_code == 404 assert resp.status_code == 404
def test_remove_missing_mac(self, client):
resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400
class TestDhcpDnsRecord: class TestDhcpDnsRecord:
@patch("webui.api.dhcp.add_dns_record") @patch("webui.api.dhcp.add_dns_record")
@@ -241,6 +347,27 @@ class TestDhcpDnsRecord:
resp = client.post("/api/dhcp/dns-record", json={}) resp = client.post("/api/dhcp/dns-record", json={})
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dns_record")
@patch("webui.api.dhcp.get_config")
def test_remove(self, mock_get, mock_remove, client):
mock_get.return_value = {
"dns": {"custom_records": [{"name": "host.local", "address": "10.0.0.10"}]}
}
mock_remove.return_value = None
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 200
@patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dns": {"custom_records": []}}
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 404
# ============================================================================
# Proxy
# ============================================================================
class TestProxyDomains: class TestProxyDomains:
@patch("webui.api.proxy.get_domains") @patch("webui.api.proxy.get_domains")
@@ -271,6 +398,30 @@ class TestProxyApply:
assert resp.status_code == 200 assert resp.status_code == 200
class TestProxyTest:
@patch("webui.api.proxy.test_config")
def test_valid(self, mock_test, client):
mock_test.return_value = (True, "syntax ok")
resp = client.post("/api/proxy/test")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["valid"] is True
@patch("webui.api.proxy.test_config")
def test_invalid(self, mock_test, client):
mock_test.return_value = (False, "error msg")
resp = client.post("/api/proxy/test")
assert resp.status_code == 400
data = resp.get_json()
assert data["ok"] is False
assert data["error"] == "error msg"
# ============================================================================
# Certs
# ============================================================================
class TestCertsList: class TestCertsList:
@patch("webui.api.certs.list_certs") @patch("webui.api.certs.list_certs")
def test_list(self, mock_list, client): def test_list(self, mock_list, client):
@@ -297,6 +448,11 @@ class TestCertsEmail:
assert resp.status_code == 400 assert resp.status_code == 400
# ============================================================================
# WireGuard
# ============================================================================
class TestWireguardConfig: class TestWireguardConfig:
@patch("webui.api.wireguard.get_config") @patch("webui.api.wireguard.get_config")
def test_get(self, mock_get, client): def test_get(self, mock_get, client):
@@ -315,6 +471,38 @@ class TestWireguardConfig:
resp = client.post("/api/wireguard/config", json={"peers": {}}) resp = client.post("/api/wireguard/config", json={"peers": {}})
assert resp.status_code == 200 assert resp.status_code == 200
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_post_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.post(
"/api/wireguard/config",
json={"interface": {"name": "wg0", "private_key": "secret"}, "peers": {}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved["interface"]["private_key"] == "existing"
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_patch_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.patch(
"/api/wireguard/config",
json={"interface": {"name": "wg1", "private_key": "injected"}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved.get("interface", {}).get("private_key") == "existing"
class TestWireguardPeers: class TestWireguardPeers:
@patch("webui.api.wireguard.get_peers") @patch("webui.api.wireguard.get_peers")
@@ -325,9 +513,12 @@ class TestWireguardPeers:
@patch("webui.api.wireguard.add_peer") @patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client): def test_add(self, mock_add, client):
mock_add.return_value = {"public_key": "pub", "private_key": "priv"} mock_add.return_value = {
"name": "client1",
"public_key": "pub",
}
resp = client.post( resp = client.post(
"/api/wireguard/add-peer", "/api/wireguard/peers",
json={"name": "client1"}, json={"name": "client1"},
) )
data = resp.get_json() data = resp.get_json()
@@ -335,21 +526,31 @@ class TestWireguardPeers:
assert "private_key" not in data["data"] assert "private_key" not in data["data"]
def test_add_missing_name(self, client): def test_add_missing_name(self, client):
resp = client.post("/api/wireguard/add-peer", json={}) resp = client.post("/api/wireguard/peers", json={})
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.wireguard.remove_peer")
@patch("webui.api.wireguard.get_config")
def test_remove_by_name(self, mock_get, mock_remove, client):
mock_get.return_value = {"peers": {"client1": {}}}
mock_remove.return_value = None
resp = client.delete("/api/wireguard/peers/client1")
assert resp.status_code == 200
@patch("webui.api.wireguard.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"peers": {}}
resp = client.delete("/api/wireguard/peers/unknown")
assert resp.status_code == 404
class TestWireguardInitialize: class TestWireguardInitialize:
@patch("webui.api.wireguard.initialize") @patch("webui.api.wireguard.initialize")
def test_initialize(self, mock_init, client): def test_initialize(self, mock_init, client):
mock_init.return_value = { mock_init.return_value = None
"interface": {"name": "wg0", "private_key": "priv"},
"peers": {},
}
resp = client.post("/api/wireguard/initialize") resp = client.post("/api/wireguard/initialize")
data = resp.get_json() data = resp.get_json()
assert data["ok"] is True assert data["ok"] is True
assert data["data"] is None
class TestWireguardGenerateClient: class TestWireguardGenerateClient:
@@ -366,6 +567,41 @@ class TestWireguardStatus:
assert resp.status_code == 200 assert resp.status_code == 200
class TestWireguardUp:
@patch("webui.api.wireguard.apply")
def test_up_starts_tunnel(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/up")
assert resp.status_code == 200
@patch("webui.api.wireguard.apply")
def test_up_error(self, mock_apply, client):
mock_apply.side_effect = RuntimeError("interface down")
resp = client.post("/api/wireguard/up")
assert resp.status_code == 500
class TestWireguardDown:
@patch("webui.api.wireguard.down")
def test_down_stops_tunnel(self, mock_down, client):
mock_down.return_value = None
resp = client.post("/api/wireguard/down")
assert resp.status_code == 200
class TestWireguardApply:
@patch("webui.api.wireguard.apply")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/apply")
assert resp.status_code == 200
# ============================================================================
# Helpers
# ============================================================================
class TestResponseHelpers: class TestResponseHelpers:
@patch("webui.api.firewall.get_active_zones") @patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones") @patch("webui.api.firewall.get_available_zones")
+6 -6
View File
@@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest import pytest
from lib import dnsmasq from lib import common, dnsmasq
@pytest.fixture @pytest.fixture
@@ -29,24 +29,24 @@ class TestDeepMerge:
def test_merge_flat_dicts(self): def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2} base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4} override = {"b": 3, "c": 4}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": 1, "b": 3, "c": 4} assert result == {"a": 1, "b": 3, "c": 4}
def test_merge_nested_dicts(self): def test_merge_nested_dicts(self):
base = {"a": {"x": 1, "y": 2}} base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}} override = {"a": {"y": 3, "z": 4}}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": {"x": 1, "y": 3, "z": 4}} assert result == {"a": {"x": 1, "y": 3, "z": 4}}
def test_merge_non_dict_override(self): def test_merge_non_dict_override(self):
base = {"a": {"x": 1}} base = {"a": {"x": 1}}
override = {"a": "flat"} override = {"a": "flat"}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": "flat"} assert result == {"a": "flat"}
class TestGetConfig: class TestGetConfig:
@patch("lib.dnsmasq._load_json") @patch("lib.dnsmasq.load_json")
def test_returns_default_when_no_config(self, mock_load, temp_data_dir): def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
mock_load.return_value = {} mock_load.return_value = {}
result = dnsmasq.get_config() result = dnsmasq.get_config()
@@ -54,7 +54,7 @@ class TestGetConfig:
assert "dns" in result assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"] assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@patch("lib.dnsmasq._load_json") @patch("lib.dnsmasq.load_json")
def test_merges_with_existing_config(self, mock_load, temp_data_dir): def test_merges_with_existing_config(self, mock_load, temp_data_dir):
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}} mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
result = dnsmasq.get_config() result = dnsmasq.get_config()
+293 -10
View File
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
class TestGetActiveZones: class TestGetActiveZones:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_active_zones(self, mock_run): def test_parses_active_zones(self, mock_run):
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2" mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
result = firewall.get_active_zones() result = firewall.get_active_zones()
@@ -35,13 +35,13 @@ class TestGetActiveZones:
"internal": ["eth1", "eth2"], "internal": ["eth1", "eth2"],
} }
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_empty_output(self, mock_run): def test_empty_output(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
result = firewall.get_active_zones() result = firewall.get_active_zones()
assert result == {} assert result == {}
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_zone_with_no_interfaces(self, mock_run): def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz" mock_run.return_value = "dmz"
result = firewall.get_active_zones() result = firewall.get_active_zones()
@@ -49,7 +49,7 @@ class TestGetActiveZones:
class TestGetZoneInfo: class TestGetZoneInfo:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_zone_info(self, mock_run): def test_parses_zone_info(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
"target: default\n" "target: default\n"
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
class TestGetInterfaces: class TestGetInterfaces:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_interfaces(self, mock_run): def test_parses_interfaces(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n" "1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
@@ -88,7 +88,7 @@ class TestGetInterfaces:
class TestGetRichRules: class TestGetRichRules:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_single_rule(self, mock_run): def test_single_rule(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;' 'rule family="ipv4" port protocol="tcp" port="443" accept;'
@@ -96,13 +96,13 @@ class TestGetRichRules:
result = firewall.get_rich_rules("public") result = firewall.get_rich_rules("public")
assert len(result) == 1 assert len(result) == 1
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_empty_rules(self, mock_run): def test_empty_rules(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
result = firewall.get_rich_rules("public") result = firewall.get_rich_rules("public")
assert result == [] assert result == []
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_multiline_rule(self, mock_run): def test_multiline_rule(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;' 'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
@@ -120,14 +120,14 @@ class TestNowIso:
class TestAddForwardPort: class TestAddForwardPort:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_forward_port_basic(self, mock_run): def test_forward_port_basic(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080) firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
calls = [c[0][0] for c in mock_run.call_args_list] calls = [c[0][0] for c in mock_run.call_args_list]
assert any("--add-forward-port=" in str(c) for c in calls) assert any("--add-forward-port=" in str(c) for c in calls)
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_forward_port_port_only(self, mock_run): def test_forward_port_port_only(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080) firewall.add_forward_port("public", 80, "tcp", toport=8080)
@@ -159,3 +159,286 @@ class TestGetState:
assert "zones" in result assert "zones" in result
assert "active_zones" in result assert "active_zones" in result
assert "timestamp" in result assert "timestamp" in result
class TestNormalizeTarget:
def test_accept(self):
assert firewall._normalize_target("ACCEPT") == "ACCEPT"
def test_drop(self):
assert firewall._normalize_target("DROP") == "DROP"
def test_reject(self):
assert firewall._normalize_target("REJECT") == "REJECT"
def test_default(self):
assert firewall._normalize_target("DEFAULT") == "default"
assert firewall._normalize_target("default") == "default"
assert firewall._normalize_target("UNKNOWN") == "default"
class TestLiveTargetToConfig:
def test_accept(self):
assert firewall._live_target_to_config("ACCEPT") == "ACCEPT"
def test_drop(self):
assert firewall._live_target_to_config("DROP") == "DROP"
def test_reject(self):
assert firewall._live_target_to_config("REJECT") == "REJECT"
def test_default(self):
assert firewall._live_target_to_config("default") == "DEFAULT"
assert firewall._live_target_to_config("") == "DEFAULT"
class TestEnsureConfigFile:
def test_creates_file_if_missing(self, tmp_path):
cfg_dir = tmp_path / "config" / "firewall"
cfg_file = cfg_dir / "config.json"
with (
patch.object(firewall, "CONFIG_DIR", cfg_dir),
patch.object(firewall, "CONFIG_FILE", cfg_file),
):
firewall._ensure_config_file()
assert cfg_file.exists()
import json as _json
content = _json.loads(cfg_file.read_text())
assert content == {"zones": {}}
def test_skips_existing_file(self, tmp_path):
cfg_dir = tmp_path / "config" / "firewall"
cfg_file = cfg_dir / "config.json"
cfg_dir.mkdir(parents=True)
cfg_file.write_text('{"zones": {"public": {}}}')
with (
patch.object(firewall, "CONFIG_DIR", cfg_dir),
patch.object(firewall, "CONFIG_FILE", cfg_file),
):
firewall._ensure_config_file()
content = cfg_file.read_text()
assert '{"zones": {"public": {}}}' in content
class TestConfigGet:
@patch("lib.firewall._ensure_config_file")
def test_returns_config(self, mock_ensure, tmp_path):
cfg_file = tmp_path / "config.json"
cfg_file.write_text(
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
)
with patch.object(firewall, "CONFIG_FILE", cfg_file):
result = firewall.get_config()
assert result["zones"]["public"]["interfaces"] == ["eth0"]
assert result["zones"]["public"]["services"] == ["http"]
class TestConfigSet:
def test_writes_config_atomic(self, tmp_path):
cfg_file = tmp_path / "config.json"
with (
patch.object(firewall, "CONFIG_FILE", cfg_file),
patch.object(firewall, "CONFIG_DIR", tmp_path),
):
firewall.save_config({"zones": {"test": {"interfaces": ["eth0"]}}})
import json as _json
content = _json.loads(cfg_file.read_text())
assert content["zones"]["test"]["interfaces"] == ["eth0"]
class TestConfigApply:
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_applies_existing_zone(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
mock_cfg.return_value = {
"zones": {
"public": {
"target": "DEFAULT",
"interfaces": ["eth0"],
"services": ["http", "https"],
"masquerade": True,
},
},
}
mock_available.return_value = ["public", "internal"]
mock_backup.return_value = "/tmp/rules.json"
result = firewall.config_apply()
assert result["applied_zones"] == ["public"]
assert result["backup"] == "/tmp/rules.json"
mock_set_ifaces.assert_called_once_with("public", ["eth0"])
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
mock_set_mq.assert_called_once_with("public", True)
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_creates_new_zone(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
mock_cfg.return_value = {
"zones": {
"custom": {
"target": "ACCEPT",
"interfaces": ["eth2"],
"services": [],
"masquerade": False,
},
},
}
mock_available.return_value = ["public", "internal"]
mock_backup.return_value = "/tmp/rules.json"
result = firewall.config_apply()
assert result["applied_zones"] == ["custom"]
mock_create.assert_called_once_with("custom", "ACCEPT")
mock_set_ifaces.assert_called_once_with("custom", ["eth2"])
class TestConfigPending:
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_interface_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth1"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending()
assert result["needs_apply"] is True
assert any(c["type"] == "interfaces" for c in result["pending"])
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_in_sync(self, mock_state, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending()
assert result["needs_apply"] is False
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_services_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http", "ssh"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending()
assert any(c["type"] == "services" for c in result["pending"])
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
mock_cfg.return_value = {"zones": {}}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": [],
"masquerade": False,
},
},
}
result = firewall.config_pending()
assert "public" in result["unmanaged_zones"]
class TestConfigEmptyZones:
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_empty_config_no_ops(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
mock_cfg.return_value = {"zones": {}}
mock_available.return_value = []
mock_backup.return_value = "/tmp/rules.json"
result = firewall.config_apply()
assert result["applied_zones"] == []
mock_create.assert_not_called()
mock_set_ifaces.assert_not_called()
+2 -2
View File
@@ -200,7 +200,7 @@ class TestWriteAllSites:
class TestTestConfig: class TestTestConfig:
@patch("lib.nginx._run") @patch("lib.nginx.subprocess.run")
def test_passes(self, mock_run, temp_data_dir): def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n" returncode=0, stdout="", stderr="test passed\n"
@@ -208,7 +208,7 @@ class TestTestConfig:
ok, _msg = nginx.test_config() ok, _msg = nginx.test_config()
assert ok is True assert ok is True
@patch("lib.nginx._run") @patch("lib.nginx.subprocess.run")
def test_fails(self, mock_run, temp_data_dir): def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n" returncode=1, stdout="", stderr="nginx: configuration test failed\n"
+18 -14
View File
@@ -1,5 +1,5 @@
import json import json
from pathlib import Path from copy import deepcopy
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -10,14 +10,14 @@ from lib import wireguard
@pytest.fixture @pytest.fixture
def temp_config(tmp_path): def temp_config(tmp_path):
original = wireguard.CONFIG_PATH original = wireguard.CONFIG_PATH
wireguard.CONFIG_PATH = str(tmp_path / "config.json") wireguard.CONFIG_PATH = tmp_path / "config.json"
yield tmp_path yield tmp_path
wireguard.CONFIG_PATH = original wireguard.CONFIG_PATH = original
class TestDefaultConfig: class TestDefaultConfig:
def test_returns_skeleton(self): def test_returns_skeleton(self):
cfg = wireguard._default_config() cfg = wireguard.DEFAULT_CONFIG
assert cfg["interface"]["name"] == "wg0" assert cfg["interface"]["name"] == "wg0"
assert cfg["interface"]["listen_port"] == 51820 assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["private_key"] == "" assert cfg["interface"]["private_key"] == ""
@@ -31,8 +31,9 @@ class TestGetConfig:
assert cfg["peers"] == {} assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config): def test_loads_existing_config(self, temp_config):
path = Path(wireguard.CONFIG_PATH) wireguard.CONFIG_PATH.write_text(
expected = { json.dumps(
{
"interface": { "interface": {
"name": "wg0", "name": "wg0",
"listen_port": 51820, "listen_port": 51820,
@@ -44,14 +45,15 @@ class TestGetConfig:
}, },
"peers": {}, "peers": {},
} }
path.write_text(json.dumps(expected)) )
)
cfg = wireguard.get_config() cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key" assert cfg["interface"]["private_key"] == "existing-key"
class TestSaveConfig: class TestSaveConfig:
def test_save_and_reload(self, temp_config): def test_save_and_reload(self, temp_config):
cfg = wireguard._default_config() cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["interface"]["listen_port"] = 51821 cfg["interface"]["listen_port"] = 51821
wireguard.save_config(cfg) wireguard.save_config(cfg)
loaded = wireguard.get_config() loaded = wireguard.get_config()
@@ -59,7 +61,7 @@ class TestSaveConfig:
class TestGenerateKeyPair: class TestGenerateKeyPair:
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_returns_keypair(self, mock_run): def test_returns_keypair(self, mock_run):
mock_run.side_effect = [ mock_run.side_effect = [
MagicMock(returncode=0, stdout="private-key\n"), MagicMock(returncode=0, stdout="private-key\n"),
@@ -68,6 +70,8 @@ class TestGenerateKeyPair:
private, public = wireguard.generate_keypair() private, public = wireguard.generate_keypair()
assert private == "private-key" assert private == "private-key"
assert public == "public-key" assert public == "public-key"
assert mock_run.call_count == 2
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
class TestGetPeers: class TestGetPeers:
@@ -97,7 +101,7 @@ class TestGetPeers:
} }
}, },
} }
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg)) wireguard.CONFIG_PATH.write_text(json.dumps(cfg))
peers = wireguard.get_peers() peers = wireguard.get_peers()
assert len(peers) == 1 assert len(peers) == 1
assert peers[0]["name"] == "client1" assert peers[0]["name"] == "client1"
@@ -110,7 +114,7 @@ class TestAddPeer:
mock_gen.return_value = ("priv", "pub") mock_gen.return_value = ("priv", "pub")
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"]) result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
assert result["public_key"] == "pub" assert result["public_key"] == "pub"
assert result["private_key"] == "priv" assert "private_key" not in result
assert result["allowed_ips"] == ["10.0.0.0/24"] assert result["allowed_ips"] == ["10.0.0.0/24"]
@patch("lib.wireguard.generate_keypair") @patch("lib.wireguard.generate_keypair")
@@ -180,14 +184,14 @@ class TestInitialize:
}, },
"peers": {}, "peers": {},
} }
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing)) wireguard.CONFIG_PATH.write_text(json.dumps(existing))
cfg = wireguard.initialize() cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "original-private" assert cfg["interface"]["private_key"] == "original-private"
mock_gen.assert_not_called() mock_gen.assert_not_called()
class TestStatus: class TestStatus:
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_returns_down_when_interface_down(self, mock_run, temp_config): def test_returns_down_when_interface_down(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="interface not found" returncode=1, stdout="", stderr="interface not found"
@@ -195,7 +199,7 @@ class TestStatus:
result = wireguard.status() result = wireguard.status()
assert result["up"] is False assert result["up"] is False
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_parses_interface_info(self, mock_run, temp_config): def test_parses_interface_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, returncode=0,
@@ -206,7 +210,7 @@ class TestStatus:
assert result["interface"]["public_key"] == "ABCDEF" assert result["interface"]["public_key"] == "ABCDEF"
assert result["interface"]["listen_port"] == 51820 assert result["interface"]["listen_port"] == 51820
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_parses_peer_info(self, mock_run, temp_config): def test_parses_peer_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, returncode=0,
Vendored Executable
+8326
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
htmx.defineExtension('json-enc', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json'
}
},
encodeParameters: function(xhr, parameters, elt) {
xhr.overrideMimeType('text/json')
return (JSON.stringify(parameters))
}
})
+32 -32
View File
@@ -1,10 +1,11 @@
""" """ACME certificate management API blueprint.
webui/api/certs.py - ACME certificate management API blueprint.
Exposed at /api/certs/* and delegates to lib.acme. Exposed at /api/certs/* and delegates to lib.acme.
""" """
from flask import Blueprint, jsonify, request import logging
from flask import Blueprint, request
from lib.acme import ( from lib.acme import (
get_cert_info, get_cert_info,
@@ -14,23 +15,12 @@ from lib.acme import (
renew, renew,
set_email, set_email,
) )
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("certs", __name__) bp = Blueprint("certs", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Certificate listing # Certificate listing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -40,18 +30,20 @@ def _ok(data=None):
def list_certs_bp(): def list_certs_bp():
try: try:
return _ok(list_certs()) return _ok(list_certs())
except RuntimeError as exc: except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/<domain>", methods=["GET"]) @bp.route("/<domain>", methods=["GET"])
def cert_details(domain): def cert_details(domain: str):
try: try:
info = get_cert_info(domain) info = get_cert_info(domain)
return _ok(info) return _ok(info)
except ValueError as exc: except ValueError as exc:
return _error(str(exc), 404) return _error(str(exc), 404)
except RuntimeError as exc: except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -67,12 +59,14 @@ def issue_bp():
if not domain: if not domain:
return _error("'domain' is required", 400) return _error("'domain' is required", 400)
webroot = body.get("webroot") webroot = body.get("webroot")
email = body.get("email", "").strip() or None
try: try:
result = issue(domain, webroot=webroot) logger.info("Certificate issuance requested for '%s' via API", domain)
if result.get("success"): issue(domain, webroot=webroot, email=email)
logger.info("Certificate issued for '%s'", domain)
return _ok(None) return _ok(None)
return _error(result.get("error", "Unknown error"), 500) except (RuntimeError, FileNotFoundError) as exc:
except RuntimeError as exc: logger.error("Failed to issue cert for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -82,13 +76,14 @@ def issue_bp():
@bp.route("/<domain>/renew", methods=["POST"]) @bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain): def renew_bp(domain: str):
try: try:
result = renew(domain) logger.info("Certificate renewal requested for '%s' via API", domain)
if result.get("success"): renew(domain)
logger.info("Certificate renewed for '%s'", domain)
return _ok(None) return _ok(None)
return _error(result.get("error", "Unknown error"), 500) except (RuntimeError, FileNotFoundError) as exc:
except RuntimeError as exc: logger.error("Failed to renew cert for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -98,17 +93,20 @@ def renew_bp(domain):
@bp.route("/<domain>", methods=["DELETE"]) @bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain): def remove_bp(domain: str):
try: try:
get_cert_info(domain) get_cert_info(domain)
except ValueError as exc: except ValueError as exc:
return _error(str(exc), 404) return _error(str(exc), 404)
except RuntimeError as exc: except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to verify cert '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
try: try:
remove(domain) remove(domain)
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None) return _ok(None)
except RuntimeError as exc: except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -125,6 +123,8 @@ def set_email_bp():
return _error("'email' is required", 400) return _error("'email' is required", 400)
try: try:
set_email(email) set_email(email)
logger.info("ACME email set via API: %s", email)
return _ok({"email": email}) return _ok({"email": email})
except RuntimeError as exc: except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+18
View File
@@ -0,0 +1,18 @@
"""Shared API response helpers.
Used by all API blueprints to produce consistent JSON responses
per the API response contract: ``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``.
"""
from flask import jsonify
def _ok(data=None):
"""Return a success JSON response."""
return jsonify({"ok": True, "data": data})
def _error(msg: str, code: int = 400):
"""Return an error JSON response with the given HTTP status code."""
return jsonify({"ok": False, "error": msg}), code
+90 -36
View File
@@ -4,45 +4,32 @@ webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint.
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq. Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
""" """
from flask import Blueprint, jsonify, request import logging
from flask import Blueprint, request
from lib.common import deep_merge
from lib.dnsmasq import ( from lib.dnsmasq import (
add_dns_record, add_dns_record,
add_static_lease, add_static_lease,
apply_config, apply_config,
get_config, get_config,
get_lease_table, get_lease_table,
remove_dhcp_range,
remove_dns_record, remove_dns_record,
remove_static_lease, remove_static_lease,
save_config, save_config,
set_dhcp_range,
) )
from lib.dnsmasq import (
get_status as dnsmasq_status,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("dhcp", __name__) bp = Blueprint("dhcp", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
def _deep_merge(base, overrides):
result = dict(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = v
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -53,6 +40,7 @@ def get_config_bp():
try: try:
return _ok(get_config()) return _ok(get_config())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to read DHCP config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -65,6 +53,7 @@ def post_config():
save_config(body) save_config(body)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to save DHCP config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -75,10 +64,11 @@ def patch_config():
return _error("Request body must be a JSON object", 400) return _error("Request body must be a JSON object", 400)
try: try:
current = get_config() current = get_config()
merged = _deep_merge(current, body) merged = deep_merge(current, body)
save_config(merged) save_config(merged)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to patch DHCP config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -86,13 +76,74 @@ def patch_config():
def apply_bp(): def apply_bp():
try: try:
apply_config() apply_config()
logger.info("dnsmasq config applied via API")
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to apply dnsmasq config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Leases # Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
def status_bp():
try:
return _ok(dnsmasq_status())
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DHCP ranges
# ---------------------------------------------------------------------------
@bp.route("/ranges", methods=["POST"])
def add_range_bp():
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or None
start = body.get("start", "").strip()
end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h")
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
set_dhcp_range(
iface if iface else "",
start,
end,
lease_time=lease_time,
)
logger.info("DHCP range added via API: %s-%s", start, end)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to add DHCP range: %s", exc)
return _error(str(exc), 500)
@bp.route("/ranges", methods=["DELETE"])
def remove_range_bp():
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
end = body.get("end", "").strip()
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
remove_dhcp_range(iface, start, end)
logger.info("DHCP range removed via API: %s-%s", start, end)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to remove DHCP range: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Static leases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -101,6 +152,7 @@ def leases_bp():
try: try:
return _ok(get_lease_table()) return _ok(get_lease_table())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to read lease table: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -119,16 +171,15 @@ def add_static_lease_bp():
return _error("'mac' and 'ip' are required", 400) return _error("'mac' and 'ip' are required", 400)
try: try:
add_static_lease(mac, ip, hostname) add_static_lease(mac, ip, hostname)
logger.info("Static lease added via API: %s -> %s", mac, ip)
return _ok({"mac": mac, "ip": ip, "hostname": hostname}) return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to add static lease: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/static-lease", methods=["DELETE"]) @bp.route("/static-lease/<mac>", methods=["DELETE"])
def remove_static_lease_bp(): def remove_static_lease_bp(mac):
mac = request.args.get("mac", "").strip()
if not mac:
return _error("Query parameter 'mac' is required", 400)
current = get_config() current = get_config()
found = any( found = any(
lease["mac"].lower() == mac.lower() lease["mac"].lower() == mac.lower()
@@ -138,8 +189,10 @@ def remove_static_lease_bp():
return _error(f"No static lease found for MAC '{mac}'", 404) return _error(f"No static lease found for MAC '{mac}'", 404)
try: try:
remove_static_lease(mac) remove_static_lease(mac)
logger.info("Static lease removed via API: %s", mac)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to remove static lease: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -158,16 +211,15 @@ def add_dns_record_bp():
return _error("'name' and 'address' are required", 400) return _error("'name' and 'address' are required", 400)
try: try:
add_dns_record(name, address, hostname) add_dns_record(name, address, hostname)
logger.info("DNS record added via API: %s -> %s", name, address)
return _ok({"name": name, "address": address, "hostname": hostname}) return _ok({"name": name, "address": address, "hostname": hostname})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to add DNS record: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/dns-record", methods=["DELETE"]) @bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(): def remove_dns_record_bp(name):
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
current = get_config() current = get_config()
found = any( found = any(
r["name"] == name for r in current.get("dns", {}).get("custom_records", []) r["name"] == name for r in current.get("dns", {}).get("custom_records", [])
@@ -176,6 +228,8 @@ def remove_dns_record_bp():
return _error(f"No DNS record found for '{name}'", 404) return _error(f"No DNS record found for '{name}'", 404)
try: try:
remove_dns_record(name) remove_dns_record(name)
logger.info("DNS record removed via API: %s", name)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to remove DNS record: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+152 -65
View File
@@ -1,43 +1,120 @@
""" """Firewall (firewalld) management API blueprint.
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
Exposed at /api/firewall/* and delegates all mutations to lib.firewall. Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
""" """
from flask import Blueprint, jsonify, request import logging
from flask import Blueprint, request
from lib.common import deep_merge
from lib.firewall import ( from lib.firewall import (
add_forward_port, add_forward_port,
add_rich_rule, add_rich_rule,
config_apply,
config_pending,
create_zone, create_zone,
delete_zone, delete_zone,
get_active_zones, get_active_zones,
get_available_zones, get_available_zones,
get_config,
get_interfaces, get_interfaces,
get_rich_rules, get_rich_rules,
get_services, get_services,
get_zone_info, get_zone_info,
remove_forward_port, remove_forward_port_by_id,
remove_rich_rule, remove_rich_rule_by_id,
save_config,
set_masquerade, set_masquerade,
set_zone_interfaces, set_zone_interfaces,
set_zone_services, set_zone_services,
) )
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__) bp = Blueprint("firewall", __name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Error helpers # Declarative config (two-step: save -> apply)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _error(msg, code=400): @bp.route("/config", methods=["GET"])
return jsonify({"ok": False, "error": msg}), code def config_list():
try:
return _ok(get_config())
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
def _ok(data=None): @bp.route("/config", methods=["POST"])
return jsonify({"ok": True, "data": data}) def config_save():
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:
save_config(body)
pending_info = config_pending()
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
{
"config_saved": True,
"pending": pending_info["pending"],
"needs_apply": pending_info["needs_apply"],
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
}
)
except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["PATCH"])
def patch_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
current = get_config()
merged = deep_merge(current, body)
save_config(merged)
pending_info = config_pending()
logger.info("Firewall config patched: %s", sorted(body.keys()))
return _ok(
{
"config_saved": True,
"pending": pending_info["pending"],
"needs_apply": pending_info["needs_apply"],
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
}
)
except RuntimeError as exc:
logger.error("Failed to patch firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config/apply", methods=["POST"])
def config_apply_bp():
try:
result = config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config/pending", methods=["GET"])
def config_pending_bp():
try:
return _ok(config_pending())
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -50,27 +127,21 @@ def list_zones():
try: try:
active = get_active_zones() active = get_active_zones()
available = get_available_zones() available = get_available_zones()
return jsonify( return _ok({"active": active, "available": available})
{
"ok": True,
"data": {
"active": active,
"available": available,
},
}
)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to list zones: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/zones/<name>", methods=["GET"]) @bp.route("/zones/<name>", methods=["GET"])
def zone_details(name): def zone_details(name: str):
try: try:
if name not in get_available_zones(): if name not in get_available_zones():
return _error(f"Zone '{name}' does not exist", 404) return _error(f"Zone '{name}' does not exist", 404)
info = get_zone_info(name) info = get_zone_info(name)
return jsonify({"ok": True, "data": info}) return _ok(info)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -85,20 +156,24 @@ def create_zone_bp():
if zone_name in get_available_zones(): if zone_name in get_available_zones():
return _error(f"Zone '{zone_name}' already exists", 400) return _error(f"Zone '{zone_name}' already exists", 400)
create_zone(zone_name, target) create_zone(zone_name, target)
logger.info("Zone '%s' created via API", zone_name)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to create zone '%s': %s", zone_name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/zones/<name>", methods=["DELETE"]) @bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name): def delete_zone_bp(name: str):
try: try:
available = get_available_zones() available = get_available_zones()
if name not in available: if name not in available:
return _error(f"Zone '{name}' does not exist", 404) return _error(f"Zone '{name}' does not exist", 404)
delete_zone(name) delete_zone(name)
logger.info("Zone '%s' deleted via API", name)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to delete zone '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -108,15 +183,17 @@ def delete_zone_bp(name):
@bp.route("/zones/<name>/interfaces", methods=["POST"]) @bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name): def set_zone_interfaces_bp(name: str):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", []) interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list): if not isinstance(interfaces, list):
return _error("'interfaces' must be a list", 400) return _error("'interfaces' must be a list", 400)
try: try:
set_zone_interfaces(name, interfaces) set_zone_interfaces(name, interfaces)
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces}) return _ok({"zone": name, "interfaces": interfaces})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -126,7 +203,7 @@ def set_zone_interfaces_bp(name):
@bp.route("/zones/<name>/services", methods=["POST"]) @bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name): def set_zone_services_bp(name: str):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
services = body.get("services", []) services = body.get("services", [])
if not isinstance(services, list): if not isinstance(services, list):
@@ -135,6 +212,7 @@ def set_zone_services_bp(name):
set_zone_services(name, services) set_zone_services(name, services)
return _ok({"zone": name, "services": services}) return _ok({"zone": name, "services": services})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -148,6 +226,7 @@ def list_services():
try: try:
return _ok(get_services()) return _ok(get_services())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -156,6 +235,7 @@ def list_interfaces():
try: try:
return _ok(get_interfaces()) return _ok(get_interfaces())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -172,32 +252,43 @@ def add_rich_rule_bp():
if not zone or not rule: if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400) return _error("Both 'zone' and 'rule' are required", 400)
try: try:
add_rich_rule(zone, rule) entry = add_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule}) logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
except RuntimeError as exc: return _ok({"zone": zone, "id": entry["id"], "rule": rule})
return _error(str(exc), 500)
@bp.route("/rich-rules", methods=["DELETE"])
def remove_rich_rule_bp():
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:
remove_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/rich-rules/<zone>", methods=["GET"]) @bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone): def list_rich_rules(zone: str):
try: try:
rules = get_rich_rules(zone) rules = get_rich_rules(zone)
return _ok(rules) cfg = get_config()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result = []
for rule_str in rules:
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
if matched:
result.append({"id": matched["id"], "rule": rule_str})
else:
result.append({"rule": rule_str})
return _ok(result)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone: str, rule_id: str):
try:
remove_rich_rule_by_id(zone, rule_id)
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -215,8 +306,14 @@ def set_masquerade_bp():
return _error("'zone' and 'enable' (bool) are required", 400) return _error("'zone' and 'enable' (bool) are required", 400)
try: try:
set_masquerade(zone, bool(enable)) set_masquerade(zone, bool(enable))
logger.info(
"Masquerade %s on zone '%s' via API",
"enabled" if enable else "disabled",
zone,
)
return _ok({"zone": zone, "masquerade": bool(enable)}) return _ok({"zone": zone, "masquerade": bool(enable)})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -236,38 +333,28 @@ def add_forward_port_bp():
if not zone or port is None or not proto: if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400) return _error("'zone', 'port', and 'proto' are required", 400)
try: try:
add_forward_port( entry = add_forward_port(
zone, zone,
int(port), int(port),
proto, proto,
toaddr=str(toaddr) if toaddr else None, toaddr=str(toaddr) if toaddr else None,
toport=int(toport) if toport else None, toport=int(toport) if toport else None,
) )
return _ok({"zone": zone, "port": int(port), "proto": proto}) return _ok({"zone": zone, "id": entry["id"], "port": int(port), "proto": proto})
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500 code = 400 if isinstance(exc, ValueError) else 500
logger.error("Failed to add forward port: %s", exc)
return _error(str(exc), code) return _error(str(exc), code)
@bp.route("/forward-port", methods=["DELETE"]) @bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(): def remove_forward_port_bp(zone: str, port: int, proto: str):
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try: try:
remove_forward_port( remove_forward_port_by_id(zone, port, proto)
zone, logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
int(port), return _ok({"zone": zone, "port": port, "proto": proto})
proto, except ValueError as exc:
toaddr=str(toaddr) if toaddr else None, return _error(str(exc), 404)
toport=int(toport) if toport else None, except RuntimeError as exc:
) logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
return _ok({"zone": zone, "port": int(port), "proto": proto}) return _error(str(exc), 500)
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
+99
View File
@@ -0,0 +1,99 @@
"""
webui/api/logs.py - Log viewing API blueprint.
Serves log content to the /logs page via HTMX endpoints:
/api/logs/journal systemd journal for vacuum-wall
/api/logs/nginx/access nginx access log tail
/api/logs/nginx/error nginx error log tail
/api/logs/dnsmasq systemd journal for dnsmasq
/api/logs/app Vacuum Wall application log file
"""
import logging
import subprocess
from pathlib import Path
from flask import Blueprint, render_template_string
logger = logging.getLogger(__name__)
bp = Blueprint("logs", __name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
_MAX_LINES = 200
def _tail_file(path: str, n: int = _MAX_LINES) -> str:
"""Return the last *n* lines of a file."""
try:
with open(path) as f:
lines = f.readlines()
return "".join(lines[-n:])
except FileNotFoundError:
return "(log file not found)\n"
except PermissionError:
return "(permission denied)\n"
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
"""Run ``sudo journalctl -u <unit> --no-pager -n <n>`` and return output."""
try:
result = subprocess.run(
["sudo", "journalctl", "-u", unit, "--no-pager", "-n", str(n)],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout.strip()
return output if output else f"(no journal entries for {unit})\n"
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
return f"(error reading journal: {exc})\n"
_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:
"""Render raw log text into HTML fragment with line-by-line coloring."""
lines = text.rstrip("\n").split("\n") if text.strip() else []
return render_template_string(_LOG_LINE_TEMPLATE, lines=lines)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@bp.route("/journal")
def journal():
text = _sudo_journalctl("vacuum-wall")
return _render_log_lines(text)
@bp.route("/nginx/access")
def nginx_access():
text = _tail_file("/var/log/nginx/access.log")
return _render_log_lines(text)
@bp.route("/nginx/error")
def nginx_error():
text = _tail_file("/var/log/nginx/error.log")
return _render_log_lines(text)
@bp.route("/dnsmasq")
def dnsmasq():
text = _sudo_journalctl("dnsmasq")
return _render_log_lines(text)
@bp.route("/app")
def app_log():
text = _tail_file(str(APP_LOG_FILE))
return _render_log_lines(text)
+70 -7
View File
@@ -4,33 +4,83 @@ webui/api/proxy.py - Nginx proxy domain management API blueprint.
Exposed at /api/proxy/* and delegates to lib.nginx. Exposed at /api/proxy/* and delegates to lib.nginx.
""" """
from flask import Blueprint, jsonify, request import logging
from flask import Blueprint, request
from lib.common import deep_merge
from lib.nginx import ( from lib.nginx import (
add_domain, add_domain,
apply, apply,
get_config, get_config,
get_domains, get_domains,
remove_domain, remove_domain,
save_config,
set_management_proxy, set_management_proxy,
test_config, test_config,
update_domain, update_domain,
write_ssl_snippet,
) )
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("proxy", __name__) bp = Blueprint("proxy", __name__)
@bp.route("/ssl-apply", methods=["POST"])
def ssl_apply_bp():
"""Apply (write) the global SSL snippet for all Nginx server blocks."""
try:
write_ssl_snippet()
logger.info("SSL snippet written via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to write SSL snippet: %s", exc)
return _error(str(exc), 500)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Config (declarative)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _error(msg, code=400): @bp.route("/config", methods=["GET"])
return jsonify({"ok": False, "error": msg}), code def get_config_bp():
try:
return _ok(get_config())
except RuntimeError as exc:
logger.error("Failed to read proxy config: %s", exc)
return _error(str(exc), 500)
def _ok(data=None): @bp.route("/config", methods=["POST"])
return jsonify({"ok": True, "data": data}) def post_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
save_config(body)
logger.info("Proxy config saved: %s", sorted(body.keys()))
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to save proxy config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["PATCH"])
def patch_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
current = get_config()
merged = deep_merge(current, body)
save_config(merged)
logger.info("Proxy config patched: %s", sorted(body.keys()))
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to patch proxy config: %s", exc)
return _error(str(exc), 500)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -43,6 +93,7 @@ def list_domains():
try: try:
return _ok(get_domains()) return _ok(get_domains())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to list proxy domains: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -65,8 +116,10 @@ def add_domain_bp():
add_domain( add_domain(
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
) )
logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain}) return _ok({"domain": domain})
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -79,6 +132,7 @@ def domain_details(domain):
return _error(f"Domain '{domain}' not found", 404) return _error(f"Domain '{domain}' not found", 404)
return _ok({"domain": domain, **entry}) return _ok({"domain": domain, **entry})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get domain details: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -89,10 +143,12 @@ def update_domain_bp(domain):
return _error("Request body must be a JSON object with fields to update", 400) return _error("Request body must be a JSON object with fields to update", 400)
try: try:
update_domain(domain, **body) update_domain(domain, **body)
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain}) return _ok({"domain": domain})
except KeyError as exc: except KeyError as exc:
return _error(str(exc), 404) return _error(str(exc), 404)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to update domain '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -103,8 +159,10 @@ def remove_domain_bp(domain):
if domain not in cfg.get("domains", {}): if domain not in cfg.get("domains", {}):
return _error(f"Domain '{domain}' not found", 404) return _error(f"Domain '{domain}' not found", 404)
remove_domain(domain) remove_domain(domain)
logger.info("Proxy domain removed via API: %s", domain)
return _ok({"domain": domain}) return _ok({"domain": domain})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to remove domain '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -117,8 +175,10 @@ def remove_domain_bp(domain):
def apply_bp(): def apply_bp():
try: try:
apply() apply()
logger.info("nginx config applied via API")
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to apply nginx config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -128,8 +188,9 @@ def test_bp():
valid, output = test_config() valid, output = test_config()
if valid: if valid:
return _ok({"valid": True, "output": output}) return _ok({"valid": True, "output": output})
return jsonify({"ok": False, "error": output, "valid": False}), 400 return _error(output, 400)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -150,7 +211,9 @@ def management_bp():
auth_pass = body.get("auth_pass") auth_pass = body.get("auth_pass")
try: try:
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass) set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
logger.info("Management proxy configured via API: %s", domain)
return _ok(None) return _ok(None)
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500 code = 400 if isinstance(exc, ValueError) else 500
logger.error("Failed to set management proxy: %s", exc)
return _error(str(exc), code) return _error(str(exc), code)
+69 -30
View File
@@ -4,8 +4,11 @@ webui/api/wireguard.py - WireGuard tunnel management API blueprint.
Exposed at /api/wireguard/* and delegates to lib.wireguard. Exposed at /api/wireguard/* and delegates to lib.wireguard.
""" """
from flask import Blueprint, jsonify, request import logging
from flask import Blueprint, request
from lib.common import deep_merge
from lib.wireguard import ( from lib.wireguard import (
add_peer, add_peer,
apply, apply,
@@ -19,23 +22,12 @@ from lib.wireguard import (
save_config, save_config,
status, status,
) )
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("wireguard", __name__) bp = Blueprint("wireguard", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -51,6 +43,7 @@ def get_config_bp():
safe["interface"].pop("private_key", None) safe["interface"].pop("private_key", None)
return _ok(safe) return _ok(safe)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to read WireGuard config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -60,13 +53,40 @@ def post_config():
if not isinstance(body, dict): if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400) return _error("Request body must be a JSON object", 400)
try: try:
# Preserve existing server private key through full replacement
current = get_config()
current_key = current.get("interface", {}).get("private_key", "")
if "interface" in body:
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
if current_key:
body.setdefault("interface", {})["private_key"] = current_key
save_config(body) save_config(body)
safe = dict(body) return _ok(None)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to save WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["PATCH"])
def patch_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
if "interface" in body:
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
current = get_config()
merged = deep_merge(current, body)
save_config(merged)
logger.info("WireGuard config patched: %s", sorted(body.keys()))
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to patch WireGuard config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -79,8 +99,21 @@ def post_config():
def apply_bp(): def apply_bp():
try: try:
apply() apply()
logger.info("WireGuard tunnel applied via API")
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to apply WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/up", methods=["POST"])
def up_bp():
try:
apply()
logger.info("WireGuard tunnel started via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to start WireGuard tunnel: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -88,8 +121,10 @@ def apply_bp():
def down_bp(): def down_bp():
try: try:
down() down()
logger.info("WireGuard tunnel brought down via API")
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -103,6 +138,7 @@ def status_bp():
try: try:
return _ok(status()) return _ok(status())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get WireGuard status: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -115,8 +151,10 @@ def status_bp():
def initialize_bp(): def initialize_bp():
try: try:
initialize() initialize()
logger.info("WireGuard initialized via API")
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to initialize WireGuard: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -125,7 +163,7 @@ def initialize_bp():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/add-peer", methods=["POST"]) @bp.route("/peers", methods=["POST"])
def add_peer_bp(): def add_peer_bp():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
name = body.get("name", "").strip() name = body.get("name", "").strip()
@@ -139,25 +177,24 @@ def add_peer_bp():
persistent_keepalive=body.get("persistent_keepalive"), persistent_keepalive=body.get("persistent_keepalive"),
preshared_key=body.get("preshared_key"), preshared_key=body.get("preshared_key"),
) )
safe = dict(peer) logger.info("WireGuard peer '%s' added via API", name)
safe.pop("private_key", None) return _ok(peer)
return _ok(safe)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to add peer '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/remove-peer", methods=["DELETE"]) @bp.route("/peers/<name>", methods=["DELETE"])
def remove_peer_bp(): def remove_peer_bp(name):
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
try: try:
cfg = get_config() cfg = get_config()
if name not in cfg.get("peers", {}): if name not in cfg.get("peers", {}):
return _error(f"Peer '{name}' not found", 404) return _error(f"Peer '{name}' not found", 404)
remove_peer(name) remove_peer(name)
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name}) return _ok({"name": name})
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to remove peer '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -166,6 +203,7 @@ def peers_bp():
try: try:
return _ok(get_peers()) return _ok(get_peers())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -174,6 +212,7 @@ def peer_status_bp():
try: try:
return _ok(get_peer_status()) return _ok(get_peer_status())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -195,13 +234,13 @@ def generate_client_bp():
server_endpoint = body.get("server_endpoint", "") server_endpoint = body.get("server_endpoint", "")
server_pubkey = cfg["interface"].get("public_key", "") server_pubkey = cfg["interface"].get("public_key", "")
if not server_endpoint: if not server_endpoint:
_ = cfg["interface"].get("listen_port", 51820)
# Can't auto-derive public IP; ask user to provide it
return _error( return _error(
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400 "Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
) )
conf_text = generate_client_conf(name, server_endpoint, server_pubkey) conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
logger.info("Client config generated for peer '%s' via API", name)
return _ok({"config": conf_text}) return _ok({"config": conf_text})
except (KeyError, ValueError, RuntimeError) as exc: except (KeyError, ValueError, RuntimeError) as exc:
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500 code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), code) return _error(str(exc), code)
+94 -14
View File
@@ -7,15 +7,28 @@ and enforces basic authentication before proxying to this port.
import logging import logging
import os import os
import sys
import time
from datetime import datetime from datetime import datetime
from pathlib import Path
from flask import Flask, render_template from flask import Flask, render_template, request
from lib.acme import get_email, list_certs from lib.acme import get_email, list_certs
from lib.dnsmasq import get_config as dnsmasq_config from lib.dnsmasq import get_config as dnsmasq_config
from lib.dnsmasq import get_lease_table from lib.dnsmasq import get_lease_table
from lib.dnsmasq import get_status as dnsmasq_status from lib.dnsmasq import get_status as dnsmasq_status
from lib.firewall import get_active_zones, get_interfaces, get_zone_info from lib.firewall import (
config_pending,
get_active_zones,
get_interfaces,
get_services,
get_zone_info,
)
from lib.firewall import (
get_config as fw_config_get,
)
from lib.logging import setup_logging
from lib.nginx import get_config as nginx_config from lib.nginx import get_config as nginx_config
from lib.nginx import get_domains from lib.nginx import get_domains
from lib.wireguard import get_config as wg_config from lib.wireguard import get_config as wg_config
@@ -23,9 +36,28 @@ from lib.wireguard import status as wg_status
from webui.api.certs import bp as certs_bp from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.proxy import bp as proxy_bp from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wireguard_bp from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
# Logging — must be first so subsequent modules inherit the config
# ---------------------------------------------------------------------------
PROJECT_DIR = Path(__file__).resolve().parent.parent
setup_logging()
logger = logging.getLogger(__name__)
logger.info(
"Python %s.%s.%s",
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
)
logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App factory # App factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -38,6 +70,44 @@ app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy") app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs") app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard") app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
BLUEPRINTS = [
("firewall", firewall_bp),
("dhcp", dhcp_bp),
("proxy", proxy_bp),
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
]
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
@app.before_request
def _log_request_start():
request._start_time = time.monotonic()
@app.after_request
def _log_request_finish(response):
elapsed_ms = (
time.monotonic() - getattr(request, "_start_time", time.monotonic())
) * 1000
logger.info(
"%s %s -> %d (%.1f ms)",
request.method,
request.path,
response.status_code,
elapsed_ms,
)
return response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -111,8 +181,6 @@ def json_pretty_filter(value):
# Page routes # Page routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
def _safely(fn, default=None): def _safely(fn, default=None):
"""Call *fn* and return *default* on any exception.""" """Call *fn* and return *default* on any exception."""
@@ -155,40 +223,51 @@ def dashboard():
certs=certs, certs=certs,
wg_status=wg, wg_status=wg,
services=_get_service_status(dnsmasq, wg), services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(fw_config_get, {}),
firewall_pending=_safely(config_pending, {}),
) )
@app.route("/interfaces") @app.route("/interfaces")
def interfaces_page(): def interfaces_page():
firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {})
return render_template( return render_template(
"interfaces.html", "interfaces.html",
interfaces=_safely(get_interfaces, []), interfaces=_safely(get_interfaces, []),
active_zones=_safely(get_active_zones, {}), active_zones=_safely(get_active_zones, {}),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
) )
@app.route("/zones") @app.route("/zones")
def zones_page(): def zones_page():
zones = {} firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {})
zones_data = {}
for name in _safely(get_active_zones, {}): for name in _safely(get_active_zones, {}):
zones[name] = _safely(lambda n=name: get_zone_info(n), {}) zones_data[name] = _safely(lambda n=name: get_zone_info(n), {})
return render_template( return render_template(
"zones.html", "zones.html",
zones=zones, zones=zones_data,
interfaces=_safely(get_interfaces, []), interfaces=_safely(get_interfaces, []),
services=_safely( services=_safely(get_services, []),
lambda: __import__( firewall_config=firewall_config,
"lib.firewall", fromlist=["get_services"] firewall_pending=firewall_pending,
).get_services(),
[],
),
) )
@app.route("/rules") @app.route("/rules")
def rules_page(): def rules_page():
zones = list(_safely(get_active_zones, {}).keys()) zones = list(_safely(get_active_zones, {}).keys())
return render_template("rules.html", zones=zones) raw = _safely(fw_config_get, {})
rules = {}
for zname, zcfg in raw.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") @app.route("/nat")
@@ -236,4 +315,5 @@ def logs_page():
if __name__ == "__main__": if __name__ == "__main__":
logger.info("Starting Flask on 127.0.0.1:9090")
app.run(host="127.0.0.1", port=9090) app.run(host="127.0.0.1", port=9090)
+289 -113
View File
@@ -1,137 +1,86 @@
// Toast notification system // Toast notifications
function showToast(message, type = "info") { const showToast = (message, type, duration = 4000) => {
const container = document.querySelector(".toast") || createToastContainer(); const container = document.getElementById('toast-container');
const toast = document.createElement("div"); if (!container) return;
toast.className = `toast-message toast-${type}`; const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => { setTimeout(() => {
toast.style.opacity = "0"; toast.classList.remove('show');
toast.style.transform = "translateX(40px)";
toast.style.transition = "all 0.3s ease";
setTimeout(() => toast.remove(), 300); setTimeout(() => toast.remove(), 300);
}, 5000); }, duration);
} };
function createToastContainer() { const showSuccessToast = (msg) => showToast(msg, 'success');
const el = document.createElement("div");
el.className = "toast"; const showErrorToast = (msg) => showToast(msg, 'error');
document.body.appendChild(el);
return el;
}
// Modal helpers // Modal helpers
function openModal(id) { const openModal = (id) => {
const modal = document.getElementById(id); const el = document.getElementById(id);
if (modal) modal.classList.add("show"); if (el) el.classList.add('active');
} };
function closeModal(id) { const closeModal = (id) => {
const modal = document.getElementById(id); const el = document.getElementById(id);
if (modal) modal.classList.remove("show"); if (el) el.classList.remove('active');
} };
// Confirm dialog // Tab switching
function confirmAction(message, onConfirm) { const switchTab = (tabName) => {
const existing = document.getElementById("confirm-modal"); document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
if (existing) existing.remove(); 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');
};
const modal = document.createElement("div"); // Refresh a container from a JSON GET endpoint using a renderer callback
modal.id = "confirm-modal"; const refreshTable = (url, container, renderer) => {
modal.className = "modal"; fetch(url)
modal.innerHTML = ` .then(r => r.json())
<div class="modal-content"> .then(data => {
<p class="mb-2">${message}</p> const json = data.ok ? data.data : data;
<div style="display:flex; gap:0.75rem; justify-content:flex-end;"> container.innerHTML = renderer(json);
<button class="btn btn-outline" id="confirm-cancel">Cancel</button> htmx.process(container);
<button class="btn btn-danger" id="confirm-ok">Confirm</button> })
</div> .catch(() => {});
</div>`; };
document.body.appendChild(modal);
openModal("confirm-modal");
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal("confirm-modal");
});
}
function setupConfirmCallback(callback) {
document.getElementById("confirm-ok")?.addEventListener("click", () => {
closeModal("confirm-modal");
callback();
});
}
// Auto-refresh with HTMX
function startAutoRefresh(endpoint, target, interval) {
const el = document.createElement("div");
el.setAttribute("hx-get", endpoint);
el.setAttribute("hx-target", `#${target}`);
el.setAttribute("hx-swap", "innerHTML");
el.setAttribute("hx-trigger", `every ${interval}s`);
el.setAttribute("hx-swap-oob", "true");
document.body.appendChild(el);
}
// Time formatting
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// Bytes formatting
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
// Form helpers
function resetForm(formId) {
const form = document.getElementById(formId);
if (form) form.reset();
}
function fillForm(formId, data) {
const form = document.getElementById(formId);
if (!form) return;
for (const [key, value] of Object.entries(data)) {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = value;
}
}
// HTMX event handlers // HTMX event handlers
document.body.addEventListener("htmx:afterSwap", (evt) => { document.body.addEventListener('htmx:afterSwap', (evt) => {
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast"); const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
if (toastHeader) { if (toastHeader) {
const parts = toastHeader.split(":"); const parts = toastHeader.split(':');
const msg = parts.slice(1).join(":").trim(); const msg = parts.slice(1).join(':').trim();
showToast(msg, parts[0]?.trim() || "info"); showToast(msg, parts[0]?.trim() || 'info');
} }
}); });
document.body.addEventListener("htmx:responseError", (evt) => { document.body.addEventListener('htmx:responseError', (evt) => {
const status = evt.detail.xhr?.status || 0; const status = evt.detail.xhr?.status || 0;
showToast(`Request failed (${status})`, "error"); 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');
}); });
document.body.addEventListener("htmx:beforeRequest", (evt) => { document.body.addEventListener('htmx:beforeRequest', (evt) => {
const target = evt.target; const btn = evt.target.closest('.btn');
const btn = target.closest(".btn");
if (btn) { if (btn) {
btn.dataset.originalText = btn.textContent; btn.dataset.originalText = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = "Loading..."; btn.textContent = 'Loading...';
} }
}); });
document.body.addEventListener("htmx:afterRequest", (evt) => { document.body.addEventListener('htmx:afterRequest', (evt) => {
const target = evt.target; const btn = evt.target.closest('.btn');
const btn = target.closest(".btn");
if (btn && btn.dataset.originalText !== undefined) { if (btn && btn.dataset.originalText !== undefined) {
btn.disabled = false; btn.disabled = false;
btn.textContent = btn.dataset.originalText; btn.textContent = btn.dataset.originalText;
@@ -139,9 +88,236 @@ document.body.addEventListener("htmx:afterRequest", (evt) => {
} }
}); });
// Close on escape // Keyboard: Escape closes all modals
document.addEventListener("keydown", (e) => { document.addEventListener('keydown', (e) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show")); document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
} }
}); });
// -------- Renderer helpers for htmx-driven DOM updates --------
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('');
};
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>';
};
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('');
};
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);
};
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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
+1
View File
@@ -0,0 +1 @@
../../vendor/htmx-2.0.4.min.js
+1
View File
@@ -0,0 +1 @@
../../vendor/json-enc-2.0.0.js
+4 -54
View File
@@ -565,7 +565,7 @@
} }
</style> </style>
</head> </head>
<body> <body hx-ext="json-enc">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
VACUUM WALL VACUUM WALL
@@ -591,58 +591,8 @@
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="/static/htmx.min.js"></script>
<script> <script src="/static/json-enc.js"></script>
function showToast(message, type, duration) { <script src="/static/app.js"></script>
duration = duration || 4000;
var container = document.getElementById('toast-container');
if (!container) return;
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() { toast.classList.add('show'); });
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, duration);
}
function openModal(id) {
var el = document.getElementById(id);
if (el) el.classList.add('active');
}
function closeModal(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('tab-' + tabName).classList.add('active');
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (clickedTab) clickedTab.classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
var msg = el.getAttribute('data-success') || 'Operation successful';
var type = el.getAttribute('data-type') || 'success';
el.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail && evt.detail.successful) {
showToast(msg, type);
}
});
});
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
}
});
</script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -21,7 +21,7 @@
<th style="width:120px;">Actions</th> <th style="width:120px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="cert-rows">
{% for cert in (certs or []) %} {% for cert in (certs or []) %}
<tr> <tr>
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td> <td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
@@ -38,7 +38,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);"> <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> <button type="submit" class="btn btn-sm btn-outline">Renew</button>
</form> </form>
</td> </td>
@@ -57,7 +57,7 @@
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')"> <div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
<div class="modal"> <div class="modal">
<h2>Issue New Certificate</h2> <h2>Issue New Certificate</h2>
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);"> <form hx-post="/api/certs/issue" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
<div class="form-group"> <div class="form-group">
<label for="cert-domain">Domain</label> <label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required> <input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
+19 -19
View File
@@ -13,7 +13,7 @@
<div class="section-title">DHCP Ranges</div> <div class="section-title">DHCP Ranges</div>
<div class="card mb-4"> <div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="range-interface">Interface</label> <label for="range-interface">Interface</label>
@@ -50,7 +50,7 @@
<th style="width:80px;">Action</th> <th style="width:80px;">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="range-rows">
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %} {% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
<tr> <tr>
<td>{{ rng.get('interface', '(global)') }}</td> <td>{{ rng.get('interface', '(global)') }}</td>
@@ -58,8 +58,8 @@
<td>{{ rng.get('end', '') }}</td> <td>{{ rng.get('end', '') }}</td>
<td>{{ rng.get('lease_time', '1h') }}</td> <td>{{ rng.get('lease_time', '1h') }}</td>
<td> <td>
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove this range?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -78,7 +78,7 @@
<div class="section-title">Static Leases</div> <div class="section-title">Static Leases</div>
<div class="card mb-4"> <div class="card mb-4">
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="lease-mac">MAC Address</label> <label for="lease-mac">MAC Address</label>
@@ -105,15 +105,15 @@
<th style="width:80px;">Action</th> <th style="width:80px;">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="lease-rows">
{% for lease in ((config or {}).get('static_leases', []) or []) %} {% for lease in ((config or {}).get('static_leases', []) or []) %}
<tr> <tr>
<td>{{ lease.get('mac', '') }}</td> <td>{{ lease.get('mac', '') }}</td>
<td>{{ lease.get('ip', '') }}</td> <td>{{ lease.get('ip', '') }}</td>
<td>{{ lease.get('hostname', '-') }}</td> <td>{{ lease.get('hostname', '-') }}</td>
<td> <td>
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove this lease?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -132,15 +132,15 @@
<div class="section-title">Custom DNS Records</div> <div class="section-title">Custom DNS Records</div>
<div class="card mb-4"> <div class="card mb-4">
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="dns-ip">IP Address</label> <label for="dns-ip">IP Address</label>
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;"> <input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="dns-hostname">Hostname / Domain</label> <label for="dns-hostname">Hostname / Domain</label>
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;"> <input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
</div> </div>
<button type="submit" class="btn btn-primary">Add Record</button> <button type="submit" class="btn btn-primary">Add Record</button>
</div> </div>
@@ -149,19 +149,19 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>IP</th> <th>Name</th>
<th>Hostname</th> <th>Address</th>
<th style="width:80px;">Action</th> <th style="width:80px;">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="dns-rows">
{% for rec in ((config or {}).get('dns_records', []) or []) %} {% for rec in ((config or {}).get('dns_records', []) or []) %}
<tr> <tr>
<td>{{ rec.get('ip', '') }}</td> <td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
<td>{{ rec.get('hostname', '') }}</td> <td class="text-sm">{{ rec.get('address', '-') }}</td>
<td> <td>
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove this record?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -208,7 +208,7 @@
</tbody> </tbody>
</table> </table>
<div class="mt-2 text-right"> <div class="mt-2 text-right">
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply &amp; Restart Dnsmasq</button> <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 &amp; Restart Dnsmasq</button>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+2 -29
View File
@@ -18,10 +18,9 @@
<th>IP Address</th> <th>IP Address</th>
<th>State</th> <th>State</th>
<th>Zone</th> <th>Zone</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="interface-list">
{% for iface in (interfaces or []) %} {% for iface in (interfaces or []) %}
<tr> <tr>
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td> <td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
@@ -39,12 +38,7 @@
<td> <td>
{% if zones %} {% if zones %}
<select <select
class="htmx-on-success" 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.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})"
data-success="Zone updated for {{ iface.name }}"
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
hx-swap="none"
hx-select-oob="#toast-container *"
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
> >
{% for zone in zones %} {% for zone in zones %}
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option> <option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
@@ -54,9 +48,6 @@
<span class="text-muted">No zones configured</span> <span class="text-muted">No zones configured</span>
{% endif %} {% endif %}
</td> </td>
<td>
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not (interfaces or []) %} {% if not (interfaces or []) %}
@@ -67,22 +58,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<script>
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
var zoneName = selectEl.value;
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
fetch(url, { method: 'POST' })
.then(function(res) {
if (res.ok) {
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
} else {
return res.text().then(function(txt) { throw new Error(txt); });
}
})
.catch(function(err) {
showToast('Failed to assign: ' + err.message, 'error');
});
}
</script>
{% endblock %} {% endblock %}
+61 -28
View File
@@ -13,9 +13,7 @@
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()"> <input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
<span class="htmx-indicator text-sm" style="color:var(--accent);"> <span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
</span>
</div> </div>
</div> </div>
@@ -24,15 +22,16 @@
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</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="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="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
</div> </div>
<div id="tab-journal" class="tab-content active"> <div id="tab-journal" class="tab-content active">
<div class="card" style="padding:0;overflow:hidden;"> <div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-journal" <div class="log-viewer" id="log-journal"
hx-get="/api/logs/journal" hx-get="/api/logs/journal"
hx-trigger="every {{ (refresh_interval | default(15)) }}s" hx-trigger="none"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#refresh-indicator"> hx-indicator=".page-header .htmx-indicator">
Loading journal entries... Loading journal entries...
</div> </div>
</div> </div>
@@ -42,9 +41,9 @@
<div class="card" style="padding:0;overflow:hidden;"> <div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-access" <div class="log-viewer" id="log-nginx-access"
hx-get="/api/logs/nginx/access" hx-get="/api/logs/nginx/access"
hx-trigger="every {{ (refresh_interval | default(15)) }}s" hx-trigger="none"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#refresh-indicator"> hx-indicator=".page-header .htmx-indicator">
Loading Nginx access log... Loading Nginx access log...
</div> </div>
</div> </div>
@@ -54,9 +53,9 @@
<div class="card" style="padding:0;overflow:hidden;"> <div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-error" <div class="log-viewer" id="log-nginx-error"
hx-get="/api/logs/nginx/error" hx-get="/api/logs/nginx/error"
hx-trigger="every {{ (refresh_interval | default(15)) }}s" hx-trigger="none"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#refresh-indicator"> hx-indicator=".page-header .htmx-indicator">
Loading Nginx error log... Loading Nginx error log...
</div> </div>
</div> </div>
@@ -66,41 +65,75 @@
<div class="card" style="padding:0;overflow:hidden;"> <div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-dnsmasq" <div class="log-viewer" id="log-dnsmasq"
hx-get="/api/logs/dnsmasq" hx-get="/api/logs/dnsmasq"
hx-trigger="every {{ (refresh_interval | default(15)) }}s" hx-trigger="none"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#refresh-indicator"> hx-indicator=".page-header .htmx-indicator">
Loading dnsmasq log... Loading dnsmasq log...
</div> </div>
</div> </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> <script>
var autoRefreshTimer = null; var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal';
function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
}
}
function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
htmx.ajax('GET', activeEl);
}
}
var origSwitchTab = switchTab;
switchTab = function(tabName) {
currentTab = tabName;
if (typeof origSwitchTab === 'function') {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
function toggleAutoRefresh() { function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle'); var toggle = document.getElementById('auto-refresh-toggle');
var indicators = document.querySelectorAll('.log-viewer');
if (toggle.checked) { if (toggle.checked) {
indicators.forEach(function(el) { setActivePolling();
el.setAttribute('hx-trigger', 'every 15s'); loadActiveTab();
hx.trigger(el, 'htmx:refresh');
});
} else { } else {
indicators.forEach(function(el) { document.querySelectorAll('.log-viewer').forEach(function(el) {
el.setAttribute('hx-trigger', 'never'); htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
}); });
} }
} }
document.addEventListener('htmx:beforeRequest', function(evt) { document.addEventListener('DOMContentLoaded', function() {
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) { loadActiveTab();
document.getElementById('refresh-indicator').style.display = 'inline';
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
document.getElementById('refresh-indicator').style.display = 'none';
}); });
</script> </script>
{% endblock %} {% endblock %}
+14 -34
View File
@@ -17,7 +17,6 @@
<tr> <tr>
<th>Zone</th> <th>Zone</th>
<th style="width:120px;">Masquerade</th> <th style="width:120px;">Masquerade</th>
<th style="width:80px;">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -25,21 +24,23 @@
<tr> <tr>
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td> <td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
<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"> <label class="switch">
<input type="checkbox" <input type="checkbox"
{% if zone.get('masquerade') %}checked{% endif %} {% if zone.get('masquerade') %}checked{% endif %}
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)"> id="masq-{{ zone.get('name', '') }}"
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
disabled>
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</td> <button type="submit" style="display:none"></button>
<td> </form>
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not (zones or []) %} {% if not (zones or []) %}
<tr> <tr>
<td colspan="3" class="text-muted text-sm">No zones configured</td> <td colspan="2" class="text-muted text-sm">No zones configured</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
@@ -50,7 +51,7 @@
<div class="card mb-4"> <div class="card mb-4">
<h3>Add Forward Rule</h3> <h3>Add Forward Rule</h3>
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="fw-zone">Zone</label> <label for="fw-zone">Zone</label>
@@ -63,7 +64,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fw-protocol">Protocol</label> <label for="fw-protocol">Protocol</label>
<select id="fw-protocol" name="protocol"> <select id="fw-protocol" name="proto">
<option value="tcp">TCP</option> <option value="tcp">TCP</option>
<option value="udp">UDP</option> <option value="udp">UDP</option>
</select> </select>
@@ -74,11 +75,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fw-target">Target Address</label> <label for="fw-target">Target Address</label>
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;"> <input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="fw-target-port">Target Port</label> <label for="fw-target-port">Target Port</label>
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;"> <input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
</div> </div>
<button type="submit" class="btn btn-primary">Add</button> <button type="submit" class="btn btn-primary">Add</button>
</div> </div>
@@ -97,7 +98,7 @@
<th style="width:80px;">Action</th> <th style="width:80px;">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="forward-rows">
{% set all_forwards = [] %} {% set all_forwards = [] %}
{% for zone in (zones or []) %} {% for zone in (zones or []) %}
{% for fwd in zone.get('forward_ports', []) %} {% for fwd in zone.get('forward_ports', []) %}
@@ -112,8 +113,8 @@
<td>{{ fwd['to-addr'] }}</td> <td>{{ fwd['to-addr'] }}</td>
<td>{{ fwd['to-port'] }}</td> <td>{{ fwd['to-port'] }}</td>
<td> <td>
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd['proxy-protocol'] }}" 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" onclick="return confirm('Remove this forward rule?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd['proxy-protocol'] }} → {{ fwd['to-addr'] }}:{{ fwd['to-port'] }}?">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -127,25 +128,4 @@
</table> </table>
</div> </div>
<script>
function toggleMasquerade(zoneName, enabled) {
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
var body = JSON.stringify({ enable: enabled });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function(res) {
if (res.ok) {
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
} else {
return res.text().then(function(t) { throw new Error(t); });
}
})
.catch(function(err) {
showToast('Failed: ' + err.message, 'error');
});
}
</script>
{% endblock %} {% endblock %}
+7 -9
View File
@@ -9,7 +9,8 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button> <button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</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> </div>
@@ -25,7 +26,7 @@
<th style="width:140px;">Actions</th> <th style="width:140px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="domain-rows">
{% for domain in (domains or []) %} {% for domain in (domains or []) %}
<tr> <tr>
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td> <td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
@@ -56,8 +57,8 @@
<td> <td>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button> <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" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</div> </div>
</td> </td>
@@ -76,7 +77,7 @@
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')"> <div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal"> <div class="modal">
<h2>Add Proxy Domain</h2> <h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);"> <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"> <div class="form-group">
<label for="new-domain">Domain</label> <label for="new-domain">Domain</label>
<input type="text" id="new-domain" name="domain" placeholder="example.com" required> <input type="text" id="new-domain" name="domain" placeholder="example.com" required>
@@ -108,7 +109,7 @@
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')"> <div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
<div class="modal"> <div class="modal">
<h2>Edit Proxy Domain</h2> <h2>Edit Proxy Domain</h2>
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);"> <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"> <input type="hidden" id="edit-original-domain" name="original_domain">
<div class="form-group"> <div class="form-group">
<label for="edit-domain">Domain</label> <label for="edit-domain">Domain</label>
@@ -144,9 +145,6 @@ function openEditDomainModal(domainName, d) {
document.getElementById('edit-backend-host').value = d.backend_host || ''; document.getElementById('edit-backend-host').value = d.backend_host || '';
document.getElementById('edit-backend-port').value = d.backend_port || ''; document.getElementById('edit-backend-port').value = d.backend_port || '';
document.getElementById('edit-protocol').value = d.protocol || 'http'; document.getElementById('edit-protocol').value = d.protocol || 'http';
var form = document.getElementById('edit-domain-form');
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
form.setAttribute('hx-put', target);
openModal('edit-domain-modal'); openModal('edit-domain-modal');
} }
</script> </script>
+7 -4
View File
@@ -11,7 +11,7 @@
<div class="card mb-4"> <div class="card mb-4">
<h3>Add Rule</h3> <h3>Add Rule</h3>
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="rule-zone">Zone</label> <label for="rule-zone">Zone</label>
@@ -34,6 +34,7 @@
</div> </div>
</div> </div>
<div id="rules-container">
{% if rules or False %} {% if rules or False %}
{% for zone_name, zone_rules in rules.items() %} {% for zone_name, zone_rules in rules.items() %}
<div class="card"> <div class="card">
@@ -49,12 +50,13 @@
</thead> </thead>
<tbody> <tbody>
{% for rule in zone_rules %} {% for rule in zone_rules %}
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
<tr> <tr>
<td class="text-muted">{{ loop.index }}</td> <td class="text-muted">{{ loop.index }}</td>
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td> <td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
<td> <td>
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove this rule?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -71,6 +73,7 @@
<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 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> </div>
{% endif %} {% endif %}
</div>
{% if not (zones or []) %} {% if not (zones or []) %}
<div class="card" style="border-color:var(--warning);"> <div class="card" style="border-color:var(--warning);">
+8 -12
View File
@@ -11,17 +11,13 @@
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}" <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-post="/api/wireguard/down"
hx-swap="none" hx-swap="none"
class="htmx-on-success" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
data-success="Tunnel stopped"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
Stop Tunnel Stop Tunnel
</button> </button>
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}" <button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/up" hx-post="/api/wireguard/apply"
hx-swap="none" hx-swap="none"
class="htmx-on-success" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
data-success="Tunnel started"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
Start Tunnel Start Tunnel
</button> </button>
</div> </div>
@@ -51,7 +47,7 @@
<div class="card mb-4"> <div class="card mb-4">
<h3>Add Peer</h3> <h3>Add Peer</h3>
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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="inline-form">
<div class="form-group"> <div class="form-group">
<label for="peer-name">Name</label> <label for="peer-name">Name</label>
@@ -63,7 +59,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="peer-allowed">Allowed IPs</label> <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|length + 2) %}{{ next }}/32" required style="width:160px;"> <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> </div>
<button type="submit" class="btn btn-primary">Add Peer</button> <button type="submit" class="btn btn-primary">Add Peer</button>
</div> </div>
@@ -84,7 +80,7 @@
<th style="width:160px;">Actions</th> <th style="width:160px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="peer-rows">
{% for peer in (peers or []) %} {% for peer in (peers or []) %}
<tr> <tr>
<td> <td>
@@ -102,8 +98,8 @@
<td> <td>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button> <button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);"> <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" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button> <button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form> </form>
</div> </div>
</td> </td>
+3 -3
View File
@@ -10,7 +10,7 @@
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button> <button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
</div> </div>
<div class="card-grid"> <div id="zone-grid" class="card-grid">
{% for zone in (zones or []) %} {% for zone in (zones or []) %}
<div class="card" style="position:relative;"> <div class="card" style="position:relative;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;"> <div style="display:flex;justify-content:space-between;align-items:flex-start;">
@@ -45,7 +45,7 @@
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;"> <div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted"> <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> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</div> </div>
@@ -62,7 +62,7 @@
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')"> <div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
<div class="modal"> <div class="modal">
<h2>Create Zone</h2> <h2>Create Zone</h2>
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();"> <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"> <div class="form-group">
<label for="zone-name">Zone Name</label> <label for="zone-name">Zone Name</label>
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required> <input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>