refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+24 -7
View File
@@ -3,7 +3,7 @@
## What This Is ## What This Is
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy. SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
Deploys on Debian 13 (trixie). Install dir: `/opt/vacuum-wall`. Deploys on Debian 13 (trixie). Serves from repo root by default.
## Architecture ## Architecture
@@ -14,16 +14,26 @@ 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.
- `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). - `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`. - `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). 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.
**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.
| Library | Version | Local file | CDN source |
| ------- | ------- | ----------------------------------- | ---------- |
| htmx | 2.0.4 | `webui/static/htmx.min.js` | `npm:htmx.org@2.0.4` |
| htmx-ext-json-enc | 2.0.0 | `webui/static/json-enc.js` | `npm:htmx-ext-json-enc@2.0.0` |
## Deployment ## Deployment
`install.sh` deploys to `/opt/vacuum-wall` by rsyncing the repo. The `vacuum-wall` system user has `HOME=/opt/vacuum-wall` but no actual home directory (`--no-create-home`). `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/`. All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`.
@@ -56,8 +66,9 @@ 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`
@@ -78,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.
+28 -10
View File
@@ -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 | ACME registration email (ZeroSSL by default) |
`--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.
@@ -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
+27 -2
View File
@@ -103,9 +103,16 @@ Compare declarative config against live firewalld state. Returns diff for interf
PATCH /api/firewall/config PATCH /api/firewall/config
``` ```
Deep-merge the provided fields into the existing config. Deep-merge the provided fields into the existing config. Returns pending changes summary.
**Response:** `data` is `null` on success. **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
@@ -927,6 +934,24 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
**Response:** `data` contains the updated configuration (`private_key` omitted). **Response:** `data` contains the 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
#### Apply Configuration #### Apply Configuration
+4 -4
View File
@@ -37,18 +37,18 @@ Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ fire
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 service user) ──→ ZeroSSL ACME Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
Flask WebUI ──→ lib/wireguard.py ──→ render $PROJECT_DIR/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 `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. 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`, so no hardcoded paths are needed in Python code. 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 ## Install-Time Templating
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time: System configuration files in `system/` are Jinja2 templates rendered by `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/`. - **`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. - **`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. - The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is.
@@ -99,7 +99,7 @@ data/
└── wireguard/ # WireGuard runtime artifacts └── wireguard/ # WireGuard runtime artifacts
``` ```
Both `config/` and `data/` reside within the project directory (`$PROJECT_DIR/`). 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 `PROJECT_DIR` value is templated into the service unit at install time. Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the 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
+3 -3
View File
@@ -107,7 +107,7 @@ This file defines reverse proxy domains, the management interface, and global SS
}, },
"auth": { "auth": {
"user": "admin", "user": "admin",
"htpasswd": "$PROJECT_DIR/data/nginx/.htpasswd" "htpasswd": "data/nginx/.htpasswd"
} }
}, },
"ssl": { "ssl": {
@@ -142,7 +142,7 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
|---|---| |---|---|
| `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. | | `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 at `$PROJECT_DIR/data/certs/`. | | `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
@@ -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 $PROJECT_DIR/data/nginx/.htpasswd admin yourpassword htpasswd -bc data/nginx/.htpasswd admin yourpassword
``` ```
### Global SSL Settings ### Global SSL Settings
+56 -42
View File
@@ -19,54 +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
# Option A: Public DNS # Production: all env vars
PROJECT_DIR="/opt/vacuum-wall" \
USER_NAME="vacuum-wall" \
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
# Option B: mDNS (LAN-only, no DNS record needed) # Dev mode: CLI flags, auto-detects repo owner
MGMT_DOMAIN=vacuum-wall.local \ ./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
MGMT_PASS="strongpassword" \
MGMT_USER="admin" \ # mDNS (LAN-only, no DNS record needed)
ACME_EMAIL="admin@example.com" \ ./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass --acme-email "me@example.com"
bash install.sh
``` ```
### 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.
|---|---|---|
| `PROJECT_DIR` | No | Directory where the project resides. Auto-discovers from `install.sh` location if not set. | | Flag | Env Var | Required | Description |
| `USER_NAME` | No | System user that runs the WebUI service. Defaults to `vacuum-wall`. | |---|---|---|---|
| `MGMT_DOMAIN` | No | The domain for the management WebUI. Defaults to `$hostname.local` (auto-detected from the system hostname), which works with mDNS on your LAN (avahi-daemon is installed and enabled automatically). Set explicitly for a custom DNS domain (e.g., `wall.example.com`). **Errors if hostname is undetectable and this var is not set.** | | -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| `MGMT_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. | | `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. | | `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `ACME_EMAIL` | Yes | The email address registered with the ACME provider (ZeroSSL by default) for certificate issuance and expiry notifications. | | `--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 ## Container / Custom Deployment
You can deploy Vacuum Wall in a container or at any custom path. Set `PROJECT_DIR` to the mount or bind path, and `USER_NAME` to whatever system user exists in the container or host environment: 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 ```bash
# Docker volume mount example # Docker volume mount example
PROJECT_DIR="/app/vacuum-wall" \ ./install.sh --path /app/vacuum-wall --user ww-app \
USER_NAME="ww-app" \ --mgmt-domain proxy.internal --mgmt-pass strongpassword \
MGMT_DOMAIN="proxy.internal" \ --acme-email "admin@example.com"
MGMT_PASS="strongpassword" \
ACME_EMAIL="admin@example.com" \
bash install.sh
``` ```
The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `PROJECT_DIR`. This means no hardcoded paths remain after installation. 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.
--- ---
@@ -76,19 +77,20 @@ The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, 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.
- **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. - **System user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with a nologin shell that owns the project data and runs the WebUI service.
- **Python venv**: Sets up a Python virtual environment and installs project dependencies. - **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for ACME certificate management. - **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
- **Directory setup**: Creates config directories under `$PROJECT_DIR/config/` for each subsystem's declarative JSON, and data directories under `$PROJECT_DIR/data/` for nginx sites, dnsmasq fragments, firewall rules, and WireGuard config. Sets ownership to the configured system 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).
- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `PROJECT_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values. - **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`. - **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. - **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.
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network. - **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. - **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/.htpasswd` (used by install.sh's initial nginx config) and `$PROJECT_DIR/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.
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured, so the WebUI can render management proxy config out of the box. - **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): - **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.
@@ -96,9 +98,21 @@ The installer performs the following steps automatically:
- **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 WebUI service, 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 ACME 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.
--- ---
## Post-Installation ## Post-Installation
@@ -240,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 `$PROJECT_DIR/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
@@ -277,7 +291,7 @@ 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 `$PROJECT_DIR/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
@@ -294,10 +308,10 @@ Verify that:
| Component | Service | Config Location | | Component | Service | Config Location |
|---|---|---| |---|---|---|
| WebUI backend | `vacuum-wall.service` | `$PROJECT_DIR/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` | `$PROJECT_DIR/config/dnsmasq/` | | DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
| VPN | wireguard-tools | `$PROJECT_DIR/config/wireguard/` | | VPN | wireguard-tools | `config/wireguard/` |
| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` | | Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` | | Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
+11 -5
View File
@@ -29,7 +29,7 @@ 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
@@ -40,14 +40,17 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
## 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_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://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname (use `MGMT_DOMAIN` to override), provisions nginx, sets up authentication, generates an initial self-signed 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
@@ -74,6 +77,8 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime) │ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
│ └── wireguard*.conf # WireGuard templates (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
@@ -82,6 +87,7 @@ After installation, access the management interface at `https://<hostname>.local
├── 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
+3 -4
View File
@@ -39,7 +39,7 @@ Key safety properties:
### Management Interface ### Management Interface
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `$PROJECT_DIR/data/nginx/.htpasswd`. The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
### Proxy Domains ### Proxy Domains
@@ -71,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` | `$PROJECT_DIR/config`, `$PROJECT_DIR/data`, and `/tmp` | Both the application config directory and data directory, plus `/tmp`, are writable. The project path is templated at install time. |
| `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 |
@@ -89,7 +88,7 @@ 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 |
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 `PROJECT_DIR`. 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. 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.
+197 -89
View File
@@ -12,75 +12,146 @@ log() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; } warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; } err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
# --- Configurable via environment --- # --- CLI argument parsing ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)" _cli_user=""
USER_NAME="${USER_NAME:-vacuum-wall}" _cli_is_dev=false
_cli_path=""
# --- Validate required env vars --- _cli_mgmt_pass=""
missing=() _cli_mgmt_user=""
[[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS) _cli_mgmt_domain=""
[[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL) _cli_acme_email=""
_cli_force_venv=false
if (( ${#missing[@]} )); then _cli_wan_iface=""
echo -e "${RED}[!!]${NC} Missing required environment variables:" _cli_lan_ifaces=""
for v in "${missing[@]}"; do while [[ $# -gt 0 ]]; do
case "$v" in case "$1" in
MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';; --user|-u) _cli_user="$2"; shift 2 ;;
ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";; --path|-p) _cli_path="$2"; shift 2 ;;
--dev) _cli_is_dev=true; shift ;;
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
--acme-email) _cli_acme_email="$2"; shift 2 ;;
--force-venv) _cli_force_venv=true; shift ;;
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
-h|--help)
printf '%s\n' \
"Usage: install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER System user for service (default: vacuum-wall)" \
" --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --acme-email EMAIL ACME registration email (required)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
" -h, --help Show this help" \
"" \
"All options also have environment variable equivalents:" \
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh"
exit 0
;;
*)
err "Unknown argument: $1 (use --help for usage)"
;;
esac esac
done done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi
# Auto-detect MGMT_DOMAIN from system hostname if not provided # --- Resolve config: CLI flag > env var > default ---
if [[ -z "${MGMT_DOMAIN:-}" ]]; then REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
# Optional settings with defaults
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
if [[ -n "$_cli_mgmt_domain" ]]; then
DOMAIN="$_cli_mgmt_domain"
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
DOMAIN="$MGMT_DOMAIN"
else
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true) HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
if [[ -z "$HOSTNAME_F" ]]; then if [[ -z "$HOSTNAME_F" ]]; then
err "Cannot determine system hostname — set MGMT_DOMAIN env var." err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
fi fi
DOMAIN="${HOSTNAME_F}.local" DOMAIN="${HOSTNAME_F}.local"
else
DOMAIN="$MGMT_DOMAIN"
fi fi
MGMT_USER="${MGMT_USER:-admin}"
# Install directory (CLI > env > repo root)
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
if [[ -n "$INSTALL_DIR" ]]; then
PROJECT_DIR="$INSTALL_DIR"
else
PROJECT_DIR="$REPO_DIR"
fi
# Network interfaces (CLI > env — auto-detect happens later if still unset)
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
# --- Pre-flight checks --- # --- Pre-flight checks ---
[[ $EUID -eq 0 ]] || err "This script must be run as root." [[ $EUID -eq 0 ]] || err "This script must be run as root."
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu." [[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
# --- Deploy to /opt/vacuum-wall --- # --- Validate required settings ---
INSTALL_DIR="/opt/vacuum-wall" missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
# Install rsync first if not available (needed for deploy) if (( ${#missing[@]} )); then
if ! command -v rsync &>/dev/null; then echo -e "${RED}[!!]${NC} Missing required settings:"
apt-get update -qq for v in "${missing[@]}"; do
apt-get install -y -qq rsync case "$v" in
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi fi
if [[ -d "$INSTALL_DIR" ]]; then
if [[ -L "$INSTALL_DIR" ]]; then
log "Symbolic link already exists at $INSTALL_DIR, skipping deploy."
elif [[ "$INSTALL_DIR" == "$REPO_DIR" ]]; then
log "Installed from repo location, skipping deploy."
else
err "Installation directory $INSTALL_DIR already exists."
fi
else
log "Deploying $REPO_DIR$INSTALL_DIR"
rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.git' \
--exclude='build' \
"$REPO_DIR/" "$INSTALL_DIR/"
chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR"
fi
PROJECT_DIR="$INSTALL_DIR"
ACME_HOME="$PROJECT_DIR/data/acme" ACME_HOME="$PROJECT_DIR/data/acme"
# Dev mode: auto-detect repo owner as service user
if [[ "$_cli_is_dev" == true ]]; then
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
_cli_user="$_repo_owner"
log "Dev mode: using repo owner '$_repo_owner' as service user"
else
err "Dev mode: cannot determine repo owner (root or unavailable)."
fi
fi
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# --- Safety check: running service as a non-system regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME")
_gid=$(id -g "$USER_NAME")
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
warn "This grants NOPASSWD sudo and runs the web service as your login account."
warn "Only use for development. For production, use --user vacuum-wall."
fi
fi
echo "============================================" echo "============================================"
echo " Vacuum Wall Appliance Installer" echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR" echo " Install dir: $PROJECT_DIR"
@@ -114,17 +185,23 @@ else
fi fi
# --- 2b. Setup Python venv --- # --- 2b. Setup Python venv ---
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 ""
+48 -61
View File
@@ -1,13 +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 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
@@ -16,6 +13,8 @@ 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__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -46,64 +45,24 @@ 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") logger.info("dnsmasq config saved")
@@ -112,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,
@@ -121,14 +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") 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", {})
@@ -306,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)
@@ -341,7 +310,7 @@ def set_domain(domain: str | None) -> None:
# ───────── 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()
@@ -355,7 +324,7 @@ def get_status() -> dict:
except Exception: except Exception:
active = False active = False
conf_exists = os.path.isfile(DNSMASQ_CONF) conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists: if conf_exists:
try: try:
with open(DNSMASQ_CONF) as f: with open(DNSMASQ_CONF) as f:
@@ -381,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",
]
+94 -129
View File
@@ -8,23 +8,22 @@ A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the
Flask UI can inspect or restore previous configurations. Flask UI can inspect or restore previous configurations.
""" """
import json
import logging import logging
import os
import subprocess
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from lib.common import load_json, run, save_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall") DATA_DIR: Path = PROJECT_DIR / "data" / "firewall"
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json") RULES_FILE: Path = DATA_DIR / "rules.json"
CONFIG_DIR = PROJECT_DIR / "config" / "firewall" CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json" CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}} DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
@@ -34,35 +33,16 @@ DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> str:
"""Run a command via subprocess and return its stdout.
Callers must include ``"sudo"`` as the first argument when the
command requires elevated privileges.
Raises:
RuntimeError: When ``check=True`` and the process exits non-zero.
"""
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
return result.stdout.strip()
def _reload() -> None: def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately.""" """Reload firewalld so permanent changes take effect immediately."""
try: try:
_run(["sudo", "firewall-cmd", "--reload"]) run(["firewall-cmd", "--reload"], sudo=True)
logger.info("firewalld reloaded") logger.info("firewalld reloaded")
except RuntimeError as exc: except RuntimeError as exc:
logger.error("firewalld reload failed: %s", exc) logger.error("firewalld reload failed: %s", exc)
raise raise
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def _gen_id() -> str: def _gen_id() -> str:
"""Generate a short unique identifier (8 hex characters).""" """Generate a short unique identifier (8 hex characters)."""
return uuid4().hex[:8] return uuid4().hex[:8]
@@ -75,13 +55,13 @@ def _gen_id() -> str:
def get_available_zones() -> list[str]: def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names.""" """Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"]) output = run(["firewall-cmd", "--get-zones"], sudo=True)
return output.split() return output.split()
def get_active_zones() -> dict[str, list[str]]: def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces.""" """Return a dict mapping active zone names to their assigned interfaces."""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"]) output = run(["firewall-cmd", "--get-active-zones"], sudo=True)
zones: dict[str, list[str]] = {} zones: dict[str, list[str]] = {}
current_zone: str | None = None current_zone: str | None = None
for raw_line in output.splitlines(): for raw_line in output.splitlines():
@@ -105,7 +85,7 @@ def get_active_zones() -> dict[str, list[str]]:
def get_zone_info(zone: str) -> dict[str, Any]: def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*.""" """Return detailed information for *zone*."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"]) output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
info: dict[str, Any] = {"name": zone} info: dict[str, Any] = {"name": zone}
for line in output.splitlines(): for line in output.splitlines():
line = line.strip() line = line.strip()
@@ -157,19 +137,19 @@ def get_zone_info(zone: str) -> dict[str, Any]:
def get_services() -> list[str]: def get_services() -> list[str]:
"""Return the list of available service names known to firewalld.""" """Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"]) output = run(["firewall-cmd", "--get-services"], sudo=True)
return output.split() return output.split()
def get_icmp_blocks() -> list[str]: def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names.""" """Return the list of available ICMP block names."""
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"]) output = run(["firewall-cmd", "--get-icmptypes"], sudo=True)
return output.split() return output.split()
def get_interfaces() -> list[str]: def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2.""" """Return the list of network interfaces visible via iproute2."""
output = _run(["ip", "-o", "link", "show"]) output = run(["ip", "-o", "link", "show"])
ifaces: list[str] = [] ifaces: list[str] = []
for line in output.splitlines(): for line in output.splitlines():
if line: if line:
@@ -182,7 +162,7 @@ def get_interfaces() -> list[str]:
def get_rich_rules(zone: str) -> list[str]: def get_rich_rules(zone: str) -> list[str]:
"""Return the rich rules defined for *zone* as a list of raw strings.""" """Return the rich rules defined for *zone* as a list of raw strings."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-rich-rules"]) output = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
output = output.strip() output = output.strip()
if not output: if not output:
return [] return []
@@ -208,14 +188,14 @@ def get_rich_rules(zone: str) -> list[str]:
def create_zone(zone: str, target: str = "default") -> None: def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld.""" """Create a new permanent zone in firewalld."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--set-target={target}", f"--set-target={target}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Firewall zone '%s' created (target=%s)", zone, target) logger.info("Firewall zone '%s' created (target=%s)", zone, target)
@@ -223,7 +203,7 @@ def create_zone(zone: str, target: str = "default") -> None:
def delete_zone(zone: str) -> None: def delete_zone(zone: str) -> None:
"""Delete an existing zone.""" """Delete an existing zone."""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"]) run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload() _reload()
logger.info("Firewall zone '%s' deleted", zone) logger.info("Firewall zone '%s' deleted", zone)
@@ -240,26 +220,26 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
except Exception: except Exception:
current = [] current = []
for iface in current: for iface in current:
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--remove-interface=" + iface, "--remove-interface=" + iface,
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
for iface in interfaces: for iface in interfaces:
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--add-interface=" + iface, "--add-interface=" + iface,
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces) logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
@@ -267,14 +247,14 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
def add_zone_interface(zone: str, iface: str) -> None: def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*.""" """Add a single interface to *zone*."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--add-interface=" + iface, "--add-interface=" + iface,
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Interface '%s' added to zone '%s'", iface, zone) logger.info("Interface '%s' added to zone '%s'", iface, zone)
@@ -282,14 +262,14 @@ def add_zone_interface(zone: str, iface: str) -> None:
def remove_zone_interface(zone: str, iface: str) -> None: def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*.""" """Remove a single interface from *zone*."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--remove-interface=" + iface, "--remove-interface=" + iface,
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Interface '%s' removed from zone '%s'", iface, zone) logger.info("Interface '%s' removed from zone '%s'", iface, zone)
@@ -304,26 +284,26 @@ def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services.""" """Set services for *zone*, replacing any previously allowed services."""
current = get_zone_info(zone).get("services", []) current = get_zone_info(zone).get("services", [])
for svc in current: for svc in current:
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--remove-service={svc}", f"--remove-service={svc}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
for svc in services: for svc in services:
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--add-service={svc}", f"--add-service={svc}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Zone '%s' services set to %s", zone, services) logger.info("Zone '%s' services set to %s", zone, services)
@@ -331,14 +311,14 @@ def set_zone_services(zone: str, services: list[str]) -> None:
def add_zone_service(zone: str, service: str) -> None: def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*.""" """Add a single service to *zone*."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--add-service={service}", f"--add-service={service}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Service '%s' added to zone '%s'", service, zone) logger.info("Service '%s' added to zone '%s'", service, zone)
@@ -346,14 +326,14 @@ def add_zone_service(zone: str, service: str) -> None:
def remove_zone_service(zone: str, service: str) -> None: def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*.""" """Remove a single service from *zone*."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--remove-service={service}", f"--remove-service={service}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
logger.info("Service '%s' removed from zone '%s'", service, zone) logger.info("Service '%s' removed from zone '%s'", service, zone)
@@ -366,14 +346,14 @@ def remove_zone_service(zone: str, service: str) -> None:
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]: def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to *zone* and persist to declarative config.""" """Add a rich rule to *zone* and persist to declarative config."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--add-rich-rule=" + rule, "--add-rich-rule=" + rule,
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
_persist_rich_rule(zone, rule) _persist_rich_rule(zone, rule)
@@ -384,14 +364,14 @@ def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
def remove_rich_rule(zone: str, rule: str) -> None: def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*.""" """Remove a rich rule from *zone*."""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
"--remove-rich-rule=" + rule, "--remove-rich-rule=" + rule,
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
_unpersist_rich_rule(zone, rule) _unpersist_rich_rule(zone, rule)
@@ -400,7 +380,7 @@ def remove_rich_rule(zone: str, rule: str) -> None:
def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]: def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to the declarative config with a generated id.""" """Add a rich rule to the declarative config with a generated id."""
cfg = config_get() cfg = get_config()
cfg.setdefault("zones", {}) cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {}) cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("rich_rules", []) cfg["zones"][zone].setdefault("rich_rules", [])
@@ -408,22 +388,22 @@ def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
rule_id = _gen_id() rule_id = _gen_id()
entry = {"id": rule_id, "rule": rule} entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry) existing_rules.append(entry)
config_set(cfg) save_config(cfg)
return entry return entry
def _unpersist_rich_rule(zone: str, rule: str) -> None: def _unpersist_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from the declarative config by rule string.""" """Remove a rich rule from the declarative config by rule string."""
cfg = config_get() cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {}) zone_cfg = cfg.get("zones", {}).get(zone, {})
rules = zone_cfg.get("rich_rules", []) rules = zone_cfg.get("rich_rules", [])
zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule] zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule]
config_set(cfg) save_config(cfg)
def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]: def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
"""Look up a rich rule entry in the declarative config.""" """Look up a rich rule entry in the declarative config."""
cfg = config_get() cfg = get_config()
for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []): for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []):
if r.get("rule") == rule: if r.get("rule") == rule:
return r return r
@@ -432,7 +412,7 @@ def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
def remove_rich_rule_by_id(zone: str, rule_id: str) -> None: def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
"""Remove a rich rule from *zone* by its config id.""" """Remove a rich rule from *zone* by its config id."""
cfg = config_get() cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {}) zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None entry = None
for r in zone_cfg.get("rich_rules", []): for r in zone_cfg.get("rich_rules", []):
@@ -453,7 +433,7 @@ def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
def set_masquerade(zone: str, enable: bool) -> None: def set_masquerade(zone: str, enable: bool) -> None:
"""Enable or disable masquerade (source-NAT) on *zone*.""" """Enable or disable masquerade (source-NAT) on *zone*."""
action = "--add-masquerade" if enable else "--remove-masquerade" action = "--add-masquerade" if enable else "--remove-masquerade"
_run(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"]) run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload() _reload()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone) logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
@@ -479,14 +459,14 @@ def add_forward_port(
else: else:
fwd += f"/toaddr={toaddr}" if toaddr else "" fwd += f"/toaddr={toaddr}" if toaddr else ""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--add-forward-port={fwd}", f"--add-forward-port={fwd}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
_persist_forward_port(zone, port, protocol, toaddr, toport) _persist_forward_port(zone, port, protocol, toaddr, toport)
@@ -511,14 +491,14 @@ def remove_forward_port(
else: else:
fwd += f"/toaddr={toaddr}" if toaddr else "" fwd += f"/toaddr={toaddr}" if toaddr else ""
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone}", f"--zone={zone}",
f"--remove-forward-port={fwd}", f"--remove-forward-port={fwd}",
"--permanent", "--permanent",
] ],
sudo=True,
) )
_reload() _reload()
_unpersist_forward_port(zone, port, protocol) _unpersist_forward_port(zone, port, protocol)
@@ -533,7 +513,7 @@ def _persist_forward_port(
toport: int | None = None, toport: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id.""" """Add a forward port to the declarative config with a generated id."""
cfg = config_get() cfg = get_config()
cfg.setdefault("zones", {}) cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {}) cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", []) cfg["zones"][zone].setdefault("forward_ports", [])
@@ -548,26 +528,24 @@ def _persist_forward_port(
if toport: if toport:
entry["toport"] = toport entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry) cfg["zones"][zone]["forward_ports"].append(entry)
config_set(cfg) save_config(cfg)
return entry return entry
def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None: def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from the declarative config by port+proto.""" """Remove a forward port from the declarative config by port+proto."""
cfg = config_get() cfg = get_config()
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", []) fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
cfg.setdefault("zones", {}).setdefault(zone, {}) cfg.setdefault("zones", {}).setdefault(zone, {})
cfg["zones"][zone]["forward_ports"] = [ cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol) fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol)
] ]
config_set(cfg) save_config(cfg)
def _get_forward_port_entry( def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]:
zone: str, port: int, protocol: str
) -> dict[str, Any]:
"""Look up a forward port entry in the declarative config.""" """Look up a forward port entry in the declarative config."""
cfg = config_get() cfg = get_config()
for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []): for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol: if fp.get("port") == port and fp.get("proto") == protocol:
return fp return fp
@@ -577,7 +555,7 @@ def _get_forward_port_entry(
def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None: def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from *zone* by port+proto (id used by API layer).""" """Remove a forward port from *zone* by port+proto (id used by API layer)."""
cfg = config_get() cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {}) zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None entry = None
for fp in zone_cfg.get("forward_ports", []): for fp in zone_cfg.get("forward_ports", []):
@@ -585,9 +563,7 @@ def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
entry = fp entry = fp
break break
if entry is None: if entry is None:
raise ValueError( raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'")
f"Forward port {port}/{protocol} not found in zone '{zone}'"
)
remove_forward_port( remove_forward_port(
zone, zone,
port, port,
@@ -658,19 +634,15 @@ def _now_iso() -> str:
def save_backup() -> str: def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk.""" """Capture the full state and write it to RULES_FILE on disk."""
_ensure_data_dir()
state = get_state() state = get_state()
with open(RULES_FILE, "w") as fh: save_json(RULES_FILE, state)
json.dump(state, fh, indent=2, default=str)
logger.info("Firewall state backup saved to %s", RULES_FILE) logger.info("Firewall state backup saved to %s", RULES_FILE)
return RULES_FILE return RULES_FILE
def load_backup() -> dict[str, Any]: def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict.""" """Read the JSON backup file and return the state dict."""
with open(RULES_FILE) as fh: return load_json(RULES_FILE)
state: dict[str, Any] = json.load(fh)
return state
def restore_backup(state: dict[str, Any]) -> None: def restore_backup(state: dict[str, Any]) -> None:
@@ -700,26 +672,26 @@ def restore_backup(state: dict[str, Any]) -> None:
if "toport" in fp: if "toport" in fp:
parts.append(f"toport={fp['toport']}") parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts) fp_str = "/".join(parts)
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--add-forward-port={fp_str}", f"--add-forward-port={fp_str}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
for rule in zinfo.get("rich-rules", []): for rule in zinfo.get("rich-rules", []):
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--add-rich-rule={rule}", f"--add-rich-rule={rule}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
@@ -734,28 +706,20 @@ def restore_backup(state: dict[str, Any]) -> None:
def _ensure_config_file() -> None: def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist.""" """Create config directory and file if they do not exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists(): if not CONFIG_FILE.exists():
with open(CONFIG_FILE, "w") as fh: save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
json.dump(DEFAULT_CONFIG, fh, indent=2)
fh.write("\n")
def config_get() -> dict[str, Any]: def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``.""" """Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file() _ensure_config_file()
with open(CONFIG_FILE) as fh: return load_json(CONFIG_FILE)
return json.load(fh)
def config_set(cfg: dict[str, Any]) -> None: def save_config(cfg: dict[str, Any]) -> None:
"""Write *cfg* to ``config/firewall/config.json`` (atomic replace).""" """Write *cfg* to ``config/firewall/config.json`` (atomic replace)."""
_ensure_config_file() _ensure_config_file()
tmp = CONFIG_FILE.with_name(CONFIG_FILE.name + ".tmp") save_json(CONFIG_FILE, cfg, indent=2)
with open(tmp, "w") as fh:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
logger.info("Firewall declarative config saved") logger.info("Firewall declarative config saved")
@@ -783,7 +747,7 @@ def _live_target_to_config(target: str) -> str:
def config_pending() -> dict[str, Any]: def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff.""" """Compare declarative config against live firewalld state, return diff."""
cfg = config_get() cfg = get_config()
live_state = get_state() live_state = get_state()
cfg_zones = cfg.get("zones", {}) cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {}) live_zones = live_state.get("zones", {})
@@ -844,9 +808,7 @@ def config_pending() -> dict[str, Any]:
} }
) )
cfg_rules = { cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
r.get("rule") for r in zone_cfg.get("rich_rules", [])
}
live_rules = set(live_zone.get("rich-rules", [])) live_rules = set(live_zone.get("rich-rules", []))
if cfg_rules != live_rules: if cfg_rules != live_rules:
changes.append( changes.append(
@@ -891,7 +853,7 @@ def config_pending() -> dict[str, Any]:
def config_apply() -> dict[str, Any]: def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld.""" """Apply the declarative config to live firewalld."""
cfg = config_get() cfg = get_config()
cfg_zones = cfg.get("zones", {}) cfg_zones = cfg.get("zones", {})
save_backup() save_backup()
@@ -908,14 +870,14 @@ def config_apply() -> dict[str, Any]:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT")) desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default": if desired_target != "default":
with suppress(RuntimeError): with suppress(RuntimeError):
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--set-target={desired_target}", f"--set-target={desired_target}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
@@ -927,16 +889,20 @@ def config_apply() -> dict[str, Any]:
set_masquerade(zone_name, mq) set_masquerade(zone_name, mq)
for rule_entry in zone_cfg.get("rich_rules", []): for rule_entry in zone_cfg.get("rich_rules", []):
rule_str = rule_entry.get("rule", "") if isinstance(rule_entry, dict) else str(rule_entry) rule_str = (
rule_entry.get("rule", "")
if isinstance(rule_entry, dict)
else str(rule_entry)
)
if rule_str: if rule_str:
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--add-rich-rule={rule_str}", f"--add-rich-rule={rule_str}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
@@ -950,14 +916,14 @@ def config_apply() -> dict[str, Any]:
if "toport" in fp_entry: if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}") parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts) fp_str = "/".join(parts)
_run( run(
[ [
"sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--add-forward-port={fp_str}", f"--add-forward-port={fp_str}",
"--permanent", "--permanent",
], ],
sudo=True,
check=False, check=False,
) )
@@ -981,19 +947,17 @@ __all__ = [
"DEFAULT_CONFIG", "DEFAULT_CONFIG",
"RULES_FILE", "RULES_FILE",
"_reload", "_reload",
"_run",
"add_forward_port", "add_forward_port",
"add_rich_rule", "add_rich_rule",
"add_zone_interface", "add_zone_interface",
"add_zone_service", "add_zone_service",
"config_apply", "config_apply",
"config_get",
"config_pending", "config_pending",
"config_set",
"create_zone", "create_zone",
"delete_zone", "delete_zone",
"get_active_zones", "get_active_zones",
"get_available_zones", "get_available_zones",
"get_config",
"get_icmp_blocks", "get_icmp_blocks",
"get_interfaces", "get_interfaces",
"get_rich_rules", "get_rich_rules",
@@ -1009,6 +973,7 @@ __all__ = [
"remove_zone_service", "remove_zone_service",
"restore_backup", "restore_backup",
"save_backup", "save_backup",
"save_config",
"set_masquerade", "set_masquerade",
"set_zone_interfaces", "set_zone_interfaces",
"set_zone_services", "set_zone_services",
+102 -75
View File
@@ -1,18 +1,20 @@
""" """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 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__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -31,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:"
@@ -44,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(
@@ -120,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),
@@ -153,7 +127,7 @@ def add_domain(
) )
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)
@@ -163,7 +137,7 @@ def remove_domain(domain) -> None:
logger.info("Proxy domain '%s' removed", domain) 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")
@@ -182,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"],
@@ -194,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"),
@@ -211,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"),
) )
@@ -219,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:
@@ -230,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)
@@ -252,6 +247,7 @@ 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)) logger.info("All nginx site configs written (%d sites)", len(written))
@@ -262,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"])
@@ -280,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)
@@ -291,7 +287,9 @@ 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:
@@ -310,8 +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(
logger.info("nginx configuration applied and reloaded") ["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")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -320,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,
@@ -348,11 +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() ensure_dirs(DATA_DIR)
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:
@@ -373,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
@@ -383,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",
]
+1
View File
@@ -250,6 +250,7 @@ def add_peer(
save_config(cfg) save_config(cfg)
peer_out = dict(peer) peer_out = dict(peer)
peer_out.pop("private_key", None)
return peer_out return peer_out
+5
View File
@@ -7,6 +7,11 @@ server {
listen [::]:80; listen [::]:80;
server_name {{ domain }}; server_name {{ domain }};
# ACME HTTP-01 challenge
location /.well-known/acme-challenge/ {
root {{ acme_webroot }};
}
# Redirect all HTTP traffic to HTTPS # Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
+1
View File
@@ -13,6 +13,7 @@ Defaults:{{ USER_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/ {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management # Dnsmasq management
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq {{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
+1 -1
View File
@@ -7,4 +7,4 @@ User={{ USER_NAME }}
WorkingDirectory={{ PROJECT_DIR }} WorkingDirectory={{ PROJECT_DIR }}
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }} Environment=HOME={{ PROJECT_DIR }}
ExecStart=/usr/local/bin/acme.sh --cron --home {{ ACME_HOME }} ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
+2 -3
View File
@@ -20,7 +20,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening # Security hardening
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
PrivateTmp=yes PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
@@ -34,9 +34,8 @@ LockPersonality=yes
SystemCallFilter=@system-service SystemCallFilter=@system-service
PrivateDevices=yes PrivateDevices=yes
ProtectHome=read-only
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=all IPAddressDeny=any
IPAddressAllow=localhost IPAddressAllow=localhost
[Install] [Install]
+41 -4
View File
@@ -127,10 +127,12 @@ class TestFirewallRichRules:
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules") @patch("webui.api.firewall.get_rich_rules")
@patch("webui.api.firewall.config_get") @patch("webui.api.firewall.get_config")
def test_list(self, mock_cfg, mock_list, client): def test_list(self, mock_cfg, mock_list, client):
mock_list.return_value = ["rule1"] mock_list.return_value = ["rule1"]
mock_cfg.return_value = {"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "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()
@@ -250,7 +252,7 @@ class TestDhcpApply:
class TestDhcpStatus: class TestDhcpStatus:
@patch("lib.dnsmasq.get_status") @patch("webui.api.dhcp.dnsmasq_status")
def test_success(self, mock_status, client): def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True} mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status") resp = client.get("/api/dhcp/status")
@@ -469,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")
@@ -479,7 +513,10 @@ 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 = {"name": "client1", "public_key": "pub", "private_key": "priv"} mock_add.return_value = {
"name": "client1",
"public_key": "pub",
}
resp = client.post( resp = client.post(
"/api/wireguard/peers", "/api/wireguard/peers",
json={"name": "client1"}, json={"name": "client1"},
+6 -6
View File
@@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest import pytest
from lib import dnsmasq from lib import common, dnsmasq
@pytest.fixture @pytest.fixture
@@ -29,24 +29,24 @@ class TestDeepMerge:
def test_merge_flat_dicts(self): def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2} base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4} override = {"b": 3, "c": 4}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": 1, "b": 3, "c": 4} assert result == {"a": 1, "b": 3, "c": 4}
def test_merge_nested_dicts(self): def test_merge_nested_dicts(self):
base = {"a": {"x": 1, "y": 2}} base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}} override = {"a": {"y": 3, "z": 4}}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": {"x": 1, "y": 3, "z": 4}} assert result == {"a": {"x": 1, "y": 3, "z": 4}}
def test_merge_non_dict_override(self): def test_merge_non_dict_override(self):
base = {"a": {"x": 1}} base = {"a": {"x": 1}}
override = {"a": "flat"} override = {"a": "flat"}
result = dnsmasq._deep_merge(base, override) result = common.deep_merge(base, override)
assert result == {"a": "flat"} assert result == {"a": "flat"}
class TestGetConfig: class TestGetConfig:
@patch("lib.dnsmasq._load_json") @patch("lib.dnsmasq.load_json")
def test_returns_default_when_no_config(self, mock_load, temp_data_dir): def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
mock_load.return_value = {} mock_load.return_value = {}
result = dnsmasq.get_config() result = dnsmasq.get_config()
@@ -54,7 +54,7 @@ class TestGetConfig:
assert "dns" in result assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"] assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@patch("lib.dnsmasq._load_json") @patch("lib.dnsmasq.load_json")
def test_merges_with_existing_config(self, mock_load, temp_data_dir): def test_merges_with_existing_config(self, mock_load, temp_data_dir):
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}} mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
result = dnsmasq.get_config() result = dnsmasq.get_config()
+19 -19
View File
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
class TestGetActiveZones: class TestGetActiveZones:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_active_zones(self, mock_run): def test_parses_active_zones(self, mock_run):
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2" mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
result = firewall.get_active_zones() result = firewall.get_active_zones()
@@ -35,13 +35,13 @@ class TestGetActiveZones:
"internal": ["eth1", "eth2"], "internal": ["eth1", "eth2"],
} }
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_empty_output(self, mock_run): def test_empty_output(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
result = firewall.get_active_zones() result = firewall.get_active_zones()
assert result == {} assert result == {}
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_zone_with_no_interfaces(self, mock_run): def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz" mock_run.return_value = "dmz"
result = firewall.get_active_zones() result = firewall.get_active_zones()
@@ -49,7 +49,7 @@ class TestGetActiveZones:
class TestGetZoneInfo: class TestGetZoneInfo:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_zone_info(self, mock_run): def test_parses_zone_info(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
"target: default\n" "target: default\n"
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
class TestGetInterfaces: class TestGetInterfaces:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_parses_interfaces(self, mock_run): def test_parses_interfaces(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n" "1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
@@ -88,7 +88,7 @@ class TestGetInterfaces:
class TestGetRichRules: class TestGetRichRules:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_single_rule(self, mock_run): def test_single_rule(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;' 'rule family="ipv4" port protocol="tcp" port="443" accept;'
@@ -96,13 +96,13 @@ class TestGetRichRules:
result = firewall.get_rich_rules("public") result = firewall.get_rich_rules("public")
assert len(result) == 1 assert len(result) == 1
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_empty_rules(self, mock_run): def test_empty_rules(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
result = firewall.get_rich_rules("public") result = firewall.get_rich_rules("public")
assert result == [] assert result == []
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_multiline_rule(self, mock_run): def test_multiline_rule(self, mock_run):
mock_run.return_value = ( mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;' 'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
@@ -120,14 +120,14 @@ class TestNowIso:
class TestAddForwardPort: class TestAddForwardPort:
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_forward_port_basic(self, mock_run): def test_forward_port_basic(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080) firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
calls = [c[0][0] for c in mock_run.call_args_list] calls = [c[0][0] for c in mock_run.call_args_list]
assert any("--add-forward-port=" in str(c) for c in calls) assert any("--add-forward-port=" in str(c) for c in calls)
@patch("lib.firewall._run") @patch("lib.firewall.run")
def test_forward_port_port_only(self, mock_run): def test_forward_port_port_only(self, mock_run):
mock_run.return_value = "" mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080) firewall.add_forward_port("public", 80, "tcp", toport=8080)
@@ -229,7 +229,7 @@ class TestConfigGet:
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}' '{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
) )
with patch.object(firewall, "CONFIG_FILE", cfg_file): with patch.object(firewall, "CONFIG_FILE", cfg_file):
result = firewall.config_get() result = firewall.get_config()
assert result["zones"]["public"]["interfaces"] == ["eth0"] assert result["zones"]["public"]["interfaces"] == ["eth0"]
assert result["zones"]["public"]["services"] == ["http"] assert result["zones"]["public"]["services"] == ["http"]
@@ -241,7 +241,7 @@ class TestConfigSet:
patch.object(firewall, "CONFIG_FILE", cfg_file), patch.object(firewall, "CONFIG_FILE", cfg_file),
patch.object(firewall, "CONFIG_DIR", tmp_path), patch.object(firewall, "CONFIG_DIR", tmp_path),
): ):
firewall.config_set({"zones": {"test": {"interfaces": ["eth0"]}}}) firewall.save_config({"zones": {"test": {"interfaces": ["eth0"]}}})
import json as _json import json as _json
content = _json.loads(cfg_file.read_text()) content = _json.loads(cfg_file.read_text())
@@ -249,7 +249,7 @@ class TestConfigSet:
class TestConfigApply: class TestConfigApply:
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup") @patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones") @patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone") @patch("lib.firewall.create_zone")
@@ -287,7 +287,7 @@ class TestConfigApply:
mock_set_svcs.assert_called_once_with("public", ["http", "https"]) mock_set_svcs.assert_called_once_with("public", ["http", "https"])
mock_set_mq.assert_called_once_with("public", True) mock_set_mq.assert_called_once_with("public", True)
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup") @patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones") @patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone") @patch("lib.firewall.create_zone")
@@ -325,7 +325,7 @@ class TestConfigApply:
class TestConfigPending: class TestConfigPending:
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.get_state") @patch("lib.firewall.get_state")
def test_detects_interface_drift(self, mock_state, mock_cfg): def test_detects_interface_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = { mock_cfg.return_value = {
@@ -350,7 +350,7 @@ class TestConfigPending:
assert result["needs_apply"] is True assert result["needs_apply"] is True
assert any(c["type"] == "interfaces" for c in result["pending"]) assert any(c["type"] == "interfaces" for c in result["pending"])
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.get_state") @patch("lib.firewall.get_state")
def test_in_sync(self, mock_state, mock_cfg): def test_in_sync(self, mock_state, mock_cfg):
mock_cfg.return_value = { mock_cfg.return_value = {
@@ -374,7 +374,7 @@ class TestConfigPending:
result = firewall.config_pending() result = firewall.config_pending()
assert result["needs_apply"] is False assert result["needs_apply"] is False
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.get_state") @patch("lib.firewall.get_state")
def test_detects_services_drift(self, mock_state, mock_cfg): def test_detects_services_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = { mock_cfg.return_value = {
@@ -398,7 +398,7 @@ class TestConfigPending:
result = firewall.config_pending() result = firewall.config_pending()
assert any(c["type"] == "services" for c in result["pending"]) assert any(c["type"] == "services" for c in result["pending"])
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.get_state") @patch("lib.firewall.get_state")
def test_detects_unmanaged_zones(self, mock_state, mock_cfg): def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
mock_cfg.return_value = {"zones": {}} mock_cfg.return_value = {"zones": {}}
@@ -416,7 +416,7 @@ class TestConfigPending:
class TestConfigEmptyZones: class TestConfigEmptyZones:
@patch("lib.firewall.config_get") @patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup") @patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones") @patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone") @patch("lib.firewall.create_zone")
+2 -2
View File
@@ -200,7 +200,7 @@ class TestWriteAllSites:
class TestTestConfig: class TestTestConfig:
@patch("lib.nginx._run") @patch("lib.nginx.subprocess.run")
def test_passes(self, mock_run, temp_data_dir): def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n" returncode=0, stdout="", stderr="test passed\n"
@@ -208,7 +208,7 @@ class TestTestConfig:
ok, _msg = nginx.test_config() ok, _msg = nginx.test_config()
assert ok is True assert ok is True
@patch("lib.nginx._run") @patch("lib.nginx.subprocess.run")
def test_fails(self, mock_run, temp_data_dir): def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n" returncode=1, stdout="", stderr="nginx: configuration test failed\n"
+17 -13
View File
@@ -31,18 +31,22 @@ class TestGetConfig:
assert cfg["peers"] == {} assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config): def test_loads_existing_config(self, temp_config):
wireguard.CONFIG_PATH.write_text(json.dumps({ wireguard.CONFIG_PATH.write_text(
"interface": { json.dumps(
"name": "wg0", {
"listen_port": 51820, "interface": {
"private_key": "existing-key", "name": "wg0",
"public_key": "existing-pub", "listen_port": 51820,
"addresses": ["10.137.0.1/24"], "private_key": "existing-key",
"post_up": None, "public_key": "existing-pub",
"post_down": None, "addresses": ["10.137.0.1/24"],
}, "post_up": None,
"peers": {}, "post_down": None,
})) },
"peers": {},
}
)
)
cfg = wireguard.get_config() cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key" assert cfg["interface"]["private_key"] == "existing-key"
@@ -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")
+22 -44
View File
@@ -1,12 +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.
""" """
import logging import logging
from flask import Blueprint, jsonify, request from flask import Blueprint, request
from lib.acme import ( from lib.acme import (
get_cert_info, get_cert_info,
@@ -16,24 +15,12 @@ from lib.acme import (
renew, renew,
set_email, set_email,
) )
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__) 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -43,19 +30,19 @@ 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) 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) logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -72,18 +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:
logger.info("Certificate issuance requested for '%s' via API", domain) logger.info("Certificate issuance requested for '%s' via API", domain)
result = issue(domain, webroot=webroot) issue(domain, webroot=webroot, email=email)
if result.get("success"): logger.info("Certificate issued for '%s'", domain)
logger.info("Certificate issued for '%s'", domain) return _ok(None)
return _ok(None) except (RuntimeError, FileNotFoundError) as exc:
logger.error( logger.error("Failed to issue cert for '%s': %s", domain, exc)
"Certificate issuance failed for '%s': %s", domain, result.get("error")
)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
logger.error("Exception issuing cert for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -93,19 +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:
logger.info("Certificate renewal requested for '%s' via API", domain) logger.info("Certificate renewal requested for '%s' via API", domain)
result = renew(domain) renew(domain)
if result.get("success"): logger.info("Certificate renewed for '%s'", domain)
logger.info("Certificate renewed for '%s'", domain) return _ok(None)
return _ok(None) except (RuntimeError, FileNotFoundError) as exc:
logger.error( logger.error("Failed to renew cert for '%s': %s", domain, exc)
"Certificate renewal failed for '%s': %s", domain, result.get("error")
)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
logger.error("Exception renewing cert for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -115,19 +93,19 @@ 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) 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) 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) logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -147,6 +125,6 @@ def set_email_bp():
set_email(email) set_email(email)
logger.info("ACME email set via API: %s", 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) logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+7 -27
View File
@@ -6,8 +6,9 @@ Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
import logging import logging
from flask import Blueprint, jsonify, request 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,
@@ -20,34 +21,15 @@ from lib.dnsmasq import (
save_config, save_config,
set_dhcp_range, set_dhcp_range,
) )
from lib.dnsmasq import (
get_status as dnsmasq_status,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__) 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -82,7 +64,7 @@ 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:
@@ -109,8 +91,6 @@ def apply_bp():
@bp.route("/status", methods=["GET"]) @bp.route("/status", methods=["GET"])
def status_bp(): def status_bp():
try: try:
from lib.dnsmasq import get_status as dnsmasq_status
return _ok(dnsmasq_status()) return _ok(dnsmasq_status())
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc) logger.error("Failed to get DHCP status: %s", exc)
+51 -46
View File
@@ -1,74 +1,63 @@
""" """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.
""" """
import logging import logging
from flask import Blueprint, jsonify, request 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_get, config_apply,
config_pending, config_pending,
config_set,
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_by_id, remove_forward_port_by_id,
remove_rich_rule_by_id, 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__) logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__) bp = Blueprint("firewall", __name__)
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Declarative config (two-step: save -> apply) # Declarative config (two-step: save -> apply)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"]) @bp.route("/config", methods=["GET"])
def config_get_bp(): def config_list():
try: try:
return _ok(config_get()) return _ok(get_config())
except Exception as exc: except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc) logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/config", methods=["POST"]) @bp.route("/config", methods=["POST"])
def config_set_bp(): def config_save():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
if "zones" not in body: if "zones" not in body:
return _error("'zones' key is required", 400) return _error("'zones' key is required", 400)
if not isinstance(body["zones"], dict): if not isinstance(body["zones"], dict):
return _error("'zones' must be a dict", 400) return _error("'zones' must be a dict", 400)
try: try:
config_set(body) save_config(body)
pending_info = config_pending() pending_info = config_pending()
logger.info("Firewall config saved (%d zones)", len(body["zones"])) logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok( return _ok(
@@ -79,20 +68,42 @@ def config_set_bp():
"unmanaged_zones": pending_info.get("unmanaged_zones", {}), "unmanaged_zones": pending_info.get("unmanaged_zones", {}),
} }
) )
except Exception as exc: except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc) logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500) 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"]) @bp.route("/config/apply", methods=["POST"])
def config_apply_bp(): def config_apply_bp():
try: try:
from lib.firewall import config_apply as _config_apply result = config_apply()
result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", [])) logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result) return _ok(result)
except Exception as exc: except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc) logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -101,7 +112,7 @@ def config_apply_bp():
def config_pending_bp(): def config_pending_bp():
try: try:
return _ok(config_pending()) return _ok(config_pending())
except Exception as exc: except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc) logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -123,7 +134,7 @@ def list_zones():
@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)
@@ -153,7 +164,7 @@ def create_zone_bp():
@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:
@@ -172,7 +183,7 @@ 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):
@@ -192,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):
@@ -250,18 +261,14 @@ def add_rich_rule_bp():
@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)
from lib.firewall import config_get as firewall_config_get cfg = get_config()
cfg = firewall_config_get()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result = [] result = []
for rule_str in rules: for rule_str in rules:
matched = next( matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
(e for e in cfg_entries if e.get("rule") == rule_str), None
)
if matched: if matched:
result.append({"id": matched["id"], "rule": rule_str}) result.append({"id": matched["id"], "rule": rule_str})
else: else:
@@ -273,7 +280,7 @@ def list_rich_rules(zone):
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"]) @bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone, rule_id): def remove_rich_rule_bp(zone: str, rule_id: str):
try: try:
remove_rich_rule_by_id(zone, rule_id) remove_rich_rule_by_id(zone, rule_id)
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone) logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
@@ -333,9 +340,7 @@ def add_forward_port_bp():
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( return _ok({"zone": zone, "id": entry["id"], "port": int(port), "proto": proto})
{"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) logger.error("Failed to add forward port: %s", exc)
@@ -343,7 +348,7 @@ def add_forward_port_bp():
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"]) @bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone, port, proto): def remove_forward_port_bp(zone: str, port: int, proto: str):
try: try:
remove_forward_port_by_id(zone, port, proto) remove_forward_port_by_id(zone, port, proto)
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone) logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
+53 -6
View File
@@ -6,34 +6,81 @@ Exposed at /api/proxy/* and delegates to lib.nginx.
import logging import logging
from flask import Blueprint, jsonify, request 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__) 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8
View File
@@ -53,9 +53,17 @@ 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: if "interface" in body:
body["interface"] = dict(body["interface"]) body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None) body["interface"].pop("private_key", None)
if current_key:
body.setdefault("interface", {})["private_key"] = current_key
save_config(body) save_config(body)
return _ok(None) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
+9 -11
View File
@@ -19,12 +19,15 @@ 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 ( from lib.firewall import (
config_get,
config_pending, config_pending,
get_active_zones, get_active_zones,
get_interfaces, get_interfaces,
get_services,
get_zone_info, get_zone_info,
) )
from lib.firewall import (
get_config as fw_config_get,
)
from lib.logging import setup_logging 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
@@ -220,14 +223,14 @@ 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(config_get, {}), firewall_config=_safely(fw_config_get, {}),
firewall_pending=_safely(config_pending, {}), firewall_pending=_safely(config_pending, {}),
) )
@app.route("/interfaces") @app.route("/interfaces")
def interfaces_page(): def interfaces_page():
firewall_config = _safely(config_get, {}) firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {}) firewall_pending = _safely(config_pending, {})
return render_template( return render_template(
"interfaces.html", "interfaces.html",
@@ -240,7 +243,7 @@ def interfaces_page():
@app.route("/zones") @app.route("/zones")
def zones_page(): def zones_page():
firewall_config = _safely(config_get, {}) firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {}) firewall_pending = _safely(config_pending, {})
zones_data = {} zones_data = {}
for name in _safely(get_active_zones, {}): for name in _safely(get_active_zones, {}):
@@ -249,12 +252,7 @@ def zones_page():
"zones.html", "zones.html",
zones=zones_data, zones=zones_data,
interfaces=_safely(get_interfaces, []), interfaces=_safely(get_interfaces, []),
services=_safely( services=_safely(get_services, []),
lambda: __import__(
"lib.firewall", fromlist=["get_services"]
).get_services(),
[],
),
firewall_config=firewall_config, firewall_config=firewall_config,
firewall_pending=firewall_pending, firewall_pending=firewall_pending,
) )
@@ -263,7 +261,7 @@ def zones_page():
@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())
raw = _safely(config_get, {}) raw = _safely(fw_config_get, {})
rules = {} rules = {}
for zname, zcfg in raw.get("zones", {}).items(): for zname, zcfg in raw.get("zones", {}).items():
rr = zcfg.get("rich_rules", []) rr = zcfg.get("rich_rules", [])
+1 -1
View File
@@ -266,7 +266,7 @@ const renderCerts = (certs) => {
const days = cert.days_remaining; const days = cert.days_remaining;
let badgeHtml; let badgeHtml;
if (cert.expired || (days !== undefined && days <= 0)) { if (cert.expired || (days !== undefined && days <= 0)) {
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined ? ' (' + days + 'd ago)' : '') + '</span>'; badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
} else if (days !== undefined && days <= 30) { } else if (days !== undefined && days <= 30) {
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>'; badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
} else { } else {
+3 -2
View File
@@ -565,7 +565,7 @@
} }
</style> </style>
</head> </head>
<body> <body hx-ext="json-enc">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
VACUUM WALL VACUUM WALL
@@ -591,7 +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 src="/static/json-enc.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -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" 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'); }"> <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>
+3 -3
View File
@@ -13,7 +13,7 @@
<div class="section-title">DHCP Ranges</div> <div class="section-title">DHCP Ranges</div>
<div class="card mb-4"> <div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }"> <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>
@@ -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/static-lease" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }"> <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>
@@ -132,7 +132,7 @@
<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-record" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }"> <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>
+1 -1
View File
@@ -51,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/forward-port" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }"> <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>
+3 -2
View File
@@ -9,7 +9,8 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button> <button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('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>
@@ -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" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }"> <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>
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="card mb-4"> <div class="card mb-4">
<h3>Add Rule</h3> <h3>Add Rule</h3>
<form hx-post="/api/firewall/rich-rules" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }"> <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>
+1 -1
View File
@@ -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" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }"> <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>