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
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
@@ -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/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).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
- `webui/static/` — Vendored frontend libraries (JS + CSS). Flask auto-serves at `/static/`.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
**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
`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/`.
@@ -56,8 +66,9 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
## API Response Contract
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)`
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)`
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common`
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common`
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
- Full spec: `docs/api.md`
@@ -78,11 +89,17 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (154 tests)
.venv/bin/python -m pytest tests/ -v # test (192 tests)
```
Install dev tooling with `pip install -e ".[dev]"`.
## Docs
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, and data directory layout in detail.
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, data directory layout, and shared utility patterns in detail.
## Important Rules
1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements.
2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.
+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
- Minimum hardware: 1 CPU, 512 MB RAM, 4 GB disk
### Install
### Install (Production)
```bash
MGMT_DOMAIN=wall.example.com \
@@ -32,12 +32,28 @@ ACME_EMAIL="admin@example.com" \
bash install.sh
```
| Variable | Required | Description |
|---|---|---|
| `MGMT_DOMAIN` | Yes | Public domain for the management WebUI |
| `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
| `MGMT_USER` | No | WebUI username (defaults to `admin`) |
| `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) |
### Install (Development)
```bash
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
```
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
| Flag | Env Var | Required | Description |
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Public domain for the management WebUI (auto-detected as `hostname.local`) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | HTTP basic auth password for the WebUI |
| `--mgmt-user` | `MGMT_USER` | No | WebUI username (defaults to `admin`) |
| `--acme-email` | `ACME_EMAIL` | Yes | ACME registration email (ZeroSSL by default) |
| `--user, -u` | `USER_NAME` | No | System user for service (default: `vacuum-wall`) |
| `--path, -p` | `INSTALL_DIR` | No | Install directory (default: repo root) |
| `--dev` | -- | No | Auto-detect repo owner as service user, skip safety warning |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (alias for env var) |
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
@@ -75,13 +91,15 @@ Start the WebUI locally (binds to 127.0.0.1:9090):
.venv/bin/ruff format lib/ webui/ tests/
```
All `lib/` modules share `lib.common` utilities (`run`, `run_proc`, `load_json`, `save_json`, `deep_merge`, `ensure_dirs`) and have full type hints and `__all__` exports. API blueprints share `_ok`/`_error` from `webui.api.common`.
### Tests
```bash
.venv/bin/python -m pytest tests/ -v
```
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required.
Tests mock all subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. 192 tests across 5 test modules.
### Documentation MCP Server
@@ -109,7 +127,7 @@ Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
See [docs/architecture.md](docs/architecture.md) for detailed request flow and zone model.
See [docs/architecture.md](docs/architecture.md) for detailed request flow, zone model, and shared utility patterns.
---
@@ -117,7 +135,7 @@ See [docs/architecture.md](docs/architecture.md) for detailed request flow and z
- [Overview](docs/overview.md) — Feature summary and tech stack
- [Deployment Guide](docs/deployment.md) — Full installation and post-install configuration
- [Architecture](docs/architecture.md) — Request flow, subsystems, zone model
- [Architecture](docs/architecture.md) — Request flow, subsystems, zone model, shared utilities
- [API Reference](docs/api.md) — REST API endpoints
- [Security Model](docs/security.md) — Privilege model and sudo whitelist
- [Configuration](docs/config.md) — Declarative config file formats and locations
+27 -2
View File
@@ -103,9 +103,16 @@ Compare declarative config against live firewalld state. Returns diff for interf
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
@@ -927,6 +934,24 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
**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
#### 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/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/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.
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
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.
- 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
```
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
+3 -3
View File
@@ -107,7 +107,7 @@ This file defines reverse proxy domains, the management interface, and global SS
},
"auth": {
"user": "admin",
"htpasswd": "$PROJECT_DIR/data/nginx/.htpasswd"
"htpasswd": "data/nginx/.htpasswd"
}
},
"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. |
| `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
@@ -159,7 +159,7 @@ The `management` block configures the Vacuum Wall admin interface itself. It fol
The `.htpasswd` file can be created with the `htpasswd` utility:
```bash
htpasswd -bc $PROJECT_DIR/data/nginx/.htpasswd admin yourpassword
htpasswd -bc data/nginx/.htpasswd admin yourpassword
```
### 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
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
# Option A: Public DNS
PROJECT_DIR="/opt/vacuum-wall" \
USER_NAME="vacuum-wall" \
# Production: all env vars
MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \
MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \
bash install.sh
# Option B: mDNS (LAN-only, no DNS record needed)
MGMT_DOMAIN=vacuum-wall.local \
MGMT_PASS="strongpassword" \
MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \
bash install.sh
# Dev mode: CLI flags, auto-detects repo owner
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
# mDNS (LAN-only, no DNS record needed)
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass --acme-email "me@example.com"
```
### Environment Variables
### Options
| Variable | Required | Description |
|---|---|---|
| `PROJECT_DIR` | No | Directory where the project resides. Auto-discovers from `install.sh` location if not set. |
| `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_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. |
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. |
| `ACME_EMAIL` | Yes | The email address registered with the ACME provider (ZeroSSL by default) for certificate issuance and expiry notifications. |
All settings that can be passed as an environment variable also have a CLI flag equivalent. CLI flags take precedence over environment variables.
| Flag | Env Var | Required | Description |
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
| `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). |
| `--user, -u` | `USER_NAME` | No | System user for the WebUI service. Defaults to `vacuum-wall`. |
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
| `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. |
| `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
Run `./install.sh --help` for full usage.
---
## Container / Custom Deployment
You can deploy Vacuum Wall in a container or at any custom path. 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
# Docker volume mount example
PROJECT_DIR="/app/vacuum-wall" \
USER_NAME="ww-app" \
MGMT_DOMAIN="proxy.internal" \
MGMT_PASS="strongpassword" \
ACME_EMAIL="admin@example.com" \
bash install.sh
./install.sh --path /app/vacuum-wall --user ww-app \
--mgmt-domain proxy.internal --mgmt-pass strongpassword \
--acme-email "admin@example.com"
```
The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `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.
- **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.
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for ACME certificate management.
- **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.
- **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.
- **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
- **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config).
- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present.
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
- **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.
- **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).
- **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.
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
- **Systemd units**: Installs three units (rendered from Jinja2 templates):
- `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
@@ -96,9 +98,21 @@ The installer performs the following steps automatically:
- **Firewalld zones**: Creates initial zones:
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
- `vpn` — WireGuard tunnel zone.
- **Service startup**: Enables and starts nginx, the 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.
### 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
@@ -240,7 +254,7 @@ journalctl -u nginx --no-pager -n 50
nginx -t
```
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `$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
@@ -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`).
- 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`.
### WebUI Not Accessible
@@ -294,10 +308,10 @@ Verify that:
| 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` |
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
| DHCP/DNS | `dnsmasq` | `$PROJECT_DIR/config/dnsmasq/` |
| VPN | wireguard-tools | `$PROJECT_DIR/config/wireguard/` |
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
| VPN | wireguard-tools | `config/wireguard/` |
| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
| 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
- Debian 13 (trixie) target platform
- Python 3, Flask 3.x for web management
- Python 3.13+, Flask 3.x for web management
- firewalld (nftables backend)
- nginx 1.26+
- dnsmasq
@@ -40,14 +40,17 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
## Quick Start
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with the required environment variables:
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
```bash
MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \
bash install.sh
# Production
./install.sh --mgmt-pass yourpassword --acme-email "admin@example.com"
# Development (auto-detects your user)
./install.sh --dev --mgmt-pass yourpassword --acme-email "admin@example.com"
```
After installation, access the management interface at `https://<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
@@ -74,6 +77,8 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
├── lib/ # Subsystem abstraction layer
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
│ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
@@ -82,6 +87,7 @@ After installation, access the management interface at `https://<hostname>.local
├── webui/ # Flask web application
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules
│ │ └── common.py # Shared API response helpers (_ok, _error)
│ ├── templates/ # Jinja2/HTMX templates
│ └── static/ # CSS and client-side JS
└── docs/ # Documentation
+3 -4
View File
@@ -39,7 +39,7 @@ Key safety properties:
### 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
@@ -71,8 +71,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| Directive | Value | Effect |
|---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ProtectHome` | `read-only` | Makes `/home`, `/root`, and `/run/user` inaccessible |
| `ReadWritePaths` | `$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. |
| `ReadWritePaths` | `$INSTALL_DIR`, `$INSTALL_DIR/config`, `$INSTALL_DIR/data`, and `/tmp` | The project directory, config directory, data directory, and `/tmp` are writable (required by `ProtectSystem=strict`). The project path is templated at install time. |
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `IPAddressDeny` | `all` | Drops all network traffic |
@@ -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 |
| `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.
+191 -83
View File
@@ -12,75 +12,146 @@ log() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
# --- Configurable via environment ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
USER_NAME="${USER_NAME:-vacuum-wall}"
# --- Validate required env vars ---
missing=()
[[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS)
[[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL)
if (( ${#missing[@]} )); then
echo -e "${RED}[!!]${NC} Missing required environment variables:"
for v in "${missing[@]}"; do
case "$v" in
MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';;
ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";;
# --- CLI argument parsing ---
_cli_user=""
_cli_is_dev=false
_cli_path=""
_cli_mgmt_pass=""
_cli_mgmt_user=""
_cli_mgmt_domain=""
_cli_acme_email=""
_cli_force_venv=false
_cli_wan_iface=""
_cli_lan_ifaces=""
while [[ $# -gt 0 ]]; do
case "$1" in
--user|-u) _cli_user="$2"; shift 2 ;;
--path|-p) _cli_path="$2"; shift 2 ;;
--dev) _cli_is_dev=true; shift ;;
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
--acme-email) _cli_acme_email="$2"; shift 2 ;;
--force-venv) _cli_force_venv=true; shift ;;
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
-h|--help)
printf '%s\n' \
"Usage: install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER System user for service (default: vacuum-wall)" \
" --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --acme-email EMAIL ACME registration email (required)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
" -h, --help Show this help" \
"" \
"All options also have environment variable equivalents:" \
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh"
exit 0
;;
*)
err "Unknown argument: $1 (use --help for usage)"
;;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi
done
# Auto-detect MGMT_DOMAIN from system hostname if not provided
if [[ -z "${MGMT_DOMAIN:-}" ]]; then
# --- Resolve config: CLI flag > env var > default ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
# Optional settings with defaults
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
if [[ -n "$_cli_mgmt_domain" ]]; then
DOMAIN="$_cli_mgmt_domain"
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
DOMAIN="$MGMT_DOMAIN"
else
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
if [[ -z "$HOSTNAME_F" ]]; then
err "Cannot determine system hostname — set MGMT_DOMAIN env var."
err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
fi
DOMAIN="${HOSTNAME_F}.local"
else
DOMAIN="$MGMT_DOMAIN"
fi
MGMT_USER="${MGMT_USER:-admin}"
# Install directory (CLI > env > repo root)
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
if [[ -n "$INSTALL_DIR" ]]; then
PROJECT_DIR="$INSTALL_DIR"
else
PROJECT_DIR="$REPO_DIR"
fi
# Network interfaces (CLI > env — auto-detect happens later if still unset)
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
# --- Pre-flight checks ---
[[ $EUID -eq 0 ]] || err "This script must be run as root."
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
# --- Deploy to /opt/vacuum-wall ---
INSTALL_DIR="/opt/vacuum-wall"
# --- Validate required settings ---
missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
# Install rsync first if not available (needed for deploy)
if ! command -v rsync &>/dev/null; then
apt-get update -qq
apt-get install -y -qq rsync
if (( ${#missing[@]} )); then
echo -e "${RED}[!!]${NC} Missing required settings:"
for v in "${missing[@]}"; do
case "$v" in
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi
if [[ -d "$INSTALL_DIR" ]]; then
if [[ -L "$INSTALL_DIR" ]]; then
log "Symbolic link already exists at $INSTALL_DIR, skipping deploy."
elif [[ "$INSTALL_DIR" == "$REPO_DIR" ]]; then
log "Installed from repo location, skipping deploy."
else
err "Installation directory $INSTALL_DIR already exists."
fi
else
log "Deploying $REPO_DIR$INSTALL_DIR"
rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.git' \
--exclude='build' \
"$REPO_DIR/" "$INSTALL_DIR/"
chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR"
fi
PROJECT_DIR="$INSTALL_DIR"
ACME_HOME="$PROJECT_DIR/data/acme"
# Dev mode: auto-detect repo owner as service user
if [[ "$_cli_is_dev" == true ]]; then
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
_cli_user="$_repo_owner"
log "Dev mode: using repo owner '$_repo_owner' as service user"
else
err "Dev mode: cannot determine repo owner (root or unavailable)."
fi
fi
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# --- Safety check: running service as a non-system regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME")
_gid=$(id -g "$USER_NAME")
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
warn "This grants NOPASSWD sudo and runs the web service as your login account."
warn "Only use for development. For production, use --user vacuum-wall."
fi
fi
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
@@ -114,17 +185,23 @@ else
fi
# --- 2b. Setup Python venv ---
log "Setting up Python virtual environment..."
python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
log "Python venv already exists, skipping (use --force-venv to recreate)."
else
log "Setting up Python virtual environment..."
rm -rf "${PROJECT_DIR}/.venv"
python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh ---
if [[ ! -d "$ACME_HOME" ]]; then
log "Installing acme.sh..."
# --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME"
chown "$USER_NAME:$USER_NAME" "$ACME_HOME"
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
sh -c 'curl -sS https://get.acme.sh | sh'
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
else
log "acme.sh already installed."
fi
@@ -201,20 +278,29 @@ systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no int
log "dnsmasq configured (will fully start after DHCP ranges are set)"
# --- 10. Setup nginx management proxy ---
log "Generating self-signed certificate for management domain..."
mkdir -p "$ACME_HOME/$DOMAIN"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
log "SSL certificate already exists for $DOMAIN, skipping."
else
log "Generating self-signed certificate for management domain..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \
-out "$ACME_HOME/$DOMAIN/fullchain.cer" \
-subj "/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN"
fi
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
# Generate htpasswd directly in data/nginx/
htpasswd -cb "${PROJECT_DIR}/data/nginx/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="${PROJECT_DIR}/data/nginx/.htpasswd" python3 -c "
# Generate/update htpasswd directly in data/nginx/
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
if [[ -f "$HTPASSWD_FILE" ]]; then
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
warn "Could not update htpasswd (install apache2-utils)"
else
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" python3 -c "
import os, crypt, base64
password = os.environ['MGMT_PASS']
user = os.environ['MGMT_USER']
@@ -224,6 +310,7 @@ with open(os.environ['HTFILE'], 'w') as f:
f.write(user + ':' + hashed + '\n')
" 2>/dev/null || \
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
fi
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
@@ -279,12 +366,16 @@ server {
}
MGMTSITEEOF
# --- 11. Write initial nginx config.json ---
log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
if [[ -f "$NGINX_CFG" ]]; then
log "Nginx config already exists, skipping initial write."
else
log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import json, os
d = os.environ['MGMT_DOMAIN']
u = os.environ['MGMT_USER']
@@ -313,6 +404,7 @@ with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
json.dump(cfg, f, indent=4)
f.write('\n')
"
fi
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
log "Detecting network interfaces..."
@@ -342,12 +434,16 @@ if [[ -z "$LAN_IFACES" ]]; then
fi
fi
# Generate config/firewall/config.json
log "Writing initial firewall configuration..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
# Generate config/firewall/config.json (skip if user has customized it)
FIREWALL_CFG="${PROJECT_DIR}/config/firewall/config.json"
if [[ -f "$FIREWALL_CFG" ]]; then
log "Firewall config already exists, skipping initial write."
else
log "Writing initial firewall configuration..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import json, os
wan = os.environ.get('WAN_IFACE', '').strip() or None
@@ -384,6 +480,7 @@ with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
json.dump(cfg, f, indent=2)
f.write('\n')
"
fi
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true
@@ -435,14 +532,25 @@ systemctl enable avahi-daemon >/dev/null 2>&1 || true
log "Enabled avahi-daemon"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
systemctl start nginx >/dev/null 2>&1 && log "Started nginx" || warn "Could not start nginx (check config)"
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
warn "Could not restart nginx (check config)"
# --- 14. Configure acme.sh default email ---
log "Configuring acme.sh default email..."
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
"$ACME_HOME/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
log "acme.sh account already registered, skipping."
else
log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www"
chown "$USER_NAME:$USER_NAME" "$ACME_HOME/www"
sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
warn "Could not register acme.sh account (will be done from WebUI)"
fi
# --- Done ---
echo ""
+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,
static leases, and custom DNS records through sudo.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
@@ -16,6 +13,8 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -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 ──────────────────────────────────────────
def get_config() -> dict:
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
_ensure_dirs()
raw = _load_json(CONFIG_PATH)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
return deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> None:
def save_config(cfg: dict[str, Any]) -> None:
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
@@ -112,8 +71,8 @@ def apply_config() -> None:
cfg = get_config()
conf_text = generate_conf(cfg)
_ensure_dirs()
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
@@ -121,14 +80,19 @@ def apply_config() -> None:
text=True,
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str:
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
@@ -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."""
leases: list[dict] = []
leases: list[dict[str, Any]] = []
try:
result = _sudo("cat", LEASE_FILE)
result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
@@ -341,7 +310,7 @@ def set_domain(domain: str | None) -> None:
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict:
def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
@@ -355,7 +324,7 @@ def get_status() -> dict:
except Exception:
active = False
conf_exists = os.path.isfile(DNSMASQ_CONF)
conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
@@ -381,3 +350,21 @@ def get_status() -> dict:
"active_leases": len(leases),
"leases": leases,
}
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]
+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.
"""
import json
import logging
import os
import subprocess
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from lib.common import load_json, run, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall")
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR: Path = PROJECT_DIR / "data" / "firewall"
RULES_FILE: Path = DATA_DIR / "rules.json"
CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE: Path = CONFIG_DIR / "config.json"
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:
"""Reload firewalld so permanent changes take effect immediately."""
try:
_run(["sudo", "firewall-cmd", "--reload"])
run(["firewall-cmd", "--reload"], sudo=True)
logger.info("firewalld reloaded")
except RuntimeError as exc:
logger.error("firewalld reload failed: %s", exc)
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:
"""Generate a short unique identifier (8 hex characters)."""
return uuid4().hex[:8]
@@ -75,13 +55,13 @@ def _gen_id() -> str:
def get_available_zones() -> list[str]:
"""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()
def get_active_zones() -> dict[str, list[str]]:
"""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]] = {}
current_zone: str | None = None
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]:
"""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}
for line in output.splitlines():
line = line.strip()
@@ -157,19 +137,19 @@ def get_zone_info(zone: str) -> dict[str, Any]:
def get_services() -> list[str]:
"""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()
def get_icmp_blocks() -> list[str]:
"""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()
def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2."""
output = _run(["ip", "-o", "link", "show"])
output = run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
@@ -182,7 +162,7 @@ def get_interfaces() -> list[str]:
def get_rich_rules(zone: str) -> list[str]:
"""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()
if not output:
return []
@@ -208,14 +188,14 @@ def get_rich_rules(zone: str) -> list[str]:
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
],
sudo=True,
)
_reload()
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:
"""Delete an existing zone."""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Firewall zone '%s' deleted", zone)
@@ -240,26 +220,26 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
except Exception:
current = []
for iface in current:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in interfaces:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
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:
"""Add a single interface to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
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:
"""Remove a single interface from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
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."""
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
sudo=True,
check=False,
)
for svc in services:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
],
sudo=True,
)
_reload()
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:
"""Add a single service to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
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:
"""Remove a single service from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
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]:
"""Add a rich rule to *zone* and persist to declarative config."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_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:
"""Remove a rich rule from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_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]:
"""Add a rich rule to the declarative config with a generated id."""
cfg = config_get()
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
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()
entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry)
config_set(cfg)
save_config(cfg)
return entry
def _unpersist_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from the declarative config by rule string."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
rules = zone_cfg.get("rich_rules", [])
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]:
"""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", []):
if r.get("rule") == rule:
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:
"""Remove a rich rule from *zone* by its config id."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
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:
"""Enable or disable masquerade (source-NAT) on *zone*."""
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()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
@@ -479,14 +459,14 @@ def add_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_persist_forward_port(zone, port, protocol, toaddr, toport)
@@ -511,14 +491,14 @@ def remove_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_unpersist_forward_port(zone, port, protocol)
@@ -533,7 +513,7 @@ def _persist_forward_port(
toport: int | None = None,
) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id."""
cfg = config_get()
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", [])
@@ -548,26 +528,24 @@ def _persist_forward_port(
if toport:
entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry)
config_set(cfg)
save_config(cfg)
return entry
def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None:
"""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", [])
cfg.setdefault("zones", {}).setdefault(zone, {})
cfg["zones"][zone]["forward_ports"] = [
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(
zone: str, port: int, protocol: str
) -> dict[str, Any]:
def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]:
"""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", []):
if fp.get("port") == port and fp.get("proto") == protocol:
return fp
@@ -577,7 +555,7 @@ def _get_forward_port_entry(
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)."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
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
break
if entry is None:
raise ValueError(
f"Forward port {port}/{protocol} not found in zone '{zone}'"
)
raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'")
remove_forward_port(
zone,
port,
@@ -658,19 +634,15 @@ def _now_iso() -> str:
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk."""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
save_json(RULES_FILE, state)
logger.info("Firewall state backup saved to %s", RULES_FILE)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict."""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
return load_json(RULES_FILE)
def restore_backup(state: dict[str, Any]) -> None:
@@ -700,26 +672,26 @@ def restore_backup(state: dict[str, Any]) -> None:
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
for rule in zinfo.get("rich-rules", []):
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -734,28 +706,20 @@ def restore_backup(state: dict[str, Any]) -> None:
def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
with open(CONFIG_FILE, "w") as fh:
json.dump(DEFAULT_CONFIG, fh, indent=2)
fh.write("\n")
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def config_get() -> dict[str, Any]:
def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
with open(CONFIG_FILE) as fh:
return json.load(fh)
return load_json(CONFIG_FILE)
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)."""
_ensure_config_file()
tmp = CONFIG_FILE.with_name(CONFIG_FILE.name + ".tmp")
with open(tmp, "w") as fh:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
save_json(CONFIG_FILE, cfg, indent=2)
logger.info("Firewall declarative config saved")
@@ -783,7 +747,7 @@ def _live_target_to_config(target: str) -> str:
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff."""
cfg = config_get()
cfg = get_config()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
@@ -844,9 +808,7 @@ def config_pending() -> dict[str, Any]:
}
)
cfg_rules = {
r.get("rule") for r in zone_cfg.get("rich_rules", [])
}
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
live_rules = set(live_zone.get("rich-rules", []))
if cfg_rules != live_rules:
changes.append(
@@ -891,7 +853,7 @@ def config_pending() -> dict[str, Any]:
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
cfg = config_get()
cfg = get_config()
cfg_zones = cfg.get("zones", {})
save_backup()
@@ -908,14 +870,14 @@ def config_apply() -> dict[str, Any]:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default":
with suppress(RuntimeError):
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -927,16 +889,20 @@ def config_apply() -> dict[str, Any]:
set_masquerade(zone_name, mq)
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:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -950,14 +916,14 @@ def config_apply() -> dict[str, Any]:
if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -981,19 +947,17 @@ __all__ = [
"DEFAULT_CONFIG",
"RULES_FILE",
"_reload",
"_run",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"config_apply",
"config_get",
"config_pending",
"config_set",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_config",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
@@ -1009,6 +973,7 @@ __all__ = [
"remove_zone_service",
"restore_backup",
"save_backup",
"save_config",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
+101 -74
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
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -31,7 +33,7 @@ ENV = Environment(
trim_blocks=True,
)
DEFAULT_SSL = {
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
@@ -44,63 +46,35 @@ DEFAULT_SSL = {
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG = {
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": {**DEFAULT_SSL},
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_dirs():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SITES_DIR.mkdir(parents=True, exist_ok=True)
def _run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
def _json_load(path):
_ensure_dirs()
if not path.exists():
return DEFAULT_CONFIG.copy()
with open(path) as f:
data = json.load(f)
if "ssl" not in data:
data["ssl"] = DEFAULT_SSL.copy()
return data
def _json_dump(path, data):
_ensure_dirs()
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
f.write("\n")
os.replace(tmp, path)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_config() -> dict:
return _json_load(CONFIG_FILE)
def get_config() -> dict[str, Any]:
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
return raw
def save_config(cfg: dict) -> None:
_json_dump(CONFIG_FILE, cfg)
def save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg)
def get_domains() -> list[dict]:
def get_domains() -> list[dict[str, Any]]:
cfg = get_config()
result = []
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
result.append(
@@ -120,17 +94,17 @@ def get_domains() -> list[dict]:
def add_domain(
domain,
backend_host,
backend_port,
backend_proto="http",
cert=None,
extra_headers=None,
domain: str,
backend_host: str,
backend_port: int,
backend_proto: str = "http",
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> None:
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry = {
entry: dict[str, Any] = {
"backend": {
"host": backend_host,
"port": int(backend_port),
@@ -153,7 +127,7 @@ def add_domain(
)
def remove_domain(domain) -> None:
def remove_domain(domain: str) -> None:
cfg = get_config()
cfg["domains"].pop(domain, None)
save_config(cfg)
@@ -163,7 +137,7 @@ def remove_domain(domain) -> None:
logger.info("Proxy domain '%s' removed", domain)
def update_domain(domain, **kwargs) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
@@ -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")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -194,10 +168,11 @@ def generate_server_conf(domain_cfg: dict) -> str:
is_management=False,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
def _generate_management_conf(management: dict) -> str:
def _generate_management_conf(management: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain"),
@@ -211,6 +186,7 @@ def _generate_management_conf(management: dict) -> str:
is_management=True,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
@@ -219,8 +195,8 @@ def _generate_management_conf(management: dict) -> str:
# ------------------------------------------------------------------
def write_site(domain, conf_text) -> None:
_ensure_dirs()
def write_site(domain: str, conf_text: str) -> None:
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
@@ -230,13 +206,32 @@ def write_site(domain, conf_text) -> None:
os.replace(tmp, path)
def write_acme_challenge() -> None:
"""Write the ACME HTTP-01 challenge catch-all nginx config.
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
webroot for any domain not yet covered by a dedicated server block.
"""
tmpl = ENV.get_template("nginx/acme-challenge.conf")
content = tmpl.render(
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
site = SITES_DIR / "_acme-challenge.conf"
tmp = site.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(content)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, site)
def write_all_sites() -> None:
_ensure_dirs()
ensure_dirs(SITES_DIR)
cfg = get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written = set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy)
@@ -252,6 +247,7 @@ def write_all_sites() -> None:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
write_acme_challenge()
logger.info("All nginx site configs written (%d sites)", len(written))
@@ -262,15 +258,15 @@ def write_include_file() -> None:
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True)
subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True)
tmp.unlink(missing_ok=True)
def write_ssl_snippet() -> None:
cfg = get_config()
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
ssl_cfg.setdefault("prefer_server_ciphers", False)
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
@@ -280,8 +276,8 @@ def write_ssl_snippet() -> None:
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True)
subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True)
tmp.unlink(missing_ok=True)
@@ -291,7 +287,9 @@ def write_ssl_snippet() -> None:
def test_config() -> tuple[bool, str]:
result = _run(["sudo", "nginx", "-t"])
result = subprocess.run(
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
@@ -310,7 +308,12 @@ def apply() -> None:
ok, msg = test_config()
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
result = subprocess.run(
["sudo", "nginx", "-s", "reload"], capture_output=True, text=True, check=False
)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
else:
logger.info("nginx configuration applied and reloaded")
@@ -320,10 +323,14 @@ def apply() -> None:
def set_management_proxy(
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
domain: str,
flask_host: str = "127.0.0.1",
flask_port: int = 9090,
auth_user: str | None = None,
auth_pass: str | None = None,
) -> None:
cfg = get_config()
entry = {
entry: dict[str, Any] = {
"domain": domain,
"backend": {
"host": flask_host,
@@ -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*."""
_ensure_dirs()
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing = {}
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
for line in f:
@@ -373,7 +380,7 @@ def write_htpasswd(user, password) -> None:
os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password):
def _hash_password(password: str) -> str:
try:
from passlib.hash import apache_passwd
@@ -383,3 +390,23 @@ def _hash_password(password):
salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}")
__all__ = [
"add_domain",
"apply",
"generate_server_conf",
"get_config",
"get_domains",
"remove_domain",
"save_config",
"set_management_proxy",
"test_config",
"update_domain",
"write_acme_challenge",
"write_all_sites",
"write_htpasswd",
"write_include_file",
"write_site",
"write_ssl_snippet",
]
+1
View File
@@ -250,6 +250,7 @@ def add_peer(
save_config(cfg)
peer_out = dict(peer)
peer_out.pop("private_key", None)
return peer_out
+5
View File
@@ -7,6 +7,11 @@ server {
listen [::]:80;
server_name {{ domain }};
# ACME HTTP-01 challenge
location /.well-known/acme-challenge/ {
root {{ acme_webroot }};
}
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
+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/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
+1 -1
View File
@@ -7,4 +7,4 @@ User={{ USER_NAME }}
WorkingDirectory={{ PROJECT_DIR }}
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }}
ExecStart=/usr/local/bin/acme.sh --cron --home {{ ACME_HOME }}
ExecStart={{ ACME_HOME }}/acme.sh --cron --home {{ ACME_HOME }} --config-home {{ ACME_HOME }}
+2 -3
View File
@@ -20,7 +20,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
@@ -34,9 +34,8 @@ LockPersonality=yes
SystemCallFilter=@system-service
PrivateDevices=yes
ProtectHome=read-only
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=all
IPAddressDeny=any
IPAddressAllow=localhost
[Install]
+41 -4
View File
@@ -127,10 +127,12 @@ class TestFirewallRichRules:
assert resp.status_code == 400
@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):
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")
assert resp.status_code == 200
data = resp.get_json()
@@ -250,7 +252,7 @@ class TestDhcpApply:
class TestDhcpStatus:
@patch("lib.dnsmasq.get_status")
@patch("webui.api.dhcp.dnsmasq_status")
def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status")
@@ -469,6 +471,38 @@ class TestWireguardConfig:
resp = client.post("/api/wireguard/config", json={"peers": {}})
assert resp.status_code == 200
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_post_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.post(
"/api/wireguard/config",
json={"interface": {"name": "wg0", "private_key": "secret"}, "peers": {}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved["interface"]["private_key"] == "existing"
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_patch_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.patch(
"/api/wireguard/config",
json={"interface": {"name": "wg1", "private_key": "injected"}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved.get("interface", {}).get("private_key") == "existing"
class TestWireguardPeers:
@patch("webui.api.wireguard.get_peers")
@@ -479,7 +513,10 @@ class TestWireguardPeers:
@patch("webui.api.wireguard.add_peer")
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(
"/api/wireguard/peers",
json={"name": "client1"},
+6 -6
View File
@@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest
from lib import dnsmasq
from lib import common, dnsmasq
@pytest.fixture
@@ -29,24 +29,24 @@ class TestDeepMerge:
def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": 1, "b": 3, "c": 4}
def test_merge_nested_dicts(self):
base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
def test_merge_non_dict_override(self):
base = {"a": {"x": 1}}
override = {"a": "flat"}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": "flat"}
class TestGetConfig:
@patch("lib.dnsmasq._load_json")
@patch("lib.dnsmasq.load_json")
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
mock_load.return_value = {}
result = dnsmasq.get_config()
@@ -54,7 +54,7 @@ class TestGetConfig:
assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@patch("lib.dnsmasq._load_json")
@patch("lib.dnsmasq.load_json")
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
result = dnsmasq.get_config()
+19 -19
View File
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
class TestGetActiveZones:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_active_zones(self, mock_run):
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
result = firewall.get_active_zones()
@@ -35,13 +35,13 @@ class TestGetActiveZones:
"internal": ["eth1", "eth2"],
}
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_empty_output(self, mock_run):
mock_run.return_value = ""
result = firewall.get_active_zones()
assert result == {}
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz"
result = firewall.get_active_zones()
@@ -49,7 +49,7 @@ class TestGetActiveZones:
class TestGetZoneInfo:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_zone_info(self, mock_run):
mock_run.return_value = (
"target: default\n"
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
class TestGetInterfaces:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_interfaces(self, mock_run):
mock_run.return_value = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
@@ -88,7 +88,7 @@ class TestGetInterfaces:
class TestGetRichRules:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_single_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;'
@@ -96,13 +96,13 @@ class TestGetRichRules:
result = firewall.get_rich_rules("public")
assert len(result) == 1
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_empty_rules(self, mock_run):
mock_run.return_value = ""
result = firewall.get_rich_rules("public")
assert result == []
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_multiline_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
@@ -120,14 +120,14 @@ class TestNowIso:
class TestAddForwardPort:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_forward_port_basic(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
calls = [c[0][0] for c in mock_run.call_args_list]
assert any("--add-forward-port=" in str(c) for c in calls)
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_forward_port_port_only(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080)
@@ -229,7 +229,7 @@ class TestConfigGet:
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
)
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"]["services"] == ["http"]
@@ -241,7 +241,7 @@ class TestConfigSet:
patch.object(firewall, "CONFIG_FILE", cfg_file),
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
content = _json.loads(cfg_file.read_text())
@@ -249,7 +249,7 @@ class TestConfigSet:
class TestConfigApply:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@@ -287,7 +287,7 @@ class TestConfigApply:
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
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.get_available_zones")
@patch("lib.firewall.create_zone")
@@ -325,7 +325,7 @@ class TestConfigApply:
class TestConfigPending:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_interface_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -350,7 +350,7 @@ class TestConfigPending:
assert result["needs_apply"] is True
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")
def test_in_sync(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -374,7 +374,7 @@ class TestConfigPending:
result = firewall.config_pending()
assert result["needs_apply"] is False
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_services_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -398,7 +398,7 @@ class TestConfigPending:
result = firewall.config_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")
def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
mock_cfg.return_value = {"zones": {}}
@@ -416,7 +416,7 @@ class TestConfigPending:
class TestConfigEmptyZones:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
+2 -2
View File
@@ -200,7 +200,7 @@ class TestWriteAllSites:
class TestTestConfig:
@patch("lib.nginx._run")
@patch("lib.nginx.subprocess.run")
def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n"
@@ -208,7 +208,7 @@ class TestTestConfig:
ok, _msg = nginx.test_config()
assert ok is True
@patch("lib.nginx._run")
@patch("lib.nginx.subprocess.run")
def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
+7 -3
View File
@@ -31,7 +31,9 @@ class TestGetConfig:
assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config):
wireguard.CONFIG_PATH.write_text(json.dumps({
wireguard.CONFIG_PATH.write_text(
json.dumps(
{
"interface": {
"name": "wg0",
"listen_port": 51820,
@@ -42,7 +44,9 @@ class TestGetConfig:
"post_down": None,
},
"peers": {},
}))
}
)
)
cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key"
@@ -110,7 +114,7 @@ class TestAddPeer:
mock_gen.return_value = ("priv", "pub")
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
assert result["public_key"] == "pub"
assert result["private_key"] == "priv"
assert "private_key" not in result
assert result["allowed_ips"] == ["10.0.0.0/24"]
@patch("lib.wireguard.generate_keypair")
+18 -40
View File
@@ -1,12 +1,11 @@
"""
webui/api/certs.py - ACME certificate management API blueprint.
"""ACME certificate management API blueprint.
Exposed at /api/certs/* and delegates to lib.acme.
"""
import logging
from flask import Blueprint, jsonify, request
from flask import Blueprint, request
from lib.acme import (
get_cert_info,
@@ -16,24 +15,12 @@ from lib.acme import (
renew,
set_email,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("certs", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
# ---------------------------------------------------------------------------
# Certificate listing
# ---------------------------------------------------------------------------
@@ -43,19 +30,19 @@ def _ok(data=None):
def list_certs_bp():
try:
return _ok(list_certs())
except RuntimeError as exc:
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500)
@bp.route("/<domain>", methods=["GET"])
def cert_details(domain):
def cert_details(domain: str):
try:
info = get_cert_info(domain)
return _ok(info)
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -72,18 +59,14 @@ def issue_bp():
if not domain:
return _error("'domain' is required", 400)
webroot = body.get("webroot")
email = body.get("email", "").strip() or None
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
result = issue(domain, webroot=webroot)
if result.get("success"):
issue(domain, webroot=webroot, email=email)
logger.info("Certificate issued for '%s'", domain)
return _ok(None)
logger.error(
"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)
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to issue cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -93,19 +76,14 @@ def issue_bp():
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain):
def renew_bp(domain: str):
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
result = renew(domain)
if result.get("success"):
renew(domain)
logger.info("Certificate renewed for '%s'", domain)
return _ok(None)
logger.error(
"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)
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to renew cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -115,19 +93,19 @@ def renew_bp(domain):
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain):
def remove_bp(domain: str):
try:
get_cert_info(domain)
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to verify cert '%s': %s", domain, exc)
return _error(str(exc), 500)
try:
remove(domain)
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None)
except RuntimeError as exc:
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -147,6 +125,6 @@ def set_email_bp():
set_email(email)
logger.info("ACME email set via API: %s", email)
return _ok({"email": email})
except RuntimeError as exc:
except (RuntimeError, FileNotFoundError) as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500)
+7 -27
View File
@@ -6,8 +6,9 @@ Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
import logging
from flask import Blueprint, jsonify, request
from flask import Blueprint, request
from lib.common import deep_merge
from lib.dnsmasq import (
add_dns_record,
add_static_lease,
@@ -20,34 +21,15 @@ from lib.dnsmasq import (
save_config,
set_dhcp_range,
)
from lib.dnsmasq import (
get_status as dnsmasq_status,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("dhcp", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
return jsonify({"ok": True, "data": data})
def _deep_merge(base, overrides):
result = dict(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = v
return result
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@@ -82,7 +64,7 @@ def patch_config():
return _error("Request body must be a JSON object", 400)
try:
current = get_config()
merged = _deep_merge(current, body)
merged = deep_merge(current, body)
save_config(merged)
return _ok(None)
except RuntimeError as exc:
@@ -109,8 +91,6 @@ def apply_bp():
@bp.route("/status", methods=["GET"])
def status_bp():
try:
from lib.dnsmasq import get_status as dnsmasq_status
return _ok(dnsmasq_status())
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
+50 -45
View File
@@ -1,74 +1,63 @@
"""
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
"""Firewall (firewalld) management API blueprint.
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
"""
import logging
from flask import Blueprint, jsonify, request
from flask import Blueprint, request
from lib.common import deep_merge
from lib.firewall import (
add_forward_port,
add_rich_rule,
config_get,
config_apply,
config_pending,
config_set,
create_zone,
delete_zone,
get_active_zones,
get_available_zones,
get_config,
get_interfaces,
get_rich_rules,
get_services,
get_zone_info,
remove_forward_port_by_id,
remove_rich_rule_by_id,
save_config,
set_masquerade,
set_zone_interfaces,
set_zone_services,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__)
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
def config_get_bp():
def config_list():
try:
return _ok(config_get())
except Exception as exc:
return _ok(get_config())
except RuntimeError as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["POST"])
def config_set_bp():
def config_save():
body = request.get_json(silent=True) or {}
if "zones" not in body:
return _error("'zones' key is required", 400)
if not isinstance(body["zones"], dict):
return _error("'zones' must be a dict", 400)
try:
config_set(body)
save_config(body)
pending_info = config_pending()
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
@@ -79,20 +68,42 @@ def config_set_bp():
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
}
)
except Exception as exc:
except RuntimeError as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["PATCH"])
def patch_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
current = get_config()
merged = deep_merge(current, body)
save_config(merged)
pending_info = config_pending()
logger.info("Firewall config patched: %s", sorted(body.keys()))
return _ok(
{
"config_saved": True,
"pending": pending_info["pending"],
"needs_apply": pending_info["needs_apply"],
"unmanaged_zones": pending_info.get("unmanaged_zones", {}),
}
)
except RuntimeError as exc:
logger.error("Failed to patch firewall config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config/apply", methods=["POST"])
def config_apply_bp():
try:
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", []))
return _ok(result)
except Exception as exc:
except RuntimeError as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
@@ -101,7 +112,7 @@ def config_apply_bp():
def config_pending_bp():
try:
return _ok(config_pending())
except Exception as exc:
except RuntimeError as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
@@ -123,7 +134,7 @@ def list_zones():
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name):
def zone_details(name: str):
try:
if name not in get_available_zones():
return _error(f"Zone '{name}' does not exist", 404)
@@ -153,7 +164,7 @@ def create_zone_bp():
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name):
def delete_zone_bp(name: str):
try:
available = get_available_zones()
if name not in available:
@@ -172,7 +183,7 @@ def delete_zone_bp(name):
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name):
def set_zone_interfaces_bp(name: str):
body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list):
@@ -192,7 +203,7 @@ def set_zone_interfaces_bp(name):
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name):
def set_zone_services_bp(name: str):
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
@@ -250,18 +261,14 @@ def add_rich_rule_bp():
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone):
def list_rich_rules(zone: str):
try:
rules = get_rich_rules(zone)
from lib.firewall import config_get as firewall_config_get
cfg = firewall_config_get()
cfg = get_config()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result = []
for rule_str in rules:
matched = next(
(e for e in cfg_entries if e.get("rule") == rule_str), None
)
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
if matched:
result.append({"id": matched["id"], "rule": rule_str})
else:
@@ -273,7 +280,7 @@ def list_rich_rules(zone):
@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:
remove_rich_rule_by_id(zone, rule_id)
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,
toport=int(toport) if toport else None,
)
return _ok(
{"zone": zone, "id": entry["id"], "port": int(port), "proto": proto}
)
return _ok({"zone": zone, "id": entry["id"], "port": int(port), "proto": proto})
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
logger.error("Failed to add forward port: %s", exc)
@@ -343,7 +348,7 @@ def add_forward_port_bp():
@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:
remove_forward_port_by_id(zone, port, proto)
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
from flask import Blueprint, jsonify, request
from flask import Blueprint, request
from lib.common import deep_merge
from lib.nginx import (
add_domain,
apply,
get_config,
get_domains,
remove_domain,
save_config,
set_management_proxy,
test_config,
update_domain,
write_ssl_snippet,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("proxy", __name__)
@bp.route("/ssl-apply", methods=["POST"])
def ssl_apply_bp():
"""Apply (write) the global SSL snippet for all Nginx server blocks."""
try:
write_ssl_snippet()
logger.info("SSL snippet written via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to write SSL snippet: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Helpers
# Config (declarative)
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
@bp.route("/config", methods=["GET"])
def get_config_bp():
try:
return _ok(get_config())
except RuntimeError as exc:
logger.error("Failed to read proxy config: %s", exc)
return _error(str(exc), 500)
def _ok(data=None):
return jsonify({"ok": True, "data": data})
@bp.route("/config", methods=["POST"])
def post_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
save_config(body)
logger.info("Proxy config saved: %s", sorted(body.keys()))
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to save proxy config: %s", exc)
return _error(str(exc), 500)
@bp.route("/config", methods=["PATCH"])
def patch_config():
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
current = get_config()
merged = deep_merge(current, body)
save_config(merged)
logger.info("Proxy config patched: %s", sorted(body.keys()))
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to patch proxy config: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
+8
View File
@@ -53,9 +53,17 @@ def post_config():
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
# Preserve existing server private key through full replacement
current = get_config()
current_key = current.get("interface", {}).get("private_key", "")
if "interface" in body:
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
if current_key:
body.setdefault("interface", {})["private_key"] = current_key
save_config(body)
return _ok(None)
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_status as dnsmasq_status
from lib.firewall import (
config_get,
config_pending,
get_active_zones,
get_interfaces,
get_services,
get_zone_info,
)
from lib.firewall import (
get_config as fw_config_get,
)
from lib.logging import setup_logging
from lib.nginx import get_config as nginx_config
from lib.nginx import get_domains
@@ -220,14 +223,14 @@ def dashboard():
certs=certs,
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(config_get, {}),
firewall_config=_safely(fw_config_get, {}),
firewall_pending=_safely(config_pending, {}),
)
@app.route("/interfaces")
def interfaces_page():
firewall_config = _safely(config_get, {})
firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {})
return render_template(
"interfaces.html",
@@ -240,7 +243,7 @@ def interfaces_page():
@app.route("/zones")
def zones_page():
firewall_config = _safely(config_get, {})
firewall_config = _safely(fw_config_get, {})
firewall_pending = _safely(config_pending, {})
zones_data = {}
for name in _safely(get_active_zones, {}):
@@ -249,12 +252,7 @@ def zones_page():
"zones.html",
zones=zones_data,
interfaces=_safely(get_interfaces, []),
services=_safely(
lambda: __import__(
"lib.firewall", fromlist=["get_services"]
).get_services(),
[],
),
services=_safely(get_services, []),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
)
@@ -263,7 +261,7 @@ def zones_page():
@app.route("/rules")
def rules_page():
zones = list(_safely(get_active_zones, {}).keys())
raw = _safely(config_get, {})
raw = _safely(fw_config_get, {})
rules = {}
for zname, zcfg in raw.get("zones", {}).items():
rr = zcfg.get("rich_rules", [])
+1 -1
View File
@@ -266,7 +266,7 @@ const renderCerts = (certs) => {
const days = cert.days_remaining;
let badgeHtml;
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) {
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
} else {
+3 -2
View File
@@ -565,7 +565,7 @@
}
</style>
</head>
<body>
<body hx-ext="json-enc">
<aside class="sidebar">
<div class="sidebar-header">
VACUUM WALL
@@ -591,7 +591,8 @@
<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>
</body>
</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">
<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">
<label for="cert-domain">Domain</label>
<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="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="form-group">
<label for="range-interface">Interface</label>
@@ -78,7 +78,7 @@
<div class="section-title">Static Leases</div>
<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="form-group">
<label for="lease-mac">MAC Address</label>
@@ -132,7 +132,7 @@
<div class="section-title">Custom DNS Records</div>
<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="form-group">
<label for="dns-ip">IP Address</label>
+1 -1
View File
@@ -51,7 +51,7 @@
<div class="card mb-4">
<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="form-group">
<label for="fw-zone">Zone</label>
+2 -1
View File
@@ -9,6 +9,7 @@
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/ssl-apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('SSL settings applied')">Apply SSL Settings</button>
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
</div>
</div>
@@ -76,7 +77,7 @@
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal">
<h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-swap="none" 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">
<label for="new-domain">Domain</label>
<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">
<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="form-group">
<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">
<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">
<label for="zone-name">Zone Name</label>
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>