Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ac69dfa7e | |||
| f81be96e59 | |||
| ecb5a9a44d | |||
| d1ab717c0f | |||
| 8829ac579d | |||
| cf8115bb0d | |||
| 37039351be | |||
| 0e7090a2cb | |||
| dcb581a359 | |||
| 6106c1434d |
+8
-3
@@ -14,7 +14,12 @@ __pycache__/
|
||||
|
||||
# Local AI tool config (contains internal hostnames)
|
||||
opencode.json
|
||||
opencode.json.pwenv
|
||||
|
||||
# Runtime data configs (source-of-truth for services)
|
||||
data/dnsmasq/config.json
|
||||
data/nginx/sites-enabled/
|
||||
# Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
|
||||
# Runtime artifacts
|
||||
build/
|
||||
config/
|
||||
data/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## What This Is
|
||||
|
||||
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
|
||||
|
||||
@@ -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/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
|
||||
- `lib/*.py` — Backend modules. Wrap system commands via `subprocess.run(["sudo", ...])`.
|
||||
- `data/` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`.
|
||||
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
|
||||
- `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/`.
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
**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.
|
||||
Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
|
||||
|
||||
## API Response Contract
|
||||
|
||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)`
|
||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)`
|
||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common`
|
||||
- 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
|
||||
- Full spec: `docs/api.md`
|
||||
|
||||
@@ -73,11 +89,17 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
|
||||
```bash
|
||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||
.venv/bin/ruff format lib/ webui/ tests/ # format
|
||||
.venv/bin/python -m pytest tests/ -v # test (154 tests)
|
||||
.venv/bin/python -m pytest tests/ -v # test (192 tests)
|
||||
```
|
||||
|
||||
Install dev tooling with `pip install -e ".[dev]"`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,7 +7,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
|
||||
- Debian 13 (trixie) target platform
|
||||
- Python 3.13+, Flask 3.x web UI
|
||||
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
|
||||
- acme.sh for Let's Encrypt
|
||||
- acme.sh for ACME certificates (ZeroSSL)
|
||||
- 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
|
||||
- Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk
|
||||
|
||||
### Install
|
||||
### Install (Production)
|
||||
|
||||
```bash
|
||||
MGMT_DOMAIN=wall.example.com \
|
||||
@@ -32,12 +32,28 @@ ACME_EMAIL="admin@example.com" \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `MGMT_DOMAIN` | Yes | Public domain for the management WebUI |
|
||||
| `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
|
||||
| `MGMT_USER` | No | WebUI username (defaults to `admin`) |
|
||||
| `ACME_EMAIL` | Yes | Let's Encrypt registration email |
|
||||
### Install (Development)
|
||||
|
||||
```bash
|
||||
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||
```
|
||||
|
||||
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
|
||||
|
||||
| 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.
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
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/
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
.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
|
||||
|
||||
@@ -109,7 +127,7 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
|
||||
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
||||
| `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
|
||||
- [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
|
||||
- [Security Model](docs/security.md) — Privilege model and sudo whitelist
|
||||
- [Configuration](docs/config.md) — Declarative config file formats and locations
|
||||
|
||||
+304
-206
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
|
||||
@@ -34,12 +34,86 @@ Error responses carry one of the following HTTP status codes:
|
||||
| `404` | Not found — the requested resource does not exist |
|
||||
| `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
|
||||
|
||||
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
|
||||
|
||||
#### List All Zones
|
||||
@@ -76,8 +150,8 @@ Return detailed configuration for a single zone.
|
||||
| `services` | `[string, ...]` | Services allowed through the zone |
|
||||
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
|
||||
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
|
||||
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
|
||||
| `rich_rules` | `[string, ...]` | Rich rule definitions |
|
||||
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
|
||||
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
|
||||
|
||||
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 |
|
||||
|-------|------|-------------|
|
||||
| `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 |
|
||||
|-------|------|-------------|
|
||||
| `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
|
||||
|
||||
@@ -168,7 +242,7 @@ Replace all services allowed in the zone with the provided list.
|
||||
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:**
|
||||
|
||||
@@ -182,6 +256,7 @@ Add a firewalld rich rule to a zone.
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `zone` | `string` | Zone name |
|
||||
| `id` | `string` | 8-character unique ID |
|
||||
| `rule` | `string` | Full rich rule string |
|
||||
|
||||
---
|
||||
@@ -189,24 +264,19 @@ Add a firewalld rich rule to a zone.
|
||||
#### 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.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone the rule belongs to |
|
||||
| `rule` | `string` | Yes | Exact rich rule string to remove |
|
||||
Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `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>
|
||||
```
|
||||
|
||||
Return all rich rules for the specified zone.
|
||||
Return all rich rules for the specified zone, each with an `id` and `rule` string.
|
||||
|
||||
**Response:**
|
||||
|
||||
| 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
|
||||
|
||||
@@ -246,63 +365,7 @@ Toggle masquerade (source NAT) for a zone.
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `zone` | `string` | Zone name |
|
||||
| `masquerade` | `boolean` | Whether masquerade is now enabled for the zone |
|
||||
|
||||
---
|
||||
|
||||
#### 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"`) |
|
||||
| `masquerade` | `boolean` | Whether masquerade is now enabled |
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 |
|
||||
|-------|------|-------------|
|
||||
| `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
|
||||
|
||||
```
|
||||
@@ -446,28 +559,38 @@ Add a static (reserved) DHCP lease.
|
||||
|-------|------|-------------|
|
||||
| `mac` | `string` | MAC address |
|
||||
| `ip` | `string` | Reserved IP address |
|
||||
| `hostname` | `string` | Hostname for the reservation |
|
||||
| `hostname` | `string` | Hostname |
|
||||
|
||||
---
|
||||
|
||||
#### 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.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `mac` | `string` | Yes | MAC address of the lease to remove |
|
||||
Remove a static lease by MAC address.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
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
|
||||
|
||||
#### Add DNS Record
|
||||
@@ -484,6 +607,7 @@ Add a custom DNS A record served by dnsmasq.
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Fully qualified domain name |
|
||||
| `address` | `string` | Yes | IP address to resolve to |
|
||||
| `hostname` | `string` | No | Short hostname |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
@@ -498,16 +622,10 @@ Add a custom DNS A record served by dnsmasq.
|
||||
#### Remove DNS Record
|
||||
|
||||
```
|
||||
DELETE /api/dhcp/dns-record?name=nas.lan
|
||||
DELETE /api/dhcp/dns-record/<name>
|
||||
```
|
||||
|
||||
Remove a custom DNS record.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Fully qualified domain name to remove |
|
||||
Remove a custom DNS record by domain name.
|
||||
|
||||
**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_port` | `number` | Yes | Backend server port |
|
||||
| `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`):**
|
||||
|
||||
@@ -572,14 +692,7 @@ GET /api/proxy/domains/<domain>
|
||||
|
||||
Return the configuration for a single proxy domain.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `domain` | `string` | Domain name |
|
||||
| `backend_host` | `string` | Backend server address |
|
||||
| `backend_port` | `number` | Backend server port |
|
||||
| `backend_proto` | `string` | Backend protocol |
|
||||
**Response (`data`):** Domain name plus backend configuration fields.
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
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:**
|
||||
|
||||
| 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 |
|
||||
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
@@ -649,15 +756,17 @@ Returns HTTP `500` if nginx config generation fails or the reload fails.
|
||||
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 |
|
||||
|-------|------|-------------|
|
||||
| `data.valid` | `boolean` | Whether the configuration syntax is valid |
|
||||
| `data.valid` | `boolean` | Always `true` |
|
||||
| `data.output` | `string` | Raw nginx test output |
|
||||
|
||||
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
|
||||
|
||||
### Management
|
||||
|
||||
#### Configure Management WebUI Proxy
|
||||
@@ -672,21 +781,19 @@ Configure the nginx proxy block for the management WebUI itself, including optio
|
||||
|
||||
| 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_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_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. |
|
||||
| `auth_user` | `string` | No | Username for basic auth |
|
||||
| `auth_pass` | `string` | No | Password for basic auth |
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
|
||||
@@ -704,15 +811,7 @@ Return all managed certificates with metadata.
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of certificate objects |
|
||||
|
||||
Each certificate object:
|
||||
|
||||
| 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 |
|
||||
Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
|
||||
|
||||
---
|
||||
|
||||
@@ -724,15 +823,7 @@ GET /api/certs/<domain>
|
||||
|
||||
Return details for a single certificate.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| 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 |
|
||||
**Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Force-renew an existing certificate, regardless of its current expiry status.
|
||||
Force-renew an existing certificate.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
@@ -793,7 +884,7 @@ Returns HTTP `404` if the certificate is not found.
|
||||
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:**
|
||||
|
||||
@@ -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 |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `email` | `string` | Contact email address |
|
||||
**Response (`data`):** Returns the set `email` field.
|
||||
|
||||
---
|
||||
|
||||
@@ -821,13 +908,13 @@ Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, p
|
||||
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:**
|
||||
|
||||
| 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 |
|
||||
|
||||
**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
|
||||
|
||||
@@ -859,11 +960,21 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `up` | `boolean` | Whether the tunnel interface is up |
|
||||
| `interface` | `object` | Interface info (listen port, public key, etc.) |
|
||||
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) |
|
||||
| `interface` | `object` | Interface info (listen port, public key) |
|
||||
| `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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
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:**
|
||||
|
||||
@@ -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 |
|
||||
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
|
||||
| `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`):**
|
||||
|
||||
| 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 |
|
||||
**Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
|
||||
|
||||
---
|
||||
|
||||
#### Remove Peer
|
||||
|
||||
```
|
||||
DELETE /api/wireguard/remove-peer?name=alice
|
||||
DELETE /api/wireguard/peers/<name>
|
||||
```
|
||||
|
||||
Remove a configured peer.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Peer name to remove |
|
||||
Remove a configured peer by name.
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
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:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of live peer status objects |
|
||||
| `data` | `[object, ...]` | Live peer status (handshake time, bytes, endpoint) |
|
||||
|
||||
### Client Configuration
|
||||
|
||||
@@ -1000,21 +1098,21 @@ Return live per-peer connection status from `wg show`, including last handshake
|
||||
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:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `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`):**
|
||||
|
||||
| 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.
|
||||
+56
-28
@@ -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`.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
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/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/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt 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/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
|
||||
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
|
||||
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|
||||
|---|---|---|---|---|
|
||||
| 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 | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
||||
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||
| 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/
|
||||
├── 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)
|
||||
config/
|
||||
├── dnsmasq/
|
||||
│ ├── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
||||
├── firewall/
|
||||
│ └── rules.json # Declarative firewall rule state backup
|
||||
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
├── nginx/
|
||||
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
||||
└── wireguard/
|
||||
└── 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
|
||||
|
||||
@@ -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/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/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `data/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/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
||||
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||
| `/etc/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.
|
||||
|
||||
@@ -106,4 +134,4 @@ Additional zones can be created for specialized network segments:
|
||||
- **Guest zone**: For visitor Wi-Fi or untrusted devices. Access is limited to outbound Internet traffic only, with no access to `internal` or `vpn` zones.
|
||||
- **IoT zone**: For devices requiring restricted outbound access (e.g., blocking telemetry domains).
|
||||
|
||||
Each custom zone can define its own source rules, port forwardings, and inter-zone traffic policies. The Flask WebUI provides interfaces to create, modify, and assign interfaces to zones at runtime.
|
||||
Each custom zone can define its own source rules, port forwardings, and inter-zone traffic policies. The Flask WebUI provides interfaces to create, modify, and assign interfaces to zones at runtime.
|
||||
+12
-12
@@ -1,10 +1,10 @@
|
||||
# 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
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -74,7 +74,7 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -99,7 +99,7 @@ This file defines reverse proxy domains, the management interface, and global SS
|
||||
}
|
||||
},
|
||||
"management": {
|
||||
"domain": "wall.lan",
|
||||
"domain": "<hostname>.local",
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9090,
|
||||
@@ -107,7 +107,7 @@ This file defines reverse proxy domains, the management interface, and global SS
|
||||
},
|
||||
"auth": {
|
||||
"user": "admin",
|
||||
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
||||
"htpasswd": "data/nginx/.htpasswd"
|
||||
}
|
||||
},
|
||||
"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`). |
|
||||
| `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.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.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 |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -150,7 +150,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
|
||||
|
||||
| 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`. |
|
||||
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
|
||||
| `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:
|
||||
|
||||
```bash
|
||||
htpasswd -bc /home/wall/vacuum-wall/data/nginx/.htpasswd admin yourpassword
|
||||
htpasswd -bc data/nginx/.htpasswd admin yourpassword
|
||||
```
|
||||
|
||||
### Global SSL Settings
|
||||
@@ -174,7 +174,7 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -241,4 +241,4 @@ When configuration is saved through the WebUI or API, the application:
|
||||
4. Runs `sudo wg-quick up wg0` to apply the configuration.
|
||||
5. Returns success or error status to the caller.
|
||||
|
||||
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
|
||||
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
|
||||
+82
-35
@@ -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.
|
||||
|
||||
@@ -19,24 +19,55 @@ This guide walks through deploying Vacuum Wall on a real appliance or server. Va
|
||||
|
||||
## 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
|
||||
# Production: all env vars
|
||||
MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
MGMT_USER="admin" \
|
||||
ACME_EMAIL="admin@example.com" \
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `MGMT_DOMAIN` | Yes | The public-facing domain for the management WebUI. A DNS A record must point to the appliance's IP. |
|
||||
| `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`. |
|
||||
| `ACME_EMAIL` | Yes | The email address registered with Let's Encrypt for certificate issuance and expiry notifications. |
|
||||
All settings that can be passed as an environment variable also have a CLI flag equivalent. CLI flags take precedence over environment variables.
|
||||
|
||||
| Flag | Env Var | Required | Description |
|
||||
|---|---|---|---|
|
||||
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
|
||||
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
|
||||
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
|
||||
| `--mgmt-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:
|
||||
|
||||
- **Package installation**: Installs firewalld, nginx, dnsmasq, 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.
|
||||
- **Flask installation**: Ensures the Flask Python package is available via pip for the WebUI backend.
|
||||
- **System user creation**: Creates a dedicated `vacuum-wall` system user (nologin shell) that owns the project data and runs the WebUI service.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones.
|
||||
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
|
||||
- **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.
|
||||
- **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
|
||||
- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
|
||||
- **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).
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Systemd units**: Installs three units:
|
||||
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
|
||||
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
|
||||
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
|
||||
- **Systemd units**: Installs three units (rendered from Jinja2 templates):
|
||||
- `vacuum-wall.service` — the Flask WebUI backend.
|
||||
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
|
||||
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
|
||||
- **Firewalld zones**: Creates initial zones:
|
||||
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
|
||||
- `vpn` — WireGuard tunnel zone.
|
||||
- **Service startup**: Enables and starts nginx, the vacuum-wall WebUI, and the ACME renewal timer.
|
||||
- **ACME registration**: Registers the Let's Encrypt account with the provided email via acme.sh.
|
||||
- **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 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -226,14 +273,14 @@ visudo -cf /etc/sudoers.d/vacuum-wall
|
||||
|
||||
### 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.
|
||||
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
|
||||
- The ACME email was registered correctly. Check with:
|
||||
|
||||
```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.
|
||||
@@ -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`).
|
||||
- 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`.
|
||||
|
||||
### WebUI Not Accessible
|
||||
|
||||
1. Verify nginx is running: `systemctl status nginx`.
|
||||
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).
|
||||
4. Ensure the `vacuum-wall` 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.
|
||||
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 WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
|
||||
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
|
||||
|
||||
---
|
||||
|
||||
@@ -261,10 +308,10 @@ Verify that:
|
||||
|
||||
| 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` |
|
||||
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
|
||||
| DHCP/DNS | `dnsmasq` | `/home/wall/vacuum-wall/data/dnsmasq/` |
|
||||
| VPN | wireguard-tools | `/home/wall/vacuum-wall/data/wireguard/` |
|
||||
| Certificates | `vacuum-wall-acme.timer` | `/home/vacuum-wall/.acme.sh/` |
|
||||
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
|
||||
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
|
||||
| VPN | wireguard-tools | `config/wireguard/` |
|
||||
| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
|
||||
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
|
||||
+31
-13
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Vacuum Wall is built around 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
|
||||
|
||||
@@ -20,7 +20,7 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
|
||||
|
||||
### 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
|
||||
|
||||
@@ -29,39 +29,56 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
|
||||
## Tech Stack
|
||||
|
||||
- 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)
|
||||
- nginx 1.26+
|
||||
- dnsmasq
|
||||
- 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
|
||||
- Jinja2 for server-side templating
|
||||
|
||||
## 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
|
||||
MGMT_DOMAIN=wall.lan MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \
|
||||
bash install.sh
|
||||
# Production
|
||||
./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
|
||||
|
||||
```
|
||||
├── install.sh # Deployment script
|
||||
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||
├── pyproject.toml # Project metadata + dependencies
|
||||
├── .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
|
||||
│ │ ├── vacuum-wall.service # Web UI service
|
||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal service
|
||||
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
|
||||
│ │ └── 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
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||
│ ├── logging.py # Logging setup
|
||||
│ ├── firewall.py # firewalld bindings
|
||||
│ ├── dnsmasq.py # DHCP/DNS 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
|
||||
│ ├── server.py # Application entry point
|
||||
│ ├── api/ # REST API route modules
|
||||
│ │ └── common.py # Shared API response helpers (_ok, _error)
|
||||
│ ├── templates/ # Jinja2/HTMX templates
|
||||
│ └── static/ # CSS and client-side JS
|
||||
└── docs/ # Documentation
|
||||
|
||||
+12
-10
@@ -2,13 +2,13 @@
|
||||
|
||||
## 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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -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 |
|
||||
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
||||
| 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 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 |
|
||||
@@ -32,7 +32,8 @@ Key safety properties:
|
||||
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
@@ -70,8 +71,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
|
||||
| Directive | Value | Effect |
|
||||
|---|---|---|
|
||||
| `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` | `/home/wall/vacuum-wall/data /tmp` | Only the application data directory and `/tmp` are writable |
|
||||
| `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. |
|
||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -114,7 +116,7 @@ IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routin
|
||||
|
||||
### 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
|
||||
|
||||
@@ -126,4 +128,4 @@ All HTTPS proxy domains have HTTP Strict Transport Security enabled at the nginx
|
||||
|
||||
### Modern TLS Only
|
||||
|
||||
As noted in the Web Security section, the default ssl snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites. Weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers are explicitly excluded.
|
||||
As noted in the Web Security section, the default ssl snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites. Weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers are explicitly excluded.
|
||||
+197
-89
@@ -12,75 +12,146 @@ log() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
|
||||
|
||||
# --- Configurable via environment ---
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
USER_NAME="${USER_NAME:-vacuum-wall}"
|
||||
|
||||
# --- Validate required env vars ---
|
||||
missing=()
|
||||
[[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS)
|
||||
[[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL)
|
||||
|
||||
if (( ${#missing[@]} )); then
|
||||
echo -e "${RED}[!!]${NC} Missing required environment variables:"
|
||||
for v in "${missing[@]}"; do
|
||||
case "$v" in
|
||||
MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';;
|
||||
ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";;
|
||||
# --- CLI argument parsing ---
|
||||
_cli_user=""
|
||||
_cli_is_dev=false
|
||||
_cli_path=""
|
||||
_cli_mgmt_pass=""
|
||||
_cli_mgmt_user=""
|
||||
_cli_mgmt_domain=""
|
||||
_cli_acme_email=""
|
||||
_cli_force_venv=false
|
||||
_cli_wan_iface=""
|
||||
_cli_lan_ifaces=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user|-u) _cli_user="$2"; shift 2 ;;
|
||||
--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
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Auto-detect MGMT_DOMAIN from system hostname if not provided
|
||||
if [[ -z "${MGMT_DOMAIN:-}" ]]; then
|
||||
# --- Resolve config: CLI flag > env var > default ---
|
||||
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)
|
||||
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
|
||||
DOMAIN="${HOSTNAME_F}.local"
|
||||
else
|
||||
DOMAIN="$MGMT_DOMAIN"
|
||||
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 ---
|
||||
[[ $EUID -eq 0 ]] || err "This script must be run as root."
|
||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||
|
||||
# --- Deploy to /opt/vacuum-wall ---
|
||||
INSTALL_DIR="/opt/vacuum-wall"
|
||||
# --- Validate required settings ---
|
||||
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 ! command -v rsync &>/dev/null; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq rsync
|
||||
if (( ${#missing[@]} )); then
|
||||
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||
for v in "${missing[@]}"; do
|
||||
case "$v" in
|
||||
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
||||
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
|
||||
esac
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
|
||||
exit 1
|
||||
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"
|
||||
|
||||
# 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 " Vacuum Wall Appliance Installer"
|
||||
echo " Install dir: $PROJECT_DIR"
|
||||
@@ -114,17 +185,23 @@ else
|
||||
fi
|
||||
|
||||
# --- 2b. Setup Python venv ---
|
||||
log "Setting up Python virtual environment..."
|
||||
python3 -m venv "${PROJECT_DIR}/.venv"
|
||||
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
|
||||
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..."
|
||||
rm -rf "${PROJECT_DIR}/.venv"
|
||||
python3 -m venv "${PROJECT_DIR}/.venv"
|
||||
"${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
|
||||
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
|
||||
fi
|
||||
|
||||
# --- 2c. Install acme.sh ---
|
||||
if [[ ! -d "$ACME_HOME" ]]; then
|
||||
log "Installing acme.sh..."
|
||||
# --- 2c. Install acme.sh (vendored) ---
|
||||
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
|
||||
log "Installing acme.sh (vendored)..."
|
||||
mkdir -p "$ACME_HOME"
|
||||
chown "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
||||
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
||||
sh -c 'curl -sS https://get.acme.sh | sh'
|
||||
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
|
||||
chmod +x "$ACME_HOME/acme.sh"
|
||||
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
||||
else
|
||||
log "acme.sh already installed."
|
||||
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)"
|
||||
|
||||
# --- 10. Setup nginx management proxy ---
|
||||
log "Generating self-signed certificate for management domain..."
|
||||
mkdir -p "$ACME_HOME/$DOMAIN"
|
||||
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
|
||||
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
|
||||
-subj "/CN=$DOMAIN" \
|
||||
-addext "subjectAltName=DNS:$DOMAIN"
|
||||
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
|
||||
log "SSL certificate already exists for $DOMAIN, skipping."
|
||||
else
|
||||
log "Generating self-signed certificate for management domain..."
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
|
||||
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
|
||||
-subj "/CN=$DOMAIN" \
|
||||
-addext "subjectAltName=DNS:$DOMAIN"
|
||||
fi
|
||||
|
||||
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
||||
|
||||
# Generate htpasswd directly in data/nginx/
|
||||
htpasswd -cb "${PROJECT_DIR}/data/nginx/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="${PROJECT_DIR}/data/nginx/.htpasswd" python3 -c "
|
||||
# Generate/update htpasswd directly in data/nginx/
|
||||
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||
if [[ -f "$HTPASSWD_FILE" ]]; then
|
||||
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
warn "Could not update htpasswd (install apache2-utils)"
|
||||
else
|
||||
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" python3 -c "
|
||||
import os, crypt, base64
|
||||
password = os.environ['MGMT_PASS']
|
||||
user = os.environ['MGMT_USER']
|
||||
@@ -223,7 +309,8 @@ hashed = crypt.crypt(password, salt)
|
||||
with open(os.environ['HTFILE'], 'w') as f:
|
||||
f.write(user + ':' + hashed + '\n')
|
||||
" 2>/dev/null || \
|
||||
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
||||
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
||||
fi
|
||||
|
||||
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||
|
||||
@@ -279,12 +366,16 @@ server {
|
||||
}
|
||||
MGMTSITEEOF
|
||||
|
||||
# --- 11. Write initial nginx config.json ---
|
||||
log "Writing initial nginx configuration..."
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
|
||||
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
|
||||
if [[ -f "$NGINX_CFG" ]]; then
|
||||
log "Nginx config already exists, skipping initial write."
|
||||
else
|
||||
log "Writing initial nginx configuration..."
|
||||
MGMT_DOMAIN="$DOMAIN" \
|
||||
MGMT_USER="$MGMT_USER" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import json, os
|
||||
d = os.environ['MGMT_DOMAIN']
|
||||
u = os.environ['MGMT_USER']
|
||||
@@ -313,6 +404,7 @@ with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
f.write('\n')
|
||||
"
|
||||
fi
|
||||
|
||||
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
|
||||
log "Detecting network interfaces..."
|
||||
@@ -342,12 +434,16 @@ if [[ -z "$LAN_IFACES" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate config/firewall/config.json
|
||||
log "Writing initial firewall configuration..."
|
||||
WAN_IFACE="$WAN_IFACE" \
|
||||
LAN_IFACES="$LAN_IFACES" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
# 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..."
|
||||
WAN_IFACE="$WAN_IFACE" \
|
||||
LAN_IFACES="$LAN_IFACES" \
|
||||
INSTALL_DIR="$PROJECT_DIR" \
|
||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||
import json, os
|
||||
|
||||
wan = os.environ.get('WAN_IFACE', '').strip() or None
|
||||
@@ -384,6 +480,7 @@ with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write('\n')
|
||||
"
|
||||
fi
|
||||
|
||||
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
|
||||
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true
|
||||
@@ -435,14 +532,25 @@ systemctl enable avahi-daemon >/dev/null 2>&1 || true
|
||||
log "Enabled 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"
|
||||
|
||||
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 ---
|
||||
log "Configuring acme.sh default email..."
|
||||
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
||||
"$ACME_HOME/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
||||
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" \
|
||||
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
|
||||
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
||||
fi
|
||||
|
||||
# --- Done ---
|
||||
echo ""
|
||||
|
||||
+47
-50
@@ -27,6 +27,8 @@ _ACME_ENVIRON = {
|
||||
),
|
||||
}
|
||||
|
||||
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
"""Locate the acme.sh binary on the system.
|
||||
@@ -147,77 +149,52 @@ def get_email() -> str:
|
||||
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.
|
||||
|
||||
Args:
|
||||
domain: The primary domain name.
|
||||
webroot: Path to the web root directory for HTTP-01 validation.
|
||||
email: Contact email. Falls back to configured ACME email if not given.
|
||||
|
||||
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]
|
||||
|
||||
if webroot:
|
||||
args.extend(["--webroot", webroot])
|
||||
|
||||
email = get_email()
|
||||
if email:
|
||||
args.extend(["-m", email])
|
||||
args.extend(["--webroot", webroot or str(_WEBROOT)])
|
||||
contact = email or get_email()
|
||||
if contact:
|
||||
args.extend(["-m", contact])
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
deploy(domain)
|
||||
return {
|
||||
"success": True,
|
||||
"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),
|
||||
}
|
||||
output = _run_acme(args)
|
||||
deploy(domain)
|
||||
logger.info("Certificate for %s issued successfully", domain)
|
||||
return output.strip()
|
||||
|
||||
|
||||
def renew(domain: str, force: bool = False) -> dict:
|
||||
def renew(domain: str, force: bool = False) -> str:
|
||||
"""Renew an existing SSL certificate.
|
||||
|
||||
Args:
|
||||
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:
|
||||
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]
|
||||
if force:
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"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),
|
||||
}
|
||||
output = _run_acme(args)
|
||||
deploy(domain)
|
||||
logger.info("Certificate for %s renewed successfully", domain)
|
||||
return output.strip()
|
||||
|
||||
|
||||
def remove(domain: str) -> str:
|
||||
@@ -274,7 +251,10 @@ def list_certs() -> list[dict]:
|
||||
certs.append(
|
||||
{
|
||||
"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,
|
||||
"key_path": key_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))
|
||||
domain_conf = Path(acme_home_env) / f"{domain}.conf"
|
||||
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
@@ -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
@@ -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,
|
||||
static leases, and custom DNS records through sudo.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
@@ -15,6 +13,10 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Load current dnsmasq config from JSON state file."""
|
||||
_ensure_dirs()
|
||||
raw = _load_json(CONFIG_PATH)
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
raw = load_json(CONFIG_PATH)
|
||||
if not raw:
|
||||
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)."""
|
||||
_ensure_dirs()
|
||||
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||
_save_json(CONFIG_PATH, merged)
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||
save_json(CONFIG_PATH, merged)
|
||||
logger.info("dnsmasq config saved")
|
||||
|
||||
|
||||
def apply_config() -> None:
|
||||
@@ -108,8 +71,8 @@ def apply_config() -> None:
|
||||
cfg = get_config()
|
||||
conf_text = generate_conf(cfg)
|
||||
|
||||
_ensure_dirs()
|
||||
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
|
||||
subprocess.run(
|
||||
["sudo", "tee", DNSMASQ_CONF, "--"],
|
||||
input=conf_text,
|
||||
@@ -117,13 +80,19 @@ def apply_config() -> None:
|
||||
text=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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
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."""
|
||||
dhcp_cfg = cfg.get("dhcp", {})
|
||||
dns_cfg = cfg.get("dns", {})
|
||||
@@ -187,6 +156,23 @@ def set_dhcp_range(
|
||||
ranges.append(entry)
|
||||
|
||||
save_config(cfg)
|
||||
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
|
||||
|
||||
|
||||
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
|
||||
"""Remove a DHCP range by interface + IP range."""
|
||||
cfg = get_config()
|
||||
cfg["dhcp"]["ranges"] = [
|
||||
r
|
||||
for r in cfg["dhcp"]["ranges"]
|
||||
if not (
|
||||
r.get("interface") == iface
|
||||
and r.get("start") == start
|
||||
and r.get("end") == end
|
||||
)
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
|
||||
|
||||
|
||||
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
@@ -200,6 +186,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
if hostname:
|
||||
leases[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
@@ -207,6 +194,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
|
||||
|
||||
|
||||
def remove_static_lease(mac: str) -> None:
|
||||
@@ -218,6 +206,7 @@ def remove_static_lease(mac: str) -> None:
|
||||
if lease["mac"].lower() != mac.lower()
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("Static DHCP lease removed for MAC %s", mac)
|
||||
|
||||
|
||||
# ───────── dns record management ─────────────────────────────────────
|
||||
@@ -234,6 +223,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
|
||||
if hostname:
|
||||
records[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
logger.info("DNS record updated: %s -> %s", name, address)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
@@ -241,6 +231,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
save_config(cfg)
|
||||
logger.info("DNS record added: %s -> %s", name, address)
|
||||
|
||||
|
||||
def remove_dns_record(name: str) -> None:
|
||||
@@ -250,6 +241,7 @@ def remove_dns_record(name: str) -> None:
|
||||
r for r in cfg["dns"]["custom_records"] if r["name"] != name
|
||||
]
|
||||
save_config(cfg)
|
||||
logger.info("DNS record removed: %s", name)
|
||||
|
||||
|
||||
# ───────── 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."""
|
||||
leases: list[dict] = []
|
||||
leases: list[dict[str, Any]] = []
|
||||
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()):
|
||||
if entry is not None:
|
||||
leases.append(entry)
|
||||
@@ -299,6 +296,7 @@ def set_upstreams(servers: list[str]) -> None:
|
||||
cfg = get_config()
|
||||
cfg["dns"]["upstreams"] = list(servers)
|
||||
save_config(cfg)
|
||||
logger.info("DNS upstreams set to %s", servers)
|
||||
|
||||
|
||||
def set_domain(domain: str | None) -> None:
|
||||
@@ -306,16 +304,16 @@ def set_domain(domain: str | None) -> None:
|
||||
cfg = get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
save_config(cfg)
|
||||
logger.info("DNS domain set to '%s'", domain)
|
||||
|
||||
|
||||
# ───────── status / info ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
def get_status() -> dict[str, Any]:
|
||||
"""Return service status, config summary, and current lease count."""
|
||||
cfg = get_config()
|
||||
|
||||
# dnsmasq process check
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sudo", "systemctl", "is-active", "dnsmasq"],
|
||||
@@ -326,8 +324,7 @@ def get_status() -> dict:
|
||||
except Exception:
|
||||
active = False
|
||||
|
||||
# config on disk
|
||||
conf_exists = os.path.isfile(DNSMASQ_CONF)
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
if conf_exists:
|
||||
try:
|
||||
with open(DNSMASQ_CONF) as f:
|
||||
@@ -337,7 +334,6 @@ def get_status() -> dict:
|
||||
else:
|
||||
conf_on_disk = ""
|
||||
|
||||
# current expected config
|
||||
expected = generate_conf(cfg)
|
||||
|
||||
leases = get_lease_table()
|
||||
@@ -354,3 +350,21 @@ def get_status() -> dict:
|
||||
"active_leases": len(leases),
|
||||
"leases": leases,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"add_dns_record",
|
||||
"add_static_lease",
|
||||
"apply_config",
|
||||
"generate_conf",
|
||||
"get_config",
|
||||
"get_lease_table",
|
||||
"get_status",
|
||||
"remove_dhcp_range",
|
||||
"remove_dns_record",
|
||||
"remove_static_lease",
|
||||
"save_config",
|
||||
"set_dhcp_range",
|
||||
"set_domain",
|
||||
"set_upstreams",
|
||||
]
|
||||
|
||||
+481
-184
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
||||
@@ -28,7 +33,7 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_SSL = {
|
||||
DEFAULT_SSL: dict[str, Any] = {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": (
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||
@@ -41,63 +46,35 @@ DEFAULT_SSL = {
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
return _json_load(CONFIG_FILE)
|
||||
def get_config() -> dict[str, Any]:
|
||||
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:
|
||||
_json_dump(CONFIG_FILE, cfg)
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def get_domains() -> list[dict]:
|
||||
def get_domains() -> list[dict[str, Any]]:
|
||||
cfg = get_config()
|
||||
result = []
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
result.append(
|
||||
@@ -117,17 +94,17 @@ def get_domains() -> list[dict]:
|
||||
|
||||
|
||||
def add_domain(
|
||||
domain,
|
||||
backend_host,
|
||||
backend_port,
|
||||
backend_proto="http",
|
||||
cert=None,
|
||||
extra_headers=None,
|
||||
domain: str,
|
||||
backend_host: str,
|
||||
backend_port: int,
|
||||
backend_proto: str = "http",
|
||||
cert: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
cfg = get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
entry = {
|
||||
entry: dict[str, Any] = {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
@@ -141,18 +118,26 @@ def add_domain(
|
||||
entry["headers"] = extra_headers
|
||||
cfg["domains"][domain] = entry
|
||||
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["domains"].pop(domain, None)
|
||||
save_config(cfg)
|
||||
site = SITES_DIR / f"{domain}.conf"
|
||||
if site.exists():
|
||||
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()
|
||||
if domain not in cfg["domains"]:
|
||||
raise KeyError(f"Domain {domain!r} not configured")
|
||||
@@ -163,6 +148,7 @@ def update_domain(domain, **kwargs) -> None:
|
||||
else:
|
||||
entry[key] = val
|
||||
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")
|
||||
return tmpl.render(
|
||||
domain=domain_cfg["domain"],
|
||||
@@ -182,10 +168,11 @@ def generate_server_conf(domain_cfg: dict) -> str:
|
||||
is_management=False,
|
||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||
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")
|
||||
return tmpl.render(
|
||||
domain=management.get("domain"),
|
||||
@@ -199,6 +186,7 @@ def _generate_management_conf(management: dict) -> str:
|
||||
is_management=True,
|
||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||
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:
|
||||
_ensure_dirs()
|
||||
def write_site(domain: str, conf_text: str) -> None:
|
||||
ensure_dirs(SITES_DIR)
|
||||
path = SITES_DIR / f"{domain}.conf"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
@@ -218,13 +206,32 @@ def write_site(domain, conf_text) -> None:
|
||||
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:
|
||||
_ensure_dirs()
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = get_config()
|
||||
|
||||
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():
|
||||
dom_copy = dict(dom, domain=name)
|
||||
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:
|
||||
old.unlink()
|
||||
|
||||
write_acme_challenge()
|
||||
logger.info("All nginx site configs written (%d sites)", len(written))
|
||||
|
||||
|
||||
def write_include_file() -> None:
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
@@ -248,15 +258,15 @@ def write_include_file() -> None:
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
|
||||
subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_ssl_snippet() -> None:
|
||||
cfg = get_config()
|
||||
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
|
||||
ssl_cfg.setdefault("prefer_server_ciphers", False)
|
||||
ssl_cfg = cfg.get("ssl", {})
|
||||
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
|
||||
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
|
||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||
|
||||
@@ -266,8 +276,8 @@ def write_ssl_snippet() -> None:
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
|
||||
subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -277,11 +287,17 @@ def write_ssl_snippet() -> None:
|
||||
|
||||
|
||||
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
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
if not output and ok:
|
||||
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
|
||||
|
||||
|
||||
@@ -292,7 +308,13 @@ def apply() -> None:
|
||||
ok, msg = test_config()
|
||||
if not ok:
|
||||
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(
|
||||
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:
|
||||
cfg = get_config()
|
||||
entry = {
|
||||
entry: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"backend": {
|
||||
"host": flask_host,
|
||||
@@ -321,6 +347,7 @@ def set_management_proxy(
|
||||
save_config(cfg)
|
||||
if auth_user and 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:
|
||||
"""
|
||||
Append (or create) an htpasswd entry for *user*.
|
||||
|
||||
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()
|
||||
def write_htpasswd(user: str, password: str) -> None:
|
||||
"""Append (or create) an htpasswd entry for *user*."""
|
||||
ensure_dirs(DATA_DIR)
|
||||
hashed = _hash_password(password)
|
||||
existing = {}
|
||||
existing: dict[str, str] = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
for line in f:
|
||||
@@ -359,7 +380,7 @@ def write_htpasswd(user, password) -> None:
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
def _hash_password(password: str) -> str:
|
||||
try:
|
||||
from passlib.hash import apache_passwd
|
||||
|
||||
@@ -369,3 +390,23 @@ def _hash_password(password):
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
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",
|
||||
]
|
||||
|
||||
+92
-179
@@ -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
|
||||
the WireGuard tunnel interface.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
@@ -26,95 +30,41 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
# --- 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": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
|
||||
# --- Core config persistence ---
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Load the current WireGuard configuration from the JSON store.
|
||||
|
||||
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 get_config() -> dict[str, Any]:
|
||||
"""Load the current WireGuard configuration from the JSON store."""
|
||||
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
"""Persist *cfg* to the JSON store atomically.
|
||||
|
||||
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)
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
"""Persist *cfg* to the JSON store atomically."""
|
||||
save_json(CONFIG_PATH, cfg)
|
||||
|
||||
|
||||
# --- Key generation ---
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
|
||||
|
||||
Returns:
|
||||
``(private_key, public_key)`` as two 43-character base64 strings.
|
||||
"""
|
||||
res = _run([WG_BIN, "genkey"])
|
||||
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
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()
|
||||
return private_key, public_key
|
||||
|
||||
@@ -122,7 +72,7 @@ def generate_keypair() -> tuple[str, str]:
|
||||
# --- 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."""
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
@@ -139,7 +89,7 @@ def apply() -> None:
|
||||
"""Write the current config to disk and bring the tunnel up with wg-quick."""
|
||||
cfg = get_config()
|
||||
conf_text = generate_conf(cfg)
|
||||
save_config(cfg) # ensure latest state persisted
|
||||
save_config(cfg)
|
||||
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -147,61 +97,46 @@ def apply() -> None:
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
_run(["cp", "--", str(local_tmp), WG_CONF_PATH])
|
||||
_run(["chown", "root:root", WG_CONF_PATH], check=False)
|
||||
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
|
||||
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
|
||||
|
||||
def down() -> None:
|
||||
"""Bring the WireGuard tunnel interface down."""
|
||||
cfg = get_config()
|
||||
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 ---
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""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``.
|
||||
"""
|
||||
def status() -> dict[str, Any]:
|
||||
"""Query the live tunnel state via ``wg show``."""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result = {
|
||||
result: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
|
||||
try:
|
||||
proc = _run([WG_BIN, "show", name], check=False)
|
||||
if proc.returncode != 0:
|
||||
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
return result
|
||||
|
||||
raw = proc.stdout.strip()
|
||||
raw = res.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
# Parse the wg show output.
|
||||
# Format (multi-section, separated by blank lines or interleaved):
|
||||
# interface:
|
||||
# public key: ...
|
||||
# listening port: ...
|
||||
# peer: <key>
|
||||
# endpoint: ...
|
||||
# allowed ips: ...
|
||||
# latest handshake: ...
|
||||
# transfer: ...
|
||||
# persistent-keepalive: ...
|
||||
current_peer = None
|
||||
peers: list[dict] = []
|
||||
current_peer: dict[str, Any] | None = None
|
||||
peers: list[dict[str, Any]] = []
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
@@ -286,25 +221,8 @@ def add_peer(
|
||||
allowed_ips: list[str] | None = None,
|
||||
persistent_keepalive: int | None = None,
|
||||
preshared_key: str | None = None,
|
||||
) -> dict:
|
||||
"""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.
|
||||
"""
|
||||
) -> dict[str, Any]:
|
||||
"""Add (or update) a peer in the configuration."""
|
||||
cfg = get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = allowed_ips or []
|
||||
@@ -316,23 +234,23 @@ def add_peer(
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if preshared_key is not None:
|
||||
peer["preshared_key"] = preshared_key
|
||||
logger.info("WireGuard peer '%s' updated", name)
|
||||
else:
|
||||
# Generate a key pair for the new peer.
|
||||
priv, pub = generate_keypair()
|
||||
peer = {
|
||||
"public_key": pub,
|
||||
"private_key": priv, # stored so we can hand it to the client
|
||||
"private_key": priv,
|
||||
"endpoint": endpoint,
|
||||
"allowed_ips": allowed_ips,
|
||||
"persistent_keepalive": persistent_keepalive,
|
||||
"preshared_key": preshared_key,
|
||||
}
|
||||
peers[name] = peer
|
||||
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
|
||||
|
||||
save_config(cfg)
|
||||
|
||||
# Return a copy that includes the private key (safe — used for provisioning).
|
||||
peer_out = dict(peer)
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
|
||||
|
||||
@@ -341,33 +259,23 @@ def remove_peer(name: str) -> None:
|
||||
cfg = get_config()
|
||||
cfg.setdefault("peers", {}).pop(name, None)
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard peer '%s' removed", name)
|
||||
|
||||
|
||||
def get_peers() -> list[dict]:
|
||||
"""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).
|
||||
"""
|
||||
def get_peers() -> list[dict[str, Any]]:
|
||||
"""List all configured peers (from the JSON store, *not* live)."""
|
||||
cfg = get_config()
|
||||
peers = []
|
||||
peers: list[dict[str, Any]] = []
|
||||
for name, info in cfg.get("peers", {}).items():
|
||||
entry = dict(info)
|
||||
entry["name"] = name
|
||||
# Strip private key from the public listing.
|
||||
entry.pop("private_key", None)
|
||||
peers.append(entry)
|
||||
return peers
|
||||
|
||||
|
||||
def get_peer_status() -> list[dict]:
|
||||
"""Return live peer status from ``wg show``.
|
||||
|
||||
Each element contains:
|
||||
- ``public_key``, ``endpoint``, ``allowed_ips``,
|
||||
``latest_handshake``, ``transfer_received``,
|
||||
``transfer_sent``, ``persistent_keepalive``.
|
||||
"""
|
||||
def get_peer_status() -> list[dict[str, Any]]:
|
||||
"""Return live peer status from ``wg show``."""
|
||||
st = status()
|
||||
return st.get("peers", [])
|
||||
|
||||
@@ -401,7 +309,7 @@ def generate_client_conf(
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
return tmpl.render(
|
||||
conf = tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
peer_name=peer_name,
|
||||
client_priv=client_priv,
|
||||
@@ -412,29 +320,25 @@ def generate_client_conf(
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
)
|
||||
logger.info("Client config generated for peer '%s'", peer_name)
|
||||
return conf
|
||||
|
||||
|
||||
# --- Interface-level setters ---
|
||||
|
||||
|
||||
def set_listen_port(port: int) -> None:
|
||||
"""Update the server listen port in the stored configuration.
|
||||
|
||||
Does **not** hot-reload; call :func:`apply` afterwards to
|
||||
activate the change.
|
||||
"""
|
||||
"""Update the server listen port in the stored configuration."""
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Listen port must be in range 1..65535")
|
||||
cfg = get_config()
|
||||
cfg["interface"]["listen_port"] = port
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard listen port set to %d", port)
|
||||
|
||||
|
||||
def set_post_up(cmd: str | None) -> None:
|
||||
"""Set (or clear) the PostUp hook command.
|
||||
|
||||
The command is passed verbatim to the generated wg0.conf.
|
||||
"""
|
||||
"""Set (or clear) the PostUp hook command."""
|
||||
cfg = get_config()
|
||||
cfg["interface"]["post_up"] = cmd
|
||||
save_config(cfg)
|
||||
@@ -450,40 +354,28 @@ def set_post_down(cmd: str | None) -> None:
|
||||
# --- Initialise ---
|
||||
|
||||
|
||||
def initialize() -> dict:
|
||||
"""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.
|
||||
"""
|
||||
def initialize() -> dict[str, Any]:
|
||||
"""Perform first-time WireGuard setup."""
|
||||
cfg = get_config()
|
||||
|
||||
if cfg["interface"].get("private_key"):
|
||||
# Already initialised — return existing config.
|
||||
return cfg
|
||||
|
||||
priv, pub = generate_keypair()
|
||||
cfg["interface"]["private_key"] = priv
|
||||
cfg["interface"]["public_key"] = pub
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||
return cfg
|
||||
|
||||
|
||||
# --- Utility: parse wg show into structured peer map ---
|
||||
|
||||
|
||||
def _parse_wg_show(output: str) -> dict:
|
||||
"""Internal parser for ``wg show`` multiline output.
|
||||
|
||||
Returns a dict keyed by peer public key with parsed values.
|
||||
Used internally; ``status()`` is the public interface.
|
||||
"""
|
||||
peers: dict = {}
|
||||
current = None
|
||||
def _parse_wg_show(output: str) -> dict[str, Any]:
|
||||
"""Internal parser for ``wg show`` multiline output."""
|
||||
peers: dict[str, dict[str, Any]] = {}
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
@@ -509,3 +401,24 @@ def _parse_wg_show(output: str) -> dict:
|
||||
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
Executable
+42
@@ -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."
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ server {
|
||||
listen [::]:80;
|
||||
server_name {{ domain }};
|
||||
|
||||
# ACME HTTP-01 challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ acme_webroot }};
|
||||
}
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -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/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/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||
|
||||
# Dnsmasq management
|
||||
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||
|
||||
@@ -7,4 +7,4 @@ User={{ USER_NAME }}
|
||||
WorkingDirectory={{ PROJECT_DIR }}
|
||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||
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 }}
|
||||
|
||||
@@ -20,7 +20,7 @@ Environment=HOME={{ PROJECT_DIR }}
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
||||
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
@@ -34,9 +34,8 @@ LockPersonality=yes
|
||||
SystemCallFilter=@system-service
|
||||
PrivateDevices=yes
|
||||
|
||||
ProtectHome=read-only
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=all
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
|
||||
[Install]
|
||||
|
||||
+255
-19
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
API integration tests — all blueprints tested via a single Flask app fixture.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +28,11 @@ def client():
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Firewall
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestFirewallListZones:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
@@ -100,7 +109,7 @@ class TestFirewallDeleteZone:
|
||||
class TestFirewallRichRules:
|
||||
@patch("webui.api.firewall.add_rich_rule")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
mock_add.return_value = {"id": "abc123", "rule": "rule accept"}
|
||||
resp = client.post(
|
||||
"/api/firewall/rich-rules",
|
||||
json={
|
||||
@@ -111,18 +120,37 @@ class TestFirewallRichRules:
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["id"] == "abc123"
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post("/api/firewall/rich-rules", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("webui.api.firewall.get_rich_rules")
|
||||
def test_list(self, mock_list, client):
|
||||
mock_list.return_value = ["rule1", "rule2"]
|
||||
@patch("webui.api.firewall.get_config")
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
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:
|
||||
@@ -161,12 +189,14 @@ class TestFirewallMasquerade:
|
||||
class TestFirewallForwardPort:
|
||||
@patch("webui.api.firewall.add_forward_port")
|
||||
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(
|
||||
"/api/firewall/forward-port",
|
||||
json={"zone": "public", "port": 443, "proto": "tcp"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["data"]["id"] == "fp1"
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post(
|
||||
@@ -175,6 +205,25 @@ class TestFirewallForwardPort:
|
||||
)
|
||||
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:
|
||||
@patch("webui.api.dhcp.get_config")
|
||||
@@ -192,6 +241,67 @@ class TestDhcpConfig:
|
||||
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:
|
||||
@patch("webui.api.dhcp.add_static_lease")
|
||||
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"}]}
|
||||
}
|
||||
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
|
||||
|
||||
@patch("webui.api.dhcp.get_config")
|
||||
def test_remove_not_found(self, mock_get, client):
|
||||
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
|
||||
|
||||
def test_remove_missing_mac(self, client):
|
||||
resp = client.delete("/api/dhcp/static-lease")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestDhcpDnsRecord:
|
||||
@patch("webui.api.dhcp.add_dns_record")
|
||||
@@ -241,6 +347,27 @@ class TestDhcpDnsRecord:
|
||||
resp = client.post("/api/dhcp/dns-record", json={})
|
||||
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:
|
||||
@patch("webui.api.proxy.get_domains")
|
||||
@@ -271,6 +398,30 @@ class TestProxyApply:
|
||||
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:
|
||||
@patch("webui.api.certs.list_certs")
|
||||
def test_list(self, mock_list, client):
|
||||
@@ -297,6 +448,11 @@ class TestCertsEmail:
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WireGuard
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestWireguardConfig:
|
||||
@patch("webui.api.wireguard.get_config")
|
||||
def test_get(self, mock_get, client):
|
||||
@@ -315,6 +471,38 @@ class TestWireguardConfig:
|
||||
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
||||
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:
|
||||
@patch("webui.api.wireguard.get_peers")
|
||||
@@ -325,9 +513,12 @@ class TestWireguardPeers:
|
||||
|
||||
@patch("webui.api.wireguard.add_peer")
|
||||
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(
|
||||
"/api/wireguard/add-peer",
|
||||
"/api/wireguard/peers",
|
||||
json={"name": "client1"},
|
||||
)
|
||||
data = resp.get_json()
|
||||
@@ -335,21 +526,31 @@ class TestWireguardPeers:
|
||||
assert "private_key" not in data["data"]
|
||||
|
||||
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
|
||||
|
||||
@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:
|
||||
@patch("webui.api.wireguard.initialize")
|
||||
def test_initialize(self, mock_init, client):
|
||||
mock_init.return_value = {
|
||||
"interface": {"name": "wg0", "private_key": "priv"},
|
||||
"peers": {},
|
||||
}
|
||||
mock_init.return_value = None
|
||||
resp = client.post("/api/wireguard/initialize")
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"] is None
|
||||
|
||||
|
||||
class TestWireguardGenerateClient:
|
||||
@@ -366,6 +567,41 @@ class TestWireguardStatus:
|
||||
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:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
|
||||
@@ -2,7 +2,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lib import dnsmasq
|
||||
from lib import common, dnsmasq
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -29,24 +29,24 @@ class TestDeepMerge:
|
||||
def test_merge_flat_dicts(self):
|
||||
base = {"a": 1, "b": 2}
|
||||
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}
|
||||
|
||||
def test_merge_nested_dicts(self):
|
||||
base = {"a": {"x": 1, "y": 2}}
|
||||
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}}
|
||||
|
||||
def test_merge_non_dict_override(self):
|
||||
base = {"a": {"x": 1}}
|
||||
override = {"a": "flat"}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
result = common.deep_merge(base, override)
|
||||
assert result == {"a": "flat"}
|
||||
|
||||
|
||||
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):
|
||||
mock_load.return_value = {}
|
||||
result = dnsmasq.get_config()
|
||||
@@ -54,7 +54,7 @@ class TestGetConfig:
|
||||
assert "dns" in result
|
||||
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):
|
||||
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
||||
result = dnsmasq.get_config()
|
||||
|
||||
+293
-10
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
|
||||
|
||||
|
||||
class TestGetActiveZones:
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_parses_active_zones(self, mock_run):
|
||||
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
|
||||
result = firewall.get_active_zones()
|
||||
@@ -35,13 +35,13 @@ class TestGetActiveZones:
|
||||
"internal": ["eth1", "eth2"],
|
||||
}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_empty_output(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_zone_with_no_interfaces(self, mock_run):
|
||||
mock_run.return_value = "dmz"
|
||||
result = firewall.get_active_zones()
|
||||
@@ -49,7 +49,7 @@ class TestGetActiveZones:
|
||||
|
||||
|
||||
class TestGetZoneInfo:
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_parses_zone_info(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"target: default\n"
|
||||
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
|
||||
|
||||
|
||||
class TestGetInterfaces:
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_parses_interfaces(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
||||
@@ -88,7 +88,7 @@ class TestGetInterfaces:
|
||||
|
||||
|
||||
class TestGetRichRules:
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_single_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
||||
@@ -96,13 +96,13 @@ class TestGetRichRules:
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_empty_rules(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert result == []
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_multiline_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
||||
@@ -120,14 +120,14 @@ class TestNowIso:
|
||||
|
||||
|
||||
class TestAddForwardPort:
|
||||
@patch("lib.firewall._run")
|
||||
@patch("lib.firewall.run")
|
||||
def test_forward_port_basic(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
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]
|
||||
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):
|
||||
mock_run.return_value = ""
|
||||
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
||||
@@ -159,3 +159,286 @@ class TestGetState:
|
||||
assert "zones" in result
|
||||
assert "active_zones" 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
@@ -200,7 +200,7 @@ class TestWriteAllSites:
|
||||
|
||||
|
||||
class TestTestConfig:
|
||||
@patch("lib.nginx._run")
|
||||
@patch("lib.nginx.subprocess.run")
|
||||
def test_passes(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout="", stderr="test passed\n"
|
||||
@@ -208,7 +208,7 @@ class TestTestConfig:
|
||||
ok, _msg = nginx.test_config()
|
||||
assert ok is True
|
||||
|
||||
@patch("lib.nginx._run")
|
||||
@patch("lib.nginx.subprocess.run")
|
||||
def test_fails(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
|
||||
|
||||
+29
-25
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -10,14 +10,14 @@ from lib import wireguard
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
original = wireguard.CONFIG_PATH
|
||||
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
|
||||
wireguard.CONFIG_PATH = tmp_path / "config.json"
|
||||
yield tmp_path
|
||||
wireguard.CONFIG_PATH = original
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
def test_returns_skeleton(self):
|
||||
cfg = wireguard._default_config()
|
||||
cfg = wireguard.DEFAULT_CONFIG
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
assert cfg["interface"]["private_key"] == ""
|
||||
@@ -31,27 +31,29 @@ class TestGetConfig:
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
def test_loads_existing_config(self, temp_config):
|
||||
path = Path(wireguard.CONFIG_PATH)
|
||||
expected = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "existing-key",
|
||||
"public_key": "existing-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
path.write_text(json.dumps(expected))
|
||||
wireguard.CONFIG_PATH.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "existing-key",
|
||||
"public_key": "existing-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["private_key"] == "existing-key"
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_save_and_reload(self, temp_config):
|
||||
cfg = wireguard._default_config()
|
||||
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||
cfg["interface"]["listen_port"] = 51821
|
||||
wireguard.save_config(cfg)
|
||||
loaded = wireguard.get_config()
|
||||
@@ -59,7 +61,7 @@ class TestSaveConfig:
|
||||
|
||||
|
||||
class TestGenerateKeyPair:
|
||||
@patch("lib.wireguard._run")
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_returns_keypair(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="private-key\n"),
|
||||
@@ -68,6 +70,8 @@ class TestGenerateKeyPair:
|
||||
private, public = wireguard.generate_keypair()
|
||||
assert private == "private-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:
|
||||
@@ -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()
|
||||
assert len(peers) == 1
|
||||
assert peers[0]["name"] == "client1"
|
||||
@@ -110,7 +114,7 @@ class TestAddPeer:
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
|
||||
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"]
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
@@ -180,14 +184,14 @@ class TestInitialize:
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
|
||||
wireguard.CONFIG_PATH.write_text(json.dumps(existing))
|
||||
cfg = wireguard.initialize()
|
||||
assert cfg["interface"]["private_key"] == "original-private"
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
|
||||
class TestStatus:
|
||||
@patch("lib.wireguard._run")
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_returns_down_when_interface_down(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="interface not found"
|
||||
@@ -195,7 +199,7 @@ class TestStatus:
|
||||
result = wireguard.status()
|
||||
assert result["up"] is False
|
||||
|
||||
@patch("lib.wireguard._run")
|
||||
@patch("lib.wireguard.run_proc")
|
||||
def test_parses_interface_info(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
@@ -206,7 +210,7 @@ class TestStatus:
|
||||
assert result["interface"]["public_key"] == "ABCDEF"
|
||||
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):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
|
||||
+8326
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+12
@@ -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))
|
||||
}
|
||||
})
|
||||
+34
-34
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
webui/api/certs.py - ACME certificate management API blueprint.
|
||||
"""ACME certificate management API blueprint.
|
||||
|
||||
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 (
|
||||
get_cert_info,
|
||||
@@ -14,23 +15,12 @@ from lib.acme import (
|
||||
renew,
|
||||
set_email,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,18 +30,20 @@ def _ok(data=None):
|
||||
def list_certs_bp():
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["GET"])
|
||||
def cert_details(domain):
|
||||
def cert_details(domain: str):
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return _ok(info)
|
||||
except ValueError as exc:
|
||||
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)
|
||||
|
||||
|
||||
@@ -67,12 +59,14 @@ def issue_bp():
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
webroot = body.get("webroot")
|
||||
email = body.get("email", "").strip() or None
|
||||
try:
|
||||
result = issue(domain, webroot=webroot)
|
||||
if result.get("success"):
|
||||
return _ok(None)
|
||||
return _error(result.get("error", "Unknown error"), 500)
|
||||
except RuntimeError as exc:
|
||||
logger.info("Certificate issuance requested for '%s' via API", domain)
|
||||
issue(domain, webroot=webroot, email=email)
|
||||
logger.info("Certificate issued for '%s'", domain)
|
||||
return _ok(None)
|
||||
except (RuntimeError, FileNotFoundError) as exc:
|
||||
logger.error("Failed to issue cert for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -82,13 +76,14 @@ def issue_bp():
|
||||
|
||||
|
||||
@bp.route("/<domain>/renew", methods=["POST"])
|
||||
def renew_bp(domain):
|
||||
def renew_bp(domain: str):
|
||||
try:
|
||||
result = renew(domain)
|
||||
if result.get("success"):
|
||||
return _ok(None)
|
||||
return _error(result.get("error", "Unknown error"), 500)
|
||||
except RuntimeError as exc:
|
||||
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||
renew(domain)
|
||||
logger.info("Certificate renewed for '%s'", domain)
|
||||
return _ok(None)
|
||||
except (RuntimeError, FileNotFoundError) as exc:
|
||||
logger.error("Failed to renew cert for '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -98,17 +93,20 @@ def renew_bp(domain):
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["DELETE"])
|
||||
def remove_bp(domain):
|
||||
def remove_bp(domain: str):
|
||||
try:
|
||||
get_cert_info(domain)
|
||||
except ValueError as exc:
|
||||
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)
|
||||
try:
|
||||
remove(domain)
|
||||
logger.info("Certificate removed for '%s' via API", domain)
|
||||
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)
|
||||
|
||||
|
||||
@@ -125,6 +123,8 @@ def set_email_bp():
|
||||
return _error("'email' is required", 400)
|
||||
try:
|
||||
set_email(email)
|
||||
logger.info("ACME email set via API: %s", 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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from lib.common import deep_merge
|
||||
from lib.dnsmasq import (
|
||||
add_dns_record,
|
||||
add_static_lease,
|
||||
apply_config,
|
||||
get_config,
|
||||
get_lease_table,
|
||||
remove_dhcp_range,
|
||||
remove_dns_record,
|
||||
remove_static_lease,
|
||||
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__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -53,6 +40,7 @@ def get_config_bp():
|
||||
try:
|
||||
return _ok(get_config())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -65,6 +53,7 @@ def post_config():
|
||||
save_config(body)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to save DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -75,10 +64,11 @@ def patch_config():
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
current = get_config()
|
||||
merged = _deep_merge(current, body)
|
||||
merged = deep_merge(current, body)
|
||||
save_config(merged)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to patch DHCP config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -86,13 +76,74 @@ def patch_config():
|
||||
def apply_bp():
|
||||
try:
|
||||
apply_config()
|
||||
logger.info("dnsmasq config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply dnsmasq config: %s", exc)
|
||||
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:
|
||||
return _ok(get_lease_table())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read lease table: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -119,16 +171,15 @@ def add_static_lease_bp():
|
||||
return _error("'mac' and 'ip' are required", 400)
|
||||
try:
|
||||
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})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add static lease: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/static-lease", methods=["DELETE"])
|
||||
def remove_static_lease_bp():
|
||||
mac = request.args.get("mac", "").strip()
|
||||
if not mac:
|
||||
return _error("Query parameter 'mac' is required", 400)
|
||||
@bp.route("/static-lease/<mac>", methods=["DELETE"])
|
||||
def remove_static_lease_bp(mac):
|
||||
current = get_config()
|
||||
found = any(
|
||||
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)
|
||||
try:
|
||||
remove_static_lease(mac)
|
||||
logger.info("Static lease removed via API: %s", mac)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove static lease: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -158,16 +211,15 @@ def add_dns_record_bp():
|
||||
return _error("'name' and 'address' are required", 400)
|
||||
try:
|
||||
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})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add DNS record: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/dns-record", methods=["DELETE"])
|
||||
def remove_dns_record_bp():
|
||||
name = request.args.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("Query parameter 'name' is required", 400)
|
||||
@bp.route("/dns-record/<name>", methods=["DELETE"])
|
||||
def remove_dns_record_bp(name):
|
||||
current = get_config()
|
||||
found = any(
|
||||
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)
|
||||
try:
|
||||
remove_dns_record(name)
|
||||
logger.info("DNS record removed via API: %s", name)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove DNS record: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
+153
-66
@@ -1,43 +1,120 @@
|
||||
"""
|
||||
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
|
||||
"""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 (
|
||||
add_forward_port,
|
||||
add_rich_rule,
|
||||
config_apply,
|
||||
config_pending,
|
||||
create_zone,
|
||||
delete_zone,
|
||||
get_active_zones,
|
||||
get_available_zones,
|
||||
get_config,
|
||||
get_interfaces,
|
||||
get_rich_rules,
|
||||
get_services,
|
||||
get_zone_info,
|
||||
remove_forward_port,
|
||||
remove_rich_rule,
|
||||
remove_forward_port_by_id,
|
||||
remove_rich_rule_by_id,
|
||||
save_config,
|
||||
set_masquerade,
|
||||
set_zone_interfaces,
|
||||
set_zone_services,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
bp = Blueprint("firewall", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error helpers
|
||||
# Declarative config (two-step: save -> apply)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
@bp.route("/config", methods=["GET"])
|
||||
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):
|
||||
return jsonify({"ok": True, "data": data})
|
||||
@bp.route("/config", methods=["POST"])
|
||||
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:
|
||||
active = get_active_zones()
|
||||
available = get_available_zones()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"data": {
|
||||
"active": active,
|
||||
"available": available,
|
||||
},
|
||||
}
|
||||
)
|
||||
return _ok({"active": active, "available": available})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list zones: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["GET"])
|
||||
def zone_details(name):
|
||||
def zone_details(name: str):
|
||||
try:
|
||||
if name not in get_available_zones():
|
||||
return _error(f"Zone '{name}' does not exist", 404)
|
||||
info = get_zone_info(name)
|
||||
return jsonify({"ok": True, "data": info})
|
||||
return _ok(info)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get zone '%s' info: %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -85,20 +156,24 @@ def create_zone_bp():
|
||||
if zone_name in get_available_zones():
|
||||
return _error(f"Zone '{zone_name}' already exists", 400)
|
||||
create_zone(zone_name, target)
|
||||
logger.info("Zone '%s' created via API", zone_name)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to create zone '%s': %s", zone_name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
||||
def delete_zone_bp(name):
|
||||
def delete_zone_bp(name: str):
|
||||
try:
|
||||
available = get_available_zones()
|
||||
if name not in available:
|
||||
return _error(f"Zone '{name}' does not exist", 404)
|
||||
delete_zone(name)
|
||||
logger.info("Zone '%s' deleted via API", name)
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to delete zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -108,15 +183,17 @@ def delete_zone_bp(name):
|
||||
|
||||
|
||||
@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 {}
|
||||
interfaces = body.get("interfaces", [])
|
||||
if not isinstance(interfaces, list):
|
||||
return _error("'interfaces' must be a list", 400)
|
||||
try:
|
||||
set_zone_interfaces(name, interfaces)
|
||||
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
|
||||
return _ok({"zone": name, "interfaces": interfaces})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -126,7 +203,7 @@ def set_zone_interfaces_bp(name):
|
||||
|
||||
|
||||
@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 {}
|
||||
services = body.get("services", [])
|
||||
if not isinstance(services, list):
|
||||
@@ -135,6 +212,7 @@ def set_zone_services_bp(name):
|
||||
set_zone_services(name, services)
|
||||
return _ok({"zone": name, "services": services})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set services for zone '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -148,6 +226,7 @@ def list_services():
|
||||
try:
|
||||
return _ok(get_services())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list services: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -156,6 +235,7 @@ def list_interfaces():
|
||||
try:
|
||||
return _ok(get_interfaces())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list interfaces: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -172,32 +252,43 @@ def add_rich_rule_bp():
|
||||
if not zone or not rule:
|
||||
return _error("Both 'zone' and 'rule' are required", 400)
|
||||
try:
|
||||
add_rich_rule(zone, rule)
|
||||
return _ok({"zone": zone, "rule": rule})
|
||||
except RuntimeError as exc:
|
||||
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})
|
||||
entry = add_rich_rule(zone, rule)
|
||||
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
|
||||
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
||||
def list_rich_rules(zone):
|
||||
def list_rich_rules(zone: str):
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@@ -215,8 +306,14 @@ def set_masquerade_bp():
|
||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||
try:
|
||||
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)})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -236,38 +333,28 @@ def add_forward_port_bp():
|
||||
if not zone or port is None or not proto:
|
||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
||||
try:
|
||||
add_forward_port(
|
||||
entry = add_forward_port(
|
||||
zone,
|
||||
int(port),
|
||||
proto,
|
||||
toaddr=str(toaddr) if toaddr 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:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
logger.error("Failed to add forward port: %s", exc)
|
||||
return _error(str(exc), code)
|
||||
|
||||
|
||||
@bp.route("/forward-port", methods=["DELETE"])
|
||||
def remove_forward_port_bp():
|
||||
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)
|
||||
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
|
||||
def remove_forward_port_bp(zone: str, port: int, proto: str):
|
||||
try:
|
||||
remove_forward_port(
|
||||
zone,
|
||||
int(port),
|
||||
proto,
|
||||
toaddr=str(toaddr) if toaddr else None,
|
||||
toport=int(toport) if toport else None,
|
||||
)
|
||||
return _ok({"zone": zone, "port": int(port), "proto": proto})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
return _error(str(exc), code)
|
||||
remove_forward_port_by_id(zone, port, proto)
|
||||
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
||||
return _ok({"zone": zone, "port": port, "proto": proto})
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
@@ -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
@@ -4,33 +4,83 @@ webui/api/proxy.py - Nginx proxy domain management API blueprint.
|
||||
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 (
|
||||
add_domain,
|
||||
apply,
|
||||
get_config,
|
||||
get_domains,
|
||||
remove_domain,
|
||||
save_config,
|
||||
set_management_proxy,
|
||||
test_config,
|
||||
update_domain,
|
||||
write_ssl_snippet,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__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):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
@bp.route("/config", methods=["GET"])
|
||||
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):
|
||||
return jsonify({"ok": True, "data": data})
|
||||
@bp.route("/config", methods=["POST"])
|
||||
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:
|
||||
return _ok(get_domains())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list proxy domains: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -65,8 +116,10 @@ def add_domain_bp():
|
||||
add_domain(
|
||||
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
|
||||
)
|
||||
logger.info("Proxy domain added via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -79,6 +132,7 @@ def domain_details(domain):
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
return _ok({"domain": domain, **entry})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get domain details: %s", exc)
|
||||
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)
|
||||
try:
|
||||
update_domain(domain, **body)
|
||||
logger.info("Proxy domain '%s' updated via API", domain)
|
||||
return _ok({"domain": domain})
|
||||
except KeyError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to update domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -103,8 +159,10 @@ def remove_domain_bp(domain):
|
||||
if domain not in cfg.get("domains", {}):
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
remove_domain(domain)
|
||||
logger.info("Proxy domain removed via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove domain '%s': %s", domain, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -117,8 +175,10 @@ def remove_domain_bp(domain):
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
logger.info("nginx config applied via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to apply nginx config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -128,8 +188,9 @@ def test_bp():
|
||||
valid, output = test_config()
|
||||
if valid:
|
||||
return _ok({"valid": True, "output": output})
|
||||
return jsonify({"ok": False, "error": output, "valid": False}), 400
|
||||
return _error(output, 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("nginx config test failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -150,7 +211,9 @@ def management_bp():
|
||||
auth_pass = body.get("auth_pass")
|
||||
try:
|
||||
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)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
logger.error("Failed to set management proxy: %s", exc)
|
||||
return _error(str(exc), code)
|
||||
|
||||
+69
-30
@@ -4,8 +4,11 @@ webui/api/wireguard.py - WireGuard tunnel management API blueprint.
|
||||
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 (
|
||||
add_peer,
|
||||
apply,
|
||||
@@ -19,23 +22,12 @@ from lib.wireguard import (
|
||||
save_config,
|
||||
status,
|
||||
)
|
||||
from webui.api.common import _error, _ok
|
||||
|
||||
logger = logging.getLogger(__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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,6 +43,7 @@ def get_config_bp():
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to read WireGuard config: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -60,13 +53,40 @@ def post_config():
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
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)
|
||||
safe = dict(body)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
return _ok(None)
|
||||
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)
|
||||
|
||||
|
||||
@@ -79,8 +99,21 @@ def post_config():
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
logger.info("WireGuard tunnel applied via API")
|
||||
return _ok(None)
|
||||
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)
|
||||
|
||||
|
||||
@@ -88,8 +121,10 @@ def apply_bp():
|
||||
def down_bp():
|
||||
try:
|
||||
down()
|
||||
logger.info("WireGuard tunnel brought down via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -103,6 +138,7 @@ def status_bp():
|
||||
try:
|
||||
return _ok(status())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -115,8 +151,10 @@ def status_bp():
|
||||
def initialize_bp():
|
||||
try:
|
||||
initialize()
|
||||
logger.info("WireGuard initialized via API")
|
||||
return _ok(None)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to initialize WireGuard: %s", exc)
|
||||
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():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
@@ -139,25 +177,24 @@ def add_peer_bp():
|
||||
persistent_keepalive=body.get("persistent_keepalive"),
|
||||
preshared_key=body.get("preshared_key"),
|
||||
)
|
||||
safe = dict(peer)
|
||||
safe.pop("private_key", None)
|
||||
return _ok(safe)
|
||||
logger.info("WireGuard peer '%s' added via API", name)
|
||||
return _ok(peer)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to add peer '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/remove-peer", methods=["DELETE"])
|
||||
def remove_peer_bp():
|
||||
name = request.args.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("Query parameter 'name' is required", 400)
|
||||
@bp.route("/peers/<name>", methods=["DELETE"])
|
||||
def remove_peer_bp(name):
|
||||
try:
|
||||
cfg = get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
return _error(f"Peer '{name}' not found", 404)
|
||||
remove_peer(name)
|
||||
logger.info("WireGuard peer '%s' removed via API", name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to remove peer '%s': %s", name, exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -166,6 +203,7 @@ def peers_bp():
|
||||
try:
|
||||
return _ok(get_peers())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to list WireGuard peers: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -174,6 +212,7 @@ def peer_status_bp():
|
||||
try:
|
||||
return _ok(get_peer_status())
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to get WireGuard peer status: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@@ -195,13 +234,13 @@ def generate_client_bp():
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
server_pubkey = cfg["interface"].get("public_key", "")
|
||||
if not server_endpoint:
|
||||
_ = cfg["interface"].get("listen_port", 51820)
|
||||
# Can't auto-derive public IP; ask user to provide it
|
||||
return _error(
|
||||
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
|
||||
)
|
||||
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})
|
||||
except (KeyError, ValueError, RuntimeError) as exc:
|
||||
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)
|
||||
|
||||
+94
-14
@@ -7,15 +7,28 @@ and enforces basic authentication before proxying to this port.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
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.dnsmasq import get_config as dnsmasq_config
|
||||
from lib.dnsmasq import get_lease_table
|
||||
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_domains
|
||||
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.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
from webui.api.logs import bp as logs_bp
|
||||
from webui.api.proxy import bp as proxy_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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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(certs_bp, url_prefix="/api/certs")
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safely(fn, default=None):
|
||||
"""Call *fn* and return *default* on any exception."""
|
||||
@@ -155,40 +223,51 @@ def dashboard():
|
||||
certs=certs,
|
||||
wg_status=wg,
|
||||
services=_get_service_status(dnsmasq, wg),
|
||||
firewall_config=_safely(fw_config_get, {}),
|
||||
firewall_pending=_safely(config_pending, {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
firewall_config = _safely(fw_config_get, {})
|
||||
firewall_pending = _safely(config_pending, {})
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
active_zones=_safely(get_active_zones, {}),
|
||||
firewall_config=firewall_config,
|
||||
firewall_pending=firewall_pending,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
zones = {}
|
||||
firewall_config = _safely(fw_config_get, {})
|
||||
firewall_pending = _safely(config_pending, {})
|
||||
zones_data = {}
|
||||
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(
|
||||
"zones.html",
|
||||
zones=zones,
|
||||
zones=zones_data,
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
services=_safely(
|
||||
lambda: __import__(
|
||||
"lib.firewall", fromlist=["get_services"]
|
||||
).get_services(),
|
||||
[],
|
||||
),
|
||||
services=_safely(get_services, []),
|
||||
firewall_config=firewall_config,
|
||||
firewall_pending=firewall_pending,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
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")
|
||||
@@ -236,4 +315,5 @@ def logs_page():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting Flask on 127.0.0.1:9090")
|
||||
app.run(host="127.0.0.1", port=9090)
|
||||
|
||||
+289
-113
@@ -1,137 +1,86 @@
|
||||
// Toast notification system
|
||||
function showToast(message, type = "info") {
|
||||
const container = document.querySelector(".toast") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast-message toast-${type}`;
|
||||
// Toast notifications
|
||||
const showToast = (message, type, duration = 4000) => {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateX(40px)";
|
||||
toast.style.transition = "all 0.3s ease";
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}, duration);
|
||||
};
|
||||
|
||||
function createToastContainer() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast";
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
const showSuccessToast = (msg) => showToast(msg, 'success');
|
||||
|
||||
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.add("show");
|
||||
}
|
||||
const openModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
};
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.remove("show");
|
||||
}
|
||||
const closeModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
};
|
||||
|
||||
// Confirm dialog
|
||||
function confirmAction(message, onConfirm) {
|
||||
const existing = document.getElementById("confirm-modal");
|
||||
if (existing) existing.remove();
|
||||
// Tab switching
|
||||
const switchTab = (tabName) => {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
if (clickedTab) clickedTab.classList.add('active');
|
||||
};
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "confirm-modal";
|
||||
modal.className = "modal";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<p class="mb-2">${message}</p>
|
||||
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
|
||||
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
// Refresh a container from a JSON GET endpoint using a renderer callback
|
||||
const refreshTable = (url, container, renderer) => {
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const json = data.ok ? data.data : data;
|
||||
container.innerHTML = renderer(json);
|
||||
htmx.process(container);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// HTMX event handlers
|
||||
document.body.addEventListener("htmx:afterSwap", (evt) => {
|
||||
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast");
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
|
||||
if (toastHeader) {
|
||||
const parts = toastHeader.split(":");
|
||||
const msg = parts.slice(1).join(":").trim();
|
||||
showToast(msg, parts[0]?.trim() || "info");
|
||||
const parts = toastHeader.split(':');
|
||||
const msg = parts.slice(1).join(':').trim();
|
||||
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;
|
||||
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) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Loading...";
|
||||
btn.textContent = 'Loading...';
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterRequest", (evt) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
document.body.addEventListener('htmx:afterRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn && btn.dataset.originalText !== undefined) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
@@ -139,9 +88,236 @@ document.body.addEventListener("htmx:afterRequest", (evt) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
|
||||
// Keyboard: Escape closes all modals
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../vendor/htmx-2.0.4.min.js
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../vendor/json-enc-2.0.0.js
|
||||
@@ -565,7 +565,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body hx-ext="json-enc">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
VACUUM WALL
|
||||
@@ -591,58 +591,8 @@
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
function showToast(message, type, duration) {
|
||||
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>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/json-enc.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<th style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="cert-rows">
|
||||
{% for cert in (certs or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
||||
@@ -38,7 +38,7 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
</form>
|
||||
</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">
|
||||
<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">
|
||||
<label for="cert-domain">Domain</label>
|
||||
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
||||
|
||||
+20
-20
@@ -13,7 +13,7 @@
|
||||
<div class="section-title">DHCP Ranges</div>
|
||||
|
||||
<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="form-group">
|
||||
<label for="range-interface">Interface</label>
|
||||
@@ -50,7 +50,7 @@
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="range-rows">
|
||||
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rng.get('interface', '(global)') }}</td>
|
||||
@@ -58,8 +58,8 @@
|
||||
<td>{{ rng.get('end', '') }}</td>
|
||||
<td>{{ rng.get('lease_time', '1h') }}</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);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
|
||||
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="section-title">Static Leases</div>
|
||||
|
||||
<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="form-group">
|
||||
<label for="lease-mac">MAC Address</label>
|
||||
@@ -105,15 +105,15 @@
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="lease-rows">
|
||||
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('mac', '') }}</td>
|
||||
<td>{{ lease.get('ip', '') }}</td>
|
||||
<td>{{ lease.get('hostname', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
|
||||
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -132,15 +132,15 @@
|
||||
<div class="section-title">Custom DNS Records</div>
|
||||
|
||||
<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="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary">Add Record</button>
|
||||
</div>
|
||||
@@ -149,19 +149,19 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="dns-rows">
|
||||
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rec.get('ip', '') }}</td>
|
||||
<td>{{ rec.get('hostname', '') }}</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);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
|
||||
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
|
||||
<td class="text-sm">{{ rec.get('address', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -208,7 +208,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<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 & 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 & Restart Dnsmasq</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
<th>Zone</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="interface-list">
|
||||
{% for iface in (interfaces or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||
@@ -39,12 +38,7 @@
|
||||
<td>
|
||||
{% if zones %}
|
||||
<select
|
||||
class="htmx-on-success"
|
||||
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', '') }}')"
|
||||
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})"
|
||||
>
|
||||
{% for zone in zones %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (interfaces or []) %}
|
||||
@@ -67,22 +58,4 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %}
|
||||
|
||||
+69
-36
@@ -13,9 +13,7 @@
|
||||
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">
|
||||
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
|
||||
</span>
|
||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
|
||||
</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-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
||||
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
||||
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-journal" class="tab-content active">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-journal"
|
||||
hx-get="/api/logs/journal"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
hx-get="/api/logs/journal"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading journal entries...
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,10 +40,10 @@
|
||||
<div id="tab-nginx-access" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-access"
|
||||
hx-get="/api/logs/nginx/access"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
hx-get="/api/logs/nginx/access"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading Nginx access log...
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,10 +52,10 @@
|
||||
<div id="tab-nginx-error" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-error"
|
||||
hx-get="/api/logs/nginx/error"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
hx-get="/api/logs/nginx/error"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading Nginx error log...
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,42 +64,76 @@
|
||||
<div id="tab-dnsmasq" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-dnsmasq"
|
||||
hx-get="/api/logs/dnsmasq"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
hx-get="/api/logs/dnsmasq"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading dnsmasq log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-app" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-app"
|
||||
hx-get="/api/logs/app"
|
||||
hx-trigger="none"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator=".page-header .htmx-indicator">
|
||||
Loading app log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var 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() {
|
||||
var toggle = document.getElementById('auto-refresh-toggle');
|
||||
var indicators = document.querySelectorAll('.log-viewer');
|
||||
|
||||
if (toggle.checked) {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'every 15s');
|
||||
hx.trigger(el, 'htmx:refresh');
|
||||
});
|
||||
setActivePolling();
|
||||
loadActiveTab();
|
||||
} else {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'never');
|
||||
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||
htmx.abort(el);
|
||||
el.setAttribute('hx-trigger', 'none');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
|
||||
document.getElementById('refresh-indicator').style.display = 'inline';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
document.getElementById('refresh-indicator').style.display = 'none';
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadActiveTab();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+19
-39
@@ -17,7 +17,6 @@
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th style="width:120px;">Masquerade</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -25,21 +24,23 @@
|
||||
<tr>
|
||||
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
||||
<td>
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
{% if zone.get('masquerade') %}checked{% endif %}
|
||||
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
|
||||
<form hx-post="/api/firewall/masquerade" hx-encoding="json" hx-vals='{"zone": "{{ zone.get('name', '') }}", "enable": JSON.stringify(this.checked)}' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Masquerade '+(this.checked?'enabled':'disabled')+' for {{ zone.get('name', '') }}') } else { this.checked=!this.checked; }">
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
{% if zone.get('masquerade') %}checked{% endif %}
|
||||
id="masq-{{ zone.get('name', '') }}"
|
||||
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
|
||||
disabled>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<button type="submit" style="display:none"></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No zones configured</td>
|
||||
<td colspan="2" class="text-muted text-sm">No zones configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
@@ -50,7 +51,7 @@
|
||||
|
||||
<div class="card mb-4">
|
||||
<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="form-group">
|
||||
<label for="fw-zone">Zone</label>
|
||||
@@ -63,7 +64,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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="udp">UDP</option>
|
||||
</select>
|
||||
@@ -74,11 +75,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
@@ -97,7 +98,7 @@
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="forward-rows">
|
||||
{% set all_forwards = [] %}
|
||||
{% for zone in (zones or []) %}
|
||||
{% for fwd in zone.get('forward_ports', []) %}
|
||||
@@ -112,8 +113,8 @@
|
||||
<td>{{ fwd['to-addr'] }}</td>
|
||||
<td>{{ fwd['to-port'] }}</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);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
|
||||
<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" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd['proxy-protocol'] }} → {{ fwd['to-addr'] }}:{{ fwd['to-port'] }}?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -127,25 +128,4 @@
|
||||
</table>
|
||||
</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 %}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
||||
<button class="btn btn-outline" hx-post="/api/proxy/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>
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
<th style="width:140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="domain-rows">
|
||||
{% for domain in (domains or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
||||
@@ -56,8 +57,8 @@
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
|
||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
@@ -76,7 +77,7 @@
|
||||
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add Proxy Domain</h2>
|
||||
<form hx-post="/api/proxy/domains" hx-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">
|
||||
<label for="new-domain">Domain</label>
|
||||
<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">
|
||||
<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">
|
||||
<div class="form-group">
|
||||
<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-port').value = d.backend_port || '';
|
||||
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');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="card mb-4">
|
||||
<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="form-group">
|
||||
<label for="rule-zone">Zone</label>
|
||||
@@ -34,6 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rules-container">
|
||||
{% if rules or False %}
|
||||
{% for zone_name, zone_rules in rules.items() %}
|
||||
<div class="card">
|
||||
@@ -49,12 +50,13 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in zone_rules %}
|
||||
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ loop.index }}</td>
|
||||
<td 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>
|
||||
<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);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
|
||||
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not (zones or []) %}
|
||||
<div class="card" style="border-color:var(--warning);">
|
||||
|
||||
@@ -11,17 +11,13 @@
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/down"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel stopped"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
|
||||
Stop Tunnel
|
||||
</button>
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/up"
|
||||
hx-post="/api/wireguard/apply"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel started"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
|
||||
Start Tunnel
|
||||
</button>
|
||||
</div>
|
||||
@@ -51,7 +47,7 @@
|
||||
|
||||
<div class="card mb-4">
|
||||
<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="form-group">
|
||||
<label for="peer-name">Name</label>
|
||||
@@ -63,7 +59,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-allowed">Allowed IPs</label>
|
||||
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|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>
|
||||
<button type="submit" class="btn btn-primary">Add Peer</button>
|
||||
</div>
|
||||
@@ -84,7 +80,7 @@
|
||||
<th style="width:160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="peer-rows">
|
||||
{% for peer in (peers or []) %}
|
||||
<tr>
|
||||
<td>
|
||||
@@ -102,8 +98,8 @@
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
|
||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div id="zone-grid" class="card-grid">
|
||||
{% for zone in (zones or []) %}
|
||||
<div class="card" style="position:relative;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</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">
|
||||
<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">
|
||||
<label for="zone-name">Zone Name</label>
|
||||
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
||||
|
||||
Reference in New Issue
Block a user