Compare commits
10 Commits
32757e2f40
...
5ac69dfa7e
| 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)
|
# Local AI tool config (contains internal hostnames)
|
||||||
opencode.json
|
opencode.json
|
||||||
|
opencode.json.pwenv
|
||||||
|
|
||||||
# Runtime data configs (source-of-truth for services)
|
# Playwright MCP artifacts
|
||||||
data/dnsmasq/config.json
|
.playwright-mcp/
|
||||||
data/nginx/sites-enabled/
|
|
||||||
|
# Runtime artifacts
|
||||||
|
build/
|
||||||
|
config/
|
||||||
|
data/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
|
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
|
||||||
Deploys on Debian 13 (trixie). Target system: `/home/wall/vacuum-wall`.
|
Deploys on Debian 13 (trixie). Serves from repo root by default.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -14,15 +14,28 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
|
|||||||
|
|
||||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
|
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
|
||||||
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
|
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
|
||||||
- `lib/*.py` — Backend modules. Wrap system commands via `subprocess.run(["sudo", ...])`.
|
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
|
||||||
- `data/` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`.
|
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`. All `lib/` modules use these instead of defining local helpers.
|
||||||
|
- `lib/*.py` — Backend modules. All have full type hints and `__all__` exports.
|
||||||
|
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
|
||||||
|
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
|
||||||
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||||
|
- `webui/static/` — Vendored frontend libraries (JS + CSS). Flask auto-serves at `/static/`.
|
||||||
|
|
||||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
|
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
|
||||||
|
|
||||||
## Fixed Path
|
**No CDN packages.** All frontend libraries (JS and CSS) must be vendored in `webui/static/`. Never reference `unpkg.com`, `cdn.jsdelivr.net`, or similar. To add/update a library, edit the version in `scripts/update-vendor.sh` and run it.
|
||||||
|
|
||||||
Every module hardcodes `/home/wall/vacuum-wall`. Changing it requires updating `lib/*.py`, `system/systemd/*.service`, `install.sh`, and `system/sudoers.d/vacuum-wall`.
|
| Library | Version | Vendor file | Symlink (active) | CDN source |
|
||||||
|
| ------- | ------- | ------------------------------------|--------------------------------| ---------- |
|
||||||
|
| htmx | 2.0.4 | `vendor/htmx-2.0.4.min.js` | `webui/static/htmx.min.js` | `npm:htmx.org@2.0.4` |
|
||||||
|
| htmx-ext-json-enc | 2.0.0 | `vendor/json-enc-2.0.0.js` | `webui/static/json-enc.js` | `npm:htmx-ext-json-enc@2.0.0` |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
`install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user. The `vacuum-wall` system user has `HOME=$INSTALL_DIR` but no actual home directory (`--no-create-home`).
|
||||||
|
|
||||||
|
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`.
|
||||||
|
|
||||||
## Local Dev
|
## Local Dev
|
||||||
|
|
||||||
@@ -46,13 +59,16 @@ In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPriv
|
|||||||
|
|
||||||
`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`.
|
`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`.
|
||||||
|
|
||||||
|
**acme.sh must never run as root** — always as the service user via `sudo -u`.
|
||||||
|
|
||||||
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
|
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
|
||||||
Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
|
Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
|
||||||
|
|
||||||
## API Response Contract
|
## API Response Contract
|
||||||
|
|
||||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)`
|
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common`
|
||||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)`
|
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common`
|
||||||
|
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
|
||||||
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
|
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
|
||||||
- Full spec: `docs/api.md`
|
- Full spec: `docs/api.md`
|
||||||
|
|
||||||
@@ -73,11 +89,17 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
|
|||||||
```bash
|
```bash
|
||||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||||
.venv/bin/ruff format lib/ webui/ tests/ # format
|
.venv/bin/ruff format lib/ webui/ tests/ # format
|
||||||
.venv/bin/python -m pytest tests/ -v # test (154 tests)
|
.venv/bin/python -m pytest tests/ -v # test (192 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
Install dev tooling with `pip install -e ".[dev]"`.
|
Install dev tooling with `pip install -e ".[dev]"`.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, and data directory layout in detail.
|
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, data directory layout, and shared utility patterns in detail.
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements.
|
||||||
|
2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
|
||||||
|
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
|
||||||
|
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
|
|||||||
- Debian 13 (trixie) target platform
|
- Debian 13 (trixie) target platform
|
||||||
- Python 3.13+, Flask 3.x web UI
|
- Python 3.13+, Flask 3.x web UI
|
||||||
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
|
- firewalld (nftables backend), dnsmasq, nginx, WireGuard
|
||||||
- acme.sh for Let's Encrypt
|
- acme.sh for ACME certificates (ZeroSSL)
|
||||||
- HTMX + Jinja2 templates
|
- HTMX + Jinja2 templates
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -22,7 +22,7 @@ A zone-based firewall appliance with a built-in SSL reverse proxy. Combines fire
|
|||||||
- A DNS A record pointing to the appliance's public IP for the management domain
|
- A DNS A record pointing to the appliance's public IP for the management domain
|
||||||
- Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk
|
- Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk
|
||||||
|
|
||||||
### Install
|
### Install (Production)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MGMT_DOMAIN=wall.example.com \
|
MGMT_DOMAIN=wall.example.com \
|
||||||
@@ -32,12 +32,28 @@ ACME_EMAIL="admin@example.com" \
|
|||||||
bash install.sh
|
bash install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Required | Description |
|
### Install (Development)
|
||||||
|---|---|---|
|
|
||||||
| `MGMT_DOMAIN` | Yes | Public domain for the management WebUI |
|
```bash
|
||||||
| `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
|
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||||
| `MGMT_USER` | No | WebUI username (defaults to `admin`) |
|
```
|
||||||
| `ACME_EMAIL` | Yes | Let's Encrypt registration email |
|
|
||||||
|
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
|
||||||
|
|
||||||
|
| Flag | Env Var | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) |
|
||||||
|
| `--mgmt-pass` | `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
|
||||||
|
| `--mgmt-user` | `MGMT_USER` | No | WebUI username (defaults to `admin`) |
|
||||||
|
| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) |
|
||||||
|
| `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) |
|
||||||
|
| `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) |
|
||||||
|
| `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning |
|
||||||
|
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (alias for env var) |
|
||||||
|
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
|
||||||
|
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
|
||||||
|
|
||||||
|
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
|
||||||
|
|
||||||
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
|
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
|
||||||
|
|
||||||
@@ -45,7 +61,7 @@ After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial cer
|
|||||||
|
|
||||||
1. **Assign interfaces** to zones from the Interfaces tab
|
1. **Assign interfaces** to zones from the Interfaces tab
|
||||||
2. **Configure DHCP** ranges for your LAN
|
2. **Configure DHCP** ranges for your LAN
|
||||||
3. **Add proxy domains** with Let's Encrypt certificates
|
3. **Add proxy domains** with ACME certificates
|
||||||
4. **Set up WireGuard** (optional)
|
4. **Set up WireGuard** (optional)
|
||||||
|
|
||||||
See [docs/deployment.md](docs/deployment.md) for the full guide, including troubleshooting.
|
See [docs/deployment.md](docs/deployment.md) for the full guide, including troubleshooting.
|
||||||
@@ -75,13 +91,15 @@ Start the WebUI locally (binds to 127.0.0.1:9090):
|
|||||||
.venv/bin/ruff format lib/ webui/ tests/
|
.venv/bin/ruff format lib/ webui/ tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All `lib/` modules share `lib.common` utilities (`run`, `run_proc`, `load_json`, `save_json`, `deep_merge`, `ensure_dirs`) and have full type hints and `__all__` exports. API blueprints share `_ok`/`_error` from `webui.api.common`.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m pytest tests/ -v
|
.venv/bin/python -m pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required.
|
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 192 tests across 5 test modules.
|
||||||
|
|
||||||
### Documentation MCP Server
|
### Documentation MCP Server
|
||||||
|
|
||||||
@@ -109,7 +127,7 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
|
|||||||
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
||||||
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
||||||
|
|
||||||
See [docs/architecture.md](docs/architecture.md) for detailed request flow and zone model.
|
See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone model, and shared utility patterns.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,7 +135,7 @@ See [docs/architecture.md](docs/architecture.md) for detailed request flow and z
|
|||||||
|
|
||||||
- [Overview](docs/overview.md) — Feature summary and tech stack
|
- [Overview](docs/overview.md) — Feature summary and tech stack
|
||||||
- [Deployment Guide](docs/deployment.md) — Full installation and post-install configuration
|
- [Deployment Guide](docs/deployment.md) — Full installation and post-install configuration
|
||||||
- [Architecture](docs/architecture.md) — Request flow, subsystems, zone model
|
- [Architecture](docs/architecture.md) — Request flow, subsystems, zone model, shared utilities
|
||||||
- [API Reference](docs/api.md) — REST API endpoints
|
- [API Reference](docs/api.md) — REST API endpoints
|
||||||
- [Security Model](docs/security.md) — Privilege model and sudo whitelist
|
- [Security Model](docs/security.md) — Privilege model and sudo whitelist
|
||||||
- [Configuration](docs/config.md) — Declarative config file formats and locations
|
- [Configuration](docs/config.md) — Declarative config file formats and locations
|
||||||
|
|||||||
+304
-206
@@ -1,6 +1,6 @@
|
|||||||
# REST API Reference
|
# REST API Reference
|
||||||
|
|
||||||
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://wall.lan/api/...`).
|
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://<hostname>.local/api/...`).
|
||||||
|
|
||||||
Every request and response uses `Content-Type: application/json`.
|
Every request and response uses `Content-Type: application/json`.
|
||||||
|
|
||||||
@@ -34,12 +34,86 @@ Error responses carry one of the following HTTP status codes:
|
|||||||
| `404` | Not found — the requested resource does not exist |
|
| `404` | Not found — the requested resource does not exist |
|
||||||
| `500` | Internal server error — unexpected failure in the backend |
|
| `500` | Internal server error — unexpected failure in the backend |
|
||||||
|
|
||||||
|
### Route Patterns
|
||||||
|
|
||||||
|
Resource identification uses **path parameters** whenever possible. Exceptions occur only when the identifier is inherently long (e.g., a rich rule string), in which case the body carries the identifier.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Firewall API
|
## Firewall API
|
||||||
|
|
||||||
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
|
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
|
||||||
|
|
||||||
|
### Declarative Config
|
||||||
|
|
||||||
|
The firewall supports a two-step declarative workflow: save config to `config/firewall/config.json`, then apply it to live firewalld. The config tracks `rich_rules` and `forward_ports` with auto-generated `id` fields.
|
||||||
|
|
||||||
|
#### Get Config
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/firewall/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the current declarative firewall config.
|
||||||
|
|
||||||
|
**Response:** `data` contains the config object with a `zones` mapping.
|
||||||
|
|
||||||
|
#### Save Config
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/firewall/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the declarative config. Returns pending changes summary.
|
||||||
|
|
||||||
|
**Request Body:** Request body must contain `zones`.
|
||||||
|
|
||||||
|
**Response (`data`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `config_saved` | `boolean` | Always `true` |
|
||||||
|
| `pending` | `[object, ...]` | List of pending changes |
|
||||||
|
| `needs_apply` | `boolean` | Whether changes need to be applied |
|
||||||
|
| `unmanaged_zones` | `object` | Zones active on system but not in config |
|
||||||
|
|
||||||
|
#### Apply Config
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/firewall/config/apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
|
||||||
|
|
||||||
|
**Response:** `data` contains `applied_zones` list and backup path.
|
||||||
|
|
||||||
|
#### Check Pending Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/firewall/config/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
|
||||||
|
|
||||||
|
**Response:** Same structure as POST /config response.
|
||||||
|
|
||||||
|
#### Partial Update Config
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/firewall/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Deep-merge the provided fields into the existing config. Returns pending changes summary.
|
||||||
|
|
||||||
|
**Response (`data`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `config_saved` | `boolean` | Always `true` |
|
||||||
|
| `pending` | `[object, ...]` | List of pending changes |
|
||||||
|
| `needs_apply` | `boolean` | Whether changes need to be applied |
|
||||||
|
| `unmanaged_zones` | `object` | Zones active on system but not in config |
|
||||||
|
|
||||||
### Zone Management
|
### Zone Management
|
||||||
|
|
||||||
#### List All Zones
|
#### List All Zones
|
||||||
@@ -76,8 +150,8 @@ Return detailed configuration for a single zone.
|
|||||||
| `services` | `[string, ...]` | Services allowed through the zone |
|
| `services` | `[string, ...]` | Services allowed through the zone |
|
||||||
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
|
| `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
|
||||||
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
|
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
|
||||||
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
|
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
|
||||||
| `rich_rules` | `[string, ...]` | Rich rule definitions |
|
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
|
||||||
|
|
||||||
Returns HTTP `404` if the zone does not exist.
|
Returns HTTP `404` if the zone does not exist.
|
||||||
|
|
||||||
@@ -135,7 +209,7 @@ Replace all interfaces assigned to the zone with the provided list.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `zone` | `string` | Zone name |
|
| `zone` | `string` | Zone name |
|
||||||
| `interfaces` | `[string, ...]` | List of interface names now assigned to the zone |
|
| `interfaces` | `[string, ...]` | List of interface names now assigned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -158,9 +232,9 @@ Replace all services allowed in the zone with the provided list.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `zone` | `string` | Zone name |
|
| `zone` | `string` | Zone name |
|
||||||
| `services` | `[string, ...]` | List of services now allowed in the zone |
|
| `services` | `[string, ...]` | List of services now allowed |
|
||||||
|
|
||||||
### Firewall Rules
|
### Rich Rules
|
||||||
|
|
||||||
#### Add Rich Rule
|
#### Add Rich Rule
|
||||||
|
|
||||||
@@ -168,7 +242,7 @@ Replace all services allowed in the zone with the provided list.
|
|||||||
POST /api/firewall/rich-rules
|
POST /api/firewall/rich-rules
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a firewalld rich rule to a zone.
|
Add a firewalld rich rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
@@ -182,6 +256,7 @@ Add a firewalld rich rule to a zone.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `zone` | `string` | Zone name |
|
| `zone` | `string` | Zone name |
|
||||||
|
| `id` | `string` | 8-character unique ID |
|
||||||
| `rule` | `string` | Full rich rule string |
|
| `rule` | `string` | Full rich rule string |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -189,24 +264,19 @@ Add a firewalld rich rule to a zone.
|
|||||||
#### Remove Rich Rule
|
#### Remove Rich Rule
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/firewall/rich-rules
|
DELETE /api/firewall/rich-rules/<zone>/<id>
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove an existing rich rule from a zone. The `rule` string must match exactly.
|
Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `zone` | `string` | Yes | Zone the rule belongs to |
|
|
||||||
| `rule` | `string` | Yes | Exact rich rule string to remove |
|
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `zone` | `string` | Zone name |
|
| `zone` | `string` | Zone name |
|
||||||
| `rule` | `string` | Exact rich rule string that was removed |
|
| `id` | `string` | ID of the removed rule |
|
||||||
|
|
||||||
|
Returns HTTP `404` if the rule ID is not found.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,15 +286,64 @@ Remove an existing rich rule from a zone. The `rule` string must match exactly.
|
|||||||
GET /api/firewall/rich-rules/<zone>
|
GET /api/firewall/rich-rules/<zone>
|
||||||
```
|
```
|
||||||
|
|
||||||
Return all rich rules for the specified zone.
|
Return all rich rules for the specified zone, each with an `id` and `rule` string.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `[string, ...]` | Rich rule strings |
|
| `data` | `[{id, rule}, ...]` | Rich rules with IDs |
|
||||||
|
|
||||||
### NAT
|
### Port Forwarding
|
||||||
|
|
||||||
|
#### Add Port Forward
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/firewall/forward-port
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a port forwarding rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `zone` | `string` | Yes | Zone to add the rule to |
|
||||||
|
| `port` | `number` | Yes | External port |
|
||||||
|
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
|
||||||
|
| `toaddr` | `string` | No | Internal destination address |
|
||||||
|
| `toport` | `number` | No | Internal destination port |
|
||||||
|
|
||||||
|
**Response (`data`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `zone` | `string` | Zone name |
|
||||||
|
| `id` | `string` | 8-character unique ID |
|
||||||
|
| `port` | `number` | External port |
|
||||||
|
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Remove Port Forward
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove a port forwarding rule. Zone, port, and protocol are all path parameters.
|
||||||
|
|
||||||
|
**Response (`data`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `zone` | `string` | Zone name |
|
||||||
|
| `port` | `number` | External port |
|
||||||
|
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
|
||||||
|
|
||||||
|
Returns HTTP `404` if the forward port is not found.
|
||||||
|
|
||||||
|
### Masquerade (NAT)
|
||||||
|
|
||||||
#### Enable / Disable Masquerade
|
#### Enable / Disable Masquerade
|
||||||
|
|
||||||
@@ -246,63 +365,7 @@ Toggle masquerade (source NAT) for a zone.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `zone` | `string` | Zone name |
|
| `zone` | `string` | Zone name |
|
||||||
| `masquerade` | `boolean` | Whether masquerade is now enabled for the zone |
|
| `masquerade` | `boolean` | Whether masquerade is now enabled |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Add Port Forward
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/firewall/forward-port
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a port forwarding rule to a zone.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `zone` | `string` | Yes | Zone to add the rule to |
|
|
||||||
| `port` | `number` | Yes | External port |
|
|
||||||
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
|
|
||||||
| `toaddr` | `string` | No | Internal destination address |
|
|
||||||
| `toport` | `number` | No | Internal destination port |
|
|
||||||
|
|
||||||
**Response (`data`):**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `zone` | `string` | Zone name |
|
|
||||||
| `port` | `number` | External port |
|
|
||||||
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Remove Port Forward
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /api/firewall/forward-port
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove a port forwarding rule. The body must match the original rule exactly.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `zone` | `string` | Yes | Zone the rule belongs to |
|
|
||||||
| `port` | `number` | Yes | External port |
|
|
||||||
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
|
|
||||||
| `toaddr` | `string` | No | Internal destination address |
|
|
||||||
| `toport` | `number` | No | Internal destination port |
|
|
||||||
|
|
||||||
**Response (`data`):**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `zone` | `string` | Zone name |
|
|
||||||
| `port` | `number` | External port |
|
|
||||||
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
|
|
||||||
|
|
||||||
### Info
|
### Info
|
||||||
|
|
||||||
@@ -406,24 +469,74 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa
|
|||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
### Leases
|
### Status
|
||||||
|
|
||||||
#### Get Live Leases
|
#### Get Service Status
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/dhcp/leases
|
GET /api/dhcp/status
|
||||||
```
|
```
|
||||||
|
|
||||||
Return the current DHCP lease table from dnsmasq.
|
Return the current service status, config summary, and active lease count.
|
||||||
|
|
||||||
**Response:**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `[object, ...]` | Array of lease objects |
|
| `service_active` | `boolean` | Whether dnsmasq is running |
|
||||||
|
| `config_file_exists` | `boolean` | Whether config file exists on disk |
|
||||||
|
| `config_in_sync` | `boolean` | Whether disk config matches expected |
|
||||||
|
| `dhcp_ranges` | `number` | Number of DHCP ranges |
|
||||||
|
| `static_leases` | `number` | Number of static leases |
|
||||||
|
| `custom_dns_records` | `number` | Number of custom DNS records |
|
||||||
|
| `upstreams` | `[string, ...]` | Upstream DNS servers |
|
||||||
|
| `domain` | `string` | Local DNS domain |
|
||||||
|
| `active_leases` | `number` | Number of active leases |
|
||||||
|
| `leases` | `[object, ...]` | Active lease objects |
|
||||||
|
|
||||||
|
### DHCP Ranges
|
||||||
|
|
||||||
|
#### Add Range
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/dhcp/ranges
|
||||||
|
```
|
||||||
|
|
||||||
|
Add or replace the DHCP range for a given interface.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `interface` | `string` | No | Interface name (empty = all interfaces) |
|
||||||
|
| `start` | `string` | Yes | Start of IP range |
|
||||||
|
| `end` | `string` | Yes | End of IP range |
|
||||||
|
| `lease_time` | `string` | No | Lease duration; defaults to `"12h"` |
|
||||||
|
|
||||||
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Remove Range
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/dhcp/ranges
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove a DHCP range. Body contains identifying fields.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `interface` | `string` | Yes | Interface name |
|
||||||
|
| `start` | `string` | Yes | Start of IP range |
|
||||||
|
| `end` | `string` | Yes | End of IP range |
|
||||||
|
|
||||||
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
|
### Static Leases
|
||||||
|
|
||||||
#### Add Static Lease
|
#### Add Static Lease
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -446,28 +559,38 @@ Add a static (reserved) DHCP lease.
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `mac` | `string` | MAC address |
|
| `mac` | `string` | MAC address |
|
||||||
| `ip` | `string` | Reserved IP address |
|
| `ip` | `string` | Reserved IP address |
|
||||||
| `hostname` | `string` | Hostname for the reservation |
|
| `hostname` | `string` | Hostname |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Remove Static Lease
|
#### Remove Static Lease
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/dhcp/static-lease?mac=aa:bb:cc:dd:ee:ff
|
DELETE /api/dhcp/static-lease/<mac>
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove a previously configured static lease.
|
Remove a static lease by MAC address.
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `mac` | `string` | Yes | MAC address of the lease to remove |
|
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
Returns HTTP `404` if no matching lease is found.
|
Returns HTTP `404` if no matching lease is found.
|
||||||
|
|
||||||
|
### Live Leases
|
||||||
|
|
||||||
|
#### Get Live Leases
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/dhcp/leases
|
||||||
|
```
|
||||||
|
|
||||||
|
Return the current DHCP lease table from dnsmasq.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `data` | `[object, ...]` | Array of lease objects |
|
||||||
|
|
||||||
### DNS Records
|
### DNS Records
|
||||||
|
|
||||||
#### Add DNS Record
|
#### Add DNS Record
|
||||||
@@ -484,6 +607,7 @@ Add a custom DNS A record served by dnsmasq.
|
|||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `name` | `string` | Yes | Fully qualified domain name |
|
| `name` | `string` | Yes | Fully qualified domain name |
|
||||||
| `address` | `string` | Yes | IP address to resolve to |
|
| `address` | `string` | Yes | IP address to resolve to |
|
||||||
|
| `hostname` | `string` | No | Short hostname |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -498,16 +622,10 @@ Add a custom DNS A record served by dnsmasq.
|
|||||||
#### Remove DNS Record
|
#### Remove DNS Record
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/dhcp/dns-record?name=nas.lan
|
DELETE /api/dhcp/dns-record/<name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove a custom DNS record.
|
Remove a custom DNS record by domain name.
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `name` | `string` | Yes | Fully qualified domain name to remove |
|
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
@@ -553,6 +671,8 @@ Add a new reverse proxy domain.
|
|||||||
| `backend_host` | `string` | Yes | Backend server IP or hostname |
|
| `backend_host` | `string` | Yes | Backend server IP or hostname |
|
||||||
| `backend_port` | `number` | Yes | Backend server port |
|
| `backend_port` | `number` | Yes | Backend server port |
|
||||||
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
|
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
|
||||||
|
| `cert` | `string` | No | Certificate domain |
|
||||||
|
| `extra_headers` | `object` | No | Extra proxy headers |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -572,14 +692,7 @@ GET /api/proxy/domains/<domain>
|
|||||||
|
|
||||||
Return the configuration for a single proxy domain.
|
Return the configuration for a single proxy domain.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):** Domain name plus backend configuration fields.
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `domain` | `string` | Domain name |
|
|
||||||
| `backend_host` | `string` | Backend server address |
|
|
||||||
| `backend_port` | `number` | Backend server port |
|
|
||||||
| `backend_proto` | `string` | Backend protocol |
|
|
||||||
|
|
||||||
Returns HTTP `404` if the domain is not configured.
|
Returns HTTP `404` if the domain is not configured.
|
||||||
|
|
||||||
@@ -591,15 +704,9 @@ Returns HTTP `404` if the domain is not configured.
|
|||||||
PUT /api/proxy/domains/<domain>
|
PUT /api/proxy/domains/<domain>
|
||||||
```
|
```
|
||||||
|
|
||||||
Update one or more fields of an existing domain entry. Only the fields present in the body are modified.
|
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `backend_host` | `string` | No | Backend server IP or hostname |
|
|
||||||
| `backend_port` | `number` | No | Backend server port |
|
|
||||||
| `backend_proto` | `string` | No | Backend protocol |
|
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -649,15 +756,17 @@ Returns HTTP `500` if nginx config generation fails or the reload fails.
|
|||||||
POST /api/proxy/test
|
POST /api/proxy/test
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `nginx -t` against the generated configuration without reloading. Useful for validating changes before applying.
|
Run `nginx -t` against the generated configuration without reloading.
|
||||||
|
|
||||||
**Response:**
|
**Response (valid):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data.valid` | `boolean` | Whether the configuration syntax is valid |
|
| `data.valid` | `boolean` | Always `true` |
|
||||||
| `data.output` | `string` | Raw nginx test output |
|
| `data.output` | `string` | Raw nginx test output |
|
||||||
|
|
||||||
|
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
|
||||||
|
|
||||||
### Management
|
### Management
|
||||||
|
|
||||||
#### Configure Management WebUI Proxy
|
#### Configure Management WebUI Proxy
|
||||||
@@ -672,21 +781,19 @@ Configure the nginx proxy block for the management WebUI itself, including optio
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `domain` | `string` | Yes | Management domain (e.g., `"wall.lan"`) |
|
| `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
|
||||||
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
|
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
|
||||||
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
|
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
|
||||||
| `auth_user` | `string` | No | Username for basic auth. An `.htpasswd` entry is created when this field is present. |
|
| `auth_user` | `string` | No | Username for basic auth |
|
||||||
| `auth_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. |
|
| `auth_pass` | `string` | No | Password for basic auth |
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
If `auth_user` and `auth_pass` are provided, the endpoint creates or updates the corresponding `.htpasswd` file entry.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Certificate API
|
## Certificate API
|
||||||
|
|
||||||
Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (Let's Encrypt / certbot).
|
Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (ZeroSSL, Let's Encrypt, etc.).
|
||||||
|
|
||||||
### Listing & Details
|
### Listing & Details
|
||||||
|
|
||||||
@@ -704,15 +811,7 @@ Return all managed certificates with metadata.
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `[object, ...]` | Array of certificate objects |
|
| `data` | `[object, ...]` | Array of certificate objects |
|
||||||
|
|
||||||
Each certificate object:
|
Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `domain` | `string` | Domain the certificate covers |
|
|
||||||
| `expires_at` | `string` | Expiration date (ISO 8601) |
|
|
||||||
| `days_until_expiry` | `number` | Remaining days until expiration |
|
|
||||||
| `cert_path` | `string` | Path to the certificate file |
|
|
||||||
| `key_path` | `string` | Path to the private key file |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -724,15 +823,7 @@ GET /api/certs/<domain>
|
|||||||
|
|
||||||
Return details for a single certificate.
|
Return details for a single certificate.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `domain` | `string` | Domain |
|
|
||||||
| `expires_at` | `string` | Expiration date (ISO 8601) |
|
|
||||||
| `days_until_expiry` | `number` | Remaining days |
|
|
||||||
| `cert_path` | `string` | Certificate file path |
|
|
||||||
| `key_path` | `string` | Private key file path |
|
|
||||||
|
|
||||||
Returns HTTP `404` if no certificate is found for the domain.
|
Returns HTTP `404` if no certificate is found for the domain.
|
||||||
|
|
||||||
@@ -755,7 +846,7 @@ Request a new certificate for a domain.
|
|||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
Returns HTTP `400` if the domain is missing or the request is malformed. Returns HTTP `500` if the ACME challenge or certificate issuance fails.
|
Returns HTTP `400` if the domain is missing. Returns HTTP `500` if issuance fails.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -765,7 +856,7 @@ Returns HTTP `400` if the domain is missing or the request is malformed. Returns
|
|||||||
POST /api/certs/<domain>/renew
|
POST /api/certs/<domain>/renew
|
||||||
```
|
```
|
||||||
|
|
||||||
Force-renew an existing certificate, regardless of its current expiry status.
|
Force-renew an existing certificate.
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
@@ -793,7 +884,7 @@ Returns HTTP `404` if the certificate is not found.
|
|||||||
POST /api/certs/email
|
POST /api/certs/email
|
||||||
```
|
```
|
||||||
|
|
||||||
Set or update the ACME account contact email (used by Let's Encrypt for expiration and security notices).
|
Set or update the ACME account contact email.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
@@ -801,11 +892,7 @@ Set or update the ACME account contact email (used by Let's Encrypt for expirati
|
|||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `email` | `string` | Yes | Contact email address |
|
| `email` | `string` | Yes | Contact email address |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):** Returns the set `email` field.
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `email` | `string` | Contact email address |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -821,13 +908,13 @@ Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, p
|
|||||||
GET /api/wireguard/config
|
GET /api/wireguard/config
|
||||||
```
|
```
|
||||||
|
|
||||||
Return the current WireGuard server configuration. The `private_key` field is stripped from the response.
|
Return the current WireGuard server configuration. The `private_key` field is stripped.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `object` | Full WireGuard configuration dictionary (`private_key` omitted) |
|
| `data` | `object` | WireGuard config (`private_key` omitted) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -845,11 +932,25 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|
|||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
|
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
|
||||||
|
|
||||||
**Response:**
|
**Response:** `data` contains the updated configuration (`private_key` omitted).
|
||||||
|
|
||||||
| Field | Type | Description |
|
---
|
||||||
|-------|------|-------------|
|
|
||||||
| `data` | `object` | Updated configuration (`private_key` omitted) |
|
#### Partial Update Configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/wireguard/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Deep-merge the provided fields into the existing configuration.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| *(any subset)* | `any` | Yes | Fields to merge into the existing config |
|
||||||
|
|
||||||
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
### Tunnel Control
|
### Tunnel Control
|
||||||
|
|
||||||
@@ -859,11 +960,21 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|
|||||||
POST /api/wireguard/apply
|
POST /api/wireguard/apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Write the current configuration to `wg0.conf` on disk and bring the WireGuard tunnel up.
|
Write the current configuration to `wg0.conf` and bring the tunnel up.
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
Returns HTTP `500` if config write or interface bring-up fails.
|
---
|
||||||
|
|
||||||
|
#### Start Tunnel
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/wireguard/up
|
||||||
|
```
|
||||||
|
|
||||||
|
Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
|
||||||
|
|
||||||
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -885,15 +996,15 @@ Bring down the WireGuard tunnel interface (`wg0`).
|
|||||||
GET /api/wireguard/status
|
GET /api/wireguard/status
|
||||||
```
|
```
|
||||||
|
|
||||||
Return live tunnel state, including interface metrics and per-peer connection statistics.
|
Return live tunnel state with interface metrics and per-peer connection statistics.
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `up` | `boolean` | Whether the tunnel interface is up |
|
| `up` | `boolean` | Whether the tunnel interface is up |
|
||||||
| `interface` | `object` | Interface info (listen port, public key, etc.) |
|
| `interface` | `object` | Interface info (listen port, public key) |
|
||||||
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) |
|
| `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -903,19 +1014,35 @@ Return live tunnel state, including interface metrics and per-peer connection st
|
|||||||
POST /api/wireguard/initialize
|
POST /api/wireguard/initialize
|
||||||
```
|
```
|
||||||
|
|
||||||
Perform first-time setup: generate a server key pair, write an initial configuration, and prepare for peer enrollment. This endpoint is idempotent — calling it multiple times has no additional effect.
|
First-time setup: generate server key pair, write initial config. Idempotent.
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
**Response:** `data` is `null` on success.
|
||||||
|
|
||||||
### Peer Management
|
### Peer Management
|
||||||
|
|
||||||
|
#### List Peers
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/wireguard/peers
|
||||||
|
```
|
||||||
|
|
||||||
|
Return all configured peers. Private keys are stripped.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `data` | `[object, ...]` | Peer objects (private keys omitted) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Add Peer
|
#### Add Peer
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/wireguard/add-peer
|
POST /api/wireguard/peers
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a new WireGuard peer. A key pair is auto-generated for the peer. The response includes peer details with the private key stripped.
|
Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
@@ -924,33 +1051,20 @@ Add a new WireGuard peer. A key pair is auto-generated for the peer. The respons
|
|||||||
| `name` | `string` | Yes | Peer identifier name |
|
| `name` | `string` | Yes | Peer identifier name |
|
||||||
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
|
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
|
||||||
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
|
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
|
||||||
| `persistent_keepalive` | `number` | No | Persistent keepalive interval in seconds |
|
| `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) |
|
||||||
|
| `preshared_key` | `string` | No | Preshared key |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `name` | `string` | Peer name |
|
|
||||||
| `public_key` | `string` | Peer's public key |
|
|
||||||
| `allowed_ips` | `[string, ...]` | Allowed IPs |
|
|
||||||
| `endpoint` | `string` | Allowed endpoint |
|
|
||||||
| `persistent_keepalive` | `number` | Keepalive interval |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Remove Peer
|
#### Remove Peer
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/wireguard/remove-peer?name=alice
|
DELETE /api/wireguard/peers/<name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove a configured peer.
|
Remove a configured peer by name.
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `name` | `string` | Yes | Peer name to remove |
|
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -962,35 +1076,19 @@ Returns HTTP `404` if the peer is not found.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### List Peers
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/wireguard/peers
|
|
||||||
```
|
|
||||||
|
|
||||||
Return all configured peers. Private keys are stripped from the response.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `data` | `[object, ...]` | Array of peer objects (private keys omitted) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Peer Connection Status
|
#### Peer Connection Status
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/wireguard/peer-status
|
GET /api/wireguard/peer-status
|
||||||
```
|
```
|
||||||
|
|
||||||
Return live per-peer connection status from `wg show`, including last handshake time, transfer bytes, and current endpoint.
|
Return live per-peer connection status from `wg show`.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `[object, ...]` | Array of live peer status objects |
|
| `data` | `[object, ...]` | Live peer status (handshake time, bytes, endpoint) |
|
||||||
|
|
||||||
### Client Configuration
|
### Client Configuration
|
||||||
|
|
||||||
@@ -1000,21 +1098,21 @@ Return live per-peer connection status from `wg show`, including last handshake
|
|||||||
POST /api/wireguard/generate-client
|
POST /api/wireguard/generate-client
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a complete WireGuard client configuration file for provisioning a device. The returned config includes the peer's private key for the client to use.
|
Generate a complete WireGuard client configuration file. The returned config includes the peer's private key for provisioning.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `name` | `string` | Yes | Peer name to generate config for |
|
| `name` | `string` | Yes | Peer name to generate config for |
|
||||||
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) for the client's `[Peer]` section |
|
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `config` | `string` | Complete WireGuard client config text (`[Interface]` + `[Peer]` block) |
|
| `config` | `string` | Complete client config text (`[Interface]` + `[Peer]`) |
|
||||||
|
|
||||||
The client config includes the generated private key so the client can be provisioned directly. Note that this is the only endpoint that returns a WireGuard private key — all other endpoints strip private keys from responses.
|
This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
|
||||||
|
|
||||||
Returns HTTP `404` if the peer is not found.
|
Returns HTTP `404` if the peer is not found.
|
||||||
+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`.
|
1. An external client sends an HTTP request to `app.example.com`.
|
||||||
2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
|
2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
|
||||||
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
|
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
|
||||||
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `data/nginx/config.json`.
|
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `config/nginx/config.json`.
|
||||||
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
|
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
|
||||||
6. The backend service processes the request and returns an HTTP response.
|
6. The backend service processes the request and returns an HTTP response.
|
||||||
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
|
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
|
||||||
@@ -17,7 +17,7 @@ The following describes the path a request takes from an external client to a ba
|
|||||||
|
|
||||||
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
|
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
|
||||||
|
|
||||||
### Management WebUI Access (e.g., `wall.lan`)
|
### Management WebUI Access (e.g., `<hostname>.local`)
|
||||||
|
|
||||||
1. A client sends an HTTPS request to the management domain.
|
1. A client sends an HTTPS request to the management domain.
|
||||||
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
|
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
|
||||||
@@ -36,42 +36,70 @@ External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0
|
|||||||
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||||
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
|
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
|
||||||
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
||||||
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt ACME
|
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
|
||||||
Flask WebUI ──→ lib/wireguard.py ──→ render /home/wall/vacuum-wall/data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
|
Flask WebUI ──→ lib/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged `vacuum-wall` user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
|
Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `config/` and runtime artifacts from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged service user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
|
||||||
|
|
||||||
|
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
||||||
|
|
||||||
|
## Install-Time Templating
|
||||||
|
|
||||||
|
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
|
||||||
|
|
||||||
|
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
|
||||||
|
- **`sudoers.d/vacuum-wall`** — `{{ USER_NAME }}` is substituted to produce the sudoers whitelist.
|
||||||
|
- The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is.
|
||||||
|
|
||||||
|
Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data.
|
||||||
|
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
Vacuum Wall uses a declarative configuration model. The source of truth for each subsystem is a JSON file in the `data/` directory. The application renders these declarations into the format expected by the underlying system service.
|
Vacuum Wall uses a declarative configuration model. Persistent user-facing configuration lives in `config/<subsystem>/config.json`. Runtime artifacts and generated files live in `data/<subsystem>/`. The application renders these declarations into the format expected by the underlying system service.
|
||||||
|
|
||||||
| Subsystem | Declarative Config | Rendered Target | State Persistence |
|
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| firewalld | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as a declarative backup and can be used to restore firewall rules. |
|
| firewalld | N/A (firewalld manages own state) | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as an automated backup snapshot. |
|
||||||
| dnsmasq | `data/dnsmasq/config.json` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||||
| nginx | `data/nginx/config.json` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
||||||
| WireGuard | `data/wireguard/config.json` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||||
| ACME | `~/.acme.sh/` (managed by acme.sh) | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
|
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
|
||||||
|
|
||||||
## Data Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
|
### Config — Declarative Settings
|
||||||
|
|
||||||
|
Config files are persistent, user-editable JSON that defines the desired state for each subsystem:
|
||||||
|
|
||||||
```
|
```
|
||||||
data/
|
config/
|
||||||
├── nginx/
|
|
||||||
│ ├── config.json # Proxy domain definitions, management domain, SSL settings
|
|
||||||
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
|
||||||
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
|
||||||
├── dnsmasq/
|
├── dnsmasq/
|
||||||
│ ├── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
├── nginx/
|
||||||
├── firewall/
|
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
||||||
│ └── rules.json # Declarative firewall rule state backup
|
|
||||||
└── wireguard/
|
└── wireguard/
|
||||||
└── config.json # WireGuard interface and peer configuration
|
└── config.json # WireGuard interface and peer configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
The `data/` directory resides within the `vacuum-wall` user's project directory (`/home/wall/vacuum-wall/data/`). The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to this directory, while keeping the rest of the filesystem read-only.
|
### Data — Runtime Artifacts
|
||||||
|
|
||||||
|
The `data/` directory holds generated files, credentials, and subsystem artifacts:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── nginx/
|
||||||
|
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
||||||
|
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
||||||
|
├── dnsmasq/
|
||||||
|
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
||||||
|
├── firewall/
|
||||||
|
│ └── rules.json # Auto-generated firewall rule state backup
|
||||||
|
├── acme/ # ACME certificate files (acme.sh home)
|
||||||
|
└── wireguard/ # WireGuard runtime artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
||||||
|
|
||||||
## File System Layout
|
## File System Layout
|
||||||
|
|
||||||
@@ -81,9 +109,9 @@ The following file system locations are used for integration with system service
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) |
|
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | Vacuum Wall (lib/nginx.py) |
|
||||||
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
|
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
|
||||||
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `data/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
||||||
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `data/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||||
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the `vacuum-wall` user. Defines all permitted privilege escalations. | Install script (manual edits not required) |
|
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the configured system user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
||||||
|
|
||||||
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
||||||
|
|
||||||
@@ -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.
|
- **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).
|
- **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
|
# Configuration Reference
|
||||||
|
|
||||||
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All configuration is stored in the `data/` directory as declarative JSON. The application renders these declarations into the format expected by each underlying service.
|
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All persistent configuration is stored in the `config/` directory as declarative JSON. Runtime artifacts and generated files live in `data/`. The application renders these declarations into the format expected by each underlying service.
|
||||||
|
|
||||||
## DHCP/DNS Configuration
|
## DHCP/DNS Configuration
|
||||||
|
|
||||||
**File**: `data/dnsmasq/config.json`
|
**File**: `config/dnsmasq/config.json`
|
||||||
|
|
||||||
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`.
|
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`.
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
|
|||||||
|
|
||||||
## Nginx Configuration
|
## Nginx Configuration
|
||||||
|
|
||||||
**File**: `data/nginx/config.json`
|
**File**: `config/nginx/config.json`
|
||||||
|
|
||||||
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
|
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ This file defines reverse proxy domains, the management interface, and global SS
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
"domain": "wall.lan",
|
"domain": "<hostname>.local",
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 9090,
|
"port": 9090,
|
||||||
@@ -107,7 +107,7 @@ This file defines reverse proxy domains, the management interface, and global SS
|
|||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"user": "admin",
|
"user": "admin",
|
||||||
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
"htpasswd": "data/nginx/.htpasswd"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ssl": {
|
"ssl": {
|
||||||
@@ -132,7 +132,7 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
|
|||||||
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
|
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
|
||||||
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. |
|
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. |
|
||||||
| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. |
|
| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. |
|
||||||
| `cert.email` | string | Yes (if `acme`) | ACME account email used by Let's Encrypt. |
|
| `cert.email` | string | Yes (if `acme`) | ACME account email used by the CA provider. |
|
||||||
| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
|
| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
|
||||||
| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). |
|
| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). |
|
||||||
|
|
||||||
@@ -140,9 +140,9 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
|
|||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `acme` | Vacuum Wall uses acme.sh to request and renew a Let's Encrypt certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
|
| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
|
||||||
| `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
|
| `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
|
||||||
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored alongside other acme-managed files in `~/.acme.sh/` with a `.selfsigned` marker. |
|
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. |
|
||||||
|
|
||||||
### Management Domain
|
### Management Domain
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
|
|||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `wall.lan`). |
|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
|
||||||
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
|
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
|
||||||
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
|
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
|
||||||
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
|
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
|
||||||
@@ -159,7 +159,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
|
|||||||
The `.htpasswd` file can be created with the `htpasswd` utility:
|
The `.htpasswd` file can be created with the `htpasswd` utility:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
htpasswd -bc /home/wall/vacuum-wall/data/nginx/.htpasswd admin yourpassword
|
htpasswd -bc data/nginx/.htpasswd admin yourpassword
|
||||||
```
|
```
|
||||||
|
|
||||||
### Global SSL Settings
|
### Global SSL Settings
|
||||||
@@ -174,7 +174,7 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th
|
|||||||
|
|
||||||
## WireGuard Configuration
|
## WireGuard Configuration
|
||||||
|
|
||||||
**File**: `data/wireguard/config.json`
|
**File**: `config/wireguard/config.json`
|
||||||
|
|
||||||
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
|
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
|
||||||
|
|
||||||
@@ -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.
|
4. Runs `sudo wg-quick up wg0` to apply the configuration.
|
||||||
5. Returns success or error status to the caller.
|
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.
|
This guide walks through deploying Vacuum Wall on a real appliance or server. Vacuum Wall is an SSL proxy firewall appliance that combines edge proxying, firewall management, DHCP, DNS, and WireGuard in a single device.
|
||||||
|
|
||||||
@@ -19,24 +19,55 @@ This guide walks through deploying Vacuum Wall on a real appliance or server. Va
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Download the Vacuum Wall repository onto the target machine, then run the installer with the required environment variables:
|
Download the Vacuum Wall repository onto the target machine, then run the installer with required settings. All options accept both CLI flags and environment variables (CLI takes precedence).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Production: all env vars
|
||||||
MGMT_DOMAIN=wall.example.com \
|
MGMT_DOMAIN=wall.example.com \
|
||||||
MGMT_PASS="strongpassword" \
|
MGMT_PASS="strongpassword" \
|
||||||
MGMT_USER="admin" \
|
|
||||||
ACME_EMAIL="admin@example.com" \
|
ACME_EMAIL="admin@example.com" \
|
||||||
bash install.sh
|
bash install.sh
|
||||||
|
|
||||||
|
# Dev mode: CLI flags, auto-detects repo owner
|
||||||
|
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
|
||||||
|
|
||||||
|
# mDNS (LAN-only, no DNS record needed)
|
||||||
|
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass --acme-email "me@example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Options
|
||||||
|
|
||||||
| Variable | Required | Description |
|
All settings that can be passed as an environment variable also have a CLI flag equivalent. CLI flags take precedence over environment variables.
|
||||||
|---|---|---|
|
|
||||||
| `MGMT_DOMAIN` | Yes | The public-facing domain for the management WebUI. A DNS A record must point to the appliance's IP. |
|
| Flag | Env Var | Required | Description |
|
||||||
| `MGMT_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. |
|
|---|---|---|---|
|
||||||
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. |
|
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
|
||||||
| `ACME_EMAIL` | Yes | The email address registered with Let's Encrypt for certificate issuance and expiry notifications. |
|
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
|
||||||
|
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
|
||||||
|
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
|
||||||
|
| `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). |
|
||||||
|
| `--user, -u` | `USER_NAME` | No | System user for the WebUI service. Defaults to `vacuum-wall`. |
|
||||||
|
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
|
||||||
|
| `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. |
|
||||||
|
| `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. |
|
||||||
|
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
|
||||||
|
|
||||||
|
Run `./install.sh --help` for full usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Container / Custom Deployment
|
||||||
|
|
||||||
|
You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (or `INSTALL_DIR`) for the mount or bind path, and `--user` (or `USER_NAME`) for whatever system user exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker volume mount example
|
||||||
|
./install.sh --path /app/vacuum-wall --user ww-app \
|
||||||
|
--mgmt-domain proxy.internal --mgmt-pass strongpassword \
|
||||||
|
--acme-email "admin@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `INSTALL_DIR`. This means no hardcoded paths remain after installation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,27 +75,43 @@ bash install.sh
|
|||||||
|
|
||||||
The installer performs the following steps automatically:
|
The installer performs the following steps automatically:
|
||||||
|
|
||||||
- **Package installation**: Installs firewalld, nginx, dnsmasq, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
|
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
|
||||||
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for Let's Encrypt certificate management.
|
- **System user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with a nologin shell that owns the project data and runs the WebUI service.
|
||||||
- **Flask installation**: Ensures the Flask Python package is available via pip for the WebUI backend.
|
- **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
|
||||||
- **System user creation**: Creates a dedicated `vacuum-wall` system user (nologin shell) that owns the project data and runs the WebUI service.
|
- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
|
||||||
- **Directory setup**: Creates data directories under `/home/wall/vacuum-wall/data/` for nginx sites, dnsmasq config, firewall rules, and WireGuard config. Sets ownership to the `vacuum-wall` user.
|
- **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config).
|
||||||
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the `vacuum-wall` user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
|
- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
|
||||||
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones.
|
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
|
||||||
|
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present.
|
||||||
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
|
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
|
||||||
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
|
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
|
||||||
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert.
|
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
|
||||||
|
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
|
||||||
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
|
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
|
||||||
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Copies it to both `$USER_HOME/vacuum-wall/.htpasswd` (used by install.sh's initial nginx config) and `data/nginx/.htpasswd` (used by the running app).
|
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
|
||||||
- **Systemd units**: Installs three units:
|
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
|
||||||
|
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
|
||||||
|
- **Systemd units**: Installs three units (rendered from Jinja2 templates):
|
||||||
- `vacuum-wall.service` — the Flask WebUI backend.
|
- `vacuum-wall.service` — the Flask WebUI backend.
|
||||||
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
|
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
|
||||||
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
|
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
|
||||||
- **Firewalld zones**: Creates initial zones:
|
- **Firewalld zones**: Creates initial zones:
|
||||||
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
|
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
|
||||||
- `vpn` — WireGuard tunnel zone.
|
- `vpn` — WireGuard tunnel zone.
|
||||||
- **Service startup**: Enables and starts nginx, the vacuum-wall WebUI, and the ACME renewal timer.
|
- **Service startup**: Enables and starts/restarts nginx and the WebUI service, and enables the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes.
|
||||||
- **ACME registration**: Registers the Let's Encrypt account with the provided email via acme.sh.
|
- **ACME registration**: Registers the ACME account with the provided email via acme.sh.
|
||||||
|
|
||||||
|
### Idempotent Re-Runs
|
||||||
|
|
||||||
|
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
|
||||||
|
|
||||||
|
- Rebuilds the Python venv and reinstalls dependencies
|
||||||
|
- Restarts `vacuum-wall` and reloads `nginx` to pick up changes
|
||||||
|
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
|
||||||
|
- Preserves existing `config.json` files (skips initial write if file exists)
|
||||||
|
- Safely updates `htpasswd` (uses update mode instead of create mode)
|
||||||
|
|
||||||
|
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,7 +139,7 @@ Log in with the username and password you provided during installation.
|
|||||||
|
|
||||||
### Certificate Note
|
### Certificate Note
|
||||||
|
|
||||||
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real Let's Encrypt certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
|
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real ACME certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -207,7 +254,7 @@ journalctl -u nginx --no-pager -n 50
|
|||||||
nginx -t
|
nginx -t
|
||||||
```
|
```
|
||||||
|
|
||||||
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `/home/wall/vacuum-wall/data/`.
|
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `data/`.
|
||||||
|
|
||||||
### Firewall Rules Not Applying
|
### Firewall Rules Not Applying
|
||||||
|
|
||||||
@@ -226,14 +273,14 @@ visudo -cf /etc/sudoers.d/vacuum-wall
|
|||||||
|
|
||||||
### Certificate Issuance Fails
|
### Certificate Issuance Fails
|
||||||
|
|
||||||
Let's Encrypt ACME validation requires:
|
ACME validation via the ACME provider requires:
|
||||||
|
|
||||||
- The domain's DNS A record points to the appliance's public IP.
|
- The domain's DNS A record points to the appliance's public IP.
|
||||||
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
|
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
|
||||||
- The ACME email was registered correctly. Check with:
|
- The ACME email was registered correctly. Check with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
su -s /bin/bash vacuum-wall -c "~/.acme.sh/acme.sh --list"
|
su -s /bin/bash "$USER_NAME" -c "~/.acme.sh/acme.sh --list"
|
||||||
```
|
```
|
||||||
|
|
||||||
If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically.
|
If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically.
|
||||||
@@ -244,16 +291,16 @@ Verify that:
|
|||||||
|
|
||||||
- The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`).
|
- The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`).
|
||||||
- Dnsmasq is running: `systemctl status dnsmasq`.
|
- Dnsmasq is running: `systemctl status dnsmasq`.
|
||||||
- A DHCP range is configured for the correct interface. Check dnsmasq config at `/home/wall/vacuum-wall/data/dnsmasq/`.
|
- A DHCP range is configured for the correct interface. Check dnsmasq config at `data/dnsmasq/`.
|
||||||
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
|
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
|
||||||
|
|
||||||
### WebUI Not Accessible
|
### WebUI Not Accessible
|
||||||
|
|
||||||
1. Verify nginx is running: `systemctl status nginx`.
|
1. Verify nginx is running: `systemctl status nginx`.
|
||||||
2. Test nginx configuration: `nginx -t`.
|
2. Test nginx configuration: `nginx -t`.
|
||||||
2. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
|
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
|
||||||
4. Ensure the `vacuum-wall` WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
|
4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
|
||||||
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real Let's Encrypt certificate.
|
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -261,10 +308,10 @@ Verify that:
|
|||||||
|
|
||||||
| Component | Service | Config Location |
|
| Component | Service | Config Location |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| WebUI backend | `vacuum-wall.service` | `/home/wall/vacuum-wall/webui/` |
|
| WebUI backend | `vacuum-wall.service` | `webui/` |
|
||||||
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
|
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
|
||||||
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
|
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
|
||||||
| DHCP/DNS | `dnsmasq` | `/home/wall/vacuum-wall/data/dnsmasq/` |
|
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
|
||||||
| VPN | wireguard-tools | `/home/wall/vacuum-wall/data/wireguard/` |
|
| VPN | wireguard-tools | `config/wireguard/` |
|
||||||
| Certificates | `vacuum-wall-acme.timer` | `/home/vacuum-wall/.acme.sh/` |
|
| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
|
||||||
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
|
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
|
||||||
+31
-13
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic Let's Encrypt certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
|
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic ACME certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
|
||||||
|
|
||||||
## Subsystems
|
## Subsystems
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured
|
|||||||
|
|
||||||
### SSL Proxy
|
### SSL Proxy
|
||||||
|
|
||||||
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and Let's Encrypt. Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
|
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (ZeroSSL by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
|
||||||
|
|
||||||
### WireGuard
|
### WireGuard
|
||||||
|
|
||||||
@@ -29,39 +29,56 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Debian 13 (trixie) target platform
|
- Debian 13 (trixie) target platform
|
||||||
- Python 3, Flask 3.x for web management
|
- Python 3.13+, Flask 3.x for web management
|
||||||
- firewalld (nftables backend)
|
- firewalld (nftables backend)
|
||||||
- nginx 1.26+
|
- nginx 1.26+
|
||||||
- dnsmasq
|
- dnsmasq
|
||||||
- WireGuard tools (wireguard-tools)
|
- WireGuard tools (wireguard-tools)
|
||||||
- acme.sh for ACME/Let's Encrypt certificate management
|
- acme.sh for ACME certificate management (ZeroSSL by default)
|
||||||
- HTMX for dynamic UI updates
|
- HTMX for dynamic UI updates
|
||||||
- Jinja2 for server-side templating
|
- Jinja2 for server-side templating
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with the required environment variables:
|
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MGMT_DOMAIN=wall.lan MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \
|
# Production
|
||||||
bash install.sh
|
./install.sh --mgmt-pass yourpassword --acme-email "admin@example.com"
|
||||||
|
|
||||||
|
# Development (auto-detects your user)
|
||||||
|
./install.sh --dev --mgmt-pass yourpassword --acme-email "admin@example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
After installation, access the management interface at `https://wall.lan` using the credentials you configured. The `install.sh` script provisions nginx, sets up authentication, obtains an initial Let's Encrypt certificate, and starts all services.
|
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── install.sh # Deployment script
|
├── install.sh # Deployment script (renders Jinja2 templates)
|
||||||
├── pyproject.toml # Project metadata + dependencies
|
├── pyproject.toml # Project metadata + dependencies
|
||||||
├── .venv/ # Python virtual environment
|
├── .venv/ # Python virtual environment
|
||||||
├── system/ # System file templates
|
├── config/ # Declarative JSON configuration (source of truth)
|
||||||
|
│ ├── dnsmasq/ # DHCP/DNS config
|
||||||
|
│ ├── nginx/ # Proxy domain & SSL config
|
||||||
|
│ └── wireguard/ # VPN interface & peer config
|
||||||
|
├── data/ # Runtime artifacts & generated files
|
||||||
|
│ ├── nginx/sites-enabled/ # Generated server blocks
|
||||||
|
│ ├── dnsmasq/fragments/ # User config fragments
|
||||||
|
│ ├── acme/ # ACME certificates
|
||||||
|
│ └── firewall/ # Firewall rule backup
|
||||||
|
├── system/ # System file templates (all Jinja2)
|
||||||
│ ├── systemd/ # Service and timer unit files
|
│ ├── systemd/ # Service and timer unit files
|
||||||
│ │ ├── vacuum-wall.service # Web UI service
|
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal service
|
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
|
||||||
│ │ └── vacuum-wall-acme.timer # Renewal schedule
|
│ │ └── vacuum-wall-acme.timer # Renewal schedule
|
||||||
│ └── sudoers.d/ # Sudo whitelist for service account
|
│ ├── sudoers.d/ # Sudo whitelist (rendered at install)
|
||||||
|
│ ├── nginx/ # Nginx config templates (rendered at runtime)
|
||||||
|
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
|
||||||
|
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
|
||||||
├── lib/ # Subsystem abstraction layer
|
├── lib/ # Subsystem abstraction layer
|
||||||
|
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||||
|
│ ├── logging.py # Logging setup
|
||||||
│ ├── firewall.py # firewalld bindings
|
│ ├── firewall.py # firewalld bindings
|
||||||
│ ├── dnsmasq.py # DHCP/DNS configuration
|
│ ├── dnsmasq.py # DHCP/DNS configuration
|
||||||
│ ├── nginx.py # Reverse proxy configuration
|
│ ├── nginx.py # Reverse proxy configuration
|
||||||
@@ -70,6 +87,7 @@ After installation, access the management interface at `https://wall.lan` using
|
|||||||
├── webui/ # Flask web application
|
├── webui/ # Flask web application
|
||||||
│ ├── server.py # Application entry point
|
│ ├── server.py # Application entry point
|
||||||
│ ├── api/ # REST API route modules
|
│ ├── api/ # REST API route modules
|
||||||
|
│ │ └── common.py # Shared API response helpers (_ok, _error)
|
||||||
│ ├── templates/ # Jinja2/HTMX templates
|
│ ├── templates/ # Jinja2/HTMX templates
|
||||||
│ └── static/ # CSS and client-side JS
|
│ └── static/ # CSS and client-side JS
|
||||||
└── docs/ # Documentation
|
└── docs/ # Documentation
|
||||||
|
|||||||
+12
-10
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Privilege Model
|
## Privilege Model
|
||||||
|
|
||||||
The Vacuum Wall management WebUI (Flask application) runs as the unprivileged `vacuum-wall` system user. The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the `vacuum-wall` user without sudo escalation, using webroot validation that doesn't require binding to privileged ports.
|
The Vacuum Wall management WebUI (Flask application) runs as an unprivileged system user (default name: `vacuum-wall`, configurable via the `USER_NAME` environment variable at install time). The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the application user without sudo escalation, using webroot validation that doesn't require binding to privileged ports.
|
||||||
|
|
||||||
This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the `vacuum-wall` user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
|
This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the dedicated service user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
|
||||||
|
|
||||||
## Sudo Whitelist
|
## Sudo Whitelist
|
||||||
|
|
||||||
The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
|
The file `/etc/sudoers.d/vacuum-wall` grants the configured system user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
|
||||||
|
|
||||||
| Category | Whitelisted Command | Purpose |
|
| Category | Whitelisted Command | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -19,7 +19,7 @@ The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless
|
|||||||
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||||
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
||||||
| WireGuard | `wg *` | WireGuard status and peer management |
|
| WireGuard | `wg *` | WireGuard status and peer management |
|
||||||
| Certificates | (none) | acme.sh runs as the unprivileged `vacuum-wall` user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) |
|
| Certificates | (none) | acme.sh runs as the unprivileged service user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) |
|
||||||
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
|
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
|
||||||
| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
|
| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
|
||||||
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
||||||
@@ -32,7 +32,8 @@ Key safety properties:
|
|||||||
|
|
||||||
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
|
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
|
||||||
- Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none grant shell access or arbitrary command execution.
|
- Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none grant shell access or arbitrary command execution.
|
||||||
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:vacuum-wall` restricts the secure path and disables TTY requirement.
|
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement.
|
||||||
|
- The sudoers file is rendered from a Jinja2 template at install time, substituting the configured user name.
|
||||||
|
|
||||||
## Web Security
|
## Web Security
|
||||||
|
|
||||||
@@ -70,8 +71,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
|
|||||||
| Directive | Value | Effect |
|
| Directive | Value | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||||||
| `ProtectHome` | `read-only` | Makes `/home`, `/root`, and `/run/user` inaccessible |
|
| `ReadWritePaths` | `$INSTALL_DIR`, `$INSTALL_DIR/config`, `$INSTALL_DIR/data`, and `/tmp` | The project directory, config directory, data directory, and `/tmp` are writable (required by `ProtectSystem=strict`). The project path is templated at install time. |
|
||||||
| `ReadWritePaths` | `/home/wall/vacuum-wall/data /tmp` | Only the application data directory and `/tmp` are writable |
|
|
||||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||||
| `IPAddressDeny` | `all` | Drops all network traffic |
|
| `IPAddressDeny` | `all` | Drops all network traffic |
|
||||||
@@ -88,7 +88,9 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
|
|||||||
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
|
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
|
||||||
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
|
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
|
||||||
|
|
||||||
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the data directory, and no ability to escalate privileges through kernel interfaces.
|
The `User`, `Group`, `WorkingDirectory`, `ExecStart`, and `ReadWritePaths` directives in the service unit are rendered from a Jinja2 template at install time with the configured `USER_NAME` and `INSTALL_DIR`.
|
||||||
|
|
||||||
|
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the config and data directories, and no ability to escalate privileges through kernel interfaces.
|
||||||
|
|
||||||
## Network Security
|
## Network Security
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routin
|
|||||||
|
|
||||||
### acme.sh Integration
|
### acme.sh Integration
|
||||||
|
|
||||||
Certificate management is handled by acme.sh, which stores all certificates and private keys in the `vacuum-wall` user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the `vacuum-wall` user.
|
Certificate management is handled by acme.sh, which stores all certificates and private keys in the service user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the service user.
|
||||||
|
|
||||||
### Private Key Protection
|
### Private Key Protection
|
||||||
|
|
||||||
@@ -126,4 +128,4 @@ All HTTPS proxy domains have HTTP Strict Transport Security enabled at the nginx
|
|||||||
|
|
||||||
### Modern TLS Only
|
### 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} $*"; }
|
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||||||
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
|
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
# --- Configurable via environment ---
|
# --- CLI argument parsing ---
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
_cli_user=""
|
||||||
USER_NAME="${USER_NAME:-vacuum-wall}"
|
_cli_is_dev=false
|
||||||
|
_cli_path=""
|
||||||
# --- Validate required env vars ---
|
_cli_mgmt_pass=""
|
||||||
missing=()
|
_cli_mgmt_user=""
|
||||||
[[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS)
|
_cli_mgmt_domain=""
|
||||||
[[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL)
|
_cli_acme_email=""
|
||||||
|
_cli_force_venv=false
|
||||||
if (( ${#missing[@]} )); then
|
_cli_wan_iface=""
|
||||||
echo -e "${RED}[!!]${NC} Missing required environment variables:"
|
_cli_lan_ifaces=""
|
||||||
for v in "${missing[@]}"; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$v" in
|
case "$1" in
|
||||||
MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';;
|
--user|-u) _cli_user="$2"; shift 2 ;;
|
||||||
ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";;
|
--path|-p) _cli_path="$2"; shift 2 ;;
|
||||||
|
--dev) _cli_is_dev=true; shift ;;
|
||||||
|
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
|
||||||
|
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
|
||||||
|
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
|
||||||
|
--acme-email) _cli_acme_email="$2"; shift 2 ;;
|
||||||
|
--force-venv) _cli_force_venv=true; shift ;;
|
||||||
|
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
|
||||||
|
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
printf '%s\n' \
|
||||||
|
"Usage: install.sh [OPTIONS]" \
|
||||||
|
"" \
|
||||||
|
"Options:" \
|
||||||
|
" --user, -u USER System user for service (default: vacuum-wall)" \
|
||||||
|
" --path, -p DIR Install directory (default: repo root)" \
|
||||||
|
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
|
||||||
|
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||||
|
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||||
|
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||||
|
" --acme-email EMAIL ACME registration email (required)" \
|
||||||
|
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||||
|
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||||
|
" -h, --help Show this help" \
|
||||||
|
"" \
|
||||||
|
"All options also have environment variable equivalents:" \
|
||||||
|
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
|
||||||
|
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
|
||||||
|
" CLI flags take precedence over env vars." \
|
||||||
|
"" \
|
||||||
|
"Example (dev):" \
|
||||||
|
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
|
||||||
|
"" \
|
||||||
|
"Example (prod):" \
|
||||||
|
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Unknown argument: $1 (use --help for usage)"
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Auto-detect MGMT_DOMAIN from system hostname if not provided
|
# --- Resolve config: CLI flag > env var > default ---
|
||||||
if [[ -z "${MGMT_DOMAIN:-}" ]]; then
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Required settings (no defaults — must be provided)
|
||||||
|
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||||
|
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
|
||||||
|
|
||||||
|
# Optional settings with defaults
|
||||||
|
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
|
||||||
|
|
||||||
|
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
|
||||||
|
if [[ -n "$_cli_mgmt_domain" ]]; then
|
||||||
|
DOMAIN="$_cli_mgmt_domain"
|
||||||
|
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
|
||||||
|
DOMAIN="$MGMT_DOMAIN"
|
||||||
|
else
|
||||||
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
|
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
|
||||||
if [[ -z "$HOSTNAME_F" ]]; then
|
if [[ -z "$HOSTNAME_F" ]]; then
|
||||||
err "Cannot determine system hostname — set MGMT_DOMAIN env var."
|
err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
|
||||||
fi
|
fi
|
||||||
DOMAIN="${HOSTNAME_F}.local"
|
DOMAIN="${HOSTNAME_F}.local"
|
||||||
else
|
|
||||||
DOMAIN="$MGMT_DOMAIN"
|
|
||||||
fi
|
fi
|
||||||
MGMT_USER="${MGMT_USER:-admin}"
|
|
||||||
|
# Install directory (CLI > env > repo root)
|
||||||
|
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
|
||||||
|
if [[ -n "$INSTALL_DIR" ]]; then
|
||||||
|
PROJECT_DIR="$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
PROJECT_DIR="$REPO_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Network interfaces (CLI > env — auto-detect happens later if still unset)
|
||||||
|
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
|
||||||
|
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
||||||
|
|
||||||
# --- Pre-flight checks ---
|
# --- Pre-flight checks ---
|
||||||
[[ $EUID -eq 0 ]] || err "This script must be run as root."
|
[[ $EUID -eq 0 ]] || err "This script must be run as root."
|
||||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||||
|
|
||||||
# --- Deploy to /opt/vacuum-wall ---
|
# --- Validate required settings ---
|
||||||
INSTALL_DIR="/opt/vacuum-wall"
|
missing=()
|
||||||
|
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||||
|
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
|
||||||
|
|
||||||
# Install rsync first if not available (needed for deploy)
|
if (( ${#missing[@]} )); then
|
||||||
if ! command -v rsync &>/dev/null; then
|
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||||
apt-get update -qq
|
for v in "${missing[@]}"; do
|
||||||
apt-get install -y -qq rsync
|
case "$v" in
|
||||||
|
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
||||||
|
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -d "$INSTALL_DIR" ]]; then
|
|
||||||
if [[ -L "$INSTALL_DIR" ]]; then
|
|
||||||
log "Symbolic link already exists at $INSTALL_DIR, skipping deploy."
|
|
||||||
elif [[ "$INSTALL_DIR" == "$REPO_DIR" ]]; then
|
|
||||||
log "Installed from repo location, skipping deploy."
|
|
||||||
else
|
|
||||||
err "Installation directory $INSTALL_DIR already exists."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Deploying $REPO_DIR → $INSTALL_DIR"
|
|
||||||
rsync -a --delete \
|
|
||||||
--exclude='.venv' \
|
|
||||||
--exclude='__pycache__' \
|
|
||||||
--exclude='*.pyc' \
|
|
||||||
--exclude='.git' \
|
|
||||||
--exclude='build' \
|
|
||||||
"$REPO_DIR/" "$INSTALL_DIR/"
|
|
||||||
chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROJECT_DIR="$INSTALL_DIR"
|
|
||||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
ACME_HOME="$PROJECT_DIR/data/acme"
|
||||||
|
|
||||||
|
# Dev mode: auto-detect repo owner as service user
|
||||||
|
if [[ "$_cli_is_dev" == true ]]; then
|
||||||
|
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
|
||||||
|
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
|
||||||
|
_cli_user="$_repo_owner"
|
||||||
|
log "Dev mode: using repo owner '$_repo_owner' as service user"
|
||||||
|
else
|
||||||
|
err "Dev mode: cannot determine repo owner (root or unavailable)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional settings with defaults
|
||||||
|
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
|
||||||
|
|
||||||
|
# --- Safety check: running service as a non-system regular user ---
|
||||||
|
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
|
||||||
|
_uid=$(id -u "$USER_NAME")
|
||||||
|
_gid=$(id -g "$USER_NAME")
|
||||||
|
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
|
||||||
|
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
|
||||||
|
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
|
||||||
|
warn "This grants NOPASSWD sudo and runs the web service as your login account."
|
||||||
|
warn "Only use for development. For production, use --user vacuum-wall."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Vacuum Wall Appliance Installer"
|
echo " Vacuum Wall Appliance Installer"
|
||||||
echo " Install dir: $PROJECT_DIR"
|
echo " Install dir: $PROJECT_DIR"
|
||||||
@@ -114,17 +185,23 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2b. Setup Python venv ---
|
# --- 2b. Setup Python venv ---
|
||||||
log "Setting up Python virtual environment..."
|
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
|
||||||
python3 -m venv "${PROJECT_DIR}/.venv"
|
log "Python venv already exists, skipping (use --force-venv to recreate)."
|
||||||
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
|
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 ---
|
# --- 2c. Install acme.sh (vendored) ---
|
||||||
if [[ ! -d "$ACME_HOME" ]]; then
|
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
|
||||||
log "Installing acme.sh..."
|
log "Installing acme.sh (vendored)..."
|
||||||
mkdir -p "$ACME_HOME"
|
mkdir -p "$ACME_HOME"
|
||||||
chown "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
|
||||||
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
chmod +x "$ACME_HOME/acme.sh"
|
||||||
sh -c 'curl -sS https://get.acme.sh | sh'
|
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
||||||
else
|
else
|
||||||
log "acme.sh already installed."
|
log "acme.sh already installed."
|
||||||
fi
|
fi
|
||||||
@@ -201,20 +278,29 @@ systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no int
|
|||||||
log "dnsmasq configured (will fully start after DHCP ranges are set)"
|
log "dnsmasq configured (will fully start after DHCP ranges are set)"
|
||||||
|
|
||||||
# --- 10. Setup nginx management proxy ---
|
# --- 10. Setup nginx management proxy ---
|
||||||
log "Generating self-signed certificate for management domain..."
|
|
||||||
mkdir -p "$ACME_HOME/$DOMAIN"
|
mkdir -p "$ACME_HOME/$DOMAIN"
|
||||||
|
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
|
||||||
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
|
log "SSL certificate already exists for $DOMAIN, skipping."
|
||||||
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
|
else
|
||||||
-subj "/CN=$DOMAIN" \
|
log "Generating self-signed certificate for management domain..."
|
||||||
-addext "subjectAltName=DNS:$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"
|
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
|
||||||
|
|
||||||
# Generate htpasswd directly in data/nginx/
|
# Generate/update htpasswd directly in data/nginx/
|
||||||
htpasswd -cb "${PROJECT_DIR}/data/nginx/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||||
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="${PROJECT_DIR}/data/nginx/.htpasswd" python3 -c "
|
if [[ -f "$HTPASSWD_FILE" ]]; then
|
||||||
|
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||||
|
warn "Could not update htpasswd (install apache2-utils)"
|
||||||
|
else
|
||||||
|
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||||
|
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" python3 -c "
|
||||||
import os, crypt, base64
|
import os, crypt, base64
|
||||||
password = os.environ['MGMT_PASS']
|
password = os.environ['MGMT_PASS']
|
||||||
user = os.environ['MGMT_USER']
|
user = os.environ['MGMT_USER']
|
||||||
@@ -223,7 +309,8 @@ hashed = crypt.crypt(password, salt)
|
|||||||
with open(os.environ['HTFILE'], 'w') as f:
|
with open(os.environ['HTFILE'], 'w') as f:
|
||||||
f.write(user + ':' + hashed + '\n')
|
f.write(user + ':' + hashed + '\n')
|
||||||
" 2>/dev/null || \
|
" 2>/dev/null || \
|
||||||
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
||||||
|
fi
|
||||||
|
|
||||||
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
|
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
|
||||||
|
|
||||||
@@ -279,12 +366,16 @@ server {
|
|||||||
}
|
}
|
||||||
MGMTSITEEOF
|
MGMTSITEEOF
|
||||||
|
|
||||||
# --- 11. Write initial nginx config.json ---
|
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
|
||||||
log "Writing initial nginx configuration..."
|
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
|
||||||
MGMT_DOMAIN="$DOMAIN" \
|
if [[ -f "$NGINX_CFG" ]]; then
|
||||||
MGMT_USER="$MGMT_USER" \
|
log "Nginx config already exists, skipping initial write."
|
||||||
INSTALL_DIR="$PROJECT_DIR" \
|
else
|
||||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
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
|
import json, os
|
||||||
d = os.environ['MGMT_DOMAIN']
|
d = os.environ['MGMT_DOMAIN']
|
||||||
u = os.environ['MGMT_USER']
|
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)
|
json.dump(cfg, f, indent=4)
|
||||||
f.write('\n')
|
f.write('\n')
|
||||||
"
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
|
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
|
||||||
log "Detecting network interfaces..."
|
log "Detecting network interfaces..."
|
||||||
@@ -342,12 +434,16 @@ if [[ -z "$LAN_IFACES" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate config/firewall/config.json
|
# Generate config/firewall/config.json (skip if user has customized it)
|
||||||
log "Writing initial firewall configuration..."
|
FIREWALL_CFG="${PROJECT_DIR}/config/firewall/config.json"
|
||||||
WAN_IFACE="$WAN_IFACE" \
|
if [[ -f "$FIREWALL_CFG" ]]; then
|
||||||
LAN_IFACES="$LAN_IFACES" \
|
log "Firewall config already exists, skipping initial write."
|
||||||
INSTALL_DIR="$PROJECT_DIR" \
|
else
|
||||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
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
|
import json, os
|
||||||
|
|
||||||
wan = os.environ.get('WAN_IFACE', '').strip() or None
|
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)
|
json.dump(cfg, f, indent=2)
|
||||||
f.write('\n')
|
f.write('\n')
|
||||||
"
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
|
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
|
||||||
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true
|
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true
|
||||||
@@ -435,14 +532,25 @@ systemctl enable avahi-daemon >/dev/null 2>&1 || true
|
|||||||
log "Enabled avahi-daemon"
|
log "Enabled avahi-daemon"
|
||||||
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
|
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
|
||||||
|
|
||||||
systemctl start nginx >/dev/null 2>&1 && log "Started nginx" || warn "Could not start nginx (check config)"
|
systemctl stop vacuum-wall >/dev/null 2>&1 || true
|
||||||
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
|
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
|
||||||
|
|
||||||
|
nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
|
||||||
|
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
|
||||||
|
warn "Could not restart nginx (check config)"
|
||||||
|
|
||||||
# --- 14. Configure acme.sh default email ---
|
# --- 14. Configure acme.sh default email ---
|
||||||
log "Configuring acme.sh default email..."
|
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
|
||||||
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
log "acme.sh account already registered, skipping."
|
||||||
"$ACME_HOME/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
else
|
||||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
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 ---
|
# --- Done ---
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+47
-50
@@ -27,6 +27,8 @@ _ACME_ENVIRON = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||||
|
|
||||||
|
|
||||||
def _find_acme() -> str:
|
def _find_acme() -> str:
|
||||||
"""Locate the acme.sh binary on the system.
|
"""Locate the acme.sh binary on the system.
|
||||||
@@ -147,77 +149,52 @@ def get_email() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def issue(domain: str, webroot: str | None = None) -> dict:
|
def issue(domain: str, webroot: str | None = None, email: str | None = None) -> str:
|
||||||
"""Issue a new SSL certificate for a domain.
|
"""Issue a new SSL certificate for a domain.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: The primary domain name.
|
domain: The primary domain name.
|
||||||
webroot: Path to the web root directory for HTTP-01 validation.
|
webroot: Path to the web root directory for HTTP-01 validation.
|
||||||
|
email: Contact email. Falls back to configured ACME email if not given.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
Combined stdout from the acme.sh command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If issuance fails.
|
||||||
"""
|
"""
|
||||||
args: list[str] = ["--issue", "-d", domain]
|
args: list[str] = ["--issue", "-d", domain]
|
||||||
|
args.extend(["--webroot", webroot or str(_WEBROOT)])
|
||||||
if webroot:
|
contact = email or get_email()
|
||||||
args.extend(["--webroot", webroot])
|
if contact:
|
||||||
|
args.extend(["-m", contact])
|
||||||
email = get_email()
|
|
||||||
if email:
|
|
||||||
args.extend(["-m", email])
|
|
||||||
args.append("--force")
|
args.append("--force")
|
||||||
|
output = _run_acme(args)
|
||||||
try:
|
deploy(domain)
|
||||||
output = _run_acme(args)
|
logger.info("Certificate for %s issued successfully", domain)
|
||||||
deploy(domain)
|
return output.strip()
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def renew(domain: str, force: bool = False) -> dict:
|
def renew(domain: str, force: bool = False) -> str:
|
||||||
"""Renew an existing SSL certificate.
|
"""Renew an existing SSL certificate.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: The domain whose certificate should be renewed.
|
domain: The domain whose certificate should be renewed.
|
||||||
force: If True, renew even if the certificate isn't close to expiry.
|
force: If True, renew even if not close to expiry.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
Combined stdout from the acme.sh command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If renewal fails.
|
||||||
"""
|
"""
|
||||||
args: list[str] = ["--renew", "-d", domain]
|
args: list[str] = ["--renew", "-d", domain]
|
||||||
if force:
|
if force:
|
||||||
args.append("--force")
|
args.append("--force")
|
||||||
|
output = _run_acme(args)
|
||||||
try:
|
deploy(domain)
|
||||||
output = _run_acme(args)
|
logger.info("Certificate for %s renewed successfully", domain)
|
||||||
return {
|
return output.strip()
|
||||||
"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),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def remove(domain: str) -> str:
|
def remove(domain: str) -> str:
|
||||||
@@ -274,7 +251,10 @@ def list_certs() -> list[dict]:
|
|||||||
certs.append(
|
certs.append(
|
||||||
{
|
{
|
||||||
"domain": main,
|
"domain": main,
|
||||||
"ca": entry.get("CA", ""),
|
"issuer": entry.get("CA", ""),
|
||||||
|
"expiry": entry.get("certificate_expires", ""),
|
||||||
|
"days_remaining": days,
|
||||||
|
"expired": days is not None and days <= 0,
|
||||||
"cert_path": cert_path,
|
"cert_path": cert_path,
|
||||||
"key_path": key_path,
|
"key_path": key_path,
|
||||||
"ca_path": ca_path,
|
"ca_path": ca_path,
|
||||||
@@ -494,3 +474,20 @@ def _has_auto_renew(domain: str) -> bool:
|
|||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
domain_conf = Path(acme_home_env) / f"{domain}.conf"
|
domain_conf = Path(acme_home_env) / f"{domain}.conf"
|
||||||
return bool(domain_conf.is_file())
|
return bool(domain_conf.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"copy_cert",
|
||||||
|
"days_until_expiry",
|
||||||
|
"deploy",
|
||||||
|
"get_cert_info",
|
||||||
|
"get_cert_paths",
|
||||||
|
"get_email",
|
||||||
|
"get_expiry",
|
||||||
|
"is_expired",
|
||||||
|
"issue",
|
||||||
|
"list_certs",
|
||||||
|
"remove",
|
||||||
|
"renew",
|
||||||
|
"set_email",
|
||||||
|
]
|
||||||
|
|||||||
+137
@@ -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,
|
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
|
||||||
static leases, and custom DNS records through sudo.
|
static leases, and custom DNS records through sudo.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -15,6 +13,10 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.common import deep_merge, ensure_dirs, load_json, save_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||||
@@ -43,64 +45,25 @@ DEFAULT_CFG: dict[str, Any] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ───────── helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dirs() -> None:
|
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
|
|
||||||
return subprocess.run(
|
|
||||||
["sudo", *list(cmd)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict:
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def _save_json(path: Path, data: dict) -> None:
|
|
||||||
_ensure_dirs()
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(data, f, indent=4)
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_merge(base: dict, overrides: dict) -> dict:
|
|
||||||
result = deepcopy(base)
|
|
||||||
for k, v in overrides.items():
|
|
||||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
|
||||||
result[k] = _deep_merge(result[k], v)
|
|
||||||
else:
|
|
||||||
result[k] = deepcopy(v)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ───────── config lifecycle ──────────────────────────────────────────
|
# ───────── config lifecycle ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Load current dnsmasq config from JSON state file."""
|
"""Load current dnsmasq config from JSON state file."""
|
||||||
_ensure_dirs()
|
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||||
raw = _load_json(CONFIG_PATH)
|
raw = load_json(CONFIG_PATH)
|
||||||
if not raw:
|
if not raw:
|
||||||
return deepcopy(DEFAULT_CFG)
|
return deepcopy(DEFAULT_CFG)
|
||||||
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
|
return deep_merge(deepcopy(DEFAULT_CFG), raw)
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
|
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
|
||||||
_ensure_dirs()
|
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||||
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||||
_save_json(CONFIG_PATH, merged)
|
save_json(CONFIG_PATH, merged)
|
||||||
|
logger.info("dnsmasq config saved")
|
||||||
|
|
||||||
|
|
||||||
def apply_config() -> None:
|
def apply_config() -> None:
|
||||||
@@ -108,8 +71,8 @@ def apply_config() -> None:
|
|||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
conf_text = generate_conf(cfg)
|
conf_text = generate_conf(cfg)
|
||||||
|
|
||||||
_ensure_dirs()
|
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||||
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
|
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "tee", DNSMASQ_CONF, "--"],
|
["sudo", "tee", DNSMASQ_CONF, "--"],
|
||||||
input=conf_text,
|
input=conf_text,
|
||||||
@@ -117,13 +80,19 @@ def apply_config() -> None:
|
|||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
_sudo("systemctl", "reload", "dnsmasq")
|
subprocess.run(
|
||||||
|
["sudo", "systemctl", "reload", "dnsmasq"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
logger.info("dnsmasq config written and reloaded")
|
||||||
|
|
||||||
|
|
||||||
# ───────── config generation ─────────────────────────────────────────
|
# ───────── config generation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def generate_conf(cfg: dict) -> str:
|
def generate_conf(cfg: dict[str, Any]) -> str:
|
||||||
"""Render a complete dnsmasq.conf text block from the config dict."""
|
"""Render a complete dnsmasq.conf text block from the config dict."""
|
||||||
dhcp_cfg = cfg.get("dhcp", {})
|
dhcp_cfg = cfg.get("dhcp", {})
|
||||||
dns_cfg = cfg.get("dns", {})
|
dns_cfg = cfg.get("dns", {})
|
||||||
@@ -187,6 +156,23 @@ def set_dhcp_range(
|
|||||||
ranges.append(entry)
|
ranges.append(entry)
|
||||||
|
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
|
||||||
|
"""Remove a DHCP range by interface + IP range."""
|
||||||
|
cfg = get_config()
|
||||||
|
cfg["dhcp"]["ranges"] = [
|
||||||
|
r
|
||||||
|
for r in cfg["dhcp"]["ranges"]
|
||||||
|
if not (
|
||||||
|
r.get("interface") == iface
|
||||||
|
and r.get("start") == start
|
||||||
|
and r.get("end") == end
|
||||||
|
)
|
||||||
|
]
|
||||||
|
save_config(cfg)
|
||||||
|
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
|
||||||
|
|
||||||
|
|
||||||
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||||
@@ -200,6 +186,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
|||||||
if hostname:
|
if hostname:
|
||||||
leases[i]["hostname"] = hostname
|
leases[i]["hostname"] = hostname
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
|
||||||
return
|
return
|
||||||
|
|
||||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||||
@@ -207,6 +194,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
|||||||
entry["hostname"] = hostname
|
entry["hostname"] = hostname
|
||||||
leases.append(entry)
|
leases.append(entry)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
|
||||||
|
|
||||||
|
|
||||||
def remove_static_lease(mac: str) -> None:
|
def remove_static_lease(mac: str) -> None:
|
||||||
@@ -218,6 +206,7 @@ def remove_static_lease(mac: str) -> None:
|
|||||||
if lease["mac"].lower() != mac.lower()
|
if lease["mac"].lower() != mac.lower()
|
||||||
]
|
]
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("Static DHCP lease removed for MAC %s", mac)
|
||||||
|
|
||||||
|
|
||||||
# ───────── dns record management ─────────────────────────────────────
|
# ───────── dns record management ─────────────────────────────────────
|
||||||
@@ -234,6 +223,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
|
|||||||
if hostname:
|
if hostname:
|
||||||
records[i]["hostname"] = hostname
|
records[i]["hostname"] = hostname
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DNS record updated: %s -> %s", name, address)
|
||||||
return
|
return
|
||||||
|
|
||||||
entry: dict[str, Any] = {"name": name, "address": address}
|
entry: dict[str, Any] = {"name": name, "address": address}
|
||||||
@@ -241,6 +231,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
|
|||||||
entry["hostname"] = hostname
|
entry["hostname"] = hostname
|
||||||
records.append(entry)
|
records.append(entry)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DNS record added: %s -> %s", name, address)
|
||||||
|
|
||||||
|
|
||||||
def remove_dns_record(name: str) -> None:
|
def remove_dns_record(name: str) -> None:
|
||||||
@@ -250,6 +241,7 @@ def remove_dns_record(name: str) -> None:
|
|||||||
r for r in cfg["dns"]["custom_records"] if r["name"] != name
|
r for r in cfg["dns"]["custom_records"] if r["name"] != name
|
||||||
]
|
]
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DNS record removed: %s", name)
|
||||||
|
|
||||||
|
|
||||||
# ───────── lease table ───────────────────────────────────────────────
|
# ───────── lease table ───────────────────────────────────────────────
|
||||||
@@ -278,11 +270,16 @@ def _parse_lease_line(line: str) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_lease_table() -> list[dict]:
|
def get_lease_table() -> list[dict[str, Any]]:
|
||||||
"""Read and parse the current dnsmasq lease file."""
|
"""Read and parse the current dnsmasq lease file."""
|
||||||
leases: list[dict] = []
|
leases: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
result = _sudo("cat", LEASE_FILE)
|
result = subprocess.run(
|
||||||
|
["sudo", "cat", LEASE_FILE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
leases.append(entry)
|
leases.append(entry)
|
||||||
@@ -299,6 +296,7 @@ def set_upstreams(servers: list[str]) -> None:
|
|||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg["dns"]["upstreams"] = list(servers)
|
cfg["dns"]["upstreams"] = list(servers)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DNS upstreams set to %s", servers)
|
||||||
|
|
||||||
|
|
||||||
def set_domain(domain: str | None) -> None:
|
def set_domain(domain: str | None) -> None:
|
||||||
@@ -306,16 +304,16 @@ def set_domain(domain: str | None) -> None:
|
|||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg["dns"]["domain"] = domain if domain else None
|
cfg["dns"]["domain"] = domain if domain else None
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("DNS domain set to '%s'", domain)
|
||||||
|
|
||||||
|
|
||||||
# ───────── status / info ─────────────────────────────────────────────
|
# ───────── status / info ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_status() -> dict:
|
def get_status() -> dict[str, Any]:
|
||||||
"""Return service status, config summary, and current lease count."""
|
"""Return service status, config summary, and current lease count."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
|
|
||||||
# dnsmasq process check
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["sudo", "systemctl", "is-active", "dnsmasq"],
|
["sudo", "systemctl", "is-active", "dnsmasq"],
|
||||||
@@ -326,8 +324,7 @@ def get_status() -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
active = False
|
active = False
|
||||||
|
|
||||||
# config on disk
|
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||||
conf_exists = os.path.isfile(DNSMASQ_CONF)
|
|
||||||
if conf_exists:
|
if conf_exists:
|
||||||
try:
|
try:
|
||||||
with open(DNSMASQ_CONF) as f:
|
with open(DNSMASQ_CONF) as f:
|
||||||
@@ -337,7 +334,6 @@ def get_status() -> dict:
|
|||||||
else:
|
else:
|
||||||
conf_on_disk = ""
|
conf_on_disk = ""
|
||||||
|
|
||||||
# current expected config
|
|
||||||
expected = generate_conf(cfg)
|
expected = generate_conf(cfg)
|
||||||
|
|
||||||
leases = get_lease_table()
|
leases = get_lease_table()
|
||||||
@@ -354,3 +350,21 @@ def get_status() -> dict:
|
|||||||
"active_leases": len(leases),
|
"active_leases": len(leases),
|
||||||
"leases": leases,
|
"leases": leases,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"add_dns_record",
|
||||||
|
"add_static_lease",
|
||||||
|
"apply_config",
|
||||||
|
"generate_conf",
|
||||||
|
"get_config",
|
||||||
|
"get_lease_table",
|
||||||
|
"get_status",
|
||||||
|
"remove_dhcp_range",
|
||||||
|
"remove_dns_record",
|
||||||
|
"remove_static_lease",
|
||||||
|
"save_config",
|
||||||
|
"set_dhcp_range",
|
||||||
|
"set_domain",
|
||||||
|
"set_upstreams",
|
||||||
|
]
|
||||||
|
|||||||
+481
-184
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
|
Manages per-domain SSL reverse proxy configurations, certificate
|
||||||
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.common import ensure_dirs, load_json, save_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
||||||
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
||||||
@@ -28,7 +33,7 @@ ENV = Environment(
|
|||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_SSL = {
|
DEFAULT_SSL: dict[str, Any] = {
|
||||||
"protocols": "TLSv1.2 TLSv1.3",
|
"protocols": "TLSv1.2 TLSv1.3",
|
||||||
"ciphers": (
|
"ciphers": (
|
||||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||||
@@ -41,63 +46,35 @@ DEFAULT_SSL = {
|
|||||||
"prefer_server_ciphers": False,
|
"prefer_server_ciphers": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"domains": {},
|
"domains": {},
|
||||||
"management": None,
|
"management": None,
|
||||||
"ssl": {**DEFAULT_SSL},
|
"ssl": {**DEFAULT_SSL},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dirs():
|
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
SITES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd, **kw):
|
|
||||||
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_load(path):
|
|
||||||
_ensure_dirs()
|
|
||||||
if not path.exists():
|
|
||||||
return DEFAULT_CONFIG.copy()
|
|
||||||
with open(path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if "ssl" not in data:
|
|
||||||
data["ssl"] = DEFAULT_SSL.copy()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _json_dump(path, data):
|
|
||||||
_ensure_dirs()
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(data, f, indent=4)
|
|
||||||
f.write("\n")
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict:
|
def get_config() -> dict[str, Any]:
|
||||||
return _json_load(CONFIG_FILE)
|
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||||
|
raw = load_json(CONFIG_FILE)
|
||||||
|
if not raw:
|
||||||
|
raw = deepcopy(DEFAULT_CONFIG)
|
||||||
|
if "ssl" not in raw:
|
||||||
|
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
_json_dump(CONFIG_FILE, cfg)
|
save_json(CONFIG_FILE, cfg)
|
||||||
|
|
||||||
|
|
||||||
def get_domains() -> list[dict]:
|
def get_domains() -> list[dict[str, Any]]:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
result = []
|
result: list[dict[str, Any]] = []
|
||||||
for name, dom in cfg.get("domains", {}).items():
|
for name, dom in cfg.get("domains", {}).items():
|
||||||
site = SITES_DIR / f"{name}.conf"
|
site = SITES_DIR / f"{name}.conf"
|
||||||
result.append(
|
result.append(
|
||||||
@@ -117,17 +94,17 @@ def get_domains() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def add_domain(
|
def add_domain(
|
||||||
domain,
|
domain: str,
|
||||||
backend_host,
|
backend_host: str,
|
||||||
backend_port,
|
backend_port: int,
|
||||||
backend_proto="http",
|
backend_proto: str = "http",
|
||||||
cert=None,
|
cert: str | None = None,
|
||||||
extra_headers=None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
if domain in cfg["domains"]:
|
if domain in cfg["domains"]:
|
||||||
raise ValueError(f"Domain {domain!r} already configured")
|
raise ValueError(f"Domain {domain!r} already configured")
|
||||||
entry = {
|
entry: dict[str, Any] = {
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": backend_host,
|
"host": backend_host,
|
||||||
"port": int(backend_port),
|
"port": int(backend_port),
|
||||||
@@ -141,18 +118,26 @@ def add_domain(
|
|||||||
entry["headers"] = extra_headers
|
entry["headers"] = extra_headers
|
||||||
cfg["domains"][domain] = entry
|
cfg["domains"][domain] = entry
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info(
|
||||||
|
"Proxy domain '%s' added -> %s:%d (%s)",
|
||||||
|
domain,
|
||||||
|
backend_host,
|
||||||
|
backend_port,
|
||||||
|
backend_proto,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_domain(domain) -> None:
|
def remove_domain(domain: str) -> None:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg["domains"].pop(domain, None)
|
cfg["domains"].pop(domain, None)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
site = SITES_DIR / f"{domain}.conf"
|
site = SITES_DIR / f"{domain}.conf"
|
||||||
if site.exists():
|
if site.exists():
|
||||||
site.unlink()
|
site.unlink()
|
||||||
|
logger.info("Proxy domain '%s' removed", domain)
|
||||||
|
|
||||||
|
|
||||||
def update_domain(domain, **kwargs) -> None:
|
def update_domain(domain: str, **kwargs: Any) -> None:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
if domain not in cfg["domains"]:
|
if domain not in cfg["domains"]:
|
||||||
raise KeyError(f"Domain {domain!r} not configured")
|
raise KeyError(f"Domain {domain!r} not configured")
|
||||||
@@ -163,6 +148,7 @@ def update_domain(domain, **kwargs) -> None:
|
|||||||
else:
|
else:
|
||||||
entry[key] = val
|
entry[key] = val
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -170,7 +156,7 @@ def update_domain(domain, **kwargs) -> None:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def generate_server_conf(domain_cfg: dict) -> str:
|
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
domain=domain_cfg["domain"],
|
domain=domain_cfg["domain"],
|
||||||
@@ -182,10 +168,11 @@ def generate_server_conf(domain_cfg: dict) -> str:
|
|||||||
is_management=False,
|
is_management=False,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_management_conf(management: dict) -> str:
|
def _generate_management_conf(management: dict[str, Any]) -> str:
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
domain=management.get("domain"),
|
domain=management.get("domain"),
|
||||||
@@ -199,6 +186,7 @@ def _generate_management_conf(management: dict) -> str:
|
|||||||
is_management=True,
|
is_management=True,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,8 +195,8 @@ def _generate_management_conf(management: dict) -> str:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def write_site(domain, conf_text) -> None:
|
def write_site(domain: str, conf_text: str) -> None:
|
||||||
_ensure_dirs()
|
ensure_dirs(SITES_DIR)
|
||||||
path = SITES_DIR / f"{domain}.conf"
|
path = SITES_DIR / f"{domain}.conf"
|
||||||
tmp = path.with_suffix(".tmp")
|
tmp = path.with_suffix(".tmp")
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
@@ -218,13 +206,32 @@ def write_site(domain, conf_text) -> None:
|
|||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_acme_challenge() -> None:
|
||||||
|
"""Write the ACME HTTP-01 challenge catch-all nginx config.
|
||||||
|
|
||||||
|
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
|
||||||
|
webroot for any domain not yet covered by a dedicated server block.
|
||||||
|
"""
|
||||||
|
tmpl = ENV.get_template("nginx/acme-challenge.conf")
|
||||||
|
content = tmpl.render(
|
||||||
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
|
)
|
||||||
|
site = SITES_DIR / "_acme-challenge.conf"
|
||||||
|
tmp = site.with_suffix(".tmp")
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
f.write("\n")
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, site)
|
||||||
|
|
||||||
|
|
||||||
def write_all_sites() -> None:
|
def write_all_sites() -> None:
|
||||||
_ensure_dirs()
|
ensure_dirs(SITES_DIR)
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
|
|
||||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||||
|
|
||||||
written = set()
|
written: set[str] = set()
|
||||||
for name, dom in cfg.get("domains", {}).items():
|
for name, dom in cfg.get("domains", {}).items():
|
||||||
dom_copy = dict(dom, domain=name)
|
dom_copy = dict(dom, domain=name)
|
||||||
conf = generate_server_conf(dom_copy)
|
conf = generate_server_conf(dom_copy)
|
||||||
@@ -240,6 +247,9 @@ def write_all_sites() -> None:
|
|||||||
if old.suffix == ".conf" and old.name not in written:
|
if old.suffix == ".conf" and old.name not in written:
|
||||||
old.unlink()
|
old.unlink()
|
||||||
|
|
||||||
|
write_acme_challenge()
|
||||||
|
logger.info("All nginx site configs written (%d sites)", len(written))
|
||||||
|
|
||||||
|
|
||||||
def write_include_file() -> None:
|
def write_include_file() -> None:
|
||||||
tmpl = ENV.get_template("nginx/include.conf")
|
tmpl = ENV.get_template("nginx/include.conf")
|
||||||
@@ -248,15 +258,15 @@ def write_include_file() -> None:
|
|||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
os.chmod(tmp, 0o644)
|
os.chmod(tmp, 0o644)
|
||||||
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
|
subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True)
|
||||||
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
|
subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True)
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def write_ssl_snippet() -> None:
|
def write_ssl_snippet() -> None:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
|
ssl_cfg = cfg.get("ssl", {})
|
||||||
ssl_cfg.setdefault("prefer_server_ciphers", False)
|
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
|
||||||
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
|
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
|
||||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||||
|
|
||||||
@@ -266,8 +276,8 @@ def write_ssl_snippet() -> None:
|
|||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
os.chmod(tmp, 0o644)
|
os.chmod(tmp, 0o644)
|
||||||
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
|
subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True)
|
||||||
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
|
subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True)
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -277,11 +287,17 @@ def write_ssl_snippet() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_config() -> tuple[bool, str]:
|
def test_config() -> tuple[bool, str]:
|
||||||
result = _run(["sudo", "nginx", "-t"])
|
result = subprocess.run(
|
||||||
|
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
ok = result.returncode == 0
|
ok = result.returncode == 0
|
||||||
output = (result.stderr or result.stdout or "").strip()
|
output = (result.stderr or result.stdout or "").strip()
|
||||||
if not output and ok:
|
if not output and ok:
|
||||||
output = "nginx configuration test passed"
|
output = "nginx configuration test passed"
|
||||||
|
if ok:
|
||||||
|
logger.info("nginx config test passed")
|
||||||
|
else:
|
||||||
|
logger.error("nginx config test failed: %s", output)
|
||||||
return ok, output
|
return ok, output
|
||||||
|
|
||||||
|
|
||||||
@@ -292,7 +308,13 @@ def apply() -> None:
|
|||||||
ok, msg = test_config()
|
ok, msg = test_config()
|
||||||
if not ok:
|
if not ok:
|
||||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||||
_run(["sudo", "nginx", "-s", "reload"])
|
result = subprocess.run(
|
||||||
|
["sudo", "nginx", "-s", "reload"], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("nginx reload failed: %s", result.stderr.strip())
|
||||||
|
else:
|
||||||
|
logger.info("nginx configuration applied and reloaded")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -301,10 +323,14 @@ def apply() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def set_management_proxy(
|
def set_management_proxy(
|
||||||
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
|
domain: str,
|
||||||
|
flask_host: str = "127.0.0.1",
|
||||||
|
flask_port: int = 9090,
|
||||||
|
auth_user: str | None = None,
|
||||||
|
auth_pass: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
entry = {
|
entry: dict[str, Any] = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": flask_host,
|
"host": flask_host,
|
||||||
@@ -321,6 +347,7 @@ def set_management_proxy(
|
|||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
if auth_user and auth_pass:
|
if auth_user and auth_pass:
|
||||||
write_htpasswd(auth_user, auth_pass)
|
write_htpasswd(auth_user, auth_pass)
|
||||||
|
logger.info("Management proxy set to '%s'", domain)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -328,17 +355,11 @@ def set_management_proxy(
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def write_htpasswd(user, password) -> None:
|
def write_htpasswd(user: str, password: str) -> None:
|
||||||
"""
|
"""Append (or create) an htpasswd entry for *user*."""
|
||||||
Append (or create) an htpasswd entry for *user*.
|
ensure_dirs(DATA_DIR)
|
||||||
|
|
||||||
Uses passlib's apache_passwd hash so the file remains portable.
|
|
||||||
If passlib is unavailable falls back to Python's built-in crypt.
|
|
||||||
If the user already exists the line is replaced in-place.
|
|
||||||
"""
|
|
||||||
_ensure_dirs()
|
|
||||||
hashed = _hash_password(password)
|
hashed = _hash_password(password)
|
||||||
existing = {}
|
existing: dict[str, str] = {}
|
||||||
if HTPASSWD_FILE.exists():
|
if HTPASSWD_FILE.exists():
|
||||||
with open(HTPASSWD_FILE) as f:
|
with open(HTPASSWD_FILE) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
@@ -359,7 +380,7 @@ def write_htpasswd(user, password) -> None:
|
|||||||
os.replace(tmp, HTPASSWD_FILE)
|
os.replace(tmp, HTPASSWD_FILE)
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password):
|
def _hash_password(password: str) -> str:
|
||||||
try:
|
try:
|
||||||
from passlib.hash import apache_passwd
|
from passlib.hash import apache_passwd
|
||||||
|
|
||||||
@@ -369,3 +390,23 @@ def _hash_password(password):
|
|||||||
|
|
||||||
salt = os.urandom(16).hex()[:16]
|
salt = os.urandom(16).hex()[:16]
|
||||||
return _crypt.crypt(password, f"$5${salt}")
|
return _crypt.crypt(password, f"$5${salt}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"add_domain",
|
||||||
|
"apply",
|
||||||
|
"generate_server_conf",
|
||||||
|
"get_config",
|
||||||
|
"get_domains",
|
||||||
|
"remove_domain",
|
||||||
|
"save_config",
|
||||||
|
"set_management_proxy",
|
||||||
|
"test_config",
|
||||||
|
"update_domain",
|
||||||
|
"write_acme_challenge",
|
||||||
|
"write_all_sites",
|
||||||
|
"write_htpasswd",
|
||||||
|
"write_include_file",
|
||||||
|
"write_site",
|
||||||
|
"write_ssl_snippet",
|
||||||
|
]
|
||||||
|
|||||||
+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
|
Generates wg-quick configurations, manages peers, and controls
|
||||||
the WireGuard tunnel interface.
|
the WireGuard tunnel interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json")
|
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||||
WG_QUICK_BIN = "wg-quick"
|
WG_QUICK_BIN = "wg-quick"
|
||||||
WG_BIN = "wg"
|
WG_BIN = "wg"
|
||||||
@@ -26,95 +30,41 @@ ENV = Environment(
|
|||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
# --- Helpers ---
|
"interface": {
|
||||||
|
"name": "wg0",
|
||||||
|
"listen_port": 51820,
|
||||||
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
"private_key": "",
|
||||||
"""Run a command via sudo and return the completed process."""
|
"public_key": "",
|
||||||
return subprocess.run(
|
"addresses": ["10.137.0.1/24"],
|
||||||
["sudo", *cmd],
|
"post_up": None,
|
||||||
capture_output=True,
|
"post_down": None,
|
||||||
text=True,
|
},
|
||||||
check=check,
|
"peers": {},
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
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": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Core config persistence ---
|
# --- Core config persistence ---
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Load the current WireGuard configuration from the JSON store.
|
"""Load the current WireGuard configuration from the JSON store."""
|
||||||
|
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
|
||||||
Returns the full config dict. If the file does not exist or is
|
|
||||||
unreadable, returns the default (empty) config skeleton.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(CONFIG_PATH) as f:
|
|
||||||
cfg = json.load(f)
|
|
||||||
# Backfill keys that might be missing from older snapshots.
|
|
||||||
defaults = _default_config()
|
|
||||||
cfg.setdefault("interface", defaults["interface"])
|
|
||||||
cfg["interface"].setdefault("name", defaults["interface"]["name"])
|
|
||||||
cfg["interface"].setdefault("listen_port", defaults["interface"]["listen_port"])
|
|
||||||
cfg["interface"].setdefault("private_key", defaults["interface"]["private_key"])
|
|
||||||
cfg["interface"].setdefault("public_key", defaults["interface"]["public_key"])
|
|
||||||
cfg["interface"].setdefault("addresses", defaults["interface"]["addresses"])
|
|
||||||
cfg["interface"].setdefault("post_up", defaults["interface"]["post_up"])
|
|
||||||
cfg["interface"].setdefault("post_down", defaults["interface"]["post_down"])
|
|
||||||
cfg.setdefault("peers", {})
|
|
||||||
return cfg
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
return _default_config()
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict) -> None:
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
"""Persist *cfg* to the JSON store atomically.
|
"""Persist *cfg* to the JSON store atomically."""
|
||||||
|
save_json(CONFIG_PATH, cfg)
|
||||||
Writes to a temporary file in the same directory and then renames
|
|
||||||
to avoid partial reads on crash.
|
|
||||||
"""
|
|
||||||
_ensure_dir(CONFIG_PATH)
|
|
||||||
tmp = CONFIG_PATH + ".tmp"
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(cfg, f, indent=4)
|
|
||||||
f.write("\n")
|
|
||||||
os.replace(tmp, CONFIG_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Key generation ---
|
# --- Key generation ---
|
||||||
|
|
||||||
|
|
||||||
def generate_keypair() -> tuple[str, str]:
|
def generate_keypair() -> tuple[str, str]:
|
||||||
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
|
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
|
||||||
|
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||||
Returns:
|
|
||||||
``(private_key, public_key)`` as two 43-character base64 strings.
|
|
||||||
"""
|
|
||||||
res = _run([WG_BIN, "genkey"])
|
|
||||||
private_key = res.stdout.strip()
|
private_key = res.stdout.strip()
|
||||||
res2 = _run([WG_BIN, "pubkey"], input=private_key)
|
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
|
||||||
public_key = res2.stdout.strip()
|
public_key = res2.stdout.strip()
|
||||||
return private_key, public_key
|
return private_key, public_key
|
||||||
|
|
||||||
@@ -122,7 +72,7 @@ def generate_keypair() -> tuple[str, str]:
|
|||||||
# --- wg0.conf generation ---
|
# --- wg0.conf generation ---
|
||||||
|
|
||||||
|
|
||||||
def generate_conf(cfg: dict) -> str:
|
def generate_conf(cfg: dict[str, Any]) -> str:
|
||||||
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
|
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
|
||||||
tmpl = ENV.get_template("wireguard.conf")
|
tmpl = ENV.get_template("wireguard.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
@@ -139,7 +89,7 @@ def apply() -> None:
|
|||||||
"""Write the current config to disk and bring the tunnel up with wg-quick."""
|
"""Write the current config to disk and bring the tunnel up with wg-quick."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
conf_text = generate_conf(cfg)
|
conf_text = generate_conf(cfg)
|
||||||
save_config(cfg) # ensure latest state persisted
|
save_config(cfg)
|
||||||
|
|
||||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||||
local_dir.mkdir(parents=True, exist_ok=True)
|
local_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -147,61 +97,46 @@ def apply() -> None:
|
|||||||
with open(local_tmp, "w") as f:
|
with open(local_tmp, "w") as f:
|
||||||
f.write(conf_text)
|
f.write(conf_text)
|
||||||
os.chmod(local_tmp, 0o600)
|
os.chmod(local_tmp, 0o600)
|
||||||
_run(["cp", "--", str(local_tmp), WG_CONF_PATH])
|
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||||
_run(["chown", "root:root", WG_CONF_PATH], check=False)
|
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||||
local_tmp.unlink(missing_ok=True)
|
local_tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
|
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||||
|
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||||
|
|
||||||
|
|
||||||
def down() -> None:
|
def down() -> None:
|
||||||
"""Bring the WireGuard tunnel interface down."""
|
"""Bring the WireGuard tunnel interface down."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
name = cfg["interface"]["name"]
|
name = cfg["interface"]["name"]
|
||||||
_run([WG_QUICK_BIN, "down", name])
|
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||||
|
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||||
|
|
||||||
|
|
||||||
# --- Status ---
|
# --- Status ---
|
||||||
|
|
||||||
|
|
||||||
def status() -> dict:
|
def status() -> dict[str, Any]:
|
||||||
"""Query the live tunnel state via ``wg show``.
|
"""Query the live tunnel state via ``wg show``."""
|
||||||
|
|
||||||
Returns a dict with keys:
|
|
||||||
- ``up`` (bool) - whether the interface is currently up.
|
|
||||||
- ``interface`` (dict) - name, public key, listen port, fwmark.
|
|
||||||
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
name = cfg["interface"]["name"]
|
name = cfg["interface"]["name"]
|
||||||
result = {
|
result: dict[str, Any] = {
|
||||||
"up": False,
|
"up": False,
|
||||||
"interface": {},
|
"interface": {},
|
||||||
"peers": [],
|
"peers": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = _run([WG_BIN, "show", name], check=False)
|
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||||
if proc.returncode != 0:
|
if res.returncode != 0:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
raw = proc.stdout.strip()
|
raw = res.stdout.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Parse the wg show output.
|
current_peer: dict[str, Any] | None = None
|
||||||
# Format (multi-section, separated by blank lines or interleaved):
|
peers: list[dict[str, Any]] = []
|
||||||
# interface:
|
|
||||||
# public key: ...
|
|
||||||
# listening port: ...
|
|
||||||
# peer: <key>
|
|
||||||
# endpoint: ...
|
|
||||||
# allowed ips: ...
|
|
||||||
# latest handshake: ...
|
|
||||||
# transfer: ...
|
|
||||||
# persistent-keepalive: ...
|
|
||||||
current_peer = None
|
|
||||||
peers: list[dict] = []
|
|
||||||
|
|
||||||
for line in raw.splitlines():
|
for line in raw.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -286,25 +221,8 @@ def add_peer(
|
|||||||
allowed_ips: list[str] | None = None,
|
allowed_ips: list[str] | None = None,
|
||||||
persistent_keepalive: int | None = None,
|
persistent_keepalive: int | None = None,
|
||||||
preshared_key: str | None = None,
|
preshared_key: str | None = None,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
"""Add (or update) a peer in the configuration.
|
"""Add (or update) a peer in the configuration."""
|
||||||
|
|
||||||
If the peer has no public key yet, one will be generated
|
|
||||||
together with a matching private key (useful for client provi-
|
|
||||||
sioning). The returned dict mirrors the stored peer record
|
|
||||||
with an additional ``private_key`` field so the caller can
|
|
||||||
distribute the client credentials.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Human-readable identifier (dict key in config).
|
|
||||||
endpoint: e.g. ``203.0.113.1:51820``.
|
|
||||||
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
|
|
||||||
persistent_keepalive: Interval in seconds (or ``None``).
|
|
||||||
preshared_key: Optional PSK (base64 string).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The peer dict as stored, plus ``private_key`` for client use.
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
peers = cfg.setdefault("peers", {})
|
peers = cfg.setdefault("peers", {})
|
||||||
allowed_ips = allowed_ips or []
|
allowed_ips = allowed_ips or []
|
||||||
@@ -316,23 +234,23 @@ def add_peer(
|
|||||||
peer["persistent_keepalive"] = persistent_keepalive
|
peer["persistent_keepalive"] = persistent_keepalive
|
||||||
if preshared_key is not None:
|
if preshared_key is not None:
|
||||||
peer["preshared_key"] = preshared_key
|
peer["preshared_key"] = preshared_key
|
||||||
|
logger.info("WireGuard peer '%s' updated", name)
|
||||||
else:
|
else:
|
||||||
# Generate a key pair for the new peer.
|
|
||||||
priv, pub = generate_keypair()
|
priv, pub = generate_keypair()
|
||||||
peer = {
|
peer = {
|
||||||
"public_key": pub,
|
"public_key": pub,
|
||||||
"private_key": priv, # stored so we can hand it to the client
|
"private_key": priv,
|
||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"allowed_ips": allowed_ips,
|
"allowed_ips": allowed_ips,
|
||||||
"persistent_keepalive": persistent_keepalive,
|
"persistent_keepalive": persistent_keepalive,
|
||||||
"preshared_key": preshared_key,
|
"preshared_key": preshared_key,
|
||||||
}
|
}
|
||||||
peers[name] = peer
|
peers[name] = peer
|
||||||
|
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
|
||||||
|
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
|
||||||
# Return a copy that includes the private key (safe — used for provisioning).
|
|
||||||
peer_out = dict(peer)
|
peer_out = dict(peer)
|
||||||
|
peer_out.pop("private_key", None)
|
||||||
return peer_out
|
return peer_out
|
||||||
|
|
||||||
|
|
||||||
@@ -341,33 +259,23 @@ def remove_peer(name: str) -> None:
|
|||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg.setdefault("peers", {}).pop(name, None)
|
cfg.setdefault("peers", {}).pop(name, None)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("WireGuard peer '%s' removed", name)
|
||||||
|
|
||||||
|
|
||||||
def get_peers() -> list[dict]:
|
def get_peers() -> list[dict[str, Any]]:
|
||||||
"""List all configured peers (from the JSON store, *not* live).
|
"""List all configured peers (from the JSON store, *not* live)."""
|
||||||
|
|
||||||
Returns a list of dicts. Each dict includes ``name`` and all
|
|
||||||
stored fields **except** ``private_key`` (not exposed here).
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
peers = []
|
peers: list[dict[str, Any]] = []
|
||||||
for name, info in cfg.get("peers", {}).items():
|
for name, info in cfg.get("peers", {}).items():
|
||||||
entry = dict(info)
|
entry = dict(info)
|
||||||
entry["name"] = name
|
entry["name"] = name
|
||||||
# Strip private key from the public listing.
|
|
||||||
entry.pop("private_key", None)
|
entry.pop("private_key", None)
|
||||||
peers.append(entry)
|
peers.append(entry)
|
||||||
return peers
|
return peers
|
||||||
|
|
||||||
|
|
||||||
def get_peer_status() -> list[dict]:
|
def get_peer_status() -> list[dict[str, Any]]:
|
||||||
"""Return live peer status from ``wg show``.
|
"""Return live peer status from ``wg show``."""
|
||||||
|
|
||||||
Each element contains:
|
|
||||||
- ``public_key``, ``endpoint``, ``allowed_ips``,
|
|
||||||
``latest_handshake``, ``transfer_received``,
|
|
||||||
``transfer_sent``, ``persistent_keepalive``.
|
|
||||||
"""
|
|
||||||
st = status()
|
st = status()
|
||||||
return st.get("peers", [])
|
return st.get("peers", [])
|
||||||
|
|
||||||
@@ -401,7 +309,7 @@ def generate_client_conf(
|
|||||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||||
|
|
||||||
tmpl = ENV.get_template("wireguard-client.conf")
|
tmpl = ENV.get_template("wireguard-client.conf")
|
||||||
return tmpl.render(
|
conf = tmpl.render(
|
||||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
peer_name=peer_name,
|
peer_name=peer_name,
|
||||||
client_priv=client_priv,
|
client_priv=client_priv,
|
||||||
@@ -412,29 +320,25 @@ def generate_client_conf(
|
|||||||
preshared_key=peer.get("preshared_key"),
|
preshared_key=peer.get("preshared_key"),
|
||||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||||
)
|
)
|
||||||
|
logger.info("Client config generated for peer '%s'", peer_name)
|
||||||
|
return conf
|
||||||
|
|
||||||
|
|
||||||
# --- Interface-level setters ---
|
# --- Interface-level setters ---
|
||||||
|
|
||||||
|
|
||||||
def set_listen_port(port: int) -> None:
|
def set_listen_port(port: int) -> None:
|
||||||
"""Update the server listen port in the stored configuration.
|
"""Update the server listen port in the stored configuration."""
|
||||||
|
|
||||||
Does **not** hot-reload; call :func:`apply` afterwards to
|
|
||||||
activate the change.
|
|
||||||
"""
|
|
||||||
if not (1 <= port <= 65535):
|
if not (1 <= port <= 65535):
|
||||||
raise ValueError("Listen port must be in range 1..65535")
|
raise ValueError("Listen port must be in range 1..65535")
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg["interface"]["listen_port"] = port
|
cfg["interface"]["listen_port"] = port
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("WireGuard listen port set to %d", port)
|
||||||
|
|
||||||
|
|
||||||
def set_post_up(cmd: str | None) -> None:
|
def set_post_up(cmd: str | None) -> None:
|
||||||
"""Set (or clear) the PostUp hook command.
|
"""Set (or clear) the PostUp hook command."""
|
||||||
|
|
||||||
The command is passed verbatim to the generated wg0.conf.
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
cfg["interface"]["post_up"] = cmd
|
cfg["interface"]["post_up"] = cmd
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
@@ -450,40 +354,28 @@ def set_post_down(cmd: str | None) -> None:
|
|||||||
# --- Initialise ---
|
# --- Initialise ---
|
||||||
|
|
||||||
|
|
||||||
def initialize() -> dict:
|
def initialize() -> dict[str, Any]:
|
||||||
"""Perform first-time WireGuard setup.
|
"""Perform first-time WireGuard setup."""
|
||||||
|
|
||||||
Generates a fresh server key pair, writes the initial config
|
|
||||||
to disk, and returns the full config dict.
|
|
||||||
|
|
||||||
Call this once at appliance bootstrapping time. It will
|
|
||||||
**not** overwrite an existing config that already has a
|
|
||||||
non-empty private key.
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
|
|
||||||
if cfg["interface"].get("private_key"):
|
if cfg["interface"].get("private_key"):
|
||||||
# Already initialised — return existing config.
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
priv, pub = generate_keypair()
|
priv, pub = generate_keypair()
|
||||||
cfg["interface"]["private_key"] = priv
|
cfg["interface"]["private_key"] = priv
|
||||||
cfg["interface"]["public_key"] = pub
|
cfg["interface"]["public_key"] = pub
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
# --- Utility: parse wg show into structured peer map ---
|
# --- Utility: parse wg show into structured peer map ---
|
||||||
|
|
||||||
|
|
||||||
def _parse_wg_show(output: str) -> dict:
|
def _parse_wg_show(output: str) -> dict[str, Any]:
|
||||||
"""Internal parser for ``wg show`` multiline output.
|
"""Internal parser for ``wg show`` multiline output."""
|
||||||
|
peers: dict[str, dict[str, Any]] = {}
|
||||||
Returns a dict keyed by peer public key with parsed values.
|
current: dict[str, Any] | None = None
|
||||||
Used internally; ``status()`` is the public interface.
|
|
||||||
"""
|
|
||||||
peers: dict = {}
|
|
||||||
current = None
|
|
||||||
|
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -509,3 +401,24 @@ def _parse_wg_show(output: str) -> dict:
|
|||||||
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
||||||
|
|
||||||
return peers
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_CONFIG",
|
||||||
|
"add_peer",
|
||||||
|
"apply",
|
||||||
|
"down",
|
||||||
|
"generate_client_conf",
|
||||||
|
"generate_conf",
|
||||||
|
"generate_keypair",
|
||||||
|
"get_config",
|
||||||
|
"get_peer_status",
|
||||||
|
"get_peers",
|
||||||
|
"initialize",
|
||||||
|
"remove_peer",
|
||||||
|
"save_config",
|
||||||
|
"set_listen_port",
|
||||||
|
"set_post_down",
|
||||||
|
"set_post_up",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
|||||||
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;
|
listen [::]:80;
|
||||||
server_name {{ domain }};
|
server_name {{ domain }};
|
||||||
|
|
||||||
|
# ACME HTTP-01 challenge
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root {{ acme_webroot }};
|
||||||
|
}
|
||||||
|
|
||||||
# Redirect all HTTP traffic to HTTPS
|
# Redirect all HTTP traffic to HTTPS
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Defaults:{{ USER_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/
|
|||||||
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
|
||||||
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
||||||
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
|
||||||
# Dnsmasq management
|
# Dnsmasq management
|
||||||
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ User={{ USER_NAME }}
|
|||||||
WorkingDirectory={{ PROJECT_DIR }}
|
WorkingDirectory={{ PROJECT_DIR }}
|
||||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
Environment=HOME={{ PROJECT_DIR }}
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
ExecStart=/usr/local/bin/acme.sh --cron --home {{ ACME_HOME }}
|
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Environment=HOME={{ PROJECT_DIR }}
|
|||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
@@ -34,9 +34,8 @@ LockPersonality=yes
|
|||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
|
|
||||||
ProtectHome=read-only
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
IPAddressDeny=all
|
IPAddressDeny=any
|
||||||
IPAddressAllow=localhost
|
IPAddressAllow=localhost
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+255
-19
@@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
API integration tests — all blueprints tested via a single Flask app fixture.
|
||||||
|
"""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -24,6 +28,11 @@ def client():
|
|||||||
return app.test_client()
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Firewall
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestFirewallListZones:
|
class TestFirewallListZones:
|
||||||
@patch("webui.api.firewall.get_active_zones")
|
@patch("webui.api.firewall.get_active_zones")
|
||||||
@patch("webui.api.firewall.get_available_zones")
|
@patch("webui.api.firewall.get_available_zones")
|
||||||
@@ -100,7 +109,7 @@ class TestFirewallDeleteZone:
|
|||||||
class TestFirewallRichRules:
|
class TestFirewallRichRules:
|
||||||
@patch("webui.api.firewall.add_rich_rule")
|
@patch("webui.api.firewall.add_rich_rule")
|
||||||
def test_add(self, mock_add, client):
|
def test_add(self, mock_add, client):
|
||||||
mock_add.return_value = None
|
mock_add.return_value = {"id": "abc123", "rule": "rule accept"}
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/firewall/rich-rules",
|
"/api/firewall/rich-rules",
|
||||||
json={
|
json={
|
||||||
@@ -111,18 +120,37 @@ class TestFirewallRichRules:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data["ok"] is True
|
assert data["ok"] is True
|
||||||
|
assert data["data"]["id"] == "abc123"
|
||||||
|
|
||||||
def test_missing_fields(self, client):
|
def test_missing_fields(self, client):
|
||||||
resp = client.post("/api/firewall/rich-rules", json={})
|
resp = client.post("/api/firewall/rich-rules", json={})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
@patch("webui.api.firewall.get_rich_rules")
|
@patch("webui.api.firewall.get_rich_rules")
|
||||||
def test_list(self, mock_list, client):
|
@patch("webui.api.firewall.get_config")
|
||||||
mock_list.return_value = ["rule1", "rule2"]
|
def test_list(self, mock_cfg, mock_list, client):
|
||||||
|
mock_list.return_value = ["rule1"]
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}
|
||||||
|
}
|
||||||
resp = client.get("/api/firewall/rich-rules/public")
|
resp = client.get("/api/firewall/rich-rules/public")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data["data"] == ["rule1", "rule2"]
|
assert isinstance(data["data"], list)
|
||||||
|
|
||||||
|
@patch("webui.api.firewall.remove_rich_rule_by_id")
|
||||||
|
def test_remove_by_id(self, mock_remove, client):
|
||||||
|
mock_remove.return_value = None
|
||||||
|
resp = client.delete("/api/firewall/rich-rules/public/abc123")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
@patch("webui.api.firewall.remove_rich_rule_by_id")
|
||||||
|
def test_remove_not_found(self, mock_remove, client):
|
||||||
|
mock_remove.side_effect = ValueError("not found")
|
||||||
|
resp = client.delete("/api/firewall/rich-rules/public/abc123")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestFirewallServices:
|
class TestFirewallServices:
|
||||||
@@ -161,12 +189,14 @@ class TestFirewallMasquerade:
|
|||||||
class TestFirewallForwardPort:
|
class TestFirewallForwardPort:
|
||||||
@patch("webui.api.firewall.add_forward_port")
|
@patch("webui.api.firewall.add_forward_port")
|
||||||
def test_add(self, mock_add, client):
|
def test_add(self, mock_add, client):
|
||||||
mock_add.return_value = None
|
mock_add.return_value = {"id": "fp1", "port": 443, "proto": "tcp"}
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/firewall/forward-port",
|
"/api/firewall/forward-port",
|
||||||
json={"zone": "public", "port": 443, "proto": "tcp"},
|
json={"zone": "public", "port": 443, "proto": "tcp"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["data"]["id"] == "fp1"
|
||||||
|
|
||||||
def test_missing_fields(self, client):
|
def test_missing_fields(self, client):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -175,6 +205,25 @@ class TestFirewallForwardPort:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@patch("webui.api.firewall.remove_forward_port_by_id")
|
||||||
|
def test_remove_by_id(self, mock_remove, client):
|
||||||
|
mock_remove.return_value = None
|
||||||
|
resp = client.delete("/api/firewall/forward-port/public/443/tcp")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
@patch("webui.api.firewall.remove_forward_port_by_id")
|
||||||
|
def test_remove_not_found(self, mock_remove, client):
|
||||||
|
mock_remove.side_effect = ValueError("not found")
|
||||||
|
resp = client.delete("/api/firewall/forward-port/public/999/tcp")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DHCP
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestDhcpConfig:
|
class TestDhcpConfig:
|
||||||
@patch("webui.api.dhcp.get_config")
|
@patch("webui.api.dhcp.get_config")
|
||||||
@@ -192,6 +241,67 @@ class TestDhcpConfig:
|
|||||||
assert data is not None
|
assert data is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDhcpApply:
|
||||||
|
@patch("webui.api.dhcp.apply_config")
|
||||||
|
def test_apply(self, mock_apply, client):
|
||||||
|
mock_apply.return_value = None
|
||||||
|
resp = client.post("/api/dhcp/apply")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestDhcpStatus:
|
||||||
|
@patch("webui.api.dhcp.dnsmasq_status")
|
||||||
|
def test_success(self, mock_status, client):
|
||||||
|
mock_status.return_value = {"service_active": True}
|
||||||
|
resp = client.get("/api/dhcp/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["data"]["service_active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestDhcpRanges:
|
||||||
|
@patch("webui.api.dhcp.set_dhcp_range")
|
||||||
|
def test_add_range(self, mock_set, client):
|
||||||
|
mock_set.return_value = None
|
||||||
|
resp = client.post(
|
||||||
|
"/api/dhcp/ranges",
|
||||||
|
json={
|
||||||
|
"interface": "eth0",
|
||||||
|
"start": "192.168.1.100",
|
||||||
|
"end": "192.168.1.200",
|
||||||
|
"lease_time": "2h",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
def test_add_range_missing_fields(self, client):
|
||||||
|
resp = client.post("/api/dhcp/ranges", json={"start": "192.168.1.100"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@patch("webui.api.dhcp.remove_dhcp_range")
|
||||||
|
def test_remove_range(self, mock_remove, client):
|
||||||
|
mock_remove.return_value = None
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/dhcp/ranges",
|
||||||
|
json={
|
||||||
|
"interface": "eth0",
|
||||||
|
"start": "192.168.1.100",
|
||||||
|
"end": "192.168.1.200",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
def test_remove_range_missing_fields(self, client):
|
||||||
|
resp = client.delete("/api/dhcp/ranges", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
class TestDhcpStaticLease:
|
class TestDhcpStaticLease:
|
||||||
@patch("webui.api.dhcp.add_static_lease")
|
@patch("webui.api.dhcp.add_static_lease")
|
||||||
def test_add(self, mock_add, client):
|
def test_add(self, mock_add, client):
|
||||||
@@ -213,19 +323,15 @@ class TestDhcpStaticLease:
|
|||||||
"dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]}
|
"dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]}
|
||||||
}
|
}
|
||||||
mock_remove.return_value = None
|
mock_remove.return_value = None
|
||||||
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
|
resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
@patch("webui.api.dhcp.get_config")
|
@patch("webui.api.dhcp.get_config")
|
||||||
def test_remove_not_found(self, mock_get, client):
|
def test_remove_not_found(self, mock_get, client):
|
||||||
mock_get.return_value = {"dhcp": {"static_leases": []}}
|
mock_get.return_value = {"dhcp": {"static_leases": []}}
|
||||||
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
|
resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_remove_missing_mac(self, client):
|
|
||||||
resp = client.delete("/api/dhcp/static-lease")
|
|
||||||
assert resp.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
class TestDhcpDnsRecord:
|
class TestDhcpDnsRecord:
|
||||||
@patch("webui.api.dhcp.add_dns_record")
|
@patch("webui.api.dhcp.add_dns_record")
|
||||||
@@ -241,6 +347,27 @@ class TestDhcpDnsRecord:
|
|||||||
resp = client.post("/api/dhcp/dns-record", json={})
|
resp = client.post("/api/dhcp/dns-record", json={})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@patch("webui.api.dhcp.remove_dns_record")
|
||||||
|
@patch("webui.api.dhcp.get_config")
|
||||||
|
def test_remove(self, mock_get, mock_remove, client):
|
||||||
|
mock_get.return_value = {
|
||||||
|
"dns": {"custom_records": [{"name": "host.local", "address": "10.0.0.10"}]}
|
||||||
|
}
|
||||||
|
mock_remove.return_value = None
|
||||||
|
resp = client.delete("/api/dhcp/dns-record/host.local")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@patch("webui.api.dhcp.get_config")
|
||||||
|
def test_remove_not_found(self, mock_get, client):
|
||||||
|
mock_get.return_value = {"dns": {"custom_records": []}}
|
||||||
|
resp = client.delete("/api/dhcp/dns-record/host.local")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Proxy
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestProxyDomains:
|
class TestProxyDomains:
|
||||||
@patch("webui.api.proxy.get_domains")
|
@patch("webui.api.proxy.get_domains")
|
||||||
@@ -271,6 +398,30 @@ class TestProxyApply:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestProxyTest:
|
||||||
|
@patch("webui.api.proxy.test_config")
|
||||||
|
def test_valid(self, mock_test, client):
|
||||||
|
mock_test.return_value = (True, "syntax ok")
|
||||||
|
resp = client.post("/api/proxy/test")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["data"]["valid"] is True
|
||||||
|
|
||||||
|
@patch("webui.api.proxy.test_config")
|
||||||
|
def test_invalid(self, mock_test, client):
|
||||||
|
mock_test.return_value = (False, "error msg")
|
||||||
|
resp = client.post("/api/proxy/test")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert data["error"] == "error msg"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Certs
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestCertsList:
|
class TestCertsList:
|
||||||
@patch("webui.api.certs.list_certs")
|
@patch("webui.api.certs.list_certs")
|
||||||
def test_list(self, mock_list, client):
|
def test_list(self, mock_list, client):
|
||||||
@@ -297,6 +448,11 @@ class TestCertsEmail:
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WireGuard
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestWireguardConfig:
|
class TestWireguardConfig:
|
||||||
@patch("webui.api.wireguard.get_config")
|
@patch("webui.api.wireguard.get_config")
|
||||||
def test_get(self, mock_get, client):
|
def test_get(self, mock_get, client):
|
||||||
@@ -315,6 +471,38 @@ class TestWireguardConfig:
|
|||||||
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@patch("webui.api.wireguard.save_config")
|
||||||
|
@patch("webui.api.wireguard.get_config")
|
||||||
|
def test_post_strips_private_key(self, mock_get, mock_save, client):
|
||||||
|
mock_get.return_value = {
|
||||||
|
"interface": {"name": "wg0", "private_key": "existing"},
|
||||||
|
"peers": {},
|
||||||
|
}
|
||||||
|
mock_save.return_value = None
|
||||||
|
resp = client.post(
|
||||||
|
"/api/wireguard/config",
|
||||||
|
json={"interface": {"name": "wg0", "private_key": "secret"}, "peers": {}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert saved["interface"]["private_key"] == "existing"
|
||||||
|
|
||||||
|
@patch("webui.api.wireguard.save_config")
|
||||||
|
@patch("webui.api.wireguard.get_config")
|
||||||
|
def test_patch_strips_private_key(self, mock_get, mock_save, client):
|
||||||
|
mock_get.return_value = {
|
||||||
|
"interface": {"name": "wg0", "private_key": "existing"},
|
||||||
|
"peers": {},
|
||||||
|
}
|
||||||
|
mock_save.return_value = None
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/wireguard/config",
|
||||||
|
json={"interface": {"name": "wg1", "private_key": "injected"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
saved = mock_save.call_args[0][0]
|
||||||
|
assert saved.get("interface", {}).get("private_key") == "existing"
|
||||||
|
|
||||||
|
|
||||||
class TestWireguardPeers:
|
class TestWireguardPeers:
|
||||||
@patch("webui.api.wireguard.get_peers")
|
@patch("webui.api.wireguard.get_peers")
|
||||||
@@ -325,9 +513,12 @@ class TestWireguardPeers:
|
|||||||
|
|
||||||
@patch("webui.api.wireguard.add_peer")
|
@patch("webui.api.wireguard.add_peer")
|
||||||
def test_add(self, mock_add, client):
|
def test_add(self, mock_add, client):
|
||||||
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
|
mock_add.return_value = {
|
||||||
|
"name": "client1",
|
||||||
|
"public_key": "pub",
|
||||||
|
}
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/wireguard/add-peer",
|
"/api/wireguard/peers",
|
||||||
json={"name": "client1"},
|
json={"name": "client1"},
|
||||||
)
|
)
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
@@ -335,21 +526,31 @@ class TestWireguardPeers:
|
|||||||
assert "private_key" not in data["data"]
|
assert "private_key" not in data["data"]
|
||||||
|
|
||||||
def test_add_missing_name(self, client):
|
def test_add_missing_name(self, client):
|
||||||
resp = client.post("/api/wireguard/add-peer", json={})
|
resp = client.post("/api/wireguard/peers", json={})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@patch("webui.api.wireguard.remove_peer")
|
||||||
|
@patch("webui.api.wireguard.get_config")
|
||||||
|
def test_remove_by_name(self, mock_get, mock_remove, client):
|
||||||
|
mock_get.return_value = {"peers": {"client1": {}}}
|
||||||
|
mock_remove.return_value = None
|
||||||
|
resp = client.delete("/api/wireguard/peers/client1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@patch("webui.api.wireguard.get_config")
|
||||||
|
def test_remove_not_found(self, mock_get, client):
|
||||||
|
mock_get.return_value = {"peers": {}}
|
||||||
|
resp = client.delete("/api/wireguard/peers/unknown")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestWireguardInitialize:
|
class TestWireguardInitialize:
|
||||||
@patch("webui.api.wireguard.initialize")
|
@patch("webui.api.wireguard.initialize")
|
||||||
def test_initialize(self, mock_init, client):
|
def test_initialize(self, mock_init, client):
|
||||||
mock_init.return_value = {
|
mock_init.return_value = None
|
||||||
"interface": {"name": "wg0", "private_key": "priv"},
|
|
||||||
"peers": {},
|
|
||||||
}
|
|
||||||
resp = client.post("/api/wireguard/initialize")
|
resp = client.post("/api/wireguard/initialize")
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data["ok"] is True
|
assert data["ok"] is True
|
||||||
assert data["data"] is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestWireguardGenerateClient:
|
class TestWireguardGenerateClient:
|
||||||
@@ -366,6 +567,41 @@ class TestWireguardStatus:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestWireguardUp:
|
||||||
|
@patch("webui.api.wireguard.apply")
|
||||||
|
def test_up_starts_tunnel(self, mock_apply, client):
|
||||||
|
mock_apply.return_value = None
|
||||||
|
resp = client.post("/api/wireguard/up")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@patch("webui.api.wireguard.apply")
|
||||||
|
def test_up_error(self, mock_apply, client):
|
||||||
|
mock_apply.side_effect = RuntimeError("interface down")
|
||||||
|
resp = client.post("/api/wireguard/up")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestWireguardDown:
|
||||||
|
@patch("webui.api.wireguard.down")
|
||||||
|
def test_down_stops_tunnel(self, mock_down, client):
|
||||||
|
mock_down.return_value = None
|
||||||
|
resp = client.post("/api/wireguard/down")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestWireguardApply:
|
||||||
|
@patch("webui.api.wireguard.apply")
|
||||||
|
def test_apply(self, mock_apply, client):
|
||||||
|
mock_apply.return_value = None
|
||||||
|
resp = client.post("/api/wireguard/apply")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestResponseHelpers:
|
class TestResponseHelpers:
|
||||||
@patch("webui.api.firewall.get_active_zones")
|
@patch("webui.api.firewall.get_active_zones")
|
||||||
@patch("webui.api.firewall.get_available_zones")
|
@patch("webui.api.firewall.get_available_zones")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lib import dnsmasq
|
from lib import common, dnsmasq
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -29,24 +29,24 @@ class TestDeepMerge:
|
|||||||
def test_merge_flat_dicts(self):
|
def test_merge_flat_dicts(self):
|
||||||
base = {"a": 1, "b": 2}
|
base = {"a": 1, "b": 2}
|
||||||
override = {"b": 3, "c": 4}
|
override = {"b": 3, "c": 4}
|
||||||
result = dnsmasq._deep_merge(base, override)
|
result = common.deep_merge(base, override)
|
||||||
assert result == {"a": 1, "b": 3, "c": 4}
|
assert result == {"a": 1, "b": 3, "c": 4}
|
||||||
|
|
||||||
def test_merge_nested_dicts(self):
|
def test_merge_nested_dicts(self):
|
||||||
base = {"a": {"x": 1, "y": 2}}
|
base = {"a": {"x": 1, "y": 2}}
|
||||||
override = {"a": {"y": 3, "z": 4}}
|
override = {"a": {"y": 3, "z": 4}}
|
||||||
result = dnsmasq._deep_merge(base, override)
|
result = common.deep_merge(base, override)
|
||||||
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
||||||
|
|
||||||
def test_merge_non_dict_override(self):
|
def test_merge_non_dict_override(self):
|
||||||
base = {"a": {"x": 1}}
|
base = {"a": {"x": 1}}
|
||||||
override = {"a": "flat"}
|
override = {"a": "flat"}
|
||||||
result = dnsmasq._deep_merge(base, override)
|
result = common.deep_merge(base, override)
|
||||||
assert result == {"a": "flat"}
|
assert result == {"a": "flat"}
|
||||||
|
|
||||||
|
|
||||||
class TestGetConfig:
|
class TestGetConfig:
|
||||||
@patch("lib.dnsmasq._load_json")
|
@patch("lib.dnsmasq.load_json")
|
||||||
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
|
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
result = dnsmasq.get_config()
|
result = dnsmasq.get_config()
|
||||||
@@ -54,7 +54,7 @@ class TestGetConfig:
|
|||||||
assert "dns" in result
|
assert "dns" in result
|
||||||
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
||||||
|
|
||||||
@patch("lib.dnsmasq._load_json")
|
@patch("lib.dnsmasq.load_json")
|
||||||
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
|
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
|
||||||
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
||||||
result = dnsmasq.get_config()
|
result = dnsmasq.get_config()
|
||||||
|
|||||||
+293
-10
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
|
|||||||
|
|
||||||
|
|
||||||
class TestGetActiveZones:
|
class TestGetActiveZones:
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_parses_active_zones(self, mock_run):
|
def test_parses_active_zones(self, mock_run):
|
||||||
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
|
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
|
||||||
result = firewall.get_active_zones()
|
result = firewall.get_active_zones()
|
||||||
@@ -35,13 +35,13 @@ class TestGetActiveZones:
|
|||||||
"internal": ["eth1", "eth2"],
|
"internal": ["eth1", "eth2"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_empty_output(self, mock_run):
|
def test_empty_output(self, mock_run):
|
||||||
mock_run.return_value = ""
|
mock_run.return_value = ""
|
||||||
result = firewall.get_active_zones()
|
result = firewall.get_active_zones()
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_zone_with_no_interfaces(self, mock_run):
|
def test_zone_with_no_interfaces(self, mock_run):
|
||||||
mock_run.return_value = "dmz"
|
mock_run.return_value = "dmz"
|
||||||
result = firewall.get_active_zones()
|
result = firewall.get_active_zones()
|
||||||
@@ -49,7 +49,7 @@ class TestGetActiveZones:
|
|||||||
|
|
||||||
|
|
||||||
class TestGetZoneInfo:
|
class TestGetZoneInfo:
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_parses_zone_info(self, mock_run):
|
def test_parses_zone_info(self, mock_run):
|
||||||
mock_run.return_value = (
|
mock_run.return_value = (
|
||||||
"target: default\n"
|
"target: default\n"
|
||||||
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
|
|||||||
|
|
||||||
|
|
||||||
class TestGetInterfaces:
|
class TestGetInterfaces:
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_parses_interfaces(self, mock_run):
|
def test_parses_interfaces(self, mock_run):
|
||||||
mock_run.return_value = (
|
mock_run.return_value = (
|
||||||
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
||||||
@@ -88,7 +88,7 @@ class TestGetInterfaces:
|
|||||||
|
|
||||||
|
|
||||||
class TestGetRichRules:
|
class TestGetRichRules:
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_single_rule(self, mock_run):
|
def test_single_rule(self, mock_run):
|
||||||
mock_run.return_value = (
|
mock_run.return_value = (
|
||||||
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
||||||
@@ -96,13 +96,13 @@ class TestGetRichRules:
|
|||||||
result = firewall.get_rich_rules("public")
|
result = firewall.get_rich_rules("public")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_empty_rules(self, mock_run):
|
def test_empty_rules(self, mock_run):
|
||||||
mock_run.return_value = ""
|
mock_run.return_value = ""
|
||||||
result = firewall.get_rich_rules("public")
|
result = firewall.get_rich_rules("public")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_multiline_rule(self, mock_run):
|
def test_multiline_rule(self, mock_run):
|
||||||
mock_run.return_value = (
|
mock_run.return_value = (
|
||||||
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
||||||
@@ -120,14 +120,14 @@ class TestNowIso:
|
|||||||
|
|
||||||
|
|
||||||
class TestAddForwardPort:
|
class TestAddForwardPort:
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_forward_port_basic(self, mock_run):
|
def test_forward_port_basic(self, mock_run):
|
||||||
mock_run.return_value = ""
|
mock_run.return_value = ""
|
||||||
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
|
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
|
||||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||||
assert any("--add-forward-port=" in str(c) for c in calls)
|
assert any("--add-forward-port=" in str(c) for c in calls)
|
||||||
|
|
||||||
@patch("lib.firewall._run")
|
@patch("lib.firewall.run")
|
||||||
def test_forward_port_port_only(self, mock_run):
|
def test_forward_port_port_only(self, mock_run):
|
||||||
mock_run.return_value = ""
|
mock_run.return_value = ""
|
||||||
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
||||||
@@ -159,3 +159,286 @@ class TestGetState:
|
|||||||
assert "zones" in result
|
assert "zones" in result
|
||||||
assert "active_zones" in result
|
assert "active_zones" in result
|
||||||
assert "timestamp" in result
|
assert "timestamp" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTarget:
|
||||||
|
def test_accept(self):
|
||||||
|
assert firewall._normalize_target("ACCEPT") == "ACCEPT"
|
||||||
|
|
||||||
|
def test_drop(self):
|
||||||
|
assert firewall._normalize_target("DROP") == "DROP"
|
||||||
|
|
||||||
|
def test_reject(self):
|
||||||
|
assert firewall._normalize_target("REJECT") == "REJECT"
|
||||||
|
|
||||||
|
def test_default(self):
|
||||||
|
assert firewall._normalize_target("DEFAULT") == "default"
|
||||||
|
assert firewall._normalize_target("default") == "default"
|
||||||
|
assert firewall._normalize_target("UNKNOWN") == "default"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveTargetToConfig:
|
||||||
|
def test_accept(self):
|
||||||
|
assert firewall._live_target_to_config("ACCEPT") == "ACCEPT"
|
||||||
|
|
||||||
|
def test_drop(self):
|
||||||
|
assert firewall._live_target_to_config("DROP") == "DROP"
|
||||||
|
|
||||||
|
def test_reject(self):
|
||||||
|
assert firewall._live_target_to_config("REJECT") == "REJECT"
|
||||||
|
|
||||||
|
def test_default(self):
|
||||||
|
assert firewall._live_target_to_config("default") == "DEFAULT"
|
||||||
|
assert firewall._live_target_to_config("") == "DEFAULT"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureConfigFile:
|
||||||
|
def test_creates_file_if_missing(self, tmp_path):
|
||||||
|
cfg_dir = tmp_path / "config" / "firewall"
|
||||||
|
cfg_file = cfg_dir / "config.json"
|
||||||
|
with (
|
||||||
|
patch.object(firewall, "CONFIG_DIR", cfg_dir),
|
||||||
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
||||||
|
):
|
||||||
|
firewall._ensure_config_file()
|
||||||
|
assert cfg_file.exists()
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
content = _json.loads(cfg_file.read_text())
|
||||||
|
assert content == {"zones": {}}
|
||||||
|
|
||||||
|
def test_skips_existing_file(self, tmp_path):
|
||||||
|
cfg_dir = tmp_path / "config" / "firewall"
|
||||||
|
cfg_file = cfg_dir / "config.json"
|
||||||
|
cfg_dir.mkdir(parents=True)
|
||||||
|
cfg_file.write_text('{"zones": {"public": {}}}')
|
||||||
|
with (
|
||||||
|
patch.object(firewall, "CONFIG_DIR", cfg_dir),
|
||||||
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
||||||
|
):
|
||||||
|
firewall._ensure_config_file()
|
||||||
|
content = cfg_file.read_text()
|
||||||
|
assert '{"zones": {"public": {}}}' in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigGet:
|
||||||
|
@patch("lib.firewall._ensure_config_file")
|
||||||
|
def test_returns_config(self, mock_ensure, tmp_path):
|
||||||
|
cfg_file = tmp_path / "config.json"
|
||||||
|
cfg_file.write_text(
|
||||||
|
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
|
||||||
|
)
|
||||||
|
with patch.object(firewall, "CONFIG_FILE", cfg_file):
|
||||||
|
result = firewall.get_config()
|
||||||
|
assert result["zones"]["public"]["interfaces"] == ["eth0"]
|
||||||
|
assert result["zones"]["public"]["services"] == ["http"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSet:
|
||||||
|
def test_writes_config_atomic(self, tmp_path):
|
||||||
|
cfg_file = tmp_path / "config.json"
|
||||||
|
with (
|
||||||
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
||||||
|
patch.object(firewall, "CONFIG_DIR", tmp_path),
|
||||||
|
):
|
||||||
|
firewall.save_config({"zones": {"test": {"interfaces": ["eth0"]}}})
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
content = _json.loads(cfg_file.read_text())
|
||||||
|
assert content["zones"]["test"]["interfaces"] == ["eth0"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigApply:
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.save_backup")
|
||||||
|
@patch("lib.firewall.get_available_zones")
|
||||||
|
@patch("lib.firewall.create_zone")
|
||||||
|
@patch("lib.firewall.set_zone_services")
|
||||||
|
@patch("lib.firewall.set_zone_interfaces")
|
||||||
|
@patch("lib.firewall.set_masquerade")
|
||||||
|
@patch("lib.firewall._reload")
|
||||||
|
def test_applies_existing_zone(
|
||||||
|
self,
|
||||||
|
mock_reload,
|
||||||
|
mock_set_mq,
|
||||||
|
mock_set_ifaces,
|
||||||
|
mock_set_svcs,
|
||||||
|
mock_create,
|
||||||
|
mock_available,
|
||||||
|
mock_backup,
|
||||||
|
mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"target": "DEFAULT",
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http", "https"],
|
||||||
|
"masquerade": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_available.return_value = ["public", "internal"]
|
||||||
|
mock_backup.return_value = "/tmp/rules.json"
|
||||||
|
result = firewall.config_apply()
|
||||||
|
assert result["applied_zones"] == ["public"]
|
||||||
|
assert result["backup"] == "/tmp/rules.json"
|
||||||
|
mock_set_ifaces.assert_called_once_with("public", ["eth0"])
|
||||||
|
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
|
||||||
|
mock_set_mq.assert_called_once_with("public", True)
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.save_backup")
|
||||||
|
@patch("lib.firewall.get_available_zones")
|
||||||
|
@patch("lib.firewall.create_zone")
|
||||||
|
@patch("lib.firewall.set_zone_services")
|
||||||
|
@patch("lib.firewall.set_zone_interfaces")
|
||||||
|
@patch("lib.firewall.set_masquerade")
|
||||||
|
@patch("lib.firewall._reload")
|
||||||
|
def test_creates_new_zone(
|
||||||
|
self,
|
||||||
|
mock_reload,
|
||||||
|
mock_set_mq,
|
||||||
|
mock_set_ifaces,
|
||||||
|
mock_set_svcs,
|
||||||
|
mock_create,
|
||||||
|
mock_available,
|
||||||
|
mock_backup,
|
||||||
|
mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"custom": {
|
||||||
|
"target": "ACCEPT",
|
||||||
|
"interfaces": ["eth2"],
|
||||||
|
"services": [],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_available.return_value = ["public", "internal"]
|
||||||
|
mock_backup.return_value = "/tmp/rules.json"
|
||||||
|
result = firewall.config_apply()
|
||||||
|
assert result["applied_zones"] == ["custom"]
|
||||||
|
mock_create.assert_called_once_with("custom", "ACCEPT")
|
||||||
|
mock_set_ifaces.assert_called_once_with("custom", ["eth2"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigPending:
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.get_state")
|
||||||
|
def test_detects_interface_drift(self, mock_state, mock_cfg):
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_state.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth1"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending()
|
||||||
|
assert result["needs_apply"] is True
|
||||||
|
assert any(c["type"] == "interfaces" for c in result["pending"])
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.get_state")
|
||||||
|
def test_in_sync(self, mock_state, mock_cfg):
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_state.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending()
|
||||||
|
assert result["needs_apply"] is False
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.get_state")
|
||||||
|
def test_detects_services_drift(self, mock_state, mock_cfg):
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http", "ssh"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_state.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending()
|
||||||
|
assert any(c["type"] == "services" for c in result["pending"])
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.get_state")
|
||||||
|
def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
|
||||||
|
mock_cfg.return_value = {"zones": {}}
|
||||||
|
mock_state.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": [],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending()
|
||||||
|
assert "public" in result["unmanaged_zones"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigEmptyZones:
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
@patch("lib.firewall.save_backup")
|
||||||
|
@patch("lib.firewall.get_available_zones")
|
||||||
|
@patch("lib.firewall.create_zone")
|
||||||
|
@patch("lib.firewall.set_zone_services")
|
||||||
|
@patch("lib.firewall.set_zone_interfaces")
|
||||||
|
@patch("lib.firewall.set_masquerade")
|
||||||
|
@patch("lib.firewall._reload")
|
||||||
|
def test_empty_config_no_ops(
|
||||||
|
self,
|
||||||
|
mock_reload,
|
||||||
|
mock_set_mq,
|
||||||
|
mock_set_ifaces,
|
||||||
|
mock_set_svcs,
|
||||||
|
mock_create,
|
||||||
|
mock_available,
|
||||||
|
mock_backup,
|
||||||
|
mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = {"zones": {}}
|
||||||
|
mock_available.return_value = []
|
||||||
|
mock_backup.return_value = "/tmp/rules.json"
|
||||||
|
result = firewall.config_apply()
|
||||||
|
assert result["applied_zones"] == []
|
||||||
|
mock_create.assert_not_called()
|
||||||
|
mock_set_ifaces.assert_not_called()
|
||||||
|
|||||||
+2
-2
@@ -200,7 +200,7 @@ class TestWriteAllSites:
|
|||||||
|
|
||||||
|
|
||||||
class TestTestConfig:
|
class TestTestConfig:
|
||||||
@patch("lib.nginx._run")
|
@patch("lib.nginx.subprocess.run")
|
||||||
def test_passes(self, mock_run, temp_data_dir):
|
def test_passes(self, mock_run, temp_data_dir):
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=0, stdout="", stderr="test passed\n"
|
returncode=0, stdout="", stderr="test passed\n"
|
||||||
@@ -208,7 +208,7 @@ class TestTestConfig:
|
|||||||
ok, _msg = nginx.test_config()
|
ok, _msg = nginx.test_config()
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
@patch("lib.nginx._run")
|
@patch("lib.nginx.subprocess.run")
|
||||||
def test_fails(self, mock_run, temp_data_dir):
|
def test_fails(self, mock_run, temp_data_dir):
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
|
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
|
||||||
|
|||||||
+29
-25
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from copy import deepcopy
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -10,14 +10,14 @@ from lib import wireguard
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_config(tmp_path):
|
def temp_config(tmp_path):
|
||||||
original = wireguard.CONFIG_PATH
|
original = wireguard.CONFIG_PATH
|
||||||
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
|
wireguard.CONFIG_PATH = tmp_path / "config.json"
|
||||||
yield tmp_path
|
yield tmp_path
|
||||||
wireguard.CONFIG_PATH = original
|
wireguard.CONFIG_PATH = original
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultConfig:
|
class TestDefaultConfig:
|
||||||
def test_returns_skeleton(self):
|
def test_returns_skeleton(self):
|
||||||
cfg = wireguard._default_config()
|
cfg = wireguard.DEFAULT_CONFIG
|
||||||
assert cfg["interface"]["name"] == "wg0"
|
assert cfg["interface"]["name"] == "wg0"
|
||||||
assert cfg["interface"]["listen_port"] == 51820
|
assert cfg["interface"]["listen_port"] == 51820
|
||||||
assert cfg["interface"]["private_key"] == ""
|
assert cfg["interface"]["private_key"] == ""
|
||||||
@@ -31,27 +31,29 @@ class TestGetConfig:
|
|||||||
assert cfg["peers"] == {}
|
assert cfg["peers"] == {}
|
||||||
|
|
||||||
def test_loads_existing_config(self, temp_config):
|
def test_loads_existing_config(self, temp_config):
|
||||||
path = Path(wireguard.CONFIG_PATH)
|
wireguard.CONFIG_PATH.write_text(
|
||||||
expected = {
|
json.dumps(
|
||||||
"interface": {
|
{
|
||||||
"name": "wg0",
|
"interface": {
|
||||||
"listen_port": 51820,
|
"name": "wg0",
|
||||||
"private_key": "existing-key",
|
"listen_port": 51820,
|
||||||
"public_key": "existing-pub",
|
"private_key": "existing-key",
|
||||||
"addresses": ["10.137.0.1/24"],
|
"public_key": "existing-pub",
|
||||||
"post_up": None,
|
"addresses": ["10.137.0.1/24"],
|
||||||
"post_down": None,
|
"post_up": None,
|
||||||
},
|
"post_down": None,
|
||||||
"peers": {},
|
},
|
||||||
}
|
"peers": {},
|
||||||
path.write_text(json.dumps(expected))
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
cfg = wireguard.get_config()
|
cfg = wireguard.get_config()
|
||||||
assert cfg["interface"]["private_key"] == "existing-key"
|
assert cfg["interface"]["private_key"] == "existing-key"
|
||||||
|
|
||||||
|
|
||||||
class TestSaveConfig:
|
class TestSaveConfig:
|
||||||
def test_save_and_reload(self, temp_config):
|
def test_save_and_reload(self, temp_config):
|
||||||
cfg = wireguard._default_config()
|
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
|
||||||
cfg["interface"]["listen_port"] = 51821
|
cfg["interface"]["listen_port"] = 51821
|
||||||
wireguard.save_config(cfg)
|
wireguard.save_config(cfg)
|
||||||
loaded = wireguard.get_config()
|
loaded = wireguard.get_config()
|
||||||
@@ -59,7 +61,7 @@ class TestSaveConfig:
|
|||||||
|
|
||||||
|
|
||||||
class TestGenerateKeyPair:
|
class TestGenerateKeyPair:
|
||||||
@patch("lib.wireguard._run")
|
@patch("lib.wireguard.run_proc")
|
||||||
def test_returns_keypair(self, mock_run):
|
def test_returns_keypair(self, mock_run):
|
||||||
mock_run.side_effect = [
|
mock_run.side_effect = [
|
||||||
MagicMock(returncode=0, stdout="private-key\n"),
|
MagicMock(returncode=0, stdout="private-key\n"),
|
||||||
@@ -68,6 +70,8 @@ class TestGenerateKeyPair:
|
|||||||
private, public = wireguard.generate_keypair()
|
private, public = wireguard.generate_keypair()
|
||||||
assert private == "private-key"
|
assert private == "private-key"
|
||||||
assert public == "public-key"
|
assert public == "public-key"
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
|
||||||
|
|
||||||
|
|
||||||
class TestGetPeers:
|
class TestGetPeers:
|
||||||
@@ -97,7 +101,7 @@ class TestGetPeers:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg))
|
wireguard.CONFIG_PATH.write_text(json.dumps(cfg))
|
||||||
peers = wireguard.get_peers()
|
peers = wireguard.get_peers()
|
||||||
assert len(peers) == 1
|
assert len(peers) == 1
|
||||||
assert peers[0]["name"] == "client1"
|
assert peers[0]["name"] == "client1"
|
||||||
@@ -110,7 +114,7 @@ class TestAddPeer:
|
|||||||
mock_gen.return_value = ("priv", "pub")
|
mock_gen.return_value = ("priv", "pub")
|
||||||
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
|
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
|
||||||
assert result["public_key"] == "pub"
|
assert result["public_key"] == "pub"
|
||||||
assert result["private_key"] == "priv"
|
assert "private_key" not in result
|
||||||
assert result["allowed_ips"] == ["10.0.0.0/24"]
|
assert result["allowed_ips"] == ["10.0.0.0/24"]
|
||||||
|
|
||||||
@patch("lib.wireguard.generate_keypair")
|
@patch("lib.wireguard.generate_keypair")
|
||||||
@@ -180,14 +184,14 @@ class TestInitialize:
|
|||||||
},
|
},
|
||||||
"peers": {},
|
"peers": {},
|
||||||
}
|
}
|
||||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
|
wireguard.CONFIG_PATH.write_text(json.dumps(existing))
|
||||||
cfg = wireguard.initialize()
|
cfg = wireguard.initialize()
|
||||||
assert cfg["interface"]["private_key"] == "original-private"
|
assert cfg["interface"]["private_key"] == "original-private"
|
||||||
mock_gen.assert_not_called()
|
mock_gen.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestStatus:
|
class TestStatus:
|
||||||
@patch("lib.wireguard._run")
|
@patch("lib.wireguard.run_proc")
|
||||||
def test_returns_down_when_interface_down(self, mock_run, temp_config):
|
def test_returns_down_when_interface_down(self, mock_run, temp_config):
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=1, stdout="", stderr="interface not found"
|
returncode=1, stdout="", stderr="interface not found"
|
||||||
@@ -195,7 +199,7 @@ class TestStatus:
|
|||||||
result = wireguard.status()
|
result = wireguard.status()
|
||||||
assert result["up"] is False
|
assert result["up"] is False
|
||||||
|
|
||||||
@patch("lib.wireguard._run")
|
@patch("lib.wireguard.run_proc")
|
||||||
def test_parses_interface_info(self, mock_run, temp_config):
|
def test_parses_interface_info(self, mock_run, temp_config):
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=0,
|
returncode=0,
|
||||||
@@ -206,7 +210,7 @@ class TestStatus:
|
|||||||
assert result["interface"]["public_key"] == "ABCDEF"
|
assert result["interface"]["public_key"] == "ABCDEF"
|
||||||
assert result["interface"]["listen_port"] == 51820
|
assert result["interface"]["listen_port"] == 51820
|
||||||
|
|
||||||
@patch("lib.wireguard._run")
|
@patch("lib.wireguard.run_proc")
|
||||||
def test_parses_peer_info(self, mock_run, temp_config):
|
def test_parses_peer_info(self, mock_run, temp_config):
|
||||||
mock_run.return_value = MagicMock(
|
mock_run.return_value = MagicMock(
|
||||||
returncode=0,
|
returncode=0,
|
||||||
|
|||||||
+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 @@
|
|||||||
"""
|
"""ACME certificate management API blueprint.
|
||||||
webui/api/certs.py - ACME certificate management API blueprint.
|
|
||||||
|
|
||||||
Exposed at /api/certs/* and delegates to lib.acme.
|
Exposed at /api/certs/* and delegates to lib.acme.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from lib.acme import (
|
from lib.acme import (
|
||||||
get_cert_info,
|
get_cert_info,
|
||||||
@@ -14,23 +15,12 @@ from lib.acme import (
|
|||||||
renew,
|
renew,
|
||||||
set_email,
|
set_email,
|
||||||
)
|
)
|
||||||
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
bp = Blueprint("certs", __name__)
|
bp = Blueprint("certs", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _error(msg, code=400):
|
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
|
||||||
return jsonify({"ok": True, "data": data})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Certificate listing
|
# Certificate listing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -40,18 +30,20 @@ def _ok(data=None):
|
|||||||
def list_certs_bp():
|
def list_certs_bp():
|
||||||
try:
|
try:
|
||||||
return _ok(list_certs())
|
return _ok(list_certs())
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to list certificates: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>", methods=["GET"])
|
@bp.route("/<domain>", methods=["GET"])
|
||||||
def cert_details(domain):
|
def cert_details(domain: str):
|
||||||
try:
|
try:
|
||||||
info = get_cert_info(domain)
|
info = get_cert_info(domain)
|
||||||
return _ok(info)
|
return _ok(info)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return _error(str(exc), 404)
|
return _error(str(exc), 404)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to get cert info for '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,12 +59,14 @@ def issue_bp():
|
|||||||
if not domain:
|
if not domain:
|
||||||
return _error("'domain' is required", 400)
|
return _error("'domain' is required", 400)
|
||||||
webroot = body.get("webroot")
|
webroot = body.get("webroot")
|
||||||
|
email = body.get("email", "").strip() or None
|
||||||
try:
|
try:
|
||||||
result = issue(domain, webroot=webroot)
|
logger.info("Certificate issuance requested for '%s' via API", domain)
|
||||||
if result.get("success"):
|
issue(domain, webroot=webroot, email=email)
|
||||||
return _ok(None)
|
logger.info("Certificate issued for '%s'", domain)
|
||||||
return _error(result.get("error", "Unknown error"), 500)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to issue cert for '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,13 +76,14 @@ def issue_bp():
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>/renew", methods=["POST"])
|
@bp.route("/<domain>/renew", methods=["POST"])
|
||||||
def renew_bp(domain):
|
def renew_bp(domain: str):
|
||||||
try:
|
try:
|
||||||
result = renew(domain)
|
logger.info("Certificate renewal requested for '%s' via API", domain)
|
||||||
if result.get("success"):
|
renew(domain)
|
||||||
return _ok(None)
|
logger.info("Certificate renewed for '%s'", domain)
|
||||||
return _error(result.get("error", "Unknown error"), 500)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to renew cert for '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,17 +93,20 @@ def renew_bp(domain):
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/<domain>", methods=["DELETE"])
|
@bp.route("/<domain>", methods=["DELETE"])
|
||||||
def remove_bp(domain):
|
def remove_bp(domain: str):
|
||||||
try:
|
try:
|
||||||
get_cert_info(domain)
|
get_cert_info(domain)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return _error(str(exc), 404)
|
return _error(str(exc), 404)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to verify cert '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
try:
|
try:
|
||||||
remove(domain)
|
remove(domain)
|
||||||
|
logger.info("Certificate removed for '%s' via API", domain)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to remove cert '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +123,8 @@ def set_email_bp():
|
|||||||
return _error("'email' is required", 400)
|
return _error("'email' is required", 400)
|
||||||
try:
|
try:
|
||||||
set_email(email)
|
set_email(email)
|
||||||
|
logger.info("ACME email set via API: %s", email)
|
||||||
return _ok({"email": email})
|
return _ok({"email": email})
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, FileNotFoundError) as exc:
|
||||||
|
logger.error("Failed to set ACME email: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|||||||
@@ -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.
|
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from lib.common import deep_merge
|
||||||
from lib.dnsmasq import (
|
from lib.dnsmasq import (
|
||||||
add_dns_record,
|
add_dns_record,
|
||||||
add_static_lease,
|
add_static_lease,
|
||||||
apply_config,
|
apply_config,
|
||||||
get_config,
|
get_config,
|
||||||
get_lease_table,
|
get_lease_table,
|
||||||
|
remove_dhcp_range,
|
||||||
remove_dns_record,
|
remove_dns_record,
|
||||||
remove_static_lease,
|
remove_static_lease,
|
||||||
save_config,
|
save_config,
|
||||||
|
set_dhcp_range,
|
||||||
)
|
)
|
||||||
|
from lib.dnsmasq import (
|
||||||
|
get_status as dnsmasq_status,
|
||||||
|
)
|
||||||
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
bp = Blueprint("dhcp", __name__)
|
bp = Blueprint("dhcp", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _error(msg, code=400):
|
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
|
||||||
return jsonify({"ok": True, "data": data})
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_merge(base, overrides):
|
|
||||||
result = dict(base)
|
|
||||||
for k, v in overrides.items():
|
|
||||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
|
||||||
result[k] = _deep_merge(result[k], v)
|
|
||||||
else:
|
|
||||||
result[k] = v
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config
|
# Config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -53,6 +40,7 @@ def get_config_bp():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_config())
|
return _ok(get_config())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to read DHCP config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +53,7 @@ def post_config():
|
|||||||
save_config(body)
|
save_config(body)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to save DHCP config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,10 +64,11 @@ def patch_config():
|
|||||||
return _error("Request body must be a JSON object", 400)
|
return _error("Request body must be a JSON object", 400)
|
||||||
try:
|
try:
|
||||||
current = get_config()
|
current = get_config()
|
||||||
merged = _deep_merge(current, body)
|
merged = deep_merge(current, body)
|
||||||
save_config(merged)
|
save_config(merged)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to patch DHCP config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,13 +76,74 @@ def patch_config():
|
|||||||
def apply_bp():
|
def apply_bp():
|
||||||
try:
|
try:
|
||||||
apply_config()
|
apply_config()
|
||||||
|
logger.info("dnsmasq config applied via API")
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to apply dnsmasq config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Leases
|
# Status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/status", methods=["GET"])
|
||||||
|
def status_bp():
|
||||||
|
try:
|
||||||
|
return _ok(dnsmasq_status())
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get DHCP status: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DHCP ranges
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/ranges", methods=["POST"])
|
||||||
|
def add_range_bp():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
iface = body.get("interface", "").strip() or None
|
||||||
|
start = body.get("start", "").strip()
|
||||||
|
end = body.get("end", "").strip()
|
||||||
|
lease_time = body.get("lease_time", "12h")
|
||||||
|
if not start or not end:
|
||||||
|
return _error("'start' and 'end' are required", 400)
|
||||||
|
try:
|
||||||
|
set_dhcp_range(
|
||||||
|
iface if iface else "",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
lease_time=lease_time,
|
||||||
|
)
|
||||||
|
logger.info("DHCP range added via API: %s-%s", start, end)
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to add DHCP range: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/ranges", methods=["DELETE"])
|
||||||
|
def remove_range_bp():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
iface = body.get("interface", "").strip() or ""
|
||||||
|
start = body.get("start", "").strip()
|
||||||
|
end = body.get("end", "").strip()
|
||||||
|
if not start or not end:
|
||||||
|
return _error("'start' and 'end' are required", 400)
|
||||||
|
try:
|
||||||
|
remove_dhcp_range(iface, start, end)
|
||||||
|
logger.info("DHCP range removed via API: %s-%s", start, end)
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove DHCP range: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static leases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +152,7 @@ def leases_bp():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_lease_table())
|
return _ok(get_lease_table())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to read lease table: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,16 +171,15 @@ def add_static_lease_bp():
|
|||||||
return _error("'mac' and 'ip' are required", 400)
|
return _error("'mac' and 'ip' are required", 400)
|
||||||
try:
|
try:
|
||||||
add_static_lease(mac, ip, hostname)
|
add_static_lease(mac, ip, hostname)
|
||||||
|
logger.info("Static lease added via API: %s -> %s", mac, ip)
|
||||||
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to add static lease: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/static-lease", methods=["DELETE"])
|
@bp.route("/static-lease/<mac>", methods=["DELETE"])
|
||||||
def remove_static_lease_bp():
|
def remove_static_lease_bp(mac):
|
||||||
mac = request.args.get("mac", "").strip()
|
|
||||||
if not mac:
|
|
||||||
return _error("Query parameter 'mac' is required", 400)
|
|
||||||
current = get_config()
|
current = get_config()
|
||||||
found = any(
|
found = any(
|
||||||
lease["mac"].lower() == mac.lower()
|
lease["mac"].lower() == mac.lower()
|
||||||
@@ -138,8 +189,10 @@ def remove_static_lease_bp():
|
|||||||
return _error(f"No static lease found for MAC '{mac}'", 404)
|
return _error(f"No static lease found for MAC '{mac}'", 404)
|
||||||
try:
|
try:
|
||||||
remove_static_lease(mac)
|
remove_static_lease(mac)
|
||||||
|
logger.info("Static lease removed via API: %s", mac)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove static lease: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,16 +211,15 @@ def add_dns_record_bp():
|
|||||||
return _error("'name' and 'address' are required", 400)
|
return _error("'name' and 'address' are required", 400)
|
||||||
try:
|
try:
|
||||||
add_dns_record(name, address, hostname)
|
add_dns_record(name, address, hostname)
|
||||||
|
logger.info("DNS record added via API: %s -> %s", name, address)
|
||||||
return _ok({"name": name, "address": address, "hostname": hostname})
|
return _ok({"name": name, "address": address, "hostname": hostname})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to add DNS record: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/dns-record", methods=["DELETE"])
|
@bp.route("/dns-record/<name>", methods=["DELETE"])
|
||||||
def remove_dns_record_bp():
|
def remove_dns_record_bp(name):
|
||||||
name = request.args.get("name", "").strip()
|
|
||||||
if not name:
|
|
||||||
return _error("Query parameter 'name' is required", 400)
|
|
||||||
current = get_config()
|
current = get_config()
|
||||||
found = any(
|
found = any(
|
||||||
r["name"] == name for r in current.get("dns", {}).get("custom_records", [])
|
r["name"] == name for r in current.get("dns", {}).get("custom_records", [])
|
||||||
@@ -176,6 +228,8 @@ def remove_dns_record_bp():
|
|||||||
return _error(f"No DNS record found for '{name}'", 404)
|
return _error(f"No DNS record found for '{name}'", 404)
|
||||||
try:
|
try:
|
||||||
remove_dns_record(name)
|
remove_dns_record(name)
|
||||||
|
logger.info("DNS record removed via API: %s", name)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove DNS record: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|||||||
+153
-66
@@ -1,43 +1,120 @@
|
|||||||
"""
|
"""Firewall (firewalld) management API blueprint.
|
||||||
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
|
|
||||||
|
|
||||||
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
|
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from lib.common import deep_merge
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
add_forward_port,
|
add_forward_port,
|
||||||
add_rich_rule,
|
add_rich_rule,
|
||||||
|
config_apply,
|
||||||
|
config_pending,
|
||||||
create_zone,
|
create_zone,
|
||||||
delete_zone,
|
delete_zone,
|
||||||
get_active_zones,
|
get_active_zones,
|
||||||
get_available_zones,
|
get_available_zones,
|
||||||
|
get_config,
|
||||||
get_interfaces,
|
get_interfaces,
|
||||||
get_rich_rules,
|
get_rich_rules,
|
||||||
get_services,
|
get_services,
|
||||||
get_zone_info,
|
get_zone_info,
|
||||||
remove_forward_port,
|
remove_forward_port_by_id,
|
||||||
remove_rich_rule,
|
remove_rich_rule_by_id,
|
||||||
|
save_config,
|
||||||
set_masquerade,
|
set_masquerade,
|
||||||
set_zone_interfaces,
|
set_zone_interfaces,
|
||||||
set_zone_services,
|
set_zone_services,
|
||||||
)
|
)
|
||||||
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
bp = Blueprint("firewall", __name__)
|
bp = Blueprint("firewall", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Error helpers
|
# Declarative config (two-step: save -> apply)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _error(msg, code=400):
|
@bp.route("/config", methods=["GET"])
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
def config_list():
|
||||||
|
try:
|
||||||
|
return _ok(get_config())
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to read firewall config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
@bp.route("/config", methods=["POST"])
|
||||||
return jsonify({"ok": True, "data": data})
|
def config_save():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
if "zones" not in body:
|
||||||
|
return _error("'zones' key is required", 400)
|
||||||
|
if not isinstance(body["zones"], dict):
|
||||||
|
return _error("'zones' must be a dict", 400)
|
||||||
|
try:
|
||||||
|
save_config(body)
|
||||||
|
pending_info = config_pending()
|
||||||
|
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"config_saved": True,
|
||||||
|
"pending": pending_info["pending"],
|
||||||
|
"needs_apply": pending_info["needs_apply"],
|
||||||
|
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to save firewall config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/config", methods=["PATCH"])
|
||||||
|
def patch_config():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _error("Request body must be a JSON object", 400)
|
||||||
|
try:
|
||||||
|
current = get_config()
|
||||||
|
merged = deep_merge(current, body)
|
||||||
|
save_config(merged)
|
||||||
|
pending_info = config_pending()
|
||||||
|
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"config_saved": True,
|
||||||
|
"pending": pending_info["pending"],
|
||||||
|
"needs_apply": pending_info["needs_apply"],
|
||||||
|
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to patch firewall config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/config/apply", methods=["POST"])
|
||||||
|
def config_apply_bp():
|
||||||
|
try:
|
||||||
|
result = config_apply()
|
||||||
|
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||||
|
return _ok(result)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to apply firewall config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/config/pending", methods=["GET"])
|
||||||
|
def config_pending_bp():
|
||||||
|
try:
|
||||||
|
return _ok(config_pending())
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to check pending config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -50,27 +127,21 @@ def list_zones():
|
|||||||
try:
|
try:
|
||||||
active = get_active_zones()
|
active = get_active_zones()
|
||||||
available = get_available_zones()
|
available = get_available_zones()
|
||||||
return jsonify(
|
return _ok({"active": active, "available": available})
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"data": {
|
|
||||||
"active": active,
|
|
||||||
"available": available,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to list zones: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>", methods=["GET"])
|
@bp.route("/zones/<name>", methods=["GET"])
|
||||||
def zone_details(name):
|
def zone_details(name: str):
|
||||||
try:
|
try:
|
||||||
if name not in get_available_zones():
|
if name not in get_available_zones():
|
||||||
return _error(f"Zone '{name}' does not exist", 404)
|
return _error(f"Zone '{name}' does not exist", 404)
|
||||||
info = get_zone_info(name)
|
info = get_zone_info(name)
|
||||||
return jsonify({"ok": True, "data": info})
|
return _ok(info)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get zone '%s' info: %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,20 +156,24 @@ def create_zone_bp():
|
|||||||
if zone_name in get_available_zones():
|
if zone_name in get_available_zones():
|
||||||
return _error(f"Zone '{zone_name}' already exists", 400)
|
return _error(f"Zone '{zone_name}' already exists", 400)
|
||||||
create_zone(zone_name, target)
|
create_zone(zone_name, target)
|
||||||
|
logger.info("Zone '%s' created via API", zone_name)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to create zone '%s': %s", zone_name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
@bp.route("/zones/<name>", methods=["DELETE"])
|
||||||
def delete_zone_bp(name):
|
def delete_zone_bp(name: str):
|
||||||
try:
|
try:
|
||||||
available = get_available_zones()
|
available = get_available_zones()
|
||||||
if name not in available:
|
if name not in available:
|
||||||
return _error(f"Zone '{name}' does not exist", 404)
|
return _error(f"Zone '{name}' does not exist", 404)
|
||||||
delete_zone(name)
|
delete_zone(name)
|
||||||
|
logger.info("Zone '%s' deleted via API", name)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to delete zone '%s': %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,15 +183,17 @@ def delete_zone_bp(name):
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
||||||
def set_zone_interfaces_bp(name):
|
def set_zone_interfaces_bp(name: str):
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
interfaces = body.get("interfaces", [])
|
interfaces = body.get("interfaces", [])
|
||||||
if not isinstance(interfaces, list):
|
if not isinstance(interfaces, list):
|
||||||
return _error("'interfaces' must be a list", 400)
|
return _error("'interfaces' must be a list", 400)
|
||||||
try:
|
try:
|
||||||
set_zone_interfaces(name, interfaces)
|
set_zone_interfaces(name, interfaces)
|
||||||
|
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
|
||||||
return _ok({"zone": name, "interfaces": interfaces})
|
return _ok({"zone": name, "interfaces": interfaces})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +203,7 @@ def set_zone_interfaces_bp(name):
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/zones/<name>/services", methods=["POST"])
|
@bp.route("/zones/<name>/services", methods=["POST"])
|
||||||
def set_zone_services_bp(name):
|
def set_zone_services_bp(name: str):
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
services = body.get("services", [])
|
services = body.get("services", [])
|
||||||
if not isinstance(services, list):
|
if not isinstance(services, list):
|
||||||
@@ -135,6 +212,7 @@ def set_zone_services_bp(name):
|
|||||||
set_zone_services(name, services)
|
set_zone_services(name, services)
|
||||||
return _ok({"zone": name, "services": services})
|
return _ok({"zone": name, "services": services})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to set services for zone '%s': %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +226,7 @@ def list_services():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_services())
|
return _ok(get_services())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to list services: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +235,7 @@ def list_interfaces():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_interfaces())
|
return _ok(get_interfaces())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to list interfaces: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -172,32 +252,43 @@ def add_rich_rule_bp():
|
|||||||
if not zone or not rule:
|
if not zone or not rule:
|
||||||
return _error("Both 'zone' and 'rule' are required", 400)
|
return _error("Both 'zone' and 'rule' are required", 400)
|
||||||
try:
|
try:
|
||||||
add_rich_rule(zone, rule)
|
entry = add_rich_rule(zone, rule)
|
||||||
return _ok({"zone": zone, "rule": rule})
|
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
|
||||||
except RuntimeError as exc:
|
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/rich-rules", methods=["DELETE"])
|
|
||||||
def remove_rich_rule_bp():
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone = body.get("zone", "").strip()
|
|
||||||
rule = body.get("rule", "").strip()
|
|
||||||
if not zone or not rule:
|
|
||||||
return _error("Both 'zone' and 'rule' are required", 400)
|
|
||||||
try:
|
|
||||||
remove_rich_rule(zone, rule)
|
|
||||||
return _ok({"zone": zone, "rule": rule})
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
||||||
def list_rich_rules(zone):
|
def list_rich_rules(zone: str):
|
||||||
try:
|
try:
|
||||||
rules = get_rich_rules(zone)
|
rules = get_rich_rules(zone)
|
||||||
return _ok(rules)
|
cfg = get_config()
|
||||||
|
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
|
||||||
|
result = []
|
||||||
|
for rule_str in rules:
|
||||||
|
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
|
||||||
|
if matched:
|
||||||
|
result.append({"id": matched["id"], "rule": rule_str})
|
||||||
|
else:
|
||||||
|
result.append({"rule": rule_str})
|
||||||
|
return _ok(result)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
|
||||||
|
def remove_rich_rule_bp(zone: str, rule_id: str):
|
||||||
|
try:
|
||||||
|
remove_rich_rule_by_id(zone, rule_id)
|
||||||
|
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
|
||||||
|
return _ok({"zone": zone, "id": rule_id})
|
||||||
|
except ValueError as exc:
|
||||||
|
return _error(str(exc), 404)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,8 +306,14 @@ def set_masquerade_bp():
|
|||||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||||
try:
|
try:
|
||||||
set_masquerade(zone, bool(enable))
|
set_masquerade(zone, bool(enable))
|
||||||
|
logger.info(
|
||||||
|
"Masquerade %s on zone '%s' via API",
|
||||||
|
"enabled" if enable else "disabled",
|
||||||
|
zone,
|
||||||
|
)
|
||||||
return _ok({"zone": zone, "masquerade": bool(enable)})
|
return _ok({"zone": zone, "masquerade": bool(enable)})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,38 +333,28 @@ def add_forward_port_bp():
|
|||||||
if not zone or port is None or not proto:
|
if not zone or port is None or not proto:
|
||||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
return _error("'zone', 'port', and 'proto' are required", 400)
|
||||||
try:
|
try:
|
||||||
add_forward_port(
|
entry = add_forward_port(
|
||||||
zone,
|
zone,
|
||||||
int(port),
|
int(port),
|
||||||
proto,
|
proto,
|
||||||
toaddr=str(toaddr) if toaddr else None,
|
toaddr=str(toaddr) if toaddr else None,
|
||||||
toport=int(toport) if toport else None,
|
toport=int(toport) if toport else None,
|
||||||
)
|
)
|
||||||
return _ok({"zone": zone, "port": int(port), "proto": proto})
|
return _ok({"zone": zone, "id": entry["id"], "port": int(port), "proto": proto})
|
||||||
except (ValueError, RuntimeError) as exc:
|
except (ValueError, RuntimeError) as exc:
|
||||||
code = 400 if isinstance(exc, ValueError) else 500
|
code = 400 if isinstance(exc, ValueError) else 500
|
||||||
|
logger.error("Failed to add forward port: %s", exc)
|
||||||
return _error(str(exc), code)
|
return _error(str(exc), code)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/forward-port", methods=["DELETE"])
|
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
|
||||||
def remove_forward_port_bp():
|
def remove_forward_port_bp(zone: str, port: int, proto: str):
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
zone = body.get("zone", "").strip()
|
|
||||||
port = body.get("port")
|
|
||||||
proto = body.get("proto", "").strip()
|
|
||||||
toaddr = body.get("toaddr")
|
|
||||||
toport = body.get("toport")
|
|
||||||
if not zone or port is None or not proto:
|
|
||||||
return _error("'zone', 'port', and 'proto' are required", 400)
|
|
||||||
try:
|
try:
|
||||||
remove_forward_port(
|
remove_forward_port_by_id(zone, port, proto)
|
||||||
zone,
|
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
|
||||||
int(port),
|
return _ok({"zone": zone, "port": port, "proto": proto})
|
||||||
proto,
|
except ValueError as exc:
|
||||||
toaddr=str(toaddr) if toaddr else None,
|
return _error(str(exc), 404)
|
||||||
toport=int(toport) if toport else None,
|
except RuntimeError as exc:
|
||||||
)
|
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
|
||||||
return _ok({"zone": zone, "port": int(port), "proto": proto})
|
return _error(str(exc), 500)
|
||||||
except (ValueError, RuntimeError) as exc:
|
|
||||||
code = 400 if isinstance(exc, ValueError) else 500
|
|
||||||
return _error(str(exc), code)
|
|
||||||
|
|||||||
@@ -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.
|
Exposed at /api/proxy/* and delegates to lib.nginx.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from lib.common import deep_merge
|
||||||
from lib.nginx import (
|
from lib.nginx import (
|
||||||
add_domain,
|
add_domain,
|
||||||
apply,
|
apply,
|
||||||
get_config,
|
get_config,
|
||||||
get_domains,
|
get_domains,
|
||||||
remove_domain,
|
remove_domain,
|
||||||
|
save_config,
|
||||||
set_management_proxy,
|
set_management_proxy,
|
||||||
test_config,
|
test_config,
|
||||||
update_domain,
|
update_domain,
|
||||||
|
write_ssl_snippet,
|
||||||
)
|
)
|
||||||
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
bp = Blueprint("proxy", __name__)
|
bp = Blueprint("proxy", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/ssl-apply", methods=["POST"])
|
||||||
|
def ssl_apply_bp():
|
||||||
|
"""Apply (write) the global SSL snippet for all Nginx server blocks."""
|
||||||
|
try:
|
||||||
|
write_ssl_snippet()
|
||||||
|
logger.info("SSL snippet written via API")
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to write SSL snippet: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Config (declarative)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _error(msg, code=400):
|
@bp.route("/config", methods=["GET"])
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
def get_config_bp():
|
||||||
|
try:
|
||||||
|
return _ok(get_config())
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to read proxy config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
@bp.route("/config", methods=["POST"])
|
||||||
return jsonify({"ok": True, "data": data})
|
def post_config():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _error("Request body must be a JSON object", 400)
|
||||||
|
try:
|
||||||
|
save_config(body)
|
||||||
|
logger.info("Proxy config saved: %s", sorted(body.keys()))
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to save proxy config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/config", methods=["PATCH"])
|
||||||
|
def patch_config():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _error("Request body must be a JSON object", 400)
|
||||||
|
try:
|
||||||
|
current = get_config()
|
||||||
|
merged = deep_merge(current, body)
|
||||||
|
save_config(merged)
|
||||||
|
logger.info("Proxy config patched: %s", sorted(body.keys()))
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to patch proxy config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -43,6 +93,7 @@ def list_domains():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_domains())
|
return _ok(get_domains())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to list proxy domains: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,8 +116,10 @@ def add_domain_bp():
|
|||||||
add_domain(
|
add_domain(
|
||||||
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
|
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
|
||||||
)
|
)
|
||||||
|
logger.info("Proxy domain added via API: %s", domain)
|
||||||
return _ok({"domain": domain})
|
return _ok({"domain": domain})
|
||||||
except (ValueError, RuntimeError) as exc:
|
except (ValueError, RuntimeError) as exc:
|
||||||
|
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +132,7 @@ def domain_details(domain):
|
|||||||
return _error(f"Domain '{domain}' not found", 404)
|
return _error(f"Domain '{domain}' not found", 404)
|
||||||
return _ok({"domain": domain, **entry})
|
return _ok({"domain": domain, **entry})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get domain details: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,10 +143,12 @@ def update_domain_bp(domain):
|
|||||||
return _error("Request body must be a JSON object with fields to update", 400)
|
return _error("Request body must be a JSON object with fields to update", 400)
|
||||||
try:
|
try:
|
||||||
update_domain(domain, **body)
|
update_domain(domain, **body)
|
||||||
|
logger.info("Proxy domain '%s' updated via API", domain)
|
||||||
return _ok({"domain": domain})
|
return _ok({"domain": domain})
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
return _error(str(exc), 404)
|
return _error(str(exc), 404)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to update domain '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,8 +159,10 @@ def remove_domain_bp(domain):
|
|||||||
if domain not in cfg.get("domains", {}):
|
if domain not in cfg.get("domains", {}):
|
||||||
return _error(f"Domain '{domain}' not found", 404)
|
return _error(f"Domain '{domain}' not found", 404)
|
||||||
remove_domain(domain)
|
remove_domain(domain)
|
||||||
|
logger.info("Proxy domain removed via API: %s", domain)
|
||||||
return _ok({"domain": domain})
|
return _ok({"domain": domain})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove domain '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,8 +175,10 @@ def remove_domain_bp(domain):
|
|||||||
def apply_bp():
|
def apply_bp():
|
||||||
try:
|
try:
|
||||||
apply()
|
apply()
|
||||||
|
logger.info("nginx config applied via API")
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to apply nginx config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,8 +188,9 @@ def test_bp():
|
|||||||
valid, output = test_config()
|
valid, output = test_config()
|
||||||
if valid:
|
if valid:
|
||||||
return _ok({"valid": True, "output": output})
|
return _ok({"valid": True, "output": output})
|
||||||
return jsonify({"ok": False, "error": output, "valid": False}), 400
|
return _error(output, 400)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("nginx config test failed: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,7 +211,9 @@ def management_bp():
|
|||||||
auth_pass = body.get("auth_pass")
|
auth_pass = body.get("auth_pass")
|
||||||
try:
|
try:
|
||||||
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
|
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
|
||||||
|
logger.info("Management proxy configured via API: %s", domain)
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except (ValueError, RuntimeError) as exc:
|
||||||
code = 400 if isinstance(exc, ValueError) else 500
|
code = 400 if isinstance(exc, ValueError) else 500
|
||||||
|
logger.error("Failed to set management proxy: %s", exc)
|
||||||
return _error(str(exc), code)
|
return _error(str(exc), code)
|
||||||
|
|||||||
+69
-30
@@ -4,8 +4,11 @@ webui/api/wireguard.py - WireGuard tunnel management API blueprint.
|
|||||||
Exposed at /api/wireguard/* and delegates to lib.wireguard.
|
Exposed at /api/wireguard/* and delegates to lib.wireguard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from lib.common import deep_merge
|
||||||
from lib.wireguard import (
|
from lib.wireguard import (
|
||||||
add_peer,
|
add_peer,
|
||||||
apply,
|
apply,
|
||||||
@@ -19,23 +22,12 @@ from lib.wireguard import (
|
|||||||
save_config,
|
save_config,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
bp = Blueprint("wireguard", __name__)
|
bp = Blueprint("wireguard", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _error(msg, code=400):
|
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
|
||||||
|
|
||||||
|
|
||||||
def _ok(data=None):
|
|
||||||
return jsonify({"ok": True, "data": data})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config
|
# Config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -51,6 +43,7 @@ def get_config_bp():
|
|||||||
safe["interface"].pop("private_key", None)
|
safe["interface"].pop("private_key", None)
|
||||||
return _ok(safe)
|
return _ok(safe)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to read WireGuard config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,13 +53,40 @@ def post_config():
|
|||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
return _error("Request body must be a JSON object", 400)
|
return _error("Request body must be a JSON object", 400)
|
||||||
try:
|
try:
|
||||||
|
# Preserve existing server private key through full replacement
|
||||||
|
current = get_config()
|
||||||
|
current_key = current.get("interface", {}).get("private_key", "")
|
||||||
|
|
||||||
|
if "interface" in body:
|
||||||
|
body["interface"] = dict(body["interface"])
|
||||||
|
body["interface"].pop("private_key", None)
|
||||||
|
|
||||||
|
if current_key:
|
||||||
|
body.setdefault("interface", {})["private_key"] = current_key
|
||||||
|
|
||||||
save_config(body)
|
save_config(body)
|
||||||
safe = dict(body)
|
return _ok(None)
|
||||||
if "interface" in safe:
|
|
||||||
safe["interface"] = dict(safe["interface"])
|
|
||||||
safe["interface"].pop("private_key", None)
|
|
||||||
return _ok(safe)
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to save WireGuard config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/config", methods=["PATCH"])
|
||||||
|
def patch_config():
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _error("Request body must be a JSON object", 400)
|
||||||
|
try:
|
||||||
|
if "interface" in body:
|
||||||
|
body["interface"] = dict(body["interface"])
|
||||||
|
body["interface"].pop("private_key", None)
|
||||||
|
current = get_config()
|
||||||
|
merged = deep_merge(current, body)
|
||||||
|
save_config(merged)
|
||||||
|
logger.info("WireGuard config patched: %s", sorted(body.keys()))
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to patch WireGuard config: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,8 +99,21 @@ def post_config():
|
|||||||
def apply_bp():
|
def apply_bp():
|
||||||
try:
|
try:
|
||||||
apply()
|
apply()
|
||||||
|
logger.info("WireGuard tunnel applied via API")
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to apply WireGuard config: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/up", methods=["POST"])
|
||||||
|
def up_bp():
|
||||||
|
try:
|
||||||
|
apply()
|
||||||
|
logger.info("WireGuard tunnel started via API")
|
||||||
|
return _ok(None)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to start WireGuard tunnel: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,8 +121,10 @@ def apply_bp():
|
|||||||
def down_bp():
|
def down_bp():
|
||||||
try:
|
try:
|
||||||
down()
|
down()
|
||||||
|
logger.info("WireGuard tunnel brought down via API")
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +138,7 @@ def status_bp():
|
|||||||
try:
|
try:
|
||||||
return _ok(status())
|
return _ok(status())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get WireGuard status: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,8 +151,10 @@ def status_bp():
|
|||||||
def initialize_bp():
|
def initialize_bp():
|
||||||
try:
|
try:
|
||||||
initialize()
|
initialize()
|
||||||
|
logger.info("WireGuard initialized via API")
|
||||||
return _ok(None)
|
return _ok(None)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to initialize WireGuard: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +163,7 @@ def initialize_bp():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/add-peer", methods=["POST"])
|
@bp.route("/peers", methods=["POST"])
|
||||||
def add_peer_bp():
|
def add_peer_bp():
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
name = body.get("name", "").strip()
|
name = body.get("name", "").strip()
|
||||||
@@ -139,25 +177,24 @@ def add_peer_bp():
|
|||||||
persistent_keepalive=body.get("persistent_keepalive"),
|
persistent_keepalive=body.get("persistent_keepalive"),
|
||||||
preshared_key=body.get("preshared_key"),
|
preshared_key=body.get("preshared_key"),
|
||||||
)
|
)
|
||||||
safe = dict(peer)
|
logger.info("WireGuard peer '%s' added via API", name)
|
||||||
safe.pop("private_key", None)
|
return _ok(peer)
|
||||||
return _ok(safe)
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to add peer '%s': %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/remove-peer", methods=["DELETE"])
|
@bp.route("/peers/<name>", methods=["DELETE"])
|
||||||
def remove_peer_bp():
|
def remove_peer_bp(name):
|
||||||
name = request.args.get("name", "").strip()
|
|
||||||
if not name:
|
|
||||||
return _error("Query parameter 'name' is required", 400)
|
|
||||||
try:
|
try:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
if name not in cfg.get("peers", {}):
|
if name not in cfg.get("peers", {}):
|
||||||
return _error(f"Peer '{name}' not found", 404)
|
return _error(f"Peer '{name}' not found", 404)
|
||||||
remove_peer(name)
|
remove_peer(name)
|
||||||
|
logger.info("WireGuard peer '%s' removed via API", name)
|
||||||
return _ok({"name": name})
|
return _ok({"name": name})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to remove peer '%s': %s", name, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +203,7 @@ def peers_bp():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_peers())
|
return _ok(get_peers())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to list WireGuard peers: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +212,7 @@ def peer_status_bp():
|
|||||||
try:
|
try:
|
||||||
return _ok(get_peer_status())
|
return _ok(get_peer_status())
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get WireGuard peer status: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,13 +234,13 @@ def generate_client_bp():
|
|||||||
server_endpoint = body.get("server_endpoint", "")
|
server_endpoint = body.get("server_endpoint", "")
|
||||||
server_pubkey = cfg["interface"].get("public_key", "")
|
server_pubkey = cfg["interface"].get("public_key", "")
|
||||||
if not server_endpoint:
|
if not server_endpoint:
|
||||||
_ = cfg["interface"].get("listen_port", 51820)
|
|
||||||
# Can't auto-derive public IP; ask user to provide it
|
|
||||||
return _error(
|
return _error(
|
||||||
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
|
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
|
||||||
)
|
)
|
||||||
conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
|
conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
|
||||||
|
logger.info("Client config generated for peer '%s' via API", name)
|
||||||
return _ok({"config": conf_text})
|
return _ok({"config": conf_text})
|
||||||
except (KeyError, ValueError, RuntimeError) as exc:
|
except (KeyError, ValueError, RuntimeError) as exc:
|
||||||
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
|
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
|
||||||
|
logger.error("Failed to generate client config for '%s': %s", name, exc)
|
||||||
return _error(str(exc), code)
|
return _error(str(exc), code)
|
||||||
|
|||||||
+94
-14
@@ -7,15 +7,28 @@ and enforces basic authentication before proxying to this port.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template, request
|
||||||
|
|
||||||
from lib.acme import get_email, list_certs
|
from lib.acme import get_email, list_certs
|
||||||
from lib.dnsmasq import get_config as dnsmasq_config
|
from lib.dnsmasq import get_config as dnsmasq_config
|
||||||
from lib.dnsmasq import get_lease_table
|
from lib.dnsmasq import get_lease_table
|
||||||
from lib.dnsmasq import get_status as dnsmasq_status
|
from lib.dnsmasq import get_status as dnsmasq_status
|
||||||
from lib.firewall import get_active_zones, get_interfaces, get_zone_info
|
from lib.firewall import (
|
||||||
|
config_pending,
|
||||||
|
get_active_zones,
|
||||||
|
get_interfaces,
|
||||||
|
get_services,
|
||||||
|
get_zone_info,
|
||||||
|
)
|
||||||
|
from lib.firewall import (
|
||||||
|
get_config as fw_config_get,
|
||||||
|
)
|
||||||
|
from lib.logging import setup_logging
|
||||||
from lib.nginx import get_config as nginx_config
|
from lib.nginx import get_config as nginx_config
|
||||||
from lib.nginx import get_domains
|
from lib.nginx import get_domains
|
||||||
from lib.wireguard import get_config as wg_config
|
from lib.wireguard import get_config as wg_config
|
||||||
@@ -23,9 +36,28 @@ from lib.wireguard import status as wg_status
|
|||||||
from webui.api.certs import bp as certs_bp
|
from webui.api.certs import bp as certs_bp
|
||||||
from webui.api.dhcp import bp as dhcp_bp
|
from webui.api.dhcp import bp as dhcp_bp
|
||||||
from webui.api.firewall import bp as firewall_bp
|
from webui.api.firewall import bp as firewall_bp
|
||||||
|
from webui.api.logs import bp as logs_bp
|
||||||
from webui.api.proxy import bp as proxy_bp
|
from webui.api.proxy import bp as proxy_bp
|
||||||
from webui.api.wireguard import bp as wireguard_bp
|
from webui.api.wireguard import bp as wireguard_bp
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging — must be first so subsequent modules inherit the config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
setup_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Python %s.%s.%s",
|
||||||
|
sys.version_info.major,
|
||||||
|
sys.version_info.minor,
|
||||||
|
sys.version_info.micro,
|
||||||
|
)
|
||||||
|
logger.info("Project directory: %s", PROJECT_DIR)
|
||||||
|
logger.info("Process ID: %d", os.getpid())
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App factory
|
# App factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -38,6 +70,44 @@ app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
|||||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||||
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
||||||
|
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||||
|
|
||||||
|
BLUEPRINTS = [
|
||||||
|
("firewall", firewall_bp),
|
||||||
|
("dhcp", dhcp_bp),
|
||||||
|
("proxy", proxy_bp),
|
||||||
|
("certs", certs_bp),
|
||||||
|
("wireguard", wireguard_bp),
|
||||||
|
("logs", logs_bp),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, _ in BLUEPRINTS:
|
||||||
|
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _log_request_start():
|
||||||
|
request._start_time = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _log_request_finish(response):
|
||||||
|
elapsed_ms = (
|
||||||
|
time.monotonic() - getattr(request, "_start_time", time.monotonic())
|
||||||
|
) * 1000
|
||||||
|
logger.info(
|
||||||
|
"%s %s -> %d (%.1f ms)",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
response.status_code,
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -111,8 +181,6 @@ def json_pretty_filter(value):
|
|||||||
# Page routes
|
# Page routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _safely(fn, default=None):
|
def _safely(fn, default=None):
|
||||||
"""Call *fn* and return *default* on any exception."""
|
"""Call *fn* and return *default* on any exception."""
|
||||||
@@ -155,40 +223,51 @@ def dashboard():
|
|||||||
certs=certs,
|
certs=certs,
|
||||||
wg_status=wg,
|
wg_status=wg,
|
||||||
services=_get_service_status(dnsmasq, wg),
|
services=_get_service_status(dnsmasq, wg),
|
||||||
|
firewall_config=_safely(fw_config_get, {}),
|
||||||
|
firewall_pending=_safely(config_pending, {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/interfaces")
|
@app.route("/interfaces")
|
||||||
def interfaces_page():
|
def interfaces_page():
|
||||||
|
firewall_config = _safely(fw_config_get, {})
|
||||||
|
firewall_pending = _safely(config_pending, {})
|
||||||
return render_template(
|
return render_template(
|
||||||
"interfaces.html",
|
"interfaces.html",
|
||||||
interfaces=_safely(get_interfaces, []),
|
interfaces=_safely(get_interfaces, []),
|
||||||
active_zones=_safely(get_active_zones, {}),
|
active_zones=_safely(get_active_zones, {}),
|
||||||
|
firewall_config=firewall_config,
|
||||||
|
firewall_pending=firewall_pending,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/zones")
|
@app.route("/zones")
|
||||||
def zones_page():
|
def zones_page():
|
||||||
zones = {}
|
firewall_config = _safely(fw_config_get, {})
|
||||||
|
firewall_pending = _safely(config_pending, {})
|
||||||
|
zones_data = {}
|
||||||
for name in _safely(get_active_zones, {}):
|
for name in _safely(get_active_zones, {}):
|
||||||
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
|
zones_data[name] = _safely(lambda n=name: get_zone_info(n), {})
|
||||||
return render_template(
|
return render_template(
|
||||||
"zones.html",
|
"zones.html",
|
||||||
zones=zones,
|
zones=zones_data,
|
||||||
interfaces=_safely(get_interfaces, []),
|
interfaces=_safely(get_interfaces, []),
|
||||||
services=_safely(
|
services=_safely(get_services, []),
|
||||||
lambda: __import__(
|
firewall_config=firewall_config,
|
||||||
"lib.firewall", fromlist=["get_services"]
|
firewall_pending=firewall_pending,
|
||||||
).get_services(),
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/rules")
|
@app.route("/rules")
|
||||||
def rules_page():
|
def rules_page():
|
||||||
zones = list(_safely(get_active_zones, {}).keys())
|
zones = list(_safely(get_active_zones, {}).keys())
|
||||||
return render_template("rules.html", zones=zones)
|
raw = _safely(fw_config_get, {})
|
||||||
|
rules = {}
|
||||||
|
for zname, zcfg in raw.get("zones", {}).items():
|
||||||
|
rr = zcfg.get("rich_rules", [])
|
||||||
|
if rr:
|
||||||
|
rules[zname] = rr
|
||||||
|
return render_template("rules.html", zones=zones, rules=rules or None)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/nat")
|
@app.route("/nat")
|
||||||
@@ -236,4 +315,5 @@ def logs_page():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logger.info("Starting Flask on 127.0.0.1:9090")
|
||||||
app.run(host="127.0.0.1", port=9090)
|
app.run(host="127.0.0.1", port=9090)
|
||||||
|
|||||||
+289
-113
@@ -1,137 +1,86 @@
|
|||||||
// Toast notification system
|
// Toast notifications
|
||||||
function showToast(message, type = "info") {
|
const showToast = (message, type, duration = 4000) => {
|
||||||
const container = document.querySelector(".toast") || createToastContainer();
|
const container = document.getElementById('toast-container');
|
||||||
const toast = document.createElement("div");
|
if (!container) return;
|
||||||
toast.className = `toast-message toast-${type}`;
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast toast-' + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => toast.classList.add('show'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.style.opacity = "0";
|
toast.classList.remove('show');
|
||||||
toast.style.transform = "translateX(40px)";
|
|
||||||
toast.style.transition = "all 0.3s ease";
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, 5000);
|
}, duration);
|
||||||
}
|
};
|
||||||
|
|
||||||
function createToastContainer() {
|
const showSuccessToast = (msg) => showToast(msg, 'success');
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "toast";
|
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||||
document.body.appendChild(el);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal helpers
|
// Modal helpers
|
||||||
function openModal(id) {
|
const openModal = (id) => {
|
||||||
const modal = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (modal) modal.classList.add("show");
|
if (el) el.classList.add('active');
|
||||||
}
|
};
|
||||||
|
|
||||||
function closeModal(id) {
|
const closeModal = (id) => {
|
||||||
const modal = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (modal) modal.classList.remove("show");
|
if (el) el.classList.remove('active');
|
||||||
}
|
};
|
||||||
|
|
||||||
// Confirm dialog
|
// Tab switching
|
||||||
function confirmAction(message, onConfirm) {
|
const switchTab = (tabName) => {
|
||||||
const existing = document.getElementById("confirm-modal");
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||||
if (existing) existing.remove();
|
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + tabName).classList.add('active');
|
||||||
|
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||||
|
if (clickedTab) clickedTab.classList.add('active');
|
||||||
|
};
|
||||||
|
|
||||||
const modal = document.createElement("div");
|
// Refresh a container from a JSON GET endpoint using a renderer callback
|
||||||
modal.id = "confirm-modal";
|
const refreshTable = (url, container, renderer) => {
|
||||||
modal.className = "modal";
|
fetch(url)
|
||||||
modal.innerHTML = `
|
.then(r => r.json())
|
||||||
<div class="modal-content">
|
.then(data => {
|
||||||
<p class="mb-2">${message}</p>
|
const json = data.ok ? data.data : data;
|
||||||
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
|
container.innerHTML = renderer(json);
|
||||||
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
|
htmx.process(container);
|
||||||
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
|
})
|
||||||
</div>
|
.catch(() => {});
|
||||||
</div>`;
|
};
|
||||||
document.body.appendChild(modal);
|
|
||||||
openModal("confirm-modal");
|
|
||||||
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
|
|
||||||
modal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === modal) closeModal("confirm-modal");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupConfirmCallback(callback) {
|
|
||||||
document.getElementById("confirm-ok")?.addEventListener("click", () => {
|
|
||||||
closeModal("confirm-modal");
|
|
||||||
callback();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh with HTMX
|
|
||||||
function startAutoRefresh(endpoint, target, interval) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.setAttribute("hx-get", endpoint);
|
|
||||||
el.setAttribute("hx-target", `#${target}`);
|
|
||||||
el.setAttribute("hx-swap", "innerHTML");
|
|
||||||
el.setAttribute("hx-trigger", `every ${interval}s`);
|
|
||||||
el.setAttribute("hx-swap-oob", "true");
|
|
||||||
document.body.appendChild(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time formatting
|
|
||||||
function formatTime(seconds) {
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
||||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
||||||
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bytes formatting
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form helpers
|
|
||||||
function resetForm(formId) {
|
|
||||||
const form = document.getElementById(formId);
|
|
||||||
if (form) form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillForm(formId, data) {
|
|
||||||
const form = document.getElementById(formId);
|
|
||||||
if (!form) return;
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
const input = form.querySelector(`[name="${key}"]`);
|
|
||||||
if (input) input.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTMX event handlers
|
// HTMX event handlers
|
||||||
document.body.addEventListener("htmx:afterSwap", (evt) => {
|
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||||
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast");
|
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
|
||||||
if (toastHeader) {
|
if (toastHeader) {
|
||||||
const parts = toastHeader.split(":");
|
const parts = toastHeader.split(':');
|
||||||
const msg = parts.slice(1).join(":").trim();
|
const msg = parts.slice(1).join(':').trim();
|
||||||
showToast(msg, parts[0]?.trim() || "info");
|
showToast(msg, parts[0]?.trim() || 'info');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener("htmx:responseError", (evt) => {
|
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||||
const status = evt.detail.xhr?.status || 0;
|
const status = evt.detail.xhr?.status || 0;
|
||||||
showToast(`Request failed (${status})`, "error");
|
const json = evt.detail.xhr?.response;
|
||||||
|
let msg = 'Request failed (' + status + ')';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (parsed.error) msg = parsed.error;
|
||||||
|
} catch (e) {}
|
||||||
|
showToast(msg, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeRequest", (evt) => {
|
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||||
const target = evt.target;
|
const btn = evt.target.closest('.btn');
|
||||||
const btn = target.closest(".btn");
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.dataset.originalText = btn.textContent;
|
btn.dataset.originalText = btn.textContent;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Loading...";
|
btn.textContent = 'Loading...';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener("htmx:afterRequest", (evt) => {
|
document.body.addEventListener('htmx:afterRequest', (evt) => {
|
||||||
const target = evt.target;
|
const btn = evt.target.closest('.btn');
|
||||||
const btn = target.closest(".btn");
|
|
||||||
if (btn && btn.dataset.originalText !== undefined) {
|
if (btn && btn.dataset.originalText !== undefined) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = btn.dataset.originalText;
|
btn.textContent = btn.dataset.originalText;
|
||||||
@@ -139,9 +88,236 @@ document.body.addEventListener("htmx:afterRequest", (evt) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on escape
|
// Keyboard: Escape closes all modals
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
|
document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------- Renderer helpers for htmx-driven DOM updates --------
|
||||||
|
|
||||||
|
const renderZones = (data) => {
|
||||||
|
const active = Array.isArray(data) ? data : (data.active || []);
|
||||||
|
if (!active.length) return '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
|
||||||
|
return active.map(zone =>
|
||||||
|
'<div class="card" style="position:relative;">' +
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
|
||||||
|
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
|
||||||
|
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
|
||||||
|
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
|
||||||
|
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||||
|
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
|
||||||
|
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
|
||||||
|
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
|
||||||
|
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRules = (data) => {
|
||||||
|
let html = '';
|
||||||
|
let zoneRules = {};
|
||||||
|
const cfgZones = data && data.zones ? data.zones : null;
|
||||||
|
if (cfgZones) {
|
||||||
|
Object.keys(cfgZones).forEach(zname => {
|
||||||
|
const rr = cfgZones[zname].rich_rules || [];
|
||||||
|
if (rr.length) zoneRules[zname] = rr;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
zoneRules = data || {};
|
||||||
|
}
|
||||||
|
Object.keys(zoneRules).forEach(zone => {
|
||||||
|
let entries = zoneRules[zone];
|
||||||
|
if (!Array.isArray(entries)) entries = [];
|
||||||
|
html += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
|
||||||
|
if (entries.length) {
|
||||||
|
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
|
||||||
|
entries.forEach((entry, i) => {
|
||||||
|
let ruleId, ruleText;
|
||||||
|
if (typeof entry === 'object' && entry.rule) {
|
||||||
|
ruleId = entry.id;
|
||||||
|
ruleText = entry.rule;
|
||||||
|
} else {
|
||||||
|
ruleId = null;
|
||||||
|
ruleText = String(entry);
|
||||||
|
}
|
||||||
|
html += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
|
||||||
|
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
|
||||||
|
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
|
||||||
|
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
|
||||||
|
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForwards = (forwards) => {
|
||||||
|
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
|
||||||
|
return forwards.map(fwd => {
|
||||||
|
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||||
|
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
|
||||||
|
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
|
||||||
|
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
|
||||||
|
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
|
||||||
|
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForwardsFromConfig = (data) => {
|
||||||
|
const zones = data.zones || {};
|
||||||
|
const forwards = [];
|
||||||
|
Object.keys(zones).forEach(name => {
|
||||||
|
zones[name].forward_ports = zones[name].forward_ports || [];
|
||||||
|
zones[name].forward_ports.forEach(fwd => {
|
||||||
|
forwards.push({
|
||||||
|
zone: name,
|
||||||
|
'proxy-protocol': fwd['proxy-protocol'] || fwd.proto,
|
||||||
|
port: fwd.port,
|
||||||
|
'to-addr': fwd['to-addr'] || fwd.toaddr,
|
||||||
|
'to-port': fwd['to-port'] || fwd.toport
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return renderForwards(forwards);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRanges = (ranges) => {
|
||||||
|
if (!ranges.length) return '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
|
||||||
|
return ranges.map(rng =>
|
||||||
|
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
|
||||||
|
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
|
||||||
|
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
|
||||||
|
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStaticLeases = (leases) => {
|
||||||
|
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
|
||||||
|
return leases.map(lease =>
|
||||||
|
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
|
||||||
|
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
|
||||||
|
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDnsRecords = (records) => {
|
||||||
|
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
|
||||||
|
return records.map(rec =>
|
||||||
|
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
|
||||||
|
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
|
||||||
|
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDomains = (domains) => {
|
||||||
|
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
|
||||||
|
return domains.map(d => {
|
||||||
|
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
|
||||||
|
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||||
|
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
|
||||||
|
else if (typeof d.days_remaining === 'number') {
|
||||||
|
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
|
||||||
|
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
|
||||||
|
else certHtml = '<span class="badge badge-success">Valid</span>';
|
||||||
|
}
|
||||||
|
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
|
||||||
|
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
|
||||||
|
'<td>' + (d.backend_port || '-') + '</td>' +
|
||||||
|
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
|
||||||
|
'<td>' + certHtml + '</td>' +
|
||||||
|
'<td><div class="flex gap-2">' +
|
||||||
|
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
|
||||||
|
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPeers = (peers) => {
|
||||||
|
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
|
||||||
|
return peers.map(peer =>
|
||||||
|
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
|
||||||
|
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
|
||||||
|
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
|
||||||
|
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
|
||||||
|
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
|
||||||
|
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
|
||||||
|
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
|
||||||
|
'<td><div class="flex gap-2">' +
|
||||||
|
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
|
||||||
|
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCerts = (certs) => {
|
||||||
|
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
|
||||||
|
return certs.map(cert => {
|
||||||
|
const days = cert.days_remaining;
|
||||||
|
let badgeHtml;
|
||||||
|
if (cert.expired || (days !== undefined && days <= 0)) {
|
||||||
|
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
|
||||||
|
} else if (days !== undefined && days <= 30) {
|
||||||
|
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
|
||||||
|
} else {
|
||||||
|
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
|
||||||
|
}
|
||||||
|
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
|
||||||
|
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
|
||||||
|
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
|
||||||
|
'<td>' + badgeHtml + '</td>' +
|
||||||
|
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
|
||||||
|
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInterfaces = (interfaces) => {
|
||||||
|
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
|
||||||
|
return interfaces.map(iface => {
|
||||||
|
const zoneOptions = (iface.zones || []).map(z =>
|
||||||
|
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
|
||||||
|
).join('');
|
||||||
|
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
|
||||||
|
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
|
||||||
|
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
|
||||||
|
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
|
||||||
|
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
|
||||||
|
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignZone = (ifaceName, selectEl) => {
|
||||||
|
fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ interfaces: [ifaceName] })
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (r.ok) {
|
||||||
|
showSuccessToast(ifaceName + ' assigned to ' + selectEl.value);
|
||||||
|
refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces);
|
||||||
|
}
|
||||||
|
else return r.json().then(j => { throw new Error(j.error || r.statusText); });
|
||||||
|
})
|
||||||
|
.catch(e => { showErrorToast(e.message); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const escHtml = (s) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(s));
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escAttr = (s) => {
|
||||||
|
return String(s).replace(/&/g,'&').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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body hx-ext="json-enc">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
VACUUM WALL
|
VACUUM WALL
|
||||||
@@ -591,58 +591,8 @@
|
|||||||
|
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script>
|
<script src="/static/json-enc.js"></script>
|
||||||
function showToast(message, type, duration) {
|
<script src="/static/app.js"></script>
|
||||||
duration = duration || 4000;
|
|
||||||
var container = document.getElementById('toast-container');
|
|
||||||
if (!container) return;
|
|
||||||
var toast = document.createElement('div');
|
|
||||||
toast.className = 'toast toast-' + type;
|
|
||||||
toast.textContent = message;
|
|
||||||
container.appendChild(toast);
|
|
||||||
requestAnimationFrame(function() { toast.classList.add('show'); });
|
|
||||||
setTimeout(function() {
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(function() { toast.remove(); }, 300);
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal(id) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (el) el.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal(id) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (el) el.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(tabName) {
|
|
||||||
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
|
||||||
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
|
|
||||||
document.getElementById('tab-' + tabName).classList.add('active');
|
|
||||||
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
|
||||||
if (clickedTab) clickedTab.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
|
|
||||||
var msg = el.getAttribute('data-success') || 'Operation successful';
|
|
||||||
var type = el.getAttribute('data-type') || 'success';
|
|
||||||
el.addEventListener('htmx:afterRequest', function(evt) {
|
|
||||||
if (evt.detail && evt.detail.successful) {
|
|
||||||
showToast(msg, type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<th style="width:120px;">Actions</th>
|
<th style="width:120px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="cert-rows">
|
||||||
{% for cert in (certs or []) %}
|
{% for cert in (certs or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);">
|
<form hx-post="/api/certs/{{ cert.get('domain', '') }}/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Renewal started for {{ cert.domain }}'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
|
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
|
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Issue New Certificate</h2>
|
<h2>Issue New Certificate</h2>
|
||||||
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);">
|
<form hx-post="/api/certs/issue" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="cert-domain">Domain</label>
|
<label for="cert-domain">Domain</label>
|
||||||
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
||||||
|
|||||||
+20
-20
@@ -13,7 +13,7 @@
|
|||||||
<div class="section-title">DHCP Ranges</div>
|
<div class="section-title">DHCP Ranges</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/dhcp/ranges" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="range-interface">Interface</label>
|
<label for="range-interface">Interface</label>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<th style="width:80px;">Action</th>
|
<th style="width:80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="range-rows">
|
||||||
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ rng.get('interface', '(global)') }}</td>
|
<td>{{ rng.get('interface', '(global)') }}</td>
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
<td>{{ rng.get('end', '') }}</td>
|
<td>{{ rng.get('end', '') }}</td>
|
||||||
<td>{{ rng.get('lease_time', '1h') }}</td>
|
<td>{{ rng.get('lease_time', '1h') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<div class="section-title">Static Leases</div>
|
<div class="section-title">Static Leases</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/dhcp/static-lease" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="lease-mac">MAC Address</label>
|
<label for="lease-mac">MAC Address</label>
|
||||||
@@ -105,15 +105,15 @@
|
|||||||
<th style="width:80px;">Action</th>
|
<th style="width:80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="lease-rows">
|
||||||
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ lease.get('mac', '') }}</td>
|
<td>{{ lease.get('mac', '') }}</td>
|
||||||
<td>{{ lease.get('ip', '') }}</td>
|
<td>{{ lease.get('ip', '') }}</td>
|
||||||
<td>{{ lease.get('hostname', '-') }}</td>
|
<td>{{ lease.get('hostname', '-') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -132,15 +132,15 @@
|
|||||||
<div class="section-title">Custom DNS Records</div>
|
<div class="section-title">Custom DNS Records</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/dhcp/dns-record" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="dns-ip">IP Address</label>
|
<label for="dns-ip">IP Address</label>
|
||||||
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;">
|
<input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="dns-hostname">Hostname / Domain</label>
|
<label for="dns-hostname">Hostname / Domain</label>
|
||||||
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;">
|
<input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add Record</button>
|
<button type="submit" class="btn btn-primary">Add Record</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,19 +149,19 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>IP</th>
|
<th>Name</th>
|
||||||
<th>Hostname</th>
|
<th>Address</th>
|
||||||
<th style="width:80px;">Action</th>
|
<th style="width:80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="dns-rows">
|
||||||
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ rec.get('ip', '') }}</td>
|
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
|
||||||
<td>{{ rec.get('hostname', '') }}</td>
|
<td class="text-sm">{{ rec.get('address', '-') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="mt-2 text-right">
|
<div class="mt-2 text-right">
|
||||||
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply & 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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
<th>IP Address</th>
|
<th>IP Address</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Zone</th>
|
<th>Zone</th>
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="interface-list">
|
||||||
{% for iface in (interfaces or []) %}
|
{% for iface in (interfaces or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||||
@@ -39,12 +38,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if zones %}
|
{% if zones %}
|
||||||
<select
|
<select
|
||||||
class="htmx-on-success"
|
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
|
||||||
data-success="Zone updated for {{ iface.name }}"
|
|
||||||
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-select-oob="#toast-container *"
|
|
||||||
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
|
|
||||||
>
|
>
|
||||||
{% for zone in zones %}
|
{% for zone in zones %}
|
||||||
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
|
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
|
||||||
@@ -54,9 +48,6 @@
|
|||||||
<span class="text-muted">No zones configured</span>
|
<span class="text-muted">No zones configured</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not (interfaces or []) %}
|
{% if not (interfaces or []) %}
|
||||||
@@ -67,22 +58,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
|
|
||||||
var zoneName = selectEl.value;
|
|
||||||
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
|
|
||||||
fetch(url, { method: 'POST' })
|
|
||||||
.then(function(res) {
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
|
|
||||||
} else {
|
|
||||||
return res.text().then(function(txt) { throw new Error(txt); });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
showToast('Failed to assign: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+69
-36
@@ -13,9 +13,7 @@
|
|||||||
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">
|
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
|
||||||
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,15 +22,16 @@
|
|||||||
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
|
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
|
||||||
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
||||||
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
||||||
|
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-journal" class="tab-content active">
|
<div id="tab-journal" class="tab-content active">
|
||||||
<div class="card" style="padding:0;overflow:hidden;">
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
<div class="log-viewer" id="log-journal"
|
<div class="log-viewer" id="log-journal"
|
||||||
hx-get="/api/logs/journal"
|
hx-get="/api/logs/journal"
|
||||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
hx-trigger="none"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#refresh-indicator">
|
hx-indicator=".page-header .htmx-indicator">
|
||||||
Loading journal entries...
|
Loading journal entries...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,10 +40,10 @@
|
|||||||
<div id="tab-nginx-access" class="tab-content">
|
<div id="tab-nginx-access" class="tab-content">
|
||||||
<div class="card" style="padding:0;overflow:hidden;">
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
<div class="log-viewer" id="log-nginx-access"
|
<div class="log-viewer" id="log-nginx-access"
|
||||||
hx-get="/api/logs/nginx/access"
|
hx-get="/api/logs/nginx/access"
|
||||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
hx-trigger="none"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#refresh-indicator">
|
hx-indicator=".page-header .htmx-indicator">
|
||||||
Loading Nginx access log...
|
Loading Nginx access log...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,10 +52,10 @@
|
|||||||
<div id="tab-nginx-error" class="tab-content">
|
<div id="tab-nginx-error" class="tab-content">
|
||||||
<div class="card" style="padding:0;overflow:hidden;">
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
<div class="log-viewer" id="log-nginx-error"
|
<div class="log-viewer" id="log-nginx-error"
|
||||||
hx-get="/api/logs/nginx/error"
|
hx-get="/api/logs/nginx/error"
|
||||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
hx-trigger="none"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#refresh-indicator">
|
hx-indicator=".page-header .htmx-indicator">
|
||||||
Loading Nginx error log...
|
Loading Nginx error log...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,42 +64,76 @@
|
|||||||
<div id="tab-dnsmasq" class="tab-content">
|
<div id="tab-dnsmasq" class="tab-content">
|
||||||
<div class="card" style="padding:0;overflow:hidden;">
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
<div class="log-viewer" id="log-dnsmasq"
|
<div class="log-viewer" id="log-dnsmasq"
|
||||||
hx-get="/api/logs/dnsmasq"
|
hx-get="/api/logs/dnsmasq"
|
||||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
hx-trigger="none"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#refresh-indicator">
|
hx-indicator=".page-header .htmx-indicator">
|
||||||
Loading dnsmasq log...
|
Loading dnsmasq log...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-app" class="tab-content">
|
||||||
|
<div class="card" style="padding:0;overflow:hidden;">
|
||||||
|
<div class="log-viewer" id="log-app"
|
||||||
|
hx-get="/api/logs/app"
|
||||||
|
hx-trigger="none"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator=".page-header .htmx-indicator">
|
||||||
|
Loading app log...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var autoRefreshTimer = null;
|
var refreshInterval = {{ (refresh_interval | default(15)) }};
|
||||||
|
var currentTab = 'journal';
|
||||||
|
|
||||||
|
function setActivePolling() {
|
||||||
|
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||||
|
htmx.abort(el);
|
||||||
|
el.setAttribute('hx-trigger', 'none');
|
||||||
|
});
|
||||||
|
var activeEl = document.getElementById('log-' + currentTab);
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadActiveTab() {
|
||||||
|
var activeEl = document.getElementById('log-' + currentTab);
|
||||||
|
if (activeEl) {
|
||||||
|
htmx.ajax('GET', activeEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var origSwitchTab = switchTab;
|
||||||
|
switchTab = function(tabName) {
|
||||||
|
currentTab = tabName;
|
||||||
|
if (typeof origSwitchTab === 'function') {
|
||||||
|
origSwitchTab(tabName);
|
||||||
|
}
|
||||||
|
if (document.getElementById('auto-refresh-toggle').checked) {
|
||||||
|
setActivePolling();
|
||||||
|
}
|
||||||
|
loadActiveTab();
|
||||||
|
};
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
function toggleAutoRefresh() {
|
||||||
var toggle = document.getElementById('auto-refresh-toggle');
|
var toggle = document.getElementById('auto-refresh-toggle');
|
||||||
var indicators = document.querySelectorAll('.log-viewer');
|
|
||||||
|
|
||||||
if (toggle.checked) {
|
if (toggle.checked) {
|
||||||
indicators.forEach(function(el) {
|
setActivePolling();
|
||||||
el.setAttribute('hx-trigger', 'every 15s');
|
loadActiveTab();
|
||||||
hx.trigger(el, 'htmx:refresh');
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
indicators.forEach(function(el) {
|
document.querySelectorAll('.log-viewer').forEach(function(el) {
|
||||||
el.setAttribute('hx-trigger', 'never');
|
htmx.abort(el);
|
||||||
|
el.setAttribute('hx-trigger', 'none');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
|
loadActiveTab();
|
||||||
document.getElementById('refresh-indicator').style.display = 'inline';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
|
||||||
document.getElementById('refresh-indicator').style.display = 'none';
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+19
-39
@@ -17,7 +17,6 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Zone</th>
|
<th>Zone</th>
|
||||||
<th style="width:120px;">Masquerade</th>
|
<th style="width:120px;">Masquerade</th>
|
||||||
<th style="width:80px;">Action</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -25,21 +24,23 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
<label class="switch">
|
<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; }">
|
||||||
<input type="checkbox"
|
<label class="switch">
|
||||||
{% if zone.get('masquerade') %}checked{% endif %}
|
<input type="checkbox"
|
||||||
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
|
{% if zone.get('masquerade') %}checked{% endif %}
|
||||||
<span class="slider"></span>
|
id="masq-{{ zone.get('name', '') }}"
|
||||||
</label>
|
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
|
||||||
</td>
|
disabled>
|
||||||
<td>
|
<span class="slider"></span>
|
||||||
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
|
</label>
|
||||||
|
<button type="submit" style="display:none"></button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not (zones or []) %}
|
{% if not (zones or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-muted text-sm">No zones configured</td>
|
<td colspan="2" class="text-muted text-sm">No zones configured</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<h3>Add Forward Rule</h3>
|
<h3>Add Forward Rule</h3>
|
||||||
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/firewall/forward-port" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fw-zone">Zone</label>
|
<label for="fw-zone">Zone</label>
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fw-protocol">Protocol</label>
|
<label for="fw-protocol">Protocol</label>
|
||||||
<select id="fw-protocol" name="protocol">
|
<select id="fw-protocol" name="proto">
|
||||||
<option value="tcp">TCP</option>
|
<option value="tcp">TCP</option>
|
||||||
<option value="udp">UDP</option>
|
<option value="udp">UDP</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -74,11 +75,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fw-target">Target Address</label>
|
<label for="fw-target">Target Address</label>
|
||||||
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;">
|
<input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fw-target-port">Target Port</label>
|
<label for="fw-target-port">Target Port</label>
|
||||||
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;">
|
<input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
<th style="width:80px;">Action</th>
|
<th style="width:80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="forward-rows">
|
||||||
{% set all_forwards = [] %}
|
{% set all_forwards = [] %}
|
||||||
{% for zone in (zones or []) %}
|
{% for zone in (zones or []) %}
|
||||||
{% for fwd in zone.get('forward_ports', []) %}
|
{% for fwd in zone.get('forward_ports', []) %}
|
||||||
@@ -112,8 +113,8 @@
|
|||||||
<td>{{ fwd['to-addr'] }}</td>
|
<td>{{ fwd['to-addr'] }}</td>
|
||||||
<td>{{ fwd['to-port'] }}</td>
|
<td>{{ fwd['to-port'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd['proxy-protocol'] }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Rule removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd['proxy-protocol'] }} → {{ fwd['to-addr'] }}:{{ fwd['to-port'] }}?">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -127,25 +128,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleMasquerade(zoneName, enabled) {
|
|
||||||
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
|
|
||||||
var body = JSON.stringify({ enable: enabled });
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body
|
|
||||||
})
|
|
||||||
.then(function(res) {
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
|
|
||||||
} else {
|
|
||||||
return res.text().then(function(t) { throw new Error(t); });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
showToast('Failed: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
||||||
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</button>
|
<button class="btn btn-outline" hx-post="/api/proxy/ssl-apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('SSL settings applied')">Apply SSL Settings</button>
|
||||||
|
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<th style="width:140px;">Actions</th>
|
<th style="width:140px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="domain-rows">
|
||||||
{% for domain in (domains or []) %}
|
{% for domain in (domains or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
||||||
@@ -56,8 +57,8 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
||||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Add Proxy Domain</h2>
|
<h2>Add Proxy Domain</h2>
|
||||||
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);">
|
<form hx-post="/api/proxy/domains" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new-domain">Domain</label>
|
<label for="new-domain">Domain</label>
|
||||||
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
|
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
|
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Edit Proxy Domain</h2>
|
<h2>Edit Proxy Domain</h2>
|
||||||
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);">
|
<form id="edit-domain-form" hx-post="/api/proxy/domains" hx-swap="none" hx-encoding="json" hx-on::after-request="if(evt.detail.successful){ closeModal('edit-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain updated'); }">
|
||||||
<input type="hidden" id="edit-original-domain" name="original_domain">
|
<input type="hidden" id="edit-original-domain" name="original_domain">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-domain">Domain</label>
|
<label for="edit-domain">Domain</label>
|
||||||
@@ -144,9 +145,6 @@ function openEditDomainModal(domainName, d) {
|
|||||||
document.getElementById('edit-backend-host').value = d.backend_host || '';
|
document.getElementById('edit-backend-host').value = d.backend_host || '';
|
||||||
document.getElementById('edit-backend-port').value = d.backend_port || '';
|
document.getElementById('edit-backend-port').value = d.backend_port || '';
|
||||||
document.getElementById('edit-protocol').value = d.protocol || 'http';
|
document.getElementById('edit-protocol').value = d.protocol || 'http';
|
||||||
var form = document.getElementById('edit-domain-form');
|
|
||||||
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
|
|
||||||
form.setAttribute('hx-put', target);
|
|
||||||
openModal('edit-domain-modal');
|
openModal('edit-domain-modal');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<h3>Add Rule</h3>
|
<h3>Add Rule</h3>
|
||||||
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/firewall/rich-rules" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rule-zone">Zone</label>
|
<label for="rule-zone">Zone</label>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="rules-container">
|
||||||
{% if rules or False %}
|
{% if rules or False %}
|
||||||
{% for zone_name, zone_rules in rules.items() %}
|
{% for zone_name, zone_rules in rules.items() %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -49,12 +50,13 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for rule in zone_rules %}
|
{% for rule in zone_rules %}
|
||||||
|
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted">{{ loop.index }}</td>
|
<td class="text-muted">{{ loop.index }}</td>
|
||||||
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td>
|
<td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
|
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not (zones or []) %}
|
{% if not (zones or []) %}
|
||||||
<div class="card" style="border-color:var(--warning);">
|
<div class="card" style="border-color:var(--warning);">
|
||||||
|
|||||||
@@ -11,17 +11,13 @@
|
|||||||
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
||||||
hx-post="/api/wireguard/down"
|
hx-post="/api/wireguard/down"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
class="htmx-on-success"
|
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
|
||||||
data-success="Tunnel stopped"
|
|
||||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
|
||||||
Stop Tunnel
|
Stop Tunnel
|
||||||
</button>
|
</button>
|
||||||
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
||||||
hx-post="/api/wireguard/up"
|
hx-post="/api/wireguard/apply"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
class="htmx-on-success"
|
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
|
||||||
data-success="Tunnel started"
|
|
||||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
|
||||||
Start Tunnel
|
Start Tunnel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<h3>Add Peer</h3>
|
<h3>Add Peer</h3>
|
||||||
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-post="/api/wireguard/peers" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer added'); }">
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="peer-name">Name</label>
|
<label for="peer-name">Name</label>
|
||||||
@@ -63,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="peer-allowed">Allowed IPs</label>
|
<label for="peer-allowed">Allowed IPs</label>
|
||||||
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|length + 2) %}{{ next }}/32" required style="width:160px;">
|
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers or []|length + 2) %}{{ next }}/32" required style="width:160px;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Add Peer</button>
|
<button type="submit" class="btn btn-primary">Add Peer</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +80,7 @@
|
|||||||
<th style="width:160px;">Actions</th>
|
<th style="width:160px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="peer-rows">
|
||||||
{% for peer in (peers or []) %}
|
{% for peer in (peers or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -102,8 +98,8 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
||||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
|
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-grid">
|
<div id="zone-grid" class="card-grid">
|
||||||
{% for zone in (zones or []) %}
|
{% for zone in (zones or []) %}
|
||||||
<div class="card" style="position:relative;">
|
<div class="card" style="position:relative;">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
|
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
|
||||||
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted">
|
<form hx-delete="/api/firewall/zones/{{ zone.get('name', '') }}" hx-swap="none" hx-confirm="Delete zone {{ zone.name }}? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone deleted'); }">
|
||||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
|
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Create Zone</h2>
|
<h2>Create Zone</h2>
|
||||||
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();">
|
<form hx-post="/api/firewall/zones" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="zone-name">Zone Name</label>
|
<label for="zone-name">Zone Name</label>
|
||||||
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
||||||
|
|||||||
Reference in New Issue
Block a user