Initial commit: SSL proxy / firewall appliance

Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP,
WireGuard, and ACME certificate management.
This commit is contained in:
2026-05-07 22:24:24 +00:00
commit e2f56b8cc8
56 changed files with 10013 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Virtual environment
.venv/
# Python build artifacts
__pycache__/
*.pyc
# Package build
*.egg-info/
# Tool caches
.pytest_cache/
.ruff_cache/
# Local AI tool config (contains internal hostnames)
opencode.json
# Runtime data configs (source-of-truth for services)
data/dnsmasq/config.json
data/nginx/sites-enabled/
+83
View File
@@ -0,0 +1,83 @@
# Vacuum Wall — Agent Instructions
## What This Is
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
Deploys on Debian 13 (trixie). Target system: `/home/wall/vacuum-wall`.
## Architecture
```
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
```
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
- `lib/*.py` — Backend modules. Wrap system commands via `subprocess.run(["sudo", ...])`.
- `data/` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`.
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
## Fixed Path
Every module hardcodes `/home/wall/vacuum-wall`. Changing it requires updating `lib/*.py`, `system/systemd/*.service`, `install.sh`, and `system/sudoers.d/vacuum-wall`.
## Local Dev
```bash
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
```
In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking).
## Blueprint ↔ lib Mapping (Naming Is Not 1:1)
| Blueprint | URL prefix | Backend module |
|-----------------------|-------------------|------------------|
| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
## Privileged Operations
`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`.
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
## API Response Contract
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)`
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)`
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
- Full spec: `docs/api.md`
## Page Routes vs API
`server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead.
## Deploy
`install.sh` is the single deploy script. Run as root, requires `MGMT_DOMAIN`, `MGMT_PASS`, `ACME_EMAIL` env vars.
## Lint and Tests
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
**Tests:** pytest in `tests/`. Run with `python -m pytest`. Tests mock out subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required.
```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 (149 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.
+14
View File
@@ -0,0 +1,14 @@
{
"dhcp": {
"ranges": [],
"static_leases": []
},
"dns": {
"upstreams": [
"8.8.8.8",
"1.1.1.1"
],
"domain": null,
"custom_records": []
}
}
+952
View File
@@ -0,0 +1,952 @@
# REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://wall.lan/api/...`).
Every request and response uses `Content-Type: application/json`.
## Conventions
### Success Responses
```json
{
"ok": true,
"data": <value>
}
```
The `data` field contains the payload, which may be an object, array, string, or `null`.
### Error Responses
```json
{
"ok": false,
"error": "<human-readable message>"
}
```
Error responses carry one of the following HTTP status codes:
| Code | Meaning |
|------|---------|
| `400` | Bad request — invalid body, missing required field, or malformed value |
| `404` | Not found — the requested resource does not exist |
| `500` | Internal server error — unexpected failure in the backend |
---
## Firewall API
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
### Zone Management
#### List All Zones
```
GET /api/firewall/zones
```
Returns active zone-to-interface mappings and all available zone definitions.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.active` | `object<name, [interface, ...]>` | Currently assigned interfaces per zone |
| `data.available` | `[string, ...]` | All zones known to firewalld |
---
#### Get Zone Details
```
GET /api/firewall/zones/<name>
```
Return detailed configuration for a single zone.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) |
| `interfaces` | `[string, ...]` | Interfaces assigned to this zone |
| `services` | `[string, ...]` | Services allowed through the zone |
| `ports` | `[{port: number, proto: string}, ...]` | Explicit port rules |
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
| `rich_rules` | `[string, ...]` | Rich rule definitions |
Returns HTTP `404` if the zone does not exist.
---
#### Create Zone
```
POST /api/firewall/zones
```
Create a new firewalld zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Zone name |
| `target` | `string` | No | Zone target; defaults to `"default"` |
**Response:** `data` is `null` on success.
---
#### Delete Zone
```
DELETE /api/firewall/zones/<name>
```
Remove a zone from firewalld.
**Response:** `data` is `null` on success.
Returns HTTP `404` if the zone does not exist.
### Zone Configuration
#### Set Zone Interfaces
```
POST /api/firewall/zones/<name>/interfaces
```
Replace all interfaces assigned to the zone with the provided list.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interfaces` | `[string, ...]` | Yes | List of interface names |
**Response:** `data` is `null` on success.
---
#### Set Zone Services
```
POST /api/firewall/zones/<name>/services
```
Replace all services allowed in the zone with the provided list.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names |
**Response:** `data` is `null` on success.
### Firewall Rules
#### Add Rich Rule
```
POST /api/firewall/rich-rules
```
Add a firewalld rich rule to a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `rule` | `string` | Yes | Full rich rule string |
**Response:** `data` is `null` on success.
---
#### Remove Rich Rule
```
DELETE /api/firewall/rich-rules
```
Remove an existing rich rule from a zone. The `rule` string must match exactly.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `rule` | `string` | Yes | Exact rich rule string to remove |
**Response:** `data` is `null` on success.
---
#### List Rich Rules
```
GET /api/firewall/rich-rules/<zone>
```
Return all rich rules for the specified zone.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[string, ...]` | Rich rule strings |
### NAT
#### Enable / Disable Masquerade
```
POST /api/firewall/masquerade
```
Toggle masquerade (source NAT) for a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to configure |
| `enable` | `boolean` | Yes | `true` to enable, `false` to disable |
**Response:** `data` is `null` on success.
---
#### Add Port Forward
```
POST /api/firewall/forward-port
```
Add a port forwarding rule to a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response:** `data` is `null` on success.
---
#### Remove Port Forward
```
DELETE /api/firewall/forward-port
```
Remove a port forwarding rule. The body must match the original rule exactly.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response:** `data` is `null` on success.
### Info
#### Available Services
```
GET /api/firewall/services
```
List all service names known to firewalld.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[string, ...]` | Service names |
---
#### Available Interfaces
```
GET /api/firewall/interfaces
```
List all network interfaces currently available on the system.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[string, ...]` | Interface names |
---
## DHCP / DNS API
Endpoints prefixed with `/api/dhcp/...`. Manage dnsmasq configuration, DHCP leases, and custom DNS records.
### Configuration
#### Get Configuration
```
GET /api/dhcp/config
```
Return the current DHCP/DNS configuration object.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Full dnsmasq configuration dictionary |
---
#### Replace Configuration
```
POST /api/dhcp/config
```
Replace the entire configuration with the provided JSON object.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete configuration object |
**Response:** `data` is `null` on success.
---
#### Partial Update Configuration
```
PATCH /api/dhcp/config
```
Deep-merge the provided fields into the existing configuration. Useful for targeted updates (e.g., changing DNS upstream servers without replacing the full config).
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(any subset)* | `any` | Yes | Fields to merge into the existing config |
**Response:** `data` is `null` on success.
---
#### Apply Configuration
```
POST /api/dhcp/apply
```
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
**Response:** `data` is `null` on success.
### Leases
#### Get Live Leases
```
GET /api/dhcp/leases
```
Return the current DHCP lease table from dnsmasq.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects |
---
#### Add Static Lease
```
POST /api/dhcp/static-lease
```
Add a static (reserved) DHCP lease.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mac` | `string` | Yes | MAC address (`"aa:bb:cc:dd:ee:ff"`) |
| `ip` | `string` | Yes | Reserved IP address |
| `hostname` | `string` | No | Hostname for the reservation |
**Response:** `data` is `null` on success.
---
#### Remove Static Lease
```
DELETE /api/dhcp/static-lease?mac=aa:bb:cc:dd:ee:ff
```
Remove a previously configured static lease.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mac` | `string` | Yes | MAC address of the lease to remove |
**Response:** `data` is `null` on success.
Returns HTTP `404` if no matching lease is found.
### DNS Records
#### Add DNS Record
```
POST /api/dhcp/dns-record
```
Add a custom DNS A record served by dnsmasq.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name |
| `address` | `string` | Yes | IP address to resolve to |
**Response:** `data` is `null` on success.
---
#### Remove DNS Record
```
DELETE /api/dhcp/dns-record?name=nas.lan
```
Remove a custom DNS record.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name to remove |
**Response:** `data` is `null` on success.
Returns HTTP `404` if no matching record is found.
---
## Proxy API
Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy.
### Domain Management
#### List All Domains
```
GET /api/proxy/domains
```
Return all configured proxy domains.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of domain configuration objects |
---
#### Add Domain
```
POST /api/proxy/domains
```
Add a new reverse proxy domain.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy |
| `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
**Response:** `data` is `null` on success.
Returns HTTP `400` if the domain is already configured.
---
#### Get Domain Details
```
GET /api/proxy/domains/<domain>
```
Return the configuration for a single proxy domain.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
| `backend_host` | `string` | Backend server address |
| `backend_port` | `number` | Backend server port |
| `backend_proto` | `string` | Backend protocol |
Returns HTTP `404` if the domain is not configured.
---
#### Update Domain
```
PUT /api/proxy/domains/<domain>
```
Update one or more fields of an existing domain entry. Only the fields present in the body are modified.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `backend_host` | `string` | No | Backend server IP or hostname |
| `backend_port` | `number` | No | Backend server port |
| `backend_proto` | `string` | No | Backend protocol |
**Response:** `data` is `null` on success.
Returns HTTP `404` if the domain is not configured.
---
#### Remove Domain
```
DELETE /api/proxy/domains/<domain>
```
Remove a proxy domain and its nginx configuration.
**Response:** `data` is `null` on success.
Returns HTTP `404` if the domain is not configured.
### Apply / Test
#### Apply Configuration
```
POST /api/proxy/apply
```
Regenerate nginx configuration files for all proxy domains and reload the nginx service.
**Response:** `data` is `null` on success.
Returns HTTP `500` if nginx config generation fails or the reload fails.
---
#### Test Configuration
```
POST /api/proxy/test
```
Run `nginx -t` against the generated configuration without reloading. Useful for validating changes before applying.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.valid` | `boolean` | Whether the configuration syntax is valid |
| `data.output` | `string` | Raw nginx test output |
### Management
#### Configure Management WebUI Proxy
```
POST /api/proxy/management
```
Configure the nginx proxy block for the management WebUI itself, including optional HTTP basic authentication.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Management domain (e.g., `"wall.lan"`) |
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
| `auth_user` | `string` | No | Username for basic auth. An `.htpasswd` entry is created when this field is present. |
| `auth_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. |
**Response:** `data` is `null` on success.
If `auth_user` and `auth_pass` are provided, the endpoint creates or updates the corresponding `.htpasswd` file entry.
---
## Certificate API
Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (Let's Encrypt / certbot).
### Listing & Details
#### List All Certificates
```
GET /api/certs/list
```
Return all managed certificates with metadata.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects |
Each certificate object:
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain the certificate covers |
| `expiry` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days until expiration |
| `cert_path` | `string` | Path to the certificate file |
| `key_path` | `string` | Path to the private key file |
---
#### Get Certificate Details
```
GET /api/certs/<domain>
```
Return details for a single certificate.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain |
| `expiry` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days |
| `cert_path` | `string` | Certificate file path |
| `key_path` | `string` | Private key file path |
Returns HTTP `404` if no certificate is found for the domain.
### Operations
#### Issue Certificate
```
POST /api/certs/issue
```
Request a new certificate for a domain.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain to issue the certificate for |
| `standalone` | `boolean` | No | Use standalone (TCP) validation; defaults to `false` (HTTP-01 via existing webroot) |
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
**Response:** `data` is `null` on success.
Returns HTTP `400` if the domain is missing or the request is malformed. Returns HTTP `500` if the ACME challenge or certificate issuance fails.
---
#### Renew Certificate
```
POST /api/certs/<domain>/renew
```
Force-renew an existing certificate, regardless of its current expiry status.
**Response:** `data` is `null` on success.
Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails.
---
#### Remove Certificate
```
DELETE /api/certs/<domain>
```
Delete a certificate and remove it from auto-renewal tracking.
**Response:** `data` is `null` on success.
Returns HTTP `404` if the certificate is not found.
### Account
#### Set ACME Contact Email
```
POST /api/certs/email
```
Set or update the ACME account contact email (used by Let's Encrypt for expiration and security notices).
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | `string` | Yes | Contact email address |
**Response:** `data` is `null` on success.
---
## WireGuard API
Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, peers, and client configuration.
### Configuration
#### Get Configuration
```
GET /api/wireguard/config
```
Return the current WireGuard server configuration. The `private_key` field is stripped from the response.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Full WireGuard configuration dictionary (`private_key` omitted) |
---
#### Replace Configuration
```
POST /api/wireguard/config
```
Replace the entire WireGuard configuration. The `private_key` field is stripped from the response.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Updated configuration (`private_key` omitted) |
### Tunnel Control
#### Apply Configuration
```
POST /api/wireguard/apply
```
Write the current configuration to `wg0.conf` on disk and bring the WireGuard tunnel up.
**Response:** `data` is `null` on success.
Returns HTTP `500` if config write or interface bring-up fails.
---
#### Bring Tunnel Down
```
POST /api/wireguard/down
```
Bring down the WireGuard tunnel interface (`wg0`).
**Response:** `data` is `null` on success.
### Status
#### Tunnel Status
```
GET /api/wireguard/status
```
Return live tunnel state, including interface metrics and per-peer connection statistics.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `up` | `boolean` | Whether the tunnel interface is up |
| `interface` | `object` | Interface info (listen port, public key, etc.) |
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) |
---
#### Initialize
```
POST /api/wireguard/initialize
```
Perform first-time setup: generate a server key pair, write an initial configuration, and prepare for peer enrollment. This endpoint is idempotent — calling it multiple times has no additional effect.
**Response:** `data` is `null` on success.
### Peer Management
#### Add Peer
```
POST /api/wireguard/add-peer
```
Add a new WireGuard peer. A key pair is auto-generated for the peer. The response includes peer details with the private key stripped.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer identifier name |
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
| `persistent_keepalive` | `number` | No | Persistent keepalive interval in seconds |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
| `public_key` | `string` | Peer's public key |
| `allowed_ips` | `[string, ...]` | Allowed IPs |
| `endpoint` | `string` | Allowed endpoint |
| `persistent_keepalive` | `number` | Keepalive interval |
---
#### Remove Peer
```
DELETE /api/wireguard/remove-peer?name=alice
```
Remove a configured peer.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to remove |
**Response:** `data` is `null` on success.
Returns HTTP `404` if the peer is not found.
---
#### List Peers
```
GET /api/wireguard/peers
```
Return all configured peers. Private keys are stripped from the response.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of peer objects (private keys omitted) |
---
#### Peer Connection Status
```
GET /api/wireguard/peer-status
```
Return live per-peer connection status from `wg show`, including last handshake time, transfer bytes, and current endpoint.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of live peer status objects |
### Client Configuration
#### Generate Client Config
```
POST /api/wireguard/generate-client
```
Generate a complete WireGuard client configuration file for provisioning a device. The returned config includes the peer's private key for the client to use.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to generate config for |
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) for the client's `[Peer]` section |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config` | `string` | Complete WireGuard client config text (`[Interface]` + `[Peer]` block) |
The client config includes the generated private key so the client can be provisioned directly. Note that this is the only endpoint that returns a WireGuard private key — all other endpoints strip private keys from responses.
Returns HTTP `404` if the peer is not found.
+110
View File
@@ -0,0 +1,110 @@
# Architecture
## Request Flow
The following describes the path a request takes from an external client to a backend service and back:
### Proxied Service (e.g., `app.example.com`)
1. An external client sends an HTTP request to `app.example.com`.
2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `data/nginx/config.json`.
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
6. The backend service processes the request and returns an HTTP response.
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface.
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
### Management WebUI Access (e.g., `wall.lan`)
1. A client sends an HTTPS request to the management domain.
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
3. If authentication succeeds, the request is proxied to `127.0.0.1:9090` where the Flask WebUI is listening.
4. The Flask application processes the request, performs any necessary privileged operations through the sudo whitelist, and returns an HTML or JSON response.
5. nginx returns the response to the client over the encrypted connection.
Because Flask binds only to `127.0.0.1`, it is unreachable directly from any external interface. The nginx reverse proxy is the sole entry point.
## Subsystem Communication
The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:
```
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
Flask WebUI ──→ lib/nginx.py ──→ write rendered .conf files ──→ sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
Flask WebUI ──→ lib/acme.py ──→ sudo acme.sh ──→ acme.sh CLI ──→ Let's Encrypt ACME
Flask WebUI ──→ lib/wireguard.py ──→ render /etc/wireguard/wg0.conf ──→ sudo wg-quick up wg0 ──→ kernel module
```
Each `lib/` module encapsulates the command construction, sudo invocation, and error handling for its subsystem. The modules read declarative configuration from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation through the sudo whitelist.
## State Management
Vacuum Wall uses a declarative configuration model. The source of truth for each subsystem is a JSON file in the `data/` directory. The application renders these declarations into the format expected by the underlying system service.
| Subsystem | Declarative Config | Rendered Target | State Persistence |
|---|---|---|---|
| firewalld | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as a declarative backup and can be used to restore firewall rules. |
| dnsmasq | `data/dnsmasq/config.json` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
| nginx | `data/nginx/config.json` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
| WireGuard | `data/wireguard/config.json` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
| ACME | `~/.acme.sh/` (managed by acme.sh) | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
## Data Directory Structure
```
data/
├── nginx/
│ ├── config.json # Proxy domain definitions, management domain, SSL settings
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│ ├── config.json # DHCP ranges, static leases, DNS forwarding, custom records
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
├── firewall/
│ └── rules.json # Declarative firewall rule state backup
└── wireguard/
└── config.json # WireGuard interface and peer configuration
```
The `data/` directory resides within the `vacuum-wall` user's project directory (`/home/wall/vacuum-wall/data/`). The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to this directory, while keeping the rest of the filesystem read-only.
## File System Layout
The following file system locations are used for integration with system services:
| Path | Purpose | Managed By |
|---|---|---|
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. Also contains the WebSocket proxy map shared by all server blocks. | Vacuum Wall (lib/nginx.py) |
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
| `/etc/nginx/sites-enabled/` | Symlinks or config files for Vacuum Wall-managed domains (if used alongside other sites). | Vacuum Wall / system |
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `data/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `data/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the `vacuum-wall` user. Defines all permitted privilege escalations. | Install script (manual edits not required) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
| Zone | Interfaces | Trust Level | Description |
|---|---|---|---|
| `public` / `external` | WAN (e.g., `eth0`) | Untrusted | Internet-facing. Only explicitly allowed inbound services (HTTPS/443, WireGuard/51820, ICMP echo rate-limited) are accessible. All other inbound traffic is dropped. |
| `internal` | LAN (e.g., `eth1`) | Trusted | Local area network. DHCP (UDP 67/68) and DNS (UDP/TCP 53) are served. Masquerade (NAT) is enabled for outbound Internet access from LAN clients. Inbound from WAN to this zone is not directly accessible. |
| `vpn` | WireGuard (`wg0`) | Semi-trusted | WireGuard tunnel interface. Firewall rules determine which internal services and subnets VPN peers can reach. By default, VPN peers can access the Internet but may be restricted from accessing management interfaces or sensitive LAN services. |
| `trusted` | Management interface | Administrative | Used for management traffic. The `loopback` zone covers localhost communication, enabling the Flask WebUI to receive proxied requests from nginx on `127.0.0.1:9090`. |
### Custom Zones
Additional zones can be created for specialized network segments:
- **DMZ zone**: For hosting public-facing services that need to be isolated from the internal LAN. Traffic from the DMZ to the `internal` zone is denied by default.
- **Guest zone**: For visitor Wi-Fi or untrusted devices. Access is limited to outbound Internet traffic only, with no access to `internal` or `vpn` zones.
- **IoT zone**: For devices requiring restricted outbound access (e.g., blocking telemetry domains).
Each custom zone can define its own source rules, port forwardings, and inter-zone traffic policies. The Flask WebUI provides interfaces to create, modify, and assign interfaces to zones at runtime.
+245
View File
@@ -0,0 +1,245 @@
# Configuration Reference
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All configuration is stored in the `data/` directory as declarative JSON. The application renders these declarations into the format expected by each underlying service.
## DHCP/DNS Configuration
**File**: `data/dnsmasq/config.json`
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`.
```json
{
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "192.168.2.100",
"end": "192.168.2.200",
"lease_time": "12h",
"gateway": "192.168.2.1",
"dns": "192.168.2.1"
}
],
"static_leases": [
{
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.2.50",
"hostname": "printer"
}
]
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": "lan",
"custom_records": [
{
"name": "nas.lan",
"address": "192.168.2.10",
"hostname": "nas"
}
]
}
}
```
### DHCP Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `ranges` | array | Yes | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. |
| `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). |
| `ranges[].start` | string | Yes | First IP address in the pool. |
| `ranges[].end` | string | Yes | Last IP address in the pool. |
| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `1h`. |
| `ranges[].gateway` | string | No | Default gateway advertised to DHCP clients. Typically the router's LAN IP. |
| `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. |
| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. |
| `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). |
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. |
| `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
### DNS Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `upstreams` | array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. |
| `domain` | string | Yes | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., `printer` becomes `printer.lan`). |
| `custom_records` | array | No | Static DNS A records for internal services and devices. |
| `custom_records[].name` | string | Yes | Fully qualified domain name (e.g., `nas.lan`). |
| `custom_records[].address` | string | Yes | The IP address to resolve the name to. |
| `custom_records[].hostname` | string | No | Short hostname without the domain suffix. Adds a reverse DNS entry as well. |
Additional dnsmasq directives can be appended verbatim by placing plain-text files in `data/dnsmasq/fragments/`. Each file's contents are concatenated into the generated config. This is useful for advanced options not covered by the JSON schema (e.g., `bogus-priv`, `cache-size`, `log-queries`).
## Nginx Configuration
**File**: `data/nginx/config.json`
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
```json
{
"domains": {
"app.example.com": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"force_ssl": true,
"headers": {
"X-Forwarded-Proto": "https",
"X-Real-IP": "$remote_addr"
},
"cert": {
"type": "acme",
"email": "admin@example.com"
}
}
},
"management": {
"domain": "wall.lan",
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
},
"ssl": {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384",
"prefer_server_ciphers": false
}
}
```
### Domain Entries
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service that receives proxied traffic. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | Yes | Protocol for the backend connection: `http` or `https`. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. |
| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. |
| `cert.email` | string | Yes (if `acme`) | ACME account email used by Let's Encrypt. |
| `cert.path` | object | Yes (if `file`) | Paths to certificate files. |
| `cert.path.certificate` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
| `cert.path.key` | string | Yes (if `file`) | Full path to the private key file (PEM). |
### Certificate Types
| Type | Description |
|---|---|
| `acme` | Vacuum Wall uses acme.sh to request and renew a Let's Encrypt certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
| `file` | Use a pre-existing certificate and private key from the local file system. The `path.certificate` and `path.key` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored alongside other acme-managed files in `~/.acme.sh/` with a `.selfsigned` marker. |
### Management Domain
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but includes an `auth` block for HTTP Basic Authentication.
| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `wall.lan`). |
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
| `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. |
The `.htpasswd` file can be created with the `htpasswd` utility:
```bash
htpasswd -bc /home/wall/vacuum-wall/data/nginx/.htpasswd admin yourpassword
```
### Global SSL Settings
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
| Field | Type | Required | Description |
|---|---|---|---|
| `protocols` | string | No | nginx `ssl_protocols` directive value. Default: `TLSv1.2 TLSv1.3`. |
| `ciphers` | string | No | nginx `ssl_ciphers` directive value. Default is a curated AEAD-only cipher string. |
| `prefer_server_ciphers` | boolean | No | Whether to prefer server cipher order. Default: `false`. |
## WireGuard Configuration
**File**: `data/wireguard/config.json`
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
```json
{
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "kOv8lK...',
"public_key": "YzP3xI...',
"addresses": ["10.137.0.1/24"],
"post_up": null,
"post_down": null
},
"peers": {
"alice": {
"public_key": "nR7mQ2...',
"private_key": "xLpDgF...',
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25,
"preshared_key": null
}
}
}
```
### Interface Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | WireGuard interface name. Default: `wg0`. |
| `listen_port` | integer | Yes | Port the WireGuard interface listens on. Default: `51820`. Must be opened in the firewall. |
| `private_key` | string | Yes | Base64-encoded private key for the server interface. Use `wg genkey` to generate. |
| `public_key` | string | Yes | Corresponding public key. Use `wg pubkey` to derive from the private key. |
| `addresses` | array | Yes | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). |
| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. |
| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. |
### Peer Fields
Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration.
| Field | Type | Required | Description |
|---|---|---|---|
| `public_key` | string | Yes | The peer's public key. |
| `private_key` | string | No | The peer's private key, stored for generating downloadable client configuration files. This value is stripped from all API responses — the WebUI never exposes peer private keys over the network. |
| `endpoint` | string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or `null` for peer-initiated connections where the peer connects to the server. |
| `allowed_ips` | array | Yes | CIDR blocks that traffic from this peer is allowed to route. `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. |
### Client Configuration Generation
When a peer's `private_key` is set, the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API.
### Applying Configuration
When configuration is saved through the WebUI or API, the application:
1. Validates all key pairs and IP ranges.
2. Renders the `wg0.conf` file from the JSON configuration.
3. Copies the rendered file to `/etc/wireguard/wg0.conf` using the sudo whitelist.
4. Runs `sudo wg-quick up wg0` to apply the configuration.
5. Returns success or error status to the caller.
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
+270
View File
@@ -0,0 +1,270 @@
# Vacuum Wall Deployment Guide
This guide walks through deploying Vacuum Wall on a real appliance or server. Vacuum Wall is an SSL proxy firewall appliance that combines edge proxying, firewall management, DHCP, DNS, and WireGuard in a single device.
---
## Prerequisites
- **OS**: Clean Debian 13 (Trixie) system. Also works on Debian 12 with backports for firewalld.
- **git**: Required for cloning the repository.
- **Access**: Root access to the machine.
- **Networking**:
- One public-facing network interface (external/edge). This receives inbound traffic and serves the management UI.
- At least one LAN network interface (internal). This connects to your downstream network and will serve DHCP/DNS.
- **DNS**: A DNS record pointing to the appliance's public IP for the management domain (e.g., `wall.example.com`).
- **Minimum hardware**: 1 CPU, 512 MB RAM, 4 GB disk.
---
## Installation
Download the Vacuum Wall repository onto the target machine, then run the installer with the required environment variables:
```bash
MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \
MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \
bash install.sh
```
### Environment Variables
| Variable | Required | Description |
|---|---|---|
| `MGMT_DOMAIN` | Yes | The public-facing domain for the management WebUI. A DNS A record must point to the appliance's IP. |
| `MGMT_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. |
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. |
| `ACME_EMAIL` | Yes | The email address registered with Let's Encrypt for certificate issuance and expiry notifications. |
---
## What install.sh Does
The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for Let's Encrypt certificate management.
- **Flask installation**: Ensures the Flask Python package is available via pip for the WebUI backend.
- **System user creation**: Creates a dedicated `vacuum-wall` system user (nologin shell) that owns the project data and runs the WebUI service.
- **Directory setup**: Creates data directories under `/home/wall/vacuum-wall/data/` for nginx sites, dnsmasq config, firewall rules, and WireGuard config. Sets ownership to the `vacuum-wall` user.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the `vacuum-wall` user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones.
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert.
- **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.
- **Systemd units**: Installs three units:
- `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
- **Firewalld zones**: Creates initial zones:
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
- `vpn` — WireGuard tunnel zone.
- **Service startup**: Enables and starts nginx, the vacuum-wall WebUI, and the ACME renewal timer.
- **ACME registration**: Registers the Let's Encrypt account with the provided email via acme.sh.
---
## Post-Installation
### Verify Services
After the installer completes, confirm all services are running:
```bash
systemctl status vacuum-wall nginx firewalld dnsmasq
```
Each should be active (running). The `vacuum-wall-acme.timer` should also be active (waiting).
### Access the WebUI
Open a browser and navigate to:
```
https://wall.example.com
```
Log in with the username and password you provided during installation.
### Certificate Note
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real Let's Encrypt certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
---
## Configuring Your First Network
After installation, the appliance has no interfaces assigned to zones and no DHCP ranges configured. Use the WebUI to set up your LAN.
### 1. Assign a LAN Interface to the Internal Zone
1. Navigate to the **Interfaces** tab.
2. From the interface list, select your LAN interface (e.g., `eth1`).
3. Assign it to the `internal` zone.
4. Click **Apply** to update the firewall configuration.
### 2. Enable NAT/Masquerade
1. Go to the **NAT** tab.
2. Enable masquerade on the `internal` zone. This allows devices on your LAN to reach the internet through the appliance's external interface.
3. Click **Apply**.
### 3. Configure DHCP
1. Go to the **DHCP** tab.
2. Click **Add Range**.
3. Specify:
- Address range: e.g., `192.168.2.100-192.168.2.200`
- Lease time: e.g., `12h`
- Interface: `eth1` (or whichever interface you assigned to internal)
4. Click **Apply**. This writes the dnsmasq configuration and reloads the service.
DNS resolution will also be provided on this interface by dnsmasq, which forwards queries upstream.
---
## Adding a Proxy Domain
Vacuum Wall's primary function is proxying incoming HTTPS traffic to internal backend services.
### 1. Add the Domain
1. Navigate to the **Proxy** tab.
2. Click **Add Domain**.
3. Fill in:
- **Domain**: The public domain name (e.g., `app.example.com`).
- **Backend Host**: The internal IP address of the service (e.g., `192.168.2.50`).
- **Backend Port**: The port the service listens on (e.g., `8080`).
### 2. Issue a Certificate
1. Go to the **Certs** tab.
2. Click **Issue Certificate** and enter the domain name.
3. ACME validation requires that port 80 on the appliance is reachable from the internet and that the domain's DNS A record points to the appliance's public IP.
### 3. Reload Nginx
1. Return to the **Proxy** tab.
2. Click **Apply** to write the nginx configuration and reload the service.
The proxied domain is now accessible via HTTPS at the configured domain name.
---
## Setting up WireGuard
Vacuum Wall includes integrated WireGuard server support for VPN access.
### 1. Initialize the Server
1. Navigate to the **WireGuard** tab.
2. Click **Initialize**. This generates the server's private and public keys and creates the `wg0` interface configuration.
### 2. Add a Peer
1. Click **Add Peer**.
2. Enter a peer name (e.g., `alice`).
3. Optionally set a specific AllowedIPs range for this peer (defaults to `0.0.0.0/0`).
4. Optionally set an **Endpoint** if you know the peer's static public IP (restricts incoming connections to that IP).
5. Click **Add**. The peer's public key and preshared key are generated automatically.
### 3. Activate the Tunnel
1. Click **Apply** to write the WireGuard configuration and bring up the `wg0` interface.
### 4. Download Client Configuration
1. In the peer list, use the peer actions menu to download the client configuration file for the peer.
2. Install this configuration on the client device.
### 5. Assign WireGuard to a Firewall Zone
1. Navigate to the **Interfaces** tab.
2. Assign `wg0` to the `vpn` zone.
3. The `vpn` zone allows all traffic by default (target ACCEPT). Adjust firewall rules as needed to restrict VPN access to specific services.
### 6. Configure Firewall Rules for VPN Traffic
1. Go to the **Firewall** tab or use the **NAT** tab.
2. Add rules as needed to control what VPN peers can access. For example, you can restrict VPN peers to only reach specific internal services rather than the entire LAN.
3. Optionally enable masquerade on the `vpn` zone to allow VPN clients to reach the internet through the appliance.
---
## Troubleshooting
### Services Won't Start
Check service logs and configuration:
```bash
journalctl -u vacuum-wall --no-pager -n 50
journalctl -u nginx --no-pager -n 50
nginx -t
```
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `/home/wall/vacuum-wall/data/`.
### Firewall Rules Not Applying
Verify that firewalld is running:
```bash
firewall-cmd --state
systemctl status firewalld
```
If firewalld is not running, start it with `systemctl start firewalld`. Check that the sudoers whitelist is valid:
```bash
visudo -cf /etc/sudoers.d/vacuum-wall
```
### Certificate Issuance Fails
Let's Encrypt ACME validation requires:
- The domain's DNS A record points to the appliance's public IP.
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
- The ACME email was registered correctly. Check with:
```bash
su -s /bin/bash vacuum-wall -c "~/.acme.sh/acme.sh --list"
```
If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically.
### DHCP Not Working
Verify that:
- The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`).
- Dnsmasq is running: `systemctl status dnsmasq`.
- A DHCP range is configured for the correct interface. Check dnsmasq config at `/home/wall/vacuum-wall/data/dnsmasq/`.
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`.
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf`.
4. Ensure the `vacuum-wall` WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real Let's Encrypt certificate.
---
## Reference
| Component | Service | Config Location |
|---|---|---|
| WebUI backend | `vacuum-wall.service` | `/home/wall/vacuum-wall/webui/` |
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
| DHCP/DNS | `dnsmasq` | `/home/wall/vacuum-wall/data/dnsmasq/` |
| VPN | wireguard-tools | `/home/wall/vacuum-wall/data/wireguard/` |
| Certificates | `vacuum-wall-acme.timer` | `/home/vacuum-wall/.acme.sh/` |
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
+90
View File
@@ -0,0 +1,90 @@
# Vacuum Wall
## What is Vacuum Wall?
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
## Architecture Overview
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic Let's Encrypt certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
## Subsystems
### Firewall
The firewall uses firewalld's zone model for traffic control. Network interfaces are assigned to zones such as external, internal, VPN, and trusted. Rules and services define which traffic is allowed between zones. Source NAT (masquerade) enables RFC 1918 networks to reach the internet through the external interface. Destination NAT rules provide port forwarding, exposing internal services to external networks on configurable ports.
### DHCP/DNS
dnsmasq serves as both the DHCP server and local DNS resolver. It is configured to serve address pools on specified LAN interfaces, with support for dynamic allocation ranges and static MAC-based reservations. Custom DNS records can be defined for local name resolution, and upstream DNS forwarding passes external queries to configurable resolvers.
### SSL Proxy
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and Let's Encrypt. Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
### WireGuard
WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage.
## Tech Stack
- Debian 13 (trixie) target platform
- Python 3, Flask 3.x for web management
- firewalld (nftables backend)
- nginx 1.26+
- dnsmasq
- WireGuard tools (wireguard-tools)
- acme.sh for ACME/Let's Encrypt certificate management
- HTMX for dynamic UI updates
- Jinja2 for server-side templating
## Quick Start
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with the required environment variables:
```bash
MGMT_DOMAIN=wall.lan MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \
bash install.sh
```
After installation, access the management interface at `https://wall.lan` using the credentials you configured. The `install.sh` script provisions nginx, sets up authentication, obtains an initial Let's Encrypt certificate, and starts all services.
## Project Structure
```
├── install.sh # Deployment script
├── pyproject.toml # Project metadata + dependencies
├── .venv/ # Python virtual environment
├── system/ # System file templates
│ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service
│ │ ├── vacuum-wall-acme.service # Certificate renewal service
│ │ └── vacuum-wall-acme.timer # Renewal schedule
│ └── sudoers.d/ # Sudo whitelist for service account
├── lib/ # Subsystem abstraction layer
│ ├── firewall.py # firewalld bindings
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
│ ├── acme.py # Certificate management
│ └── wireguard.py # VPN tunnel management
├── webui/ # Flask web application
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules
│ ├── templates/ # Jinja2/HTMX templates
│ └── static/ # CSS and client-side JS
└── docs/ # Documentation
├── overview.md # This file
├── deployment.md
├── api.md
├── security.md
├── architecture.md
└── config.md
```
## Documentation
- [Deployment Guide](deployment.md) - Full installation and configuration
- [API Reference](api.md) - REST API endpoints
- [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design
- [Configuration](config.md) - Config file formats and locations
+126
View File
@@ -0,0 +1,126 @@
# Security Model
## Privilege Model
The Vacuum Wall management WebUI (Flask application) runs as the unprivileged `vacuum-wall` system user. The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, TLS certificate issuance — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`.
This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the `vacuum-wall` user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
## Sudo Whitelist
The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
| Category | Whitelisted Command | Purpose |
|---|---|---|
| Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) |
| Nginx | `nginx -s reload` | Graceful nginx configuration reload |
| Nginx | `nginx -t` | Nginx configuration syntax validation |
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management |
| Certificates | `acme.sh` (via `bash -c`) | Let's Encrypt certificate issuance and renewal |
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
| Logs | `sudo journalctl --unit=*` | Query systemd journal for managed services |
| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs |
| File writes | `sudo tee` | Write configuration data to protected paths |
Key safety properties:
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
- No wildcard entries grant shell access or arbitrary command execution.
- The `acme.sh` entry is restricted to certificate operations through an explicit `bash -c` wrapper that only passes acme-related arguments.
- `DEFAULT!/usr/bin/sudo` and `NOPASSWD` are used so the application never prompts for a password and cannot chain sudo calls.
## Web Security
### 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 using an `.htpasswd` file.
### Proxy Domains
Every proxied domain configured in Vacuum Wall enforces:
- **HTTP-to-HTTPS redirect** — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent.
- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age to prevent downgrade attacks.
- **Security headers** on all proxied responses:
- `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing.
- `X-Frame-Options: DENY` — Prevents clickjacking via iframes.
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
- `Content-Security-Policy` rules can be customized per-domain via the configuration.
### TLS Configuration
The default nginx SSL configuration enforces modern TLS only:
- **Protocols**: TLSv1.2 and TLSv1.3. Older protocols (SSLv3, TLSv1.0, TLSv1.1) are disabled.
- **Cipher suites**: A curated set of AEAD ciphers (ECDHE-ECDSA and ECDHE-RSA key exchange with AES-GCM and CHACHA20-POLY1305).
- **DH parameters**: 2048-bit generated Diffie-Hellman parameters are used when ECDHE is not selected.
- **OCSP stapling** is enabled for faster certificate validation.
- **ssl_prefer_server_ciphers** can be toggled per-domain; the default is to let the client choose.
## Systemd Hardening
The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandboxing directives to isolate the WebUI process from the rest of the system:
| Directive | Value | Effect |
|---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ProtectHome` | `read-only` | Makes `/home`, `/root`, and `/run/user` inaccessible |
| `ReadWritePaths` | `/home/wall/vacuum-wall/data /tmp` | Only the application data directory and `/tmp` are writable |
| `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 |
| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach nginx upstream at 127.0.0.1:9090) |
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
| `ProtectKernelTunables` | `yes` | Makes `/proc/sys`, `/sys`, and `/proc/sysrq-trigger` read-only |
| `ProtectKernelModules` | `yes` | Disables `init_module` and `finit_module` syscalls |
| `ProtectControlGroups` | `yes` | Mounts `/sys/fs/cgroup` as read-only |
| `ProtectHostname` | `yes` | Prevents the process from changing the system hostname |
| `RestrictNamespaces` | `yes` | Prevents creating new namespaces |
| `RestrictSUIDSGID` | `yes` | Removes setuid/setgid bits from newly created files |
| `LockPersonality` | `yes` | Prevents changing the execution domain |
| `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable |
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the data directory, and no ability to escalate privileges through kernel interfaces.
## Network Security
### Default Deny
The firewalld default zone policy is set to deny all incoming traffic. Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default.
### Zone-Based Traffic Isolation
| Zone | Interface | Purpose | Behavior |
|---|---|---|---|
| `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo is rate-limited. |
| `internal` | LAN (e.g., `eth1`) | Trusted local network | DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed. |
| `vpn` | WireGuard (`wg0`) | WireGuard tunnel traffic | Semi-trusted. Firewall rules control which internal services VPN peers can reach. Traffic to the LAN is restricted to specific services and ports. |
| `trusted` / `loopback` | `lo` | Localhost communication | unrestricted; used for the Flask-to-nginx management proxy. |
| Custom zones | — | DMZ, guest networks, etc. | Additional zones can be created to isolate specific network segments with their own rule sets. |
### IP Forwarding and NAT
IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the `internal` zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.
## Certificate Security
### acme.sh Integration
Certificate management is handled by acme.sh, which stores all certificates and private keys in the `vacuum-wall` user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the `vacuum-wall` user.
### Private Key Protection
Private keys are never exposed through the WebUI API or returned in API responses. The API only returns certificate metadata such as domain names, validity dates, and renewal status. When a domain's certificate is needed by nginx, the rendered nginx configuration references the file paths managed by acme.sh (`~/.acme.sh/<domain>/fullchain.cer` and `~/.acme.sh/<domain>/<domain>.key`), and nginx reads them directly through symbolic links or includes.
### HSTS Enforcement
All HTTPS proxy domains have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the `includeSubDomains` directive. This ensures browsers always use HTTPS for the domain and all subdomains, preventing SSL stripping attacks.
### Modern TLS Only
As noted in the Web Security section, the default ssl snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites. Weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers are explicitly excluded.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Vacuum Wall - SSL Proxy Firewall Appliance Installer
# Run as root on a fresh Debian 13 (trixie) system
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
PROJECT_DIR="/home/wall/vacuum-wall"
USER_NAME="vacuum-wall"
USER_HOME="/home/$USER_NAME"
DOMAIN="${MGMT_DOMAIN:?ERROR: Set MGMT_DOMAIN env var (e.g., wall.lan)}"
MGMT_USER="${MGMT_USER:-admin}"
MGMT_PASS="${MGMT_PASS:?ERROR: Set MGMT_PASS env var for WebUI basic auth}"
ACME_EMAIL="${ACME_EMAIL:?ERROR: Set ACME_EMAIL env var for Let's Encrypt}"
# --- 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."
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Management domain: $DOMAIN"
echo "============================================"
# --- 1. Install packages ---
log "Installing system packages..."
apt-get update -qq
apt-get install -y -qq \
firewalld \
nginx \
dnsmasq \
wireguard-tools \
python3 \
python3-pip \
jq \
curl \
iptables \
nftables \
apache2-utils
# Install acme.sh under the project user's home
if [[ ! -d "$USER_HOME/.acme.sh" ]]; then
log "Installing acme.sh..."
mkdir -p "$USER_HOME"
ACME_HOME="$USER_HOME/.acme.sh" curl -sS https://get.acme.sh | sh
else
log "acme.sh already installed."
fi
# Setup Python venv with project dependencies
log "Setting up Python virtual environment..."
python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
# --- 2. Create system user ---
if ! id "$USER_NAME" &>/dev/null; then
log "Creating system user $USER_NAME..."
useradd --system --home-dir "$USER_HOME" --shell /usr/sbin/nologin "$USER_NAME"
else
log "User $USER_NAME already exists."
fi
# --- 3. Setup directories ---
log "Creating data directories..."
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard}
mkdir -p "$USER_HOME/vacuum-wall"
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data"
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/lib"
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/webui"
chown -R "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall"
# --- 4. Install sudoers ---
log "Installing sudoers whitelist..."
install -m 0440 "${PROJECT_DIR}/system/sudoers.d/vacuum-wall" /etc/sudoers.d/vacuum-wall
# Validate sudoers syntax
visudo -cf /etc/sudoers.d/vacuum-wall || err "Invalid sudoers file!"
# --- 5. Enable IP forwarding ---
log "Enabling IP forwarding..."
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
sysctl -w net.ipv4.ip_forward=1 2>/dev/null || warn "Could not enable IP forwarding (may need kernel access)"
# --- 6. Start and configure firewalld ---
log "Enabling firewalld..."
systemctl enable firewalld || warn "Could not enable firewalld (already running?)"
systemctl start firewalld || warn "Could not start firewalld (may need D-Bus)"
# Allow management access (HTTP/HTTPS for the proxy)
firewall-cmd --permanent --add-service=http 2>/dev/null || true
firewall-cmd --permanent --add-service=https 2>/dev/null || true
firewall-cmd --permanent --add-service=ssh 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
# --- 7. Configure dnsmasq ---
log "Configuring dnsmasq..."
systemctl enable dnsmasq || true
systemctl start dnsmasq || warn "Could not start dnsmasq (no interfaces configured yet)"
# --- 8. Setup nginx management proxy ---
log "Generating self-signed certificate for management domain..."
mkdir -p "$USER_HOME/.acme.sh/$DOMAIN"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$USER_HOME/.acme.sh/$DOMAIN/$DOMAIN.key" \
-out "$USER_HOME/.acme.sh/$DOMAIN/fullchain.cer" \
-subj "/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN"
chown -R "$USER_NAME:$USER_NAME" "$USER_HOME/.acme.sh"
# Generate htpasswd (credentials passed via environment to avoid shell injection)
mkdir -p "$USER_HOME/vacuum-wall"
htpasswd -cb "$USER_HOME/vacuum-wall/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" python3 -c "
import os, crypt, base64
password = os.environ['MGMT_PASS']
user = os.environ['MGMT_USER']
salt = '\$6\$' + base64.b64encode(os.urandom(16)).decode().rstrip('=')[:16]
hashed = crypt.crypt(password, salt)
with open('$USER_HOME/vacuum-wall/.htpasswd', 'w') as f:
f.write(user + ':' + hashed + '\n')
" 2>/dev/null || \
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
chown "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall/.htpasswd" 2>/dev/null
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
# Vacuum Wall - WebSocket upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
MAPEOF
# Write the management site block (conf.d/ is inside http {}, no extra http {} needed)
cat > /etc/nginx/conf.d/vacuum-wall-mgmt.conf <<MGMTSITEEOF
# Vacuum Wall - Management Proxy
# Auto-generated by install.sh
server {
listen 80;
server_name $DOMAIN;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $DOMAIN;
ssl_certificate $USER_HOME/.acme.sh/$DOMAIN/fullchain.cer;
ssl_certificate_key $USER_HOME/.acme.sh/$DOMAIN/$DOMAIN.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
auth_basic "Vacuum Wall";
auth_basic_user_file $USER_HOME/vacuum-wall/.htpasswd;
location / {
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
}
}
MGMTSITEEOF
# --- 9. Install systemd units ---
log "Installing systemd units..."
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall.service" /etc/systemd/system/vacuum-wall.service
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" /etc/systemd/system/vacuum-wall-acme.service
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
systemctl daemon-reload
# --- 10. Setup initial firewalld zones ---
log "Setting up default firewalld zones..."
# Internal zone (LAN)
firewall-cmd --permanent --new-zone=internal 2>/dev/null || true
firewall-cmd --permanent --zone=internal --set-target=ACCEPT 2>/dev/null || true
firewall-cmd --permanent --zone=internal --add-service=dhcp 2>/dev/null || true
firewall-cmd --permanent --zone=internal --add-service=dns 2>/dev/null || true
firewall-cmd --permanent --zone=internal --add-service=ntp 2>/dev/null || true
# VPN zone
firewall-cmd --permanent --new-zone=vpn 2>/dev/null || true
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT 2>/dev/null || true
# Public/external zone defaults are fine
firewall-cmd --reload 2>/dev/null || true
# --- 11. Enable and start services ---
log "Enabling services..."
systemctl enable nginx
systemctl enable vacuum-wall
systemctl enable vacuum-wall-acme.timer
systemctl start nginx 2>/dev/null || warn "Could not start nginx (check config)"
systemctl start vacuum-wall 2>/dev/null || warn "Could not start vacuum-wall WebUI"
# --- 12. Configure acme.sh default email ---
log "Configuring acme.sh default email..."
"$USER_HOME/.acme.sh/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \
warn "Could not register acme.sh account (will be done from WebUI)"
# --- Done ---
echo ""
echo "============================================"
echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
echo "============================================"
echo ""
echo " Management UI: https://$DOMAIN"
echo " User: $MGMT_USER"
echo " WebUI service: vacuum-wall.service"
echo " ACME renewal: vacuum-wall-acme.timer"
echo ""
echo " Next steps:"
echo " 1. Assign interfaces to zones from the WebUI"
echo " 2. Configure DHCP ranges for your LAN"
echo " 3. Add proxy domains with Let's Encrypt certs"
echo " 4. Set up WireGuard tunnel (optional)"
echo ""
echo " NOTE: A self-signed certificate was generated."
echo " From the WebUI, issue a real certificate for $DOMAIN"
echo " when DNS points to this appliance."
echo ""
View File
+510
View File
@@ -0,0 +1,510 @@
"""
ACME certificate manager for Vacuum Wall.
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
from Let's Encrypt (or other ACME providers).
"""
import logging
import os
import re
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
_ACME_ENVIRON = {
"HOME": str(Path.home()),
"PATH": os.environ.get(
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
}
def _find_acme() -> str:
"""Locate the acme.sh binary on the system.
Checks:
1. ~/.acme.sh/acme.sh
2. /usr/local/bin/acme.sh
Returns:
Absolute path to the acme.sh binary.
Raises:
FileNotFoundError: If acme.sh cannot be found.
"""
candidates = [
Path.home() / ".acme.sh" / "acme.sh",
Path("/usr/local/bin/acme.sh"),
]
for path in candidates:
if path.is_file() and os.access(path, os.X_OK):
logger.info("Found acme.sh at %s", path)
return str(path)
acme = shutil.which("acme.sh")
if acme:
logger.info("Found acme.sh via PATH at %s", acme)
return acme
raise FileNotFoundError(
"acme.sh not found in any standard location. "
"Install it with: curl -sSL https://get.acme.sh | sh"
)
def _run_acme(args: list[str]) -> str:
"""Execute acme.sh with the given arguments.
Runs the command as root via sudo because standalone / webroot
validation often requires binding to privileged ports (80/443).
Args:
args: List of arguments to pass to acme.sh.
Returns:
Combined stdout + stderr from the command, since acme.sh writes
meaningful output to both streams.
Raises:
RuntimeError: If the acme.sh command exits with a non-zero code.
"""
acme_bin = _find_acme()
cmd: list[str] = [
"sudo",
acme_bin,
"--home",
str(Path.home() / ".acme.sh"),
"--config-home",
str(Path.home() / ".acme.sh"),
*args,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
env={**os.environ, **_ACME_ENVIRON},
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"acme.sh command timed out after 120s: {' '.join(cmd)}"
) from exc
output = result.stdout
if result.stderr:
output = output + result.stderr if output else result.stderr
if result.returncode != 0:
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
raise RuntimeError(
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
)
return output
def set_email(email: str) -> None:
"""Configure the default ACME contact email.
Registers or updates the ACME account with the given email address.
Args:
email: The contact email for the ACME account.
"""
_run_acme(["--register-account", "-m", email])
logger.info("ACME contact email set to %s", email)
def get_email() -> str:
"""Return the ACME contact email, or '' if none is configured."""
try:
account_conf = Path.home() / ".acme.sh" / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError as exc:
logger.warning("Could not read account.conf: %s", exc)
return ""
def issue(domain: str, webroot: str | None = None, standalone: bool = False) -> dict:
"""Issue a new SSL certificate for a domain.
Args:
domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation.
standalone: If True, use standalone TCP validation (binds port 80).
Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'.
"""
args: list[str] = ["--issue", "-d", domain]
if webroot:
args.extend(["--webroot", webroot])
elif standalone:
args.append("--standalone")
email = get_email()
if email:
args.extend(["-m", email])
args.append("--force")
try:
output = _run_acme(args)
return {
"success": True,
"domain": domain,
"message": f"Certificate for {domain} issued successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to issue certificate for {domain}",
"output": "",
"error": str(exc),
}
def renew(domain: str, force: bool = False) -> dict:
"""Renew an existing SSL certificate.
Args:
domain: The domain whose certificate should be renewed.
force: If True, renew even if the certificate isn't close to expiry.
Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'.
"""
args: list[str] = ["--renew", "-d", domain]
if force:
args.append("--force")
try:
output = _run_acme(args)
return {
"success": True,
"domain": domain,
"message": f"Certificate for {domain} renewed successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to renew certificate for {domain}",
"output": "",
"error": str(exc),
}
def remove(domain: str) -> str:
"""Stop auto-renewal for a domain.
Runs ``acme.sh --remove`` which stops the cron job from renewing
the certificate. Per the acme.sh README the cert/key files are
**not** deleted from disk after ``--remove``; remove them with
the ``--ecc`` flag if needed, or delete the ``~/.acme.sh/{domain}``
directory manually.
Args:
domain: The domain to remove from the renewal list.
Returns:
The combined stdout from the acme.sh command.
"""
output = _run_acme(["--remove", "-d", domain])
logger.info("Certificate for %s removed", domain)
return output
def list_certs() -> list[dict]:
"""List all managed certificates with expiry information.
Returns:
A list of dicts, one per certificate, with keys matching
the cert-info schema (domain, ca, cert_path, etc.).
"""
raw = _run_acme(["--list"])
certs: list[dict] = []
entries = _parse_list_output(raw)
acme_home = Path.home() / ".acme.sh"
for entry in entries:
main = entry["main_domain"]
if not main:
continue
san_domains = [
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
]
cert_dir = acme_home / main
cert_path = str(cert_dir / "fullchain.cer")
key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer")
days = _days_until(entry.get("certificate_expires", ""))
auto = _has_auto_renew(main)
certs.append(
{
"domain": main,
"ca": entry.get("CA", ""),
"cert_path": cert_path,
"key_path": key_path,
"ca_path": ca_path,
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"days_until_expiry": days,
"auto_renew": auto,
"san_domains": san_domains,
}
)
return certs
def get_cert_info(domain: str) -> dict:
"""Return detailed information about a certificate.
Args:
domain: The domain name.
Returns:
A dict matching the cert-info schema.
Raises:
ValueError: If no certificate is found for the domain.
"""
certs = list_certs()
for c in certs:
if c["domain"] == domain or domain in c["san_domains"]:
return c
raise ValueError(f"No certificate found for domain: {domain}")
def get_expiry(domain: str) -> str | None:
"""Return the certificate expiry date as an ISO string, or None.
Args:
domain: The domain name.
Returns:
Expiry date string (e.g. '2026-04-15') or None.
"""
try:
info = get_cert_info(domain)
return info.get("expires_at")
except ValueError:
return None
def is_expired(domain: str) -> bool:
"""Check whether a certificate has expired.
Args:
domain: The domain name.
Returns:
True if the certificate is expired or not found, False otherwise.
"""
days = days_until_expiry(domain)
if days is None:
return True
return days < 0
def days_until_expiry(domain: str) -> int | None:
"""Calculate the number of days until a certificate expires.
Args:
domain: The domain name.
Returns:
Integer days remaining (negative if expired), or None if cert not found.
"""
try:
info = get_cert_info(domain)
return _days_until(info.get("expires_at", ""))
except ValueError:
return None
def copy_cert(domain: str, dest_dir: str) -> dict:
"""Copy certificate files to a target directory.
Copies the fullchain, key, and CA certificate files.
Args:
domain: The domain name.
dest_dir: Destination directory path.
Returns:
A dict with paths to the copied files.
"""
paths = get_cert_paths(domain)
target = Path(dest_dir)
target.mkdir(parents=True, exist_ok=True)
copied = {}
for label, src in paths.items():
src_path = Path(src)
if src_path.is_file():
dst = target / src_path.name
shutil.copy2(str(src_path), str(dst))
copied[label] = str(dst)
else:
logger.warning("Source %s (%s) not found, skipping", label, src)
return {
"domain": domain,
"dest_dir": str(target),
"copied": copied,
"failed": [k for k in paths if k not in copied],
}
def get_cert_paths(domain: str) -> dict:
"""Return the file paths for all certificate components.
Args:
domain: The domain name.
Returns:
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
"""
acme_home = str(Path.home() / ".acme.sh" / domain)
return {
"cert": f"{acme_home}/{domain}.cert",
"key": f"{acme_home}/{domain}.key",
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
}
def setup_nginx_install(domain: str) -> None:
"""Configure acme.sh to automatically install certs for nginx.
Sets up a post-hook so that nginx-specific files are copied to
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed
by an nginx reload.
Args:
domain: The domain name.
"""
cert_dest = f"/etc/ssl/certs/{domain}"
key_dest = f"/etc/ssl/private/{domain}.key"
args: list[str] = [
"--install-cert",
"-d",
domain,
"--cert-file",
cert_dest,
"--key-file",
key_dest,
"--ca-file",
f"/etc/ssl/certs/{domain}-ca.crt",
"--fullchain-file",
f"/etc/ssl/certs/{domain}-fullchain.crt",
"--reloadcmd",
"sudo nginx -t && sudo systemctl reload nginx",
]
_run_acme(args)
logger.info("nginx auto-install configured for %s", domain)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _parse_list_output(raw: str) -> list[dict]:
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
Each line in the output contains ``Key:Value`` tokens separated by
whitespace, e.g.::
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
Keys are converted to lowercase in the returned dicts.
"""
entries: list[dict] = []
for line in raw.strip().splitlines():
line = line.strip()
if not line:
continue
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
if entry:
entries.append(entry)
return entries
def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y%m%d%H%M%z"):
try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
delta = dt - datetime.now(UTC)
return delta.days
except ValueError:
continue
return None
def _has_auto_renew(domain: str) -> bool:
"""Check whether a domain has a scheduled cron renewal.
The README states the cron entry format is:
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/``
indicates the domain is being tracked by the cron job.
"""
acme_home = Path.home() / ".acme.sh"
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
# per-domain config exists, the cron will pick it up.
domain_conf = acme_home / f"{domain}.conf"
if domain_conf.is_file():
return True
# Fallback: check crontab -l for the domain.
try:
result = subprocess.run(
["sudo", "crontab", "-l"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and "--cron" in result.stdout:
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return False
+354
View File
@@ -0,0 +1,354 @@
"""
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
"""
import json
import os
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = DATA_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
# --- defaults ---
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {
"ranges": [],
"static_leases": [],
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
}
# ───────── helpers ───────────────────────────────────────────────────
def _ensure_dirs() -> None:
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:
"""Load current dnsmasq config from JSON state file."""
_ensure_dirs()
raw = _load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> 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)
def apply_config() -> None:
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
cfg = get_config()
conf_text = generate_conf(cfg)
_ensure_dirs()
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
capture_output=True,
text=True,
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interfaces=interfaces,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
)
# ───────── dhcp management ───────────────────────────────────────────
def set_dhcp_range(
iface: str,
start: str,
end: str,
lease_time: str = "12h",
gateway: str | None = None,
dns: str | None = None,
) -> None:
"""Add or replace the DHCP range for a given interface."""
cfg = get_config()
ranges = cfg["dhcp"]["ranges"]
found = False
for i, r in enumerate(ranges):
if r.get("interface") == iface:
ranges[i] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
ranges[i]["gateway"] = gateway
if dns:
ranges[i]["dns"] = dns
found = True
break
if not found:
entry: dict[str, Any] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
entry["gateway"] = gateway
if dns:
entry["dns"] = dns
ranges.append(entry)
save_config(cfg)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
"""Add (or update) a static DHCP lease by MAC address."""
cfg = get_config()
leases = cfg["dhcp"]["static_leases"]
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
leases[i] = {"mac": mac, "ip": ip}
if hostname:
leases[i]["hostname"] = hostname
save_config(cfg)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
def remove_static_lease(mac: str) -> None:
"""Remove a static DHCP lease by MAC address."""
cfg = get_config()
cfg["dhcp"]["static_leases"] = [
lease
for lease in cfg["dhcp"]["static_leases"]
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
# ───────── dns record management ─────────────────────────────────────
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
"""Add or update a custom DNS A record."""
cfg = get_config()
records = cfg["dns"]["custom_records"]
for i, r in enumerate(records):
if r["name"] == name:
records[i] = {"name": name, "address": address}
if hostname:
records[i]["hostname"] = hostname
save_config(cfg)
return
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
def remove_dns_record(name: str) -> None:
"""Remove a custom DNS record by name."""
cfg = get_config()
cfg["dns"]["custom_records"] = [
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
# ───────── lease table ───────────────────────────────────────────────
def _parse_lease_line(line: str) -> dict[str, Any] | None:
"""Parse one line from dnsmasq.leases into a dict."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split()
if len(parts) < 3:
return None
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
return {
"expires_at": ts,
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
def get_lease_table() -> list[dict]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict] = []
try:
result = _sudo("cat", LEASE_FILE)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
except subprocess.CalledProcessError:
pass
return leases
# ───────── upstream / domain helpers ─────────────────────────────────
def set_upstreams(servers: list[str]) -> None:
"""Set the list of upstream DNS forwarders."""
cfg = get_config()
cfg["dns"]["upstreams"] = list(servers)
save_config(cfg)
def set_domain(domain: str | None) -> None:
"""Set (or clear) the local DNS domain."""
cfg = get_config()
cfg["dns"]["domain"] = domain if domain else None
save_config(cfg)
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
# dnsmasq process check
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
capture_output=True,
text=True,
)
active = proc.stdout.strip() == "active"
except Exception:
active = False
# config on disk
conf_exists = os.path.isfile(DNSMASQ_CONF)
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
conf_on_disk = f.read()
except PermissionError:
conf_on_disk = ""
else:
conf_on_disk = ""
# current expected config
expected = generate_conf(cfg)
leases = get_lease_table()
return {
"service_active": active,
"config_file_exists": conf_exists,
"config_in_sync": conf_on_disk == expected,
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
"static_leases": len(cfg["dhcp"]["static_leases"]),
"custom_dns_records": len(cfg["dns"]["custom_records"]),
"upstreams": cfg["dns"]["upstreams"],
"domain": cfg["dns"].get("domain"),
"active_leases": len(leases),
"leases": leases,
}
+655
View File
@@ -0,0 +1,655 @@
"""
firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance.
Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT,
and port-forwarding. All mutations are --permanent followed by --reload.
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 os
import subprocess
from datetime import UTC
from typing import Any
DATA_DIR: str = "/home/wall/vacuum-wall/data/firewall"
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
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."""
_run(["sudo", "firewall-cmd", "--reload"])
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
# ---------------------------------------------------------------------------
# Read-only queries
# ---------------------------------------------------------------------------
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"])
return output.split()
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces.
Example return value::
{
"public": ["eth0"],
"internal": ["eth1"],
}
"""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
stripped = raw_line.strip()
if not stripped:
continue
# Indented lines belong to the current zone section.
if raw_line.startswith(" "):
current_ifaces = (
zones[current_zone]
if current_zone
else zones.get(list(zones.keys())[-1], [])
)
for piece in stripped.split():
if current_zone and piece not in current_ifaces:
current_ifaces.append(piece)
else:
current_zone = stripped
zones[current_zone] = []
return zones
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*.
Keys in the returned dict include:
``name``, ``target``, ``interfaces``, ``sources``, ``services``,
``ports``, ``protocols``, ``forward-ports``, ``masquerade``,
``rich-rules``, ``ics``, ``icmp-blocks``, ``module``.
"""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if not value:
# Lines like "interfaces: " or "masquerade: " when disabled
if key in ("masquerade", "ics"):
info[key] = False
else:
info[key] = []
else:
if key in (
"interfaces",
"sources",
"services",
"ports",
"protocols",
"icmp-blocks",
"module",
):
info[key] = value.split()
elif key == "forward-ports":
info[key] = _parse_forward_ports(value)
elif key in ("masquerade", "ics"):
info[key] = value.lower() == "yes"
elif key == "rich-rules":
# rich-rules can span multiple lines; we'll parse below.
info[key] = [value] if value else []
else:
info[key] = value
# rich-rules may already have been set; if not, default to empty.
info.setdefault("rich-rules", [])
info.setdefault("interfaces", [])
info.setdefault("sources", [])
info.setdefault("services", [])
info.setdefault("ports", [])
info.setdefault("protocols", [])
info.setdefault("forward-ports", [])
info.setdefault("masquerade", False)
info.setdefault("ics", False)
info.setdefault("icmp-blocks", [])
info.setdefault("module", [])
info.setdefault("target", "default")
return info
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"])
return output.split()
def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names."""
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"])
return output.split()
def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2."""
output = _run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
# Format: "NUM: NAME: <FLAGS> ..."
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
ifaces.append(name)
return ifaces
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 = output.strip()
if not output:
return []
rules: list[str] = []
current: list[str] = []
for line in output.splitlines():
raw = line.rstrip()
if not raw.endswith(";"):
current.append(raw)
else:
current.append(raw)
rules.append(" ".join(current))
current = []
if current:
rules.append(" ".join(current))
return rules
# ---------------------------------------------------------------------------
# Zone CRUD
# ---------------------------------------------------------------------------
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld.
Args:
zone: Name of the zone to create.
Raises:
RuntimeError: If the zone already exists or creation fails.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
)
_reload()
def delete_zone(zone: str) -> None:
"""Delete an existing zone.
Raises:
RuntimeError: If the zone does not exist or the deletion fails.
"""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
_reload()
# ---------------------------------------------------------------------------
# Interface assignment
# ---------------------------------------------------------------------------
def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
"""Assign *interfaces* to *zone*, replacing any existing assignments.
Existing interfaces on the zone are removed first so only the
provided list remains.
"""
# Remove current permanent interfaces for this zone.
try:
current = get_zone_info(zone).get("interfaces", [])
except Exception:
current = []
for iface in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
check=False,
)
# Add the desired set.
for iface in interfaces:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------
def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
# Remove all current services.
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
check=False,
)
for svc in services:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
)
_reload()
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
)
_reload()
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Rich rules
# ---------------------------------------------------------------------------
def add_rich_rule(zone: str, rule: str) -> None:
"""Add a rich rule to *zone*.
The *rule* argument should be a fully-formed rich-rule expression,
e.g. ``rule family="ipv4" port protocol="tcp" port="443" accept``.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
)
_reload()
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*.
The rule string must match exactly what was added.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Masquerade (NAT)
# ---------------------------------------------------------------------------
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"])
_reload()
# ---------------------------------------------------------------------------
# Port forwarding
# ---------------------------------------------------------------------------
def add_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Add a port forwarding rule to *zone*.
Forward traffic arriving on ``port/protocol`` to
``toaddr:toport`` (or just ``toport`` when *toaddr* is omitted).
"""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
)
_reload()
def remove_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Remove a previously added port-forwarding rule from *zone*.
All parameters must match the original rule exactly.
"""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
def _parse_forward_ports(value: str) -> list[str]:
"""Parse the 'forward-ports' line into individual forward-port specifiers.
Multiple entries are space-separated; each looks like
``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``.
"""
return value.split() if value else []
# ---------------------------------------------------------------------------
# State snapshot / backup helpers
# ---------------------------------------------------------------------------
def get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld as a Python dict.
The dict contains all zones with their per-zone configuration, all
rich rules, masquerade settings, forward-port rules, and the set of
active interfaces.
"""
zones: dict[str, dict[str, Any]] = {}
for name in get_available_zones():
try:
zones[name] = get_zone_info(name)
except Exception:
continue
return {
"active_zones": get_active_zones(),
"interfaces": get_interfaces(),
"available_services": get_services(),
"zones": zones,
"rich_rules": {name: get_rich_rules(name) for name in zones},
"timestamp": _now_iso(),
}
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
from datetime import datetime
return datetime.now(UTC).isoformat()
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk.
Returns:
Absolute path to the written file.
"""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict.
Use :func:`restore_backup` to actually apply the loaded state.
Raises:
FileNotFoundError: When no backup file exists at RULES_FILE.
json.JSONDecodeError: When the file is not valid JSON.
Returns:
The loaded state dict.
"""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
def restore_backup(state: dict[str, Any]) -> None:
"""Apply the zone configuration described in *state*.
Walks every zone in *state*["zones"] and re-creates services,
interfaces, forward ports, masquerade, and rich rules.
This is a *merge*: zones not present in the snapshot are **not**
touched.
"""
zones_cfg = state.get("zones", {})
for zone_name, zinfo in zones_cfg.items():
# Ensure the zone exists.
if zone_name not in get_available_zones():
target = zinfo.get("target", "default")
create_zone(zone_name, target)
# Services
services = zinfo.get("services", [])
set_zone_services(zone_name, services)
# Interfaces
interfaces = zinfo.get("interfaces", [])
set_zone_interfaces(zone_name, interfaces)
# Masquerade
if zinfo.get("masquerade"):
set_masquerade(zone_name, True)
# Forward ports (stored as raw strings in zinfo)
for fp in zinfo.get("forward-ports", []):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp}",
"--permanent",
],
check=False,
)
# Rich rules
for rule in zinfo.get("rich-rules", []):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
check=False,
)
_reload()
__all__ = [
"DATA_DIR",
"RULES_FILE",
"_reload",
"_run",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
"get_services",
"get_state",
"get_zone_info",
"load_backup",
"remove_forward_port",
"remove_rich_rule",
"remove_zone_interface",
"remove_zone_service",
"restore_backup",
"save_backup",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
]
+365
View File
@@ -0,0 +1,365 @@
"""
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 os
import subprocess
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
DATA_DIR = PROJECT_DIR / "data" / "nginx"
SITES_DIR = DATA_DIR / "sites-enabled"
CONFIG_FILE = DATA_DIR / "config.json"
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
DEFAULT_SSL = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-RSA-CHACHA20-POLY1305"
),
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG = {
"domains": {},
"management": None,
"ssl": DEFAULT_SSL.copy(),
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_dirs():
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 save_config(cfg: dict) -> None:
_json_dump(CONFIG_FILE, cfg)
def get_domains() -> list[dict]:
cfg = get_config()
result = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
result.append(
{
"domain": name,
"backend": dom.get("backend", {}),
"online": site.exists(),
"force_ssl": dom.get("force_ssl", True),
}
)
return result
# ------------------------------------------------------------------
# Domain CRUD
# ------------------------------------------------------------------
def add_domain(
domain,
backend_host,
backend_port,
backend_proto="http",
cert=None,
extra_headers=None,
) -> None:
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry = {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
if extra_headers is not None:
entry["headers"] = extra_headers
cfg["domains"][domain] = entry
save_config(cfg)
def remove_domain(domain) -> None:
cfg = get_config()
cfg["domains"].pop(domain, None)
save_config(cfg)
site = SITES_DIR / f"{domain}.conf"
if site.exists():
site.unlink()
def update_domain(domain, **kwargs) -> None:
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
for key, val in kwargs.items():
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
entry[key] = val
save_config(cfg)
# ------------------------------------------------------------------
# Nginx config generation
# ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}),
headers=domain_cfg.get("headers", {}),
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"),
is_management=False,
)
def _generate_management_conf(management: dict) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain", "wall.lan"),
backend=dict(
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
),
headers={},
force_ssl=True,
cert=None,
auth=management.get("auth"),
is_management=True,
)
# ------------------------------------------------------------------
# File writers
# ------------------------------------------------------------------
def write_site(domain, conf_text) -> None:
_ensure_dirs()
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(conf_text)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, path)
def write_all_sites() -> None:
_ensure_dirs()
cfg = get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy)
write_site(name, conf)
written.add(f"{name}.conf")
if cfg.get("management"):
mgmt_conf = _generate_management_conf(cfg["management"])
write_site("management", mgmt_conf)
written.add("management.conf")
for old in existing:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
def write_include_file() -> None:
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = INCLUDE_FILE.with_suffix(".tmp")
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)
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.setdefault("protocols", DEFAULT_SSL["protocols"])
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
tmp = SSL_SNIPPET.with_suffix(".tmp")
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)
tmp.unlink(missing_ok=True)
# ------------------------------------------------------------------
# nginx lifecycle
# ------------------------------------------------------------------
def test_config() -> tuple[bool, str]:
result = _run(["sudo", "nginx", "-t"])
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
output = "nginx configuration test passed"
return ok, output
def apply() -> None:
write_ssl_snippet()
write_all_sites()
write_include_file()
ok, msg = test_config()
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
# ------------------------------------------------------------------
# Management WebUI
# ------------------------------------------------------------------
def set_management_proxy(
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
) -> None:
cfg = get_config()
entry = {
"domain": domain,
"backend": {
"host": flask_host,
"port": int(flask_port),
"proto": "http",
},
}
if auth_user:
entry["auth"] = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
cfg["management"] = entry
save_config(cfg)
if auth_user and auth_pass:
write_htpasswd(auth_user, auth_pass)
# ------------------------------------------------------------------
# htpasswd
# ------------------------------------------------------------------
def write_htpasswd(user, password) -> None:
"""
Append (or create) an htpasswd entry for *user*.
Uses passlib's apache_passwd hash so the file remains portable.
If passlib is unavailable falls back to Python's built-in crypt.
If the user already exists the line is replaced in-place.
"""
_ensure_dirs()
hashed = _hash_password(password)
existing = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(":", 1)
if len(parts) == 2:
existing[parts[0]] = line
existing[user] = f"{user}:{hashed}"
tmp = HTPASSWD_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
for _uname, entry in existing.items():
f.write(entry + "\n")
os.chmod(tmp, 0o640)
os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password):
try:
from passlib.hash import apache_passwd
return apache_passwd.using(rounds=12).hash(password)
except Exception:
import crypt as _crypt
salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}")
+511
View File
@@ -0,0 +1,511 @@
"""
WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface.
"""
import json
import os
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
CONFIG_PATH = str(PROJECT_DIR / "data" / "wireguard" / "config.json")
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
# --- Helpers ---
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a command via sudo and return the completed process."""
return subprocess.run(
["sudo", *cmd],
capture_output=True,
text=True,
check=check,
)
def _ensure_dir(path: str) -> None:
"""Create parent directories for *path* if they don't exist."""
Path(path).parent.mkdir(parents=True, exist_ok=True)
def _default_config() -> dict:
"""Return the skeleton config with no keys and no peers."""
return {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
# --- Core config persistence ---
def get_config() -> dict:
"""Load the current WireGuard configuration from the JSON store.
Returns the full config dict. If the file does not exist or is
unreadable, returns the default (empty) config skeleton.
"""
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
# Backfill keys that might be missing from older snapshots.
defaults = _default_config()
cfg.setdefault("interface", defaults["interface"])
cfg["interface"].setdefault("name", defaults["interface"]["name"])
cfg["interface"].setdefault("listen_port", defaults["interface"]["listen_port"])
cfg["interface"].setdefault("private_key", defaults["interface"]["private_key"])
cfg["interface"].setdefault("public_key", defaults["interface"]["public_key"])
cfg["interface"].setdefault("addresses", defaults["interface"]["addresses"])
cfg["interface"].setdefault("post_up", defaults["interface"]["post_up"])
cfg["interface"].setdefault("post_down", defaults["interface"]["post_down"])
cfg.setdefault("peers", {})
return cfg
except (FileNotFoundError, json.JSONDecodeError):
return _default_config()
def save_config(cfg: dict) -> None:
"""Persist *cfg* to the JSON store atomically.
Writes to a temporary file in the same directory and then renames
to avoid partial reads on crash.
"""
_ensure_dir(CONFIG_PATH)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
os.replace(tmp, CONFIG_PATH)
# --- Key generation ---
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
Returns:
``(private_key, public_key)`` as two 43-character base64 strings.
"""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key)
public_key = res2.stdout.strip()
return private_key, public_key
# --- wg0.conf generation ---
def generate_conf(cfg: dict) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"],
peers=cfg.get("peers", {}),
)
# --- Apply / down ---
def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick."""
cfg = get_config()
conf_text = generate_conf(cfg)
save_config(cfg) # ensure latest state persisted
local_dir = Path("/home/wall/vacuum-wall/data/wireguard")
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / "wg0.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
_run(["cp", str(local_tmp), WG_CONF_PATH])
_run(["chown", "root:root", WG_CONF_PATH], check=False)
local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
def down() -> None:
"""Bring the WireGuard tunnel interface down."""
cfg = get_config()
name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name])
# --- Status ---
def status() -> dict:
"""Query the live tunnel state via ``wg show``.
Returns a dict with keys:
- ``up`` (bool) - whether the interface is currently up.
- ``interface`` (dict) - name, public key, listen port, fwmark.
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
"""
cfg = get_config()
name = cfg["interface"]["name"]
result = {
"up": False,
"interface": {},
"peers": [],
}
try:
proc = _run([WG_BIN, "show", name], check=False)
if proc.returncode != 0:
return result
raw = proc.stdout.strip()
except Exception:
return result
# Parse the wg show output.
# Format (multi-section, separated by blank lines or interleaved):
# interface:
# public key: ...
# listening port: ...
# peer: <key>
# endpoint: ...
# allowed ips: ...
# latest handshake: ...
# transfer: ...
# persistent-keepalive: ...
current_peer = None
peers: list[dict] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
result["up"] = True
result["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
result["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
val = line.split(":", 1)[1].strip()
result["interface"]["listen_port"] = int(val)
continue
if line.startswith("fwmark:"):
result["interface"]["fwmark"] = line.split(":", 1)[1].strip()
continue
if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip()
current_peer = {
"public_key": cur_key,
"endpoint": None,
"allowed_ips": [],
"latest_handshake": None,
"transfer_received": 0,
"transfer_sent": 0,
"persistent_keepalive": None,
}
peers.append(current_peer)
continue
if current_peer is None:
continue
if line.startswith("endpoint:"):
current_peer["endpoint"] = line.split(":", 1)[1].strip()
continue
if line.startswith("allowed ips:"):
vals = line.split(":", 1)[1].strip().split(", ")
current_peer["allowed_ips"] = vals
continue
if line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
continue
if line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip()
parts = rest.split(", ")
if parts:
current_peer["transfer_received"] = parts[0].strip()
if len(parts) > 1:
current_peer["transfer_sent"] = parts[1].strip()
continue
if line.startswith("persistent-keepalive:"):
val = line.split(":", 1)[1].strip()
try:
current_peer["persistent_keepalive"] = int(val)
except ValueError:
current_peer["persistent_keepalive"] = None
result["peers"] = peers
return result
# --- Peer management ---
def add_peer(
name: str,
endpoint: str | None = None,
allowed_ips: list[str] | None = None,
persistent_keepalive: int | None = None,
preshared_key: str | None = None,
) -> dict:
"""Add (or update) a peer in the configuration.
If the peer has no public key yet, one will be generated
together with a matching private key (useful for client provi-
sioning). The returned dict mirrors the stored peer record
with an additional ``private_key`` field so the caller can
distribute the client credentials.
Args:
name: Human-readable identifier (dict key in config).
endpoint: e.g. ``203.0.113.1:51820``.
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
persistent_keepalive: Interval in seconds (or ``None``).
preshared_key: Optional PSK (base64 string).
Returns:
The peer dict as stored, plus ``private_key`` for client use.
"""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
if name in peers:
peer = peers[name]
peer["endpoint"] = endpoint
peer["allowed_ips"] = allowed_ips
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
else:
# Generate a key pair for the new peer.
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv, # stored so we can hand it to the client
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key,
}
peers[name] = peer
save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer)
return peer_out
def remove_peer(name: str) -> None:
"""Remove a peer from the configuration by name."""
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
def get_peers() -> list[dict]:
"""List all configured peers (from the JSON store, *not* live).
Returns a list of dicts. Each dict includes ``name`` and all
stored fields **except** ``private_key`` (not exposed here).
"""
cfg = get_config()
peers = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
# Strip private key from the public listing.
entry.pop("private_key", None)
peers.append(entry)
return peers
def get_peer_status() -> list[dict]:
"""Return live peer status from ``wg show``.
Each element contains:
- ``public_key``, ``endpoint``, ``allowed_ips``,
``latest_handshake``, ``transfer_received``,
``transfer_sent``, ``persistent_keepalive``.
"""
st = status()
return st.get("peers", [])
# --- Client config generation ---
def generate_client_conf(
peer_name: str,
server_endpoint: str,
server_pubkey: str,
) -> str:
"""Build a client-side wg-quick config snippet for *peer_name*."""
cfg = get_config()
iface = cfg["interface"]
peer = cfg["peers"].get(peer_name)
if peer is None:
raise KeyError(f"Peer '{peer_name}' not found in configuration")
client_priv = peer.get("private_key", "")
if not client_priv:
raise ValueError(
f"Peer '{peer_name}' has no private key — cannot generate client config."
)
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(peer_name) + 2
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
addr_part, prefix = srv_addr.rsplit("/", 1)
prefix_base = addr_part.rsplit(".", 1)[0]
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=server_pubkey,
server_endpoint=server_endpoint,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
)
# --- Interface-level setters ---
def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration.
Does **not** hot-reload; call :func:`apply` afterwards to
activate the change.
"""
if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535")
cfg = get_config()
cfg["interface"]["listen_port"] = port
save_config(cfg)
def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command.
The command is passed verbatim to the generated wg0.conf.
"""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
def set_post_down(cmd: str | None) -> None:
"""Set (or clear) the PostDown hook command."""
cfg = get_config()
cfg["interface"]["post_down"] = cmd
save_config(cfg)
# --- Initialise ---
def initialize() -> dict:
"""Perform first-time WireGuard setup.
Generates a fresh server key pair, writes the initial config
to disk, and returns the full config dict.
Call this once at appliance bootstrapping time. It will
**not** overwrite an existing config that already has a
non-empty private key.
"""
cfg = get_config()
if cfg["interface"].get("private_key"):
# Already initialised — return existing config.
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
save_config(cfg)
return cfg
# --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict:
"""Internal parser for ``wg show`` multiline output.
Returns a dict keyed by peer public key with parsed values.
Used internally; ``status()`` is the public interface.
"""
peers: dict = {}
current = None
for line in output.splitlines():
line = line.strip()
if line.startswith("peer:"):
key = line.split(":", 1)[1].strip()
current = {"_key": key}
peers[key] = current
continue
if current is None:
continue
if line.startswith("endpoint:"):
val = line.split(":", 1)[1].strip()
current["endpoint"] = val
elif line.startswith("allowed ips:"):
current["allowed_ips"] = line.split(":", 1)[1].strip()
elif line.startswith("latest handshake:"):
current["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
current["transfer_raw"] = line.split(":", 1)[1].strip()
elif line.startswith("persistent-keepalive:"):
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers
+54
View File
@@ -0,0 +1,54 @@
[build-system]
requires = ["setuptools>=64.0"]
build-backend = "setuptools.build_meta"
[project]
name = "vacuum-wall"
version = "0.0.1"
description = "SSL proxy / firewall appliance with zone-based policies"
requires-python = ">=3.13"
dependencies = [
"Flask>=3.0,<4.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.4.0",
"pytest>=8.0",
"pytest-cov>=4.1",
]
[tool.setuptools.packages.find]
include = ["lib*", "webui*"]
[tool.ruff]
target-version = "py313"
src = ["."]
exclude = [".venv"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long handled by formatter
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
+47
View File
@@ -0,0 +1,47 @@
# ---- vacuum-wall managed dnsmasq configuration ----
# generated {{ timestamp }}
{% if interfaces %}
interface={{ interfaces | join(',') }}
bind-interfaces
{% endif %}
{% for srv in dns.upstreams %}
server={{ srv }}
{% else %}
no-resolv
{% endfor %}
{% if dns.domain %}
domain={{ dns.domain }}
expand-hosts
{% endif %}
{% for rng in dhcp.ranges %}
{% if rng.interface %}
dhcp-range=set:{{ rng.interface }},{{ rng.start }},{{ rng.end }},{{ rng.lease_time }}
{% else %}
dhcp-range={{ rng.start }},{{ rng.end }},{{ rng.lease_time }}
{% endif %}
{% if rng.gateway %}
dhcp-option=tag:{{ rng.interface }},3,{{ rng.gateway }}
{% endif %}
{% if rng.dns %}
dhcp-option=tag:{{ rng.interface }},6,{{ rng.dns }}
{% endif %}
{% endfor %}
{% for lease in dhcp.static_leases %}
{% if lease.hostname %}
dhcp-host={{ lease.mac }},{{ lease.ip }},{{ lease.hostname }}
{% else %}
dhcp-host={{ lease.mac }},{{ lease.ip }}
{% endif %}
{% endfor %}
{% for rec in dns.custom_records %}
{% if rec.hostname %}
host-record={{ rec.name }},{{ rec.address }}
addr/{{ rec.name }}/{{ rec.address }}
{% else %}
addr/{{ rec.name }}/{{ rec.address }}
{% endif %}
{% endfor %}
{% if fragments_dir %}
conf-dir={{ fragments_dir }},optional
{% endif %}# ---- end vacuum-wall config ----
+11
View File
@@ -0,0 +1,11 @@
# Vacuum Wall — auto-generated include file
# Regenerated on every config change — do not edit manually
# WebSocket upgrade mapping
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Domain server blocks
include {{ sites_glob }};
+99
View File
@@ -0,0 +1,99 @@
# Auto-generated by Vacuum Wall — do not edit manually
# Domain: {{ domain }}
{% if force_ssl %}
server {
listen 80;
listen [::]:80;
server_name {{ domain }};
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
{% endif %}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ domain }};
{% if cert %}
{% if cert.type == "acme" %}
# Certificate managed by acme.sh
{% if cert.email %} # ACME contact: {{ cert.email }}
{% endif %} ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
{% elif cert.type == "file" %}
ssl_certificate {{ cert.path }};
ssl_certificate_key {{ cert.key_path }};
{% elif cert.type == "selfsigned" %}
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
{% endif %}
{% elif is_management %}
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
{% endif %}
# Shared SSL settings
include snippets/vacuum-wall-ssl.conf;
{% if auth %}
# HTTP basic authentication
auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}";
auth_basic_user_file {{ auth.htpasswd }};
{% endif %}
{% if not is_management %}
# Security hardening headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
{% for hname, hval in headers.items() %}
proxy_set_header {{ hname }} {{ hval }};
{% endfor %}
{% endif %}
# Proxy pass to backend
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
proxy_http_version 1.1;
{% if not is_management %}
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
# Access / error logs
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% else %}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
access_log /var/log/nginx/wall_mgmt_access.log;
error_log /var/log/nginx/wall_mgmt_error.log warn;
{% endif %}
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
+10
View File
@@ -0,0 +1,10 @@
# Vacuum Wall — shared SSL settings
# Regenerated automatically — do not edit manually
ssl_protocols {{ ssl.protocols }};
ssl_prefer_server_ciphers {{ "on" if ssl.prefer_server_ciphers else "off" }};
ssl_ciphers {{ ssl.ciphers }};
ssl_session_timeout 1d;
ssl_session_cache shared:TLS:10m;
ssl_session_tickets off;
+37
View File
@@ -0,0 +1,37 @@
# Defaults directives
Defaults:vacuum-wall !requiretty
Defaults:vacuum-wall secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Firewall management
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/firewall-cmd *
# Nginx management
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -t
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
# WireGuard management
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
# Acme.sh (SSL cert management)
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/bash ~/.acme.sh/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/local/bin/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /home/*/.acme.sh/*
# Misc
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/wireguard
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=Vacuum Wall ACME Certificate Renewal
[Service]
Type=oneshot
User=vacuum-wall
WorkingDirectory=/home/wall/vacuum-wall
ExecStart=/usr/local/bin/acme.sh --cron --home /home/vacuum-wall/.acme.sh
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Vacuum Wall ACME Certificate Renewal Timer
[Timer]
OnCalendar=*-*-* 00:00:00
OnCalendar=*-*-* 12:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=Vacuum Wall Management WebUI
Documentation=https://github.com/wall/vacuum-wall
After=network.target firewalld.service nginx.service dnsmasq.service
Wants=firewalld.service
[Service]
Type=simple
User=vacuum-wall
Group=vacuum-wall
WorkingDirectory=/home/wall/vacuum-wall
ExecStart=/home/wall/vacuum-wall/.venv/bin/python webui/server.py
Restart=on-failure
RestartSec=5
Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/wall/vacuum-wall/data /tmp
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
LockPersonality=yes
# Network - only loopback (nginx proxies to us)
IPAddressDeny=all
IPAddressAllow=localhost
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
# vacuum-wall client config for {{ peer_name }} — generated {{ timestamp }}
[Interface]
PrivateKey = {{ client_priv }}
Address = {{ client_addr }}
[Peer]
PublicKey = {{ server_pubkey }}
Endpoint = {{ server_endpoint }}
AllowedIPs = {{ allowed_ips | join(',') }}
{% if preshared_key %}
PresharedKey = {{ preshared_key }}
{% endif %}
{% if persistent_keepalive is not none %}
PersistentKeepalive = {{ persistent_keepalive }}
{% endif %}
+29
View File
@@ -0,0 +1,29 @@
# Auto-generated by vacuum-wall at {{ timestamp }}
[Interface]
PrivateKey = {{ interface.private_key }}
Address = {{ interface.addresses | join(',') }}
ListenPort = {{ interface.listen_port }}
{% if interface.post_up %}
PostUp = {{ interface.post_up }}
{% endif %}
{% if interface.post_down %}
PostDown = {{ interface.post_down }}
{% endif %}
{% for peer_name, peer in peers.items() %}
[Peer] # {{ peer_name }}
PublicKey = {{ peer.public_key }}
{% if peer.preshared_key %}
PresharedKey = {{ peer.preshared_key }}
{% endif %}
{% if peer.endpoint %}
Endpoint = {{ peer.endpoint }}
{% endif %}
{% if peer.allowed_ips %}
AllowedIPs = {{ peer.allowed_ips | join(',') }}
{% endif %}
{% if peer.persistent_keepalive is not none %}
PersistentKeepalive = {{ peer.persistent_keepalive }}
{% endif %}
{% endfor %}
+3
View File
@@ -0,0 +1,3 @@
import sys
sys.path.insert(0, "/home/wall/vacuum-wall")
+130
View File
@@ -0,0 +1,130 @@
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import acme
class TestFindAcme:
@patch("lib.acme.shutil.which")
@patch("lib.acme.Path.home")
def test_finds_in_home(self, mock_home, mock_which):
mock_home.return_value = Path("/tmp/fakehome")
acme_path = mock_home.return_value / ".acme.sh" / "acme.sh"
acme_path.parent.mkdir(parents=True, exist_ok=True)
acme_path.write_text("#!/bin/sh\n")
acme_path.chmod(0o755)
try:
result = acme._find_acme()
assert "acme.sh" in result
finally:
acme_path.unlink()
@patch("lib.acme.shutil.which")
@patch("lib.acme.Path.home")
def test_raises_when_not_found(self, mock_home, mock_which):
mock_home.return_value = Path("/tmp/nonexistent-acme-dir")
mock_which.return_value = None
with pytest.raises(FileNotFoundError):
acme._find_acme()
class TestRunAcme:
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_success(self, mock_run, mock_find):
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="success\n", stderr="")
result = acme._run_acme(["--list"])
assert result == "success\n"
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_failure(self, mock_run, mock_find):
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error\n")
with pytest.raises(RuntimeError):
acme._run_acme(["--list"])
class TestParseListOutput:
def test_parses_single_entry(self):
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["ca"] == "LetsEncrypt"
def test_parses_multiple_entries(self):
raw = (
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
)
result = acme._parse_list_output(raw)
assert len(result) == 2
def test_empty_input(self):
result = acme._parse_list_output("")
assert result == []
def test_skips_lines_without_colons(self):
raw = "some random line\nMain_Domain:a.com"
result = acme._parse_list_output(raw)
assert len(result) == 1
class TestDaysUntil:
def test_future_date(self):
from datetime import UTC, timedelta
future = datetime.now(UTC) + timedelta(days=365)
result = acme._days_until(future.strftime("%Y-%m-%d"))
assert result >= 364
def test_empty_string(self):
assert acme._days_until("") is None
assert acme._days_until(None) is None
def test_invalid_format(self):
assert acme._days_until("not-a-date") is None
def test_expired_date(self):
result = acme._days_until("2020-01-01")
assert result is not None
assert result < 0
class TestGetEmail:
def test_returns_empty_when_no_account_conf(self):
with patch("lib.acme.Path.home") as mock_home:
mock_home.return_value = Path("/tmp/no-acme-email")
result = acme.get_email()
assert result == ""
def test_parses_email_from_account_conf(self):
tmpdir = tempfile.mkdtemp()
acme_dir = Path(tmpdir) / ".acme.sh"
acme_dir.mkdir(exist_ok=True)
conf = acme_dir / "account.conf"
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
with patch("lib.acme.Path.home", return_value=Path(tmpdir)):
result = acme.get_email()
assert result == "test@example.com"
class TestGetCertPaths:
@patch("lib.acme.Path.home")
def test_returns_paths(self, mock_home):
mock_home.return_value = Path("/home/user")
paths = acme.get_cert_paths("example.com")
assert paths["cert"].endswith("example.com/example.com.cert")
assert paths["key"].endswith("example.com/example.com.key")
assert paths["ca"].endswith("example.com/ca.cer")
assert paths["fullchain"].endswith("example.com/fullchain.cer")
+369
View File
@@ -0,0 +1,369 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wg_bp
@pytest.fixture
def client():
from flask import Flask
app = Flask(__name__)
app.register_blueprint(bp, url_prefix="/api/firewall")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wg_bp, url_prefix="/api/wireguard")
return app.test_client()
class TestFirewallListZones:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_available, mock_active, client):
mock_active.return_value = {"public": ["eth0"]}
mock_available.return_value = ["public", "internal"]
resp = client.get("/api/firewall/zones")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert "public" in data["data"]["active"]
@patch("webui.api.firewall.get_active_zones")
def test_runtime_error(self, mock_active, client):
mock_active.side_effect = RuntimeError("no sudo")
resp = client.get("/api/firewall/zones")
assert resp.status_code == 500
data = resp.get_json()
assert data["ok"] is False
class TestFirewallZoneDetails:
@patch("webui.api.firewall.get_zone_info")
def test_success(self, mock_info, client):
mock_info.return_value = {"name": "public", "services": ["ssh"]}
resp = client.get("/api/firewall/zones/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["name"] == "public"
class TestFirewallCreateZone:
@patch("webui.api.firewall.create_zone")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_zones, mock_create, client):
mock_zones.return_value = ["public", "internal"]
mock_create.return_value = None
resp = client.post(
"/api/firewall/zones",
json={"name": "dmz", "target": "default"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_missing_name(self, client):
resp = client.post(
"/api/firewall/zones",
json={"target": "default"},
)
assert resp.status_code == 400
data = resp.get_json()
assert data["ok"] is False
class TestFirewallDeleteZone:
@patch("webui.api.firewall.delete_zone")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_zones, mock_delete, client):
mock_zones.return_value = ["public", "dmz"]
mock_delete.return_value = None
resp = client.delete("/api/firewall/zones/dmz")
assert resp.status_code == 200
@patch("webui.api.firewall.get_available_zones")
def test_not_found(self, mock_zones, client):
mock_zones.return_value = ["public"]
resp = client.delete("/api/firewall/zones/dmz")
assert resp.status_code == 404
class TestFirewallRichRules:
@patch("webui.api.firewall.add_rich_rule")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/firewall/rich-rules",
json={
"zone": "public",
"rule": 'rule family="ipv4" port protocol="tcp" port="443" accept',
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_missing_fields(self, client):
resp = client.post("/api/firewall/rich-rules", json={})
assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules")
def test_list(self, mock_list, client):
mock_list.return_value = ["rule1", "rule2"]
resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"] == ["rule1", "rule2"]
class TestFirewallServices:
@patch("webui.api.firewall.get_services")
def test_list(self, mock_services, client):
mock_services.return_value = ["ssh", "http", "dns"]
resp = client.get("/api/firewall/services")
assert resp.status_code == 200
assert resp.get_json()["data"] == ["ssh", "http", "dns"]
class TestFirewallInterfaces:
@patch("webui.api.firewall.get_interfaces")
def test_list(self, mock_ifaces, client):
mock_ifaces.return_value = ["eth0", "eth1"]
resp = client.get("/api/firewall/interfaces")
assert resp.status_code == 200
assert resp.get_json()["data"] == ["eth0", "eth1"]
class TestFirewallMasquerade:
@patch("webui.api.firewall.set_masquerade")
def test_enable(self, mock_set, client):
mock_set.return_value = None
resp = client.post(
"/api/firewall/masquerade",
json={"zone": "internal", "enable": True},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post("/api/firewall/masquerade", json={})
assert resp.status_code == 400
class TestFirewallForwardPort:
@patch("webui.api.firewall.add_forward_port")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public", "port": 443, "proto": "tcp"},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public"},
)
assert resp.status_code == 400
class TestDhcpConfig:
@patch("webui.api.dhcp.get_config")
def test_get(self, mock_get, client):
mock_get.return_value = {"dhcp": {}, "dns": {}}
resp = client.get("/api/dhcp/config")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
def test_post_invalid_body(self, client):
resp = client.post(
"/api/dhcp/config", data="not json", content_type="text/plain"
)
data = resp.get_json()
assert data is not None
class TestDhcpStaticLease:
@patch("webui.api.dhcp.add_static_lease")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/dhcp/static-lease",
json={"mac": "AA:BB:CC", "ip": "10.0.0.5"},
)
assert resp.status_code == 200
def test_missing_mac(self, client):
resp = client.post("/api/dhcp/static-lease", json={"ip": "10.0.0.5"})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_static_lease")
def test_remove(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
assert resp.status_code == 200
def test_remove_missing_mac(self, client):
resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400
class TestDhcpDnsRecord:
@patch("webui.api.dhcp.add_dns_record")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/dhcp/dns-record",
json={"name": "host.local", "address": "10.0.0.10"},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post("/api/dhcp/dns-record", json={})
assert resp.status_code == 400
class TestProxyDomains:
@patch("webui.api.proxy.get_domains")
def test_list(self, mock_get, client):
mock_get.return_value = []
resp = client.get("/api/proxy/domains")
assert resp.status_code == 200
@patch("webui.api.proxy.add_domain")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/proxy/domains",
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
)
assert resp.status_code == 200
def test_add_missing_domain(self, client):
resp = client.post("/api/proxy/domains", json={})
assert resp.status_code == 400
class TestProxyApply:
@patch("webui.api.proxy.apply")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/proxy/apply")
assert resp.status_code == 200
class TestCertsList:
@patch("webui.api.certs.list_certs")
def test_list(self, mock_list, client):
mock_list.return_value = []
resp = client.get("/api/certs/list")
assert resp.status_code == 200
@patch("webui.api.certs.get_cert_info")
def test_details_not_found(self, mock_info, client):
mock_info.side_effect = ValueError("not found")
resp = client.get("/api/certs/example.com")
assert resp.status_code == 404
class TestCertsIssue:
def test_missing_domain(self, client):
resp = client.post("/api/certs/issue", json={})
assert resp.status_code == 400
class TestCertsEmail:
def test_missing_email(self, client):
resp = client.post("/api/certs/email", json={})
assert resp.status_code == 400
class TestWireguardConfig:
@patch("webui.api.wireguard.get_config")
def test_get(self, mock_get, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "secret"},
"peers": {},
}
resp = client.get("/api/wireguard/config")
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]["interface"]
@patch("webui.api.wireguard.save_config")
def test_post(self, mock_save, client):
mock_save.return_value = None
resp = client.post("/api/wireguard/config", json={"peers": {}})
assert resp.status_code == 200
class TestWireguardPeers:
@patch("webui.api.wireguard.get_peers")
def test_list(self, mock_get, client):
mock_get.return_value = []
resp = client.get("/api/wireguard/peers")
assert resp.status_code == 200
@patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client):
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
resp = client.post(
"/api/wireguard/add-peer",
json={"name": "client1"},
)
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]
def test_add_missing_name(self, client):
resp = client.post("/api/wireguard/add-peer", json={})
assert resp.status_code == 400
class TestWireguardInitialize:
@patch("webui.api.wireguard.initialize")
def test_initialize(self, mock_init, client):
mock_init.return_value = {
"interface": {"name": "wg0", "private_key": "priv"},
"peers": {},
}
resp = client.post("/api/wireguard/initialize")
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]["interface"]
class TestWireguardGenerateClient:
def test_missing_name(self, client):
resp = client.post("/api/wireguard/generate-client", json={})
assert resp.status_code == 400
class TestWireguardStatus:
@patch("webui.api.wireguard.status")
def test_get(self, mock_status, client):
mock_status.return_value = {"up": True, "interface": {}, "peers": []}
resp = client.get("/api/wireguard/status")
assert resp.status_code == 200
class TestResponseHelpers:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
def test_error_response_format(self, mock_a, mock_b, client):
mock_a.side_effect = RuntimeError("fail")
resp = client.get("/api/firewall/zones")
data = resp.get_json()
assert "error" in data
assert "ok" in data
assert data["ok"] is False
+166
View File
@@ -0,0 +1,166 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import dnsmasq
@pytest.fixture
def temp_data_dir(tmp_path):
original = dnsmasq.DATA_DIR
original_config = dnsmasq.CONFIG_PATH
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
dnsmasq.CONFIG_PATH = dnsmasq.DATA_DIR / "config.json"
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
dnsmasq.DATA_DIR = original
dnsmasq.CONFIG_PATH = original_config
class TestDeepMerge:
def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
result = dnsmasq._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)
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)
assert result == {"a": "flat"}
class TestGetConfig:
@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()
assert "dhcp" in result
assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@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()
assert result["dns"]["upstreams"] == ["9.9.9.9"]
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {"dns": {"upstreams": ["1.2.3.4"], "domain": "test.lan"}}
dnsmasq.save_config(cfg)
loaded = dnsmasq.get_config()
assert loaded["dns"]["upstreams"] == ["1.2.3.4"]
assert loaded["dns"]["domain"] == "test.lan"
class TestSetDhcpRange:
def test_add_new_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
def test_replace_existing_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
class TestStaticLeases:
def test_add_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
def test_update_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
def test_remove_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
dnsmasq.remove_static_lease("aa:bb:cc")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
class TestDnsRecords:
def test_add_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
def test_remove_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
dnsmasq.add_dns_record("other", "10.0.0.101")
dnsmasq.remove_dns_record("host")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0]["name"] == "other"
class TestParseLeaseLine:
def test_valid_line(self):
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
assert result["ip"] == "10.0.0.50"
assert result["hostname"] == "printer"
def test_empty_line(self):
assert dnsmasq._parse_lease_line("") is None
def test_comment_line(self):
assert dnsmasq._parse_lease_line("# comment") is None
def test_short_line(self):
assert dnsmasq._parse_lease_line("incomplete") is None
def test_minimal_fields(self):
line = "1700000000 AA:BB:CC 10.0.0.50"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["hostname"] == ""
assert result["interface"] == ""
class TestUpstreamsAndDomain:
def test_set_upstreams(self, temp_data_dir):
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
cfg = dnsmasq.get_config()
assert cfg["dns"]["upstreams"] == ["1.1.1.1", "9.9.9.9"]
def test_set_domain(self, temp_data_dir):
dnsmasq.set_domain("internal.lan")
cfg = dnsmasq.get_config()
assert cfg["dns"]["domain"] == "internal.lan"
def test_clear_domain(self, temp_data_dir):
dnsmasq.set_domain("internal.lan")
dnsmasq.set_domain(None)
cfg = dnsmasq.get_config()
assert cfg["dns"]["domain"] is None
+156
View File
@@ -0,0 +1,156 @@
from datetime import datetime
from unittest.mock import patch
from lib import firewall
class TestParseForwardPorts:
def test_single_entry(self):
result = firewall._parse_forward_ports("port=443/proto=tcp")
assert result == ["port=443/proto=tcp"]
def test_multiple_entries(self):
result = firewall._parse_forward_ports(
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
)
assert len(result) == 2
assert result[0] == "port=443/proto=tcp"
def test_empty_string(self):
assert firewall._parse_forward_ports("") == []
class TestGetActiveZones:
@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()
assert result == {
"public": ["eth0"],
"internal": ["eth1", "eth2"],
}
@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")
def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz"
result = firewall.get_active_zones()
assert result == {"dmz": []}
class TestGetZoneInfo:
@patch("lib.firewall._run")
def test_parses_zone_info(self, mock_run):
mock_run.return_value = (
"target: default\n"
"interfaces: eth0\n"
"sources: \n"
"services: ssh dhcp\n"
"ports: 8080/tcp\n"
"protocols: \n"
"forward-ports: \n"
"masquerade: yes\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
)
result = firewall.get_zone_info("public")
assert result["name"] == "public"
assert result["services"] == ["ssh", "dhcp"]
assert result["ports"] == ["8080/tcp"]
assert result["masquerade"] is True
assert result["interfaces"] == ["eth0"]
assert result["sources"] == []
assert result["rich-rules"] == []
class TestGetInterfaces:
@patch("lib.firewall._run")
def test_parses_interfaces(self, mock_run):
mock_run.return_value = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
)
result = firewall.get_interfaces()
assert result == ["lo", "eth0", "eth1"]
class TestGetRichRules:
@patch("lib.firewall._run")
def test_single_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;'
)
result = firewall.get_rich_rules("public")
assert len(result) == 1
@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")
def test_multiline_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
)
result = firewall.get_rich_rules("public")
assert len(result) == 1
assert "10.0.0.0/24" in result[0]
class TestNowIso:
def test_returns_iso_string(self):
result = firewall._now_iso()
datetime.fromisoformat(result)
assert "+" in result
class TestAddForwardPort:
@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")
def test_forward_port_port_only(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080)
class TestGetState:
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.get_zone_info")
@patch("lib.firewall.get_active_zones")
@patch("lib.firewall.get_interfaces")
@patch("lib.firewall.get_services")
@patch("lib.firewall.get_rich_rules")
def test_returns_full_state(
self,
mock_rich,
mock_services,
mock_ifaces,
mock_active,
mock_zone_info,
mock_available,
):
mock_available.return_value = ["public", "internal"]
mock_active.return_value = {"public": ["eth0"]}
mock_ifaces.return_value = ["eth0", "eth1"]
mock_services.return_value = ["ssh", "http"]
mock_zone_info.return_value = {"name": "public", "services": []}
mock_rich.return_value = []
result = firewall.get_state()
assert "zones" in result
assert "active_zones" in result
assert "timestamp" in result
+246
View File
@@ -0,0 +1,246 @@
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import nginx
@pytest.fixture(autouse=True)
def reset_default_config():
"""Reset the shared mutable DEFAULT_CONFIG before each test."""
original = nginx.DEFAULT_CONFIG.copy()
yield
# Reset the shared "domains" dict that leaks due to shallow copy in _json_load
nginx.DEFAULT_CONFIG = original
@pytest.fixture
def temp_data_dir(tmp_path):
original_config = nginx.CONFIG_FILE
original_sites = nginx.SITES_DIR
original_htpasswd = nginx.HTPASSWD_FILE
original_ssl_snippet = nginx.SSL_SNIPPET
original_include = nginx.INCLUDE_FILE
nginx.DATA_DIR = tmp_path / "nginx"
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
nginx.HTPASSWD_FILE = tmp_path / "nginx" / ".htpasswd"
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
nginx.INCLUDE_FILE = tmp_path / "include.conf"
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
nginx.CONFIG_FILE = original_config
nginx.SITES_DIR = original_sites
nginx.HTPASSWD_FILE = original_htpasswd
nginx.SSL_SNIPPET = original_ssl_snippet
nginx.INCLUDE_FILE = original_include
class TestGetConfig:
def test_returns_default_when_no_file(self, temp_data_dir):
cfg = nginx.get_config()
assert "domains" in cfg
assert "ssl" in cfg
assert cfg["domains"] == {}
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
}
nginx.save_config(cfg)
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
class TestGetDomains:
def test_empty_domains(self, temp_data_dir):
result = nginx.get_domains()
assert result == []
def test_returns_domain_list(self, temp_data_dir):
cfg = {
"domains": {
"example.com": {
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
"force_ssl": True,
}
}
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 1
assert result[0]["domain"] == "example.com"
class TestAddDomain:
@patch("lib.nginx.get_config")
def test_add_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config()
assert "example.com" in cfg["domains"]
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
@patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
with pytest.raises(ValueError):
nginx.add_domain("example.com", "10.0.0.5", 8080)
class TestRemoveDomain:
@patch("lib.nginx.get_config")
def test_remove_existing_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.remove_domain("example.com")
cfg = nginx.get_config()
assert "example.com" not in cfg["domains"]
def test_remove_nonexistent_domain(self, temp_data_dir):
nginx.remove_domain("nonexistent.com")
cfg = nginx.get_config()
assert "nonexistent.com" not in cfg["domains"]
class TestUpdateDomain:
@patch("lib.nginx.get_config")
def test_update_existing_domain(self, mock_get, temp_data_dir):
entry = {
"backend": {"host": "10.0.0.5", "port": 8080, "proto": "http"},
"force_ssl": True,
}
mock_get.return_value = {
"domains": {"example.com": entry},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.update_domain("example.com", force_ssl=False)
cfg = nginx.get_config()
assert cfg["domains"]["example.com"]["force_ssl"] is False
def test_update_nonexistent_raises(self, temp_data_dir):
with pytest.raises(KeyError):
nginx.update_domain("nonexistent.com", force_ssl=False)
class TestWriteSite:
def test_write_creates_file(self, temp_data_dir):
nginx.write_site("example.com", "server { listen 443; }")
path = nginx.SITES_DIR / "example.com.conf"
assert path.exists()
content = Path(path).read_text()
assert "server { listen 443; }" in content
class TestWriteAllSites:
@patch("lib.nginx.get_config")
def test_writes_all_domains(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"a.com": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"force_ssl": True,
},
"b.com": {
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
"force_ssl": True,
},
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.write_all_sites()
assert (nginx.SITES_DIR / "a.com.conf").exists()
assert (nginx.SITES_DIR / "b.com.conf").exists()
@patch("lib.nginx.get_config")
def test_removes_old_sites(self, mock_get, temp_data_dir):
# Pre-create an old site
nginx.write_site("old.com", "server {}")
assert (nginx.SITES_DIR / "old.com.conf").exists()
mock_get.return_value = {
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.write_all_sites()
assert not (nginx.SITES_DIR / "old.com.conf").exists()
class TestTestConfig:
@patch("lib.nginx._run")
def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n"
)
ok, _msg = nginx.test_config()
assert ok is True
@patch("lib.nginx._run")
def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
)
ok, _msg = nginx.test_config()
assert ok is False
class TestWriteHtpasswd:
@patch("lib.nginx._hash_password")
def test_creates_file(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash"
nginx.write_htpasswd("admin", "secret")
assert nginx.HTPASSWD_FILE.exists()
content = nginx.HTPASSWD_FILE.read_text()
assert "admin:" in content
@patch("lib.nginx._hash_password")
def test_replaces_existing_user(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash1"
nginx.write_htpasswd("admin", "old")
mock_hash.return_value = "$apr1$hash2"
nginx.write_htpasswd("admin", "new")
lines = [
line
for line in nginx.HTPASSWD_FILE.read_text().strip().splitlines()
if line
]
assert len([line for line in lines if line.startswith("admin:")]) == 1
class TestHashPasswordFallback:
@patch("lib.nginx._hash_password")
def test_hash_returns_string(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash"
result = nginx._hash_password("test")
assert isinstance(result, str)
assert len(result) > 0
+119
View File
@@ -0,0 +1,119 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
@pytest.fixture
def client():
from webui.server import app
app.config["TESTING"] = True
return app.test_client()
class TestTemplateFilters:
@pytest.fixture
def env(self):
from webui.server import app
return app.jinja_env
def test_timestamp_filter_valid(self, env):
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
assert "2026-04-01" in result
def test_timestamp_filter_empty(self, env):
assert env.filters["timestamp"]("") == ""
assert env.filters["timestamp"](None) == ""
def test_timestamp_filter_invalid(self, env):
result = env.filters["timestamp"]("not-a-date")
assert result == "not-a-date"
def test_bytes_filter_zero(self, env):
assert env.filters["bytes"](0) == "0.0 B"
def test_bytes_filter_kb(self, env):
result = env.filters["bytes"](1536)
assert "KB" in result
def test_bytes_filter_mb(self, env):
result = env.filters["bytes"](1500000)
assert "MB" in result
def test_bytes_filter_negative(self, env):
assert env.filters["bytes"](-1) == "0 B"
def test_bytes_filter_invalid(self, env):
assert env.filters["bytes"]("not-a-number") == "not-a-number"
def test_duration_filter_zero(self, env):
assert env.filters["duration"](0) == "0s"
def test_duration_filter_seconds(self, env):
assert env.filters["duration"](65) == "1m 5s"
def test_duration_filter_hours(self, env):
result = env.filters["duration"](3661)
assert "1h" in result
def test_duration_filter_days(self, env):
result = env.filters["duration"](90000)
assert "1d" in result
def test_duration_filter_invalid(self, env):
assert env.filters["duration"]("bad") == "bad"
def test_json_pretty_filter(self, env):
result = env.filters["json_pretty"]({"key": "value"})
assert '{"key": "value"}' in result or "key" in result
class TestSafelyHelper:
def test_returns_result(self):
from webui.server import _safely
result = _safely(lambda: 42)
assert result == 42
def test_returns_default_on_exception(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default=None)
assert result is None
def test_returns_custom_default(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default="fallback")
assert result == "fallback"
class TestPageRoutes:
@patch("webui.server.get_active_zones")
@patch("webui.server.get_interfaces")
@patch("webui.server.dnsmasq_status")
@patch("webui.server.get_domains")
@patch("webui.server.list_certs")
@patch("webui.server.wg_status")
def test_dashboard_no_crash(
self,
mock_wg,
mock_certs,
mock_domains,
mock_dnsmasq,
mock_ifaces,
mock_zones,
client,
):
mock_zones.return_value = {}
mock_ifaces.return_value = []
mock_dnsmasq.return_value = {}
mock_domains.return_value = []
mock_certs.return_value = []
mock_wg.return_value = {}
resp = client.get("/")
assert resp.status_code == 200
+248
View File
@@ -0,0 +1,248 @@
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import wireguard
@pytest.fixture
def temp_config(tmp_path):
original = wireguard.CONFIG_PATH
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
yield tmp_path
wireguard.CONFIG_PATH = original
class TestDefaultConfig:
def test_returns_skeleton(self):
cfg = wireguard._default_config()
assert cfg["interface"]["name"] == "wg0"
assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["private_key"] == ""
assert cfg["peers"] == {}
class TestGetConfig:
def test_returns_default_when_no_file(self, temp_config):
cfg = wireguard.get_config()
assert cfg["interface"]["name"] == "wg0"
assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config):
path = Path(wireguard.CONFIG_PATH)
expected = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "existing-key",
"public_key": "existing-pub",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
path.write_text(json.dumps(expected))
cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key"
class TestSaveConfig:
def test_save_and_reload(self, temp_config):
cfg = wireguard._default_config()
cfg["interface"]["listen_port"] = 51821
wireguard.save_config(cfg)
loaded = wireguard.get_config()
assert loaded["interface"]["listen_port"] == 51821
class TestGenerateKeyPair:
@patch("lib.wireguard._run")
def test_returns_keypair(self, mock_run):
mock_run.side_effect = [
MagicMock(returncode=0, stdout="private-key\n"),
MagicMock(returncode=0, stdout="public-key\n"),
]
private, public = wireguard.generate_keypair()
assert private == "private-key"
assert public == "public-key"
class TestGetPeers:
def test_empty_peers(self, temp_config):
peers = wireguard.get_peers()
assert peers == []
def test_lists_peers_without_private_keys(self, temp_config):
cfg = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {
"client1": {
"public_key": "pub1",
"private_key": "priv1",
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": None,
"preshared_key": None,
}
},
}
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg))
peers = wireguard.get_peers()
assert len(peers) == 1
assert peers[0]["name"] == "client1"
assert "private_key" not in peers[0]
class TestAddPeer:
@patch("lib.wireguard.generate_keypair")
def test_adds_new_peer(self, mock_gen, temp_config):
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 result["allowed_ips"] == ["10.0.0.0/24"]
@patch("lib.wireguard.generate_keypair")
def test_updates_existing_peer(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("client1")
wireguard.add_peer("client1", endpoint="203.0.113.1:51820")
cfg = wireguard.get_config()
assert cfg["peers"]["client1"]["endpoint"] == "203.0.113.1:51820"
class TestRemovePeer:
@patch("lib.wireguard.generate_keypair")
def test_removes_peer(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("client1")
wireguard.remove_peer("client1")
cfg = wireguard.get_config()
assert "client1" not in cfg["peers"]
class TestSetListenPort:
def test_set_valid_port(self, temp_config):
wireguard.set_listen_port(12345)
cfg = wireguard.get_config()
assert cfg["interface"]["listen_port"] == 12345
def test_set_invalid_port_raises(self, temp_config):
with pytest.raises(ValueError):
wireguard.set_listen_port(0)
with pytest.raises(ValueError):
wireguard.set_listen_port(70000)
class TestSetPostHooks:
def test_set_post_up(self, temp_config):
wireguard.set_post_up("iptables -I FORWARD -i wg0 -j ACCEPT")
cfg = wireguard.get_config()
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
def test_clear_post_up(self, temp_config):
wireguard.set_post_up("some-cmd")
wireguard.set_post_up(None)
cfg = wireguard.get_config()
assert cfg["interface"]["post_up"] is None
class TestInitialize:
@patch("lib.wireguard.generate_keypair")
def test_initializes_once(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "priv"
assert cfg["interface"]["public_key"] == "pub"
@patch("lib.wireguard.generate_keypair")
def test_does_not_overwrite_existing(self, mock_gen, temp_config):
existing = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "original-private",
"public_key": "original-pub",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "original-private"
mock_gen.assert_not_called()
class TestStatus:
@patch("lib.wireguard._run")
def test_returns_down_when_interface_down(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="interface not found"
)
result = wireguard.status()
assert result["up"] is False
@patch("lib.wireguard._run")
def test_parses_interface_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=0,
stdout=("interface:\n public key: ABCDEF\n listening port: 51820\n"),
)
result = wireguard.status()
assert result["up"] is True
assert result["interface"]["public_key"] == "ABCDEF"
assert result["interface"]["listen_port"] == 51820
@patch("lib.wireguard._run")
def test_parses_peer_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=0,
stdout=(
"interface:\n"
" public key: PUB\n"
" listening port: 51820\n"
"\n"
"peer: PUBKEY1\n"
" endpoint: 203.0.113.1:51820\n"
" allowed ips: 10.137.0.2/32\n"
" latest handshake: 2 minutes ago\n"
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
" persistent-keepalive: 25\n"
),
)
result = wireguard.status()
assert len(result["peers"]) == 1
peer = result["peers"][0]
assert peer["public_key"] == "PUBKEY1"
assert peer["endpoint"] == "203.0.113.1:51820"
assert peer["persistent_keepalive"] == 25
class TestGenerateWgShowParser:
def test_parses_peer_output(self):
output = (
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show(output)
assert "PUBKEY1" in result
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
def test_empty_output(self):
result = wireguard._parse_wg_show("")
assert result == {}
View File
View File
+132
View File
@@ -0,0 +1,132 @@
"""
webui/api/certs.py - ACME certificate management API blueprint.
Exposed at /api/certs/* and delegates to lib.acme.
"""
from flask import Blueprint, jsonify, request
from lib.acme import (
get_cert_info,
issue,
list_certs,
remove,
renew,
set_email,
)
bp = Blueprint("certs", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
# ---------------------------------------------------------------------------
# Certificate listing
# ---------------------------------------------------------------------------
@bp.route("/list", methods=["GET"])
def list_certs_bp():
try:
return _ok(list_certs())
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/<domain>", methods=["GET"])
def cert_details(domain):
try:
info = get_cert_info(domain)
return _ok(info)
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------
@bp.route("/issue", methods=["POST"])
def issue_bp():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
webroot = body.get("webroot")
standalone = body.get("standalone", False)
try:
result = issue(domain, webroot=webroot, standalone=standalone)
if result.get("success"):
return _ok(result)
return jsonify(
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Renew
# ---------------------------------------------------------------------------
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain):
try:
result = renew(domain)
if result.get("success"):
return _ok(result)
return jsonify(
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain):
try:
remove(domain)
return _ok({"domain": domain})
except (RuntimeError, FileNotFoundError) as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Contact email
# ---------------------------------------------------------------------------
@bp.route("/email", methods=["POST"])
def set_email_bp():
body = request.get_json(silent=True) or {}
email = body.get("email", "").strip()
if not email:
return _error("'email' is required", 400)
try:
set_email(email)
return _ok({"email": email})
except RuntimeError as exc:
return _error(str(exc), 500)
+171
View File
@@ -0,0 +1,171 @@
"""
webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint.
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
"""
from flask import Blueprint, jsonify, request
from lib.dnsmasq import (
add_dns_record,
add_static_lease,
apply_config,
get_config,
get_lease_table,
remove_dns_record,
remove_static_lease,
save_config,
)
bp = Blueprint("dhcp", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
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
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
def get_config_bp():
try:
return _ok(get_config())
except RuntimeError as exc:
return _error(str(exc), 500)
@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)
return _ok(body)
except RuntimeError as 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)
return _ok(merged)
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/apply", methods=["POST"])
def apply_bp():
try:
apply_config()
return _ok({"message": "dnsmasq configuration applied"})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Leases
# ---------------------------------------------------------------------------
@bp.route("/leases", methods=["GET"])
def leases_bp():
try:
return _ok(get_lease_table())
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Static leases
# ---------------------------------------------------------------------------
@bp.route("/static-lease", methods=["POST"])
def add_static_lease_bp():
body = request.get_json(silent=True) or {}
mac = body.get("mac", "").strip()
ip = body.get("ip", "").strip()
hostname = body.get("hostname")
if not mac or not ip:
return _error("'mac' and 'ip' are required", 400)
try:
add_static_lease(mac, ip, hostname)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/static-lease", methods=["DELETE"])
def remove_static_lease_bp():
mac = request.args.get("mac", "").strip()
if not mac:
return _error("Query parameter 'mac' is required", 400)
try:
remove_static_lease(mac)
return _ok({"mac": mac})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DNS records
# ---------------------------------------------------------------------------
@bp.route("/dns-record", methods=["POST"])
def add_dns_record_bp():
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
address = body.get("address", "").strip()
hostname = body.get("hostname")
if not name or not address:
return _error("'name' and 'address' are required", 400)
try:
add_dns_record(name, address, hostname)
return _ok({"name": name, "address": address, "hostname": hostname})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/dns-record", methods=["DELETE"])
def remove_dns_record_bp():
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
try:
remove_dns_record(name)
return _ok({"name": name})
except RuntimeError as exc:
return _error(str(exc), 500)
+274
View File
@@ -0,0 +1,274 @@
"""
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
"""
from flask import Blueprint, jsonify, request
from lib.firewall import (
add_forward_port,
add_rich_rule,
create_zone,
delete_zone,
get_active_zones,
get_available_zones,
get_interfaces,
get_rich_rules,
get_services,
get_zone_info,
remove_forward_port,
remove_rich_rule,
set_masquerade,
set_zone_interfaces,
set_zone_services,
)
bp = Blueprint("firewall", __name__)
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
# ---------------------------------------------------------------------------
# Zones
# ---------------------------------------------------------------------------
@bp.route("/zones", methods=["GET"])
def list_zones():
try:
active = get_active_zones()
available = get_available_zones()
return jsonify(
{
"ok": True,
"data": {
"active": active,
"available": available,
},
}
)
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name):
try:
info = get_zone_info(name)
return jsonify({"ok": True, "data": info})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/zones", methods=["POST"])
def create_zone_bp():
body = request.get_json(silent=True) or {}
zone_name = body.get("name", "").strip()
target = body.get("target", "default").strip() or "default"
if not zone_name:
return _error("Zone name is required", 400)
try:
if zone_name in get_available_zones():
return _error(f"Zone '{zone_name}' already exists", 400)
create_zone(zone_name, target)
return _ok({"name": zone_name, "target": target})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/zones/<name>", methods=["DELETE"])
def delete_zone_bp(name):
try:
available = get_available_zones()
if name not in available:
return _error(f"Zone '{name}' does not exist", 404)
delete_zone(name)
return _ok({"name": name})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Zone interfaces
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/interfaces", methods=["POST"])
def set_zone_interfaces_bp(name):
body = request.get_json(silent=True) or {}
interfaces = body.get("interfaces", [])
if not isinstance(interfaces, list):
return _error("'interfaces' must be a list", 400)
try:
set_zone_interfaces(name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Zone services
# ---------------------------------------------------------------------------
@bp.route("/zones/<name>/services", methods=["POST"])
def set_zone_services_bp(name):
body = request.get_json(silent=True) or {}
services = body.get("services", [])
if not isinstance(services, list):
return _error("'services' must be a list", 400)
try:
set_zone_services(name, services)
return _ok({"zone": name, "services": services})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Available services and interfaces
# ---------------------------------------------------------------------------
@bp.route("/services", methods=["GET"])
def list_services():
try:
return _ok(get_services())
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/interfaces", methods=["GET"])
def list_interfaces():
try:
return _ok(get_interfaces())
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Rich rules
# ---------------------------------------------------------------------------
@bp.route("/rich-rules", methods=["POST"])
def add_rich_rule_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
add_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/rich-rules", methods=["DELETE"])
def remove_rich_rule_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
remove_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/rich-rules/<zone>", methods=["GET"])
def list_rich_rules(zone):
try:
rules = get_rich_rules(zone)
return _ok(rules)
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Masquerade (NAT)
# ---------------------------------------------------------------------------
@bp.route("/masquerade", methods=["POST"])
def set_masquerade_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
enable = body.get("enable")
if not zone or enable is None:
return _error("'zone' and 'enable' (bool) are required", 400)
try:
set_masquerade(zone, bool(enable))
return _ok({"zone": zone, "masquerade": bool(enable)})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Port forwarding
# ---------------------------------------------------------------------------
@bp.route("/forward-port", methods=["POST"])
def add_forward_port_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try:
add_forward_port(
zone,
int(port),
proto,
toaddr=str(toaddr) if toaddr else None,
toport=int(toport) if toport else None,
)
return _ok({"zone": zone, "port": int(port), "proto": proto})
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
@bp.route("/forward-port", methods=["DELETE"])
def remove_forward_port_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try:
remove_forward_port(
zone,
int(port),
proto,
toaddr=str(toaddr) if toaddr else None,
toport=int(toport) if toport else None,
)
return _ok({"zone": zone, "port": int(port), "proto": proto})
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
+159
View File
@@ -0,0 +1,159 @@
"""
webui/api/proxy.py - Nginx proxy domain management API blueprint.
Exposed at /api/proxy/* and delegates to lib.nginx.
"""
from flask import Blueprint, jsonify, request
from lib.nginx import (
add_domain,
apply,
get_config,
get_domains,
remove_domain,
set_management_proxy,
test_config,
update_domain,
)
bp = Blueprint("proxy", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
# ---------------------------------------------------------------------------
# Domains
# ---------------------------------------------------------------------------
@bp.route("/domains", methods=["GET"])
def list_domains():
try:
return _ok(get_domains())
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/domains", methods=["POST"])
def add_domain_bp():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
cert = body.get("cert")
extra_headers = body.get("extra_headers")
if not domain:
return _error("'domain' is required", 400)
if not backend_host:
return _error("'backend_host' is required", 400)
if backend_port is None:
return _error("'backend_port' is required", 400)
try:
add_domain(
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
)
return _ok({"domain": domain})
except (ValueError, RuntimeError) as exc:
return _error(str(exc), 500)
@bp.route("/domains/<domain>", methods=["GET"])
def domain_details(domain):
try:
cfg = get_config()
entry = cfg.get("domains", {}).get(domain)
if entry is None:
return _error(f"Domain '{domain}' not found", 404)
return _ok({"domain": domain, **entry})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/domains/<domain>", methods=["PUT"])
def update_domain_bp(domain):
body = request.get_json(silent=True) or {}
if not body:
return _error("Request body must be a JSON object with fields to update", 400)
try:
update_domain(domain, **body)
return _ok({"domain": domain})
except KeyError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/domains/<domain>", methods=["DELETE"])
def remove_domain_bp(domain):
try:
cfg = get_config()
if domain not in cfg.get("domains", {}):
return _error(f"Domain '{domain}' not found", 404)
remove_domain(domain)
return _ok({"domain": domain})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Apply / test
# ---------------------------------------------------------------------------
@bp.route("/apply", methods=["POST"])
def apply_bp():
try:
apply()
return _ok({"message": "nginx configuration applied and reloaded"})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/test", methods=["POST"])
def test_bp():
try:
ok, message = test_config()
if ok:
return _ok({"passed": True, "message": message})
return jsonify({"ok": False, "error": message, "passed": False}), 400
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Management proxy
# ---------------------------------------------------------------------------
@bp.route("/management", methods=["POST"])
def management_bp():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
flask_port = body.get("flask_port", 9090)
auth_user = body.get("auth_user")
auth_pass = body.get("auth_pass")
try:
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
return _ok({"domain": domain})
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
+214
View File
@@ -0,0 +1,214 @@
"""
webui/api/wireguard.py - WireGuard tunnel management API blueprint.
Exposed at /api/wireguard/* and delegates to lib.wireguard.
"""
from flask import Blueprint, jsonify, request
from lib.wireguard import (
add_peer,
apply,
down,
generate_client_conf,
get_config,
get_peer_status,
get_peers,
initialize,
remove_peer,
save_config,
status,
)
bp = Blueprint("wireguard", __name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _error(msg, code=400):
return jsonify({"ok": False, "error": msg}), code
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@bp.route("/config", methods=["GET"])
def get_config_bp():
try:
cfg = get_config()
safe = dict(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
return _error(str(exc), 500)
@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)
safe = dict(body)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Apply / down
# ---------------------------------------------------------------------------
@bp.route("/apply", methods=["POST"])
def apply_bp():
try:
apply()
return _ok({"message": "WireGuard configuration applied and tunnel brought up"})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/down", methods=["POST"])
def down_bp():
try:
down()
return _ok({"message": "WireGuard tunnel brought down"})
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
def status_bp():
try:
return _ok(status())
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Initialize (first-time setup)
# ---------------------------------------------------------------------------
@bp.route("/initialize", methods=["POST"])
def initialize_bp():
try:
cfg = initialize()
safe = dict(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Peer management
# ---------------------------------------------------------------------------
@bp.route("/add-peer", methods=["POST"])
def add_peer_bp():
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("'name' is required", 400)
try:
peer = add_peer(
name=name,
endpoint=body.get("endpoint"),
allowed_ips=body.get("allowed_ips", []),
persistent_keepalive=body.get("persistent_keepalive"),
preshared_key=body.get("preshared_key"),
)
safe = dict(peer)
safe.pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/remove-peer", methods=["DELETE"])
def remove_peer_bp():
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
try:
cfg = get_config()
if name not in cfg.get("peers", {}):
return _error(f"Peer '{name}' not found", 404)
remove_peer(name)
return _ok({"name": name})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/peers", methods=["GET"])
def peers_bp():
try:
return _ok(get_peers())
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/peer-status", methods=["GET"])
def peer_status_bp():
try:
return _ok(get_peer_status())
except RuntimeError as exc:
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Client config generation
# ---------------------------------------------------------------------------
@bp.route("/generate-client", methods=["POST"])
def generate_client_bp():
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("Field 'name' is required", 400)
try:
cfg = get_config()
if name not in cfg.get("peers", {}):
return _error(f"Peer '{name}' not found", 404)
server_endpoint = body.get("server_endpoint", "")
server_pubkey = cfg["interface"].get("public_key", "")
if not server_endpoint:
_ = cfg["interface"].get("listen_port", 51820)
# Can't auto-derive public IP; ask user to provide it
return _error(
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
)
conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
return _ok({"config": conf_text, "name": name})
except (KeyError, ValueError, RuntimeError) as exc:
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
return _error(str(exc), code)
+224
View File
@@ -0,0 +1,224 @@
"""
server.py - Vacuum Wall management WebUI entry point.
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port.
"""
import logging
import os
from datetime import datetime
from flask import Flask, render_template
from lib.acme import get_email, list_certs
from lib.dnsmasq import get_config as dnsmasq_config
from lib.dnsmasq import get_lease_table
from lib.dnsmasq import get_status as dnsmasq_status
from lib.firewall import get_active_zones, get_interfaces, get_zone_info
from lib.nginx import get_config as nginx_config
from lib.nginx import get_domains
from lib.wireguard import get_config as wg_config
from lib.wireguard import status as wg_status
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
app = Flask(__name__)
app.config["SECRET_KEY"] = os.urandom(32).hex()
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
# ---------------------------------------------------------------------------
# Jinja2 custom filters
# ---------------------------------------------------------------------------
@app.template_filter("timestamp")
def timestamp_filter(value):
"""Convert an ISO timestamp string to a human-readable date."""
if not value:
return ""
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return str(value)
@app.template_filter("bytes")
def bytes_filter(value):
"""Format a byte count to a human-readable string (KB / MB / GB)."""
try:
num = float(value)
except (ValueError, TypeError):
return str(value)
if num < 0:
return "0 B"
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(num) < 1024:
return f"{num:.1f} {unit}"
num /= 1024
return f"{num:.1f} PB"
@app.template_filter("duration")
def duration_filter(value):
"""Format a duration in seconds to a human-readable string."""
try:
total = int(float(value))
except (ValueError, TypeError):
return str(value)
if total < 0:
return "0s"
parts = []
days, remainder = divmod(total, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{seconds}s")
return " ".join(parts)
@app.template_filter("json_pretty")
def json_pretty_filter(value):
"""Pretty-print a JSON-serialisable value for debug displays."""
import json
try:
return json.dumps(value, indent=2, default=str)
except (TypeError, ValueError):
return str(value)
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
def _safely(fn, default=None):
"""Call *fn* and return *default* on any exception."""
try:
return fn()
except Exception as exc:
logger.warning("WebUI data load failed: %s", exc)
return default
@app.route("/")
def dashboard():
active_zones = _safely(get_active_zones, {})
interfaces = _safely(get_interfaces, [])
dnsmasq = _safely(dnsmasq_status, {})
domains = _safely(get_domains, [])
certs = _safely(list_certs, [])
wg = _safely(wg_status, {})
return render_template(
"dashboard.html",
active_zones=active_zones,
interfaces=interfaces,
dnsmasq=dnsmasq,
domains=domains,
certs=certs,
wg_status=wg,
)
@app.route("/interfaces")
def interfaces_page():
return render_template(
"interfaces.html",
interfaces=_safely(get_interfaces, []),
active_zones=_safely(get_active_zones, {}),
)
@app.route("/zones")
def zones_page():
zones = {}
for name in _safely(get_active_zones, {}):
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
return render_template(
"zones.html",
zones=zones,
interfaces=_safely(get_interfaces, []),
services=_safely(
lambda: __import__(
"lib.firewall", fromlist=["get_services"]
).get_services(),
[],
),
)
@app.route("/rules")
def rules_page():
zones = list(_safely(get_active_zones, {}).keys())
return render_template("rules.html", zones=zones)
@app.route("/nat")
def nat_page():
zones = {}
for name in _safely(get_active_zones, {}):
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
return render_template("nat.html", zones=zones)
@app.route("/dhcp")
def dhcp_page():
return render_template(
"dhcp.html",
config=_safely(dnsmasq_config, {}),
status=_safely(dnsmasq_status, {}),
leases=_safely(get_lease_table, []),
)
@app.route("/proxy")
def proxy_page():
return render_template(
"proxy.html", domains=_safely(get_domains, []), config=_safely(nginx_config, {})
)
@app.route("/certs")
def certs_page():
return render_template(
"certs.html", certs=_safely(list_certs, []), email=_safely(get_email, "")
)
@app.route("/wireguard")
def wireguard_page():
return render_template(
"wireguard.html", config=_safely(wg_config, {}), status=_safely(wg_status, {})
)
@app.route("/logs")
def logs_page():
return render_template("logs.html")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9090)
+147
View File
@@ -0,0 +1,147 @@
// Toast notification system
function showToast(message, type = "info") {
const container = document.querySelector(".toast") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateX(40px)";
toast.style.transition = "all 0.3s ease";
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function createToastContainer() {
const el = document.createElement("div");
el.className = "toast";
document.body.appendChild(el);
return el;
}
// Modal helpers
function openModal(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.add("show");
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.remove("show");
}
// Confirm dialog
function confirmAction(message, onConfirm) {
const existing = document.getElementById("confirm-modal");
if (existing) existing.remove();
const modal = document.createElement("div");
modal.id = "confirm-modal";
modal.className = "modal";
modal.innerHTML = `
<div class="modal-content">
<p class="mb-2">${message}</p>
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
</div>
</div>`;
document.body.appendChild(modal);
openModal("confirm-modal");
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal("confirm-modal");
});
}
function setupConfirmCallback(callback) {
document.getElementById("confirm-ok")?.addEventListener("click", () => {
closeModal("confirm-modal");
callback();
});
}
// Auto-refresh with HTMX
function startAutoRefresh(endpoint, target, interval) {
const el = document.createElement("div");
el.setAttribute("hx-get", endpoint);
el.setAttribute("hx-target", `#${target}`);
el.setAttribute("hx-swap", "innerHTML");
el.setAttribute("hx-trigger", `every ${interval}s`);
el.setAttribute("hx-swap-oob", "true");
document.body.appendChild(el);
}
// Time formatting
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// Bytes formatting
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
// Form helpers
function resetForm(formId) {
const form = document.getElementById(formId);
if (form) form.reset();
}
function fillForm(formId, data) {
const form = document.getElementById(formId);
if (!form) return;
for (const [key, value] of Object.entries(data)) {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = value;
}
}
// HTMX event handlers
document.body.addEventListener("htmx:afterSwap", (evt) => {
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast");
if (toastHeader) {
const parts = toastHeader.split(":");
const msg = parts.slice(1).join(":").trim();
showToast(msg, parts[0]?.trim() || "info");
}
});
document.body.addEventListener("htmx:responseError", (evt) => {
const status = evt.detail.xhr?.status || 0;
showToast(`Request failed (${status})`, "error");
});
document.body.addEventListener("htmx:beforeRequest", (evt) => {
const target = evt.target;
const btn = target.closest(".btn");
if (btn) {
btn.dataset.originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Loading...";
}
});
document.body.addEventListener("htmx:afterRequest", (evt) => {
const target = evt.target;
const btn = target.closest(".btn");
if (btn && btn.dataset.originalText !== undefined) {
btn.disabled = false;
btn.textContent = btn.dataset.originalText;
delete btn.dataset.originalText;
}
});
// Close on escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
}
});
+486
View File
@@ -0,0 +1,486 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--bg-input: #1a1a2e;
--accent: #00b4d8;
--accent-hover: #0096c7;
--text: #e0e0e0;
--text-muted: #888;
--danger: #e63946;
--success: #2ecc71;
--warning: #f39c12;
--border: #2a2a4a;
--sidebar-width: 280px;
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* Layout */
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: #0f0f23;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s ease;
overflow-y: auto;
}
.sidebar .logo {
font-size: 1.5rem;
font-weight: 700;
padding: 1.5rem;
color: var(--accent);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.75rem;
}
.sidebar nav {
padding: 1rem 0;
flex: 1;
}
.sidebar nav a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: var(--text-muted);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.sidebar nav a:hover {
background: rgba(0, 180, 216, 0.08);
color: var(--text);
}
.sidebar nav a.active {
background: rgba(0, 180, 216, 0.12);
color: var(--accent);
border-left-color: var(--accent);
}
.main {
flex: 1;
margin-left: var(--sidebar-width);
padding: 2rem;
min-width: 0;
}
/* Cards */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
margin-bottom: 1.5rem;
}
.card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 1.05rem;
}
.card-body {
padding: 1.25rem;
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 2px solid var(--border);
background: rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.table tr:nth-child(even) {
background: rgba(0, 0, 0, 0.1);
}
.table tbody tr:hover {
background: rgba(0, 180, 216, 0.06);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
line-height: 1.4;
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: var(--success);
color: #fff;
}
.btn-success:hover {
background: #27ae60;
}
.btn-outline {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-outline:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(0, 180, 216, 0.08);
}
.btn-sm {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
}
/* Forms */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.6rem 0.85rem;
font-size: 0.95rem;
font-family: inherit;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
transition: border-color 0.2s;
outline: none;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.15);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.2rem 0.65rem;
font-size: 0.78rem;
font-weight: 600;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-success {
background: rgba(46, 204, 113, 0.15);
color: var(--success);
}
.badge-warning {
background: rgba(243, 156, 18, 0.15);
color: var(--warning);
}
.badge-danger {
background: rgba(230, 57, 70, 0.15);
color: var(--danger);
}
/* Toasts */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 360px;
}
.toast-message {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: toastSlideIn 0.3s ease forwards;
display: flex;
align-items: center;
gap: 0.6rem;
}
.toast-message.toast-success {
background: var(--success);
color: #fff;
}
.toast-message.toast-error {
background: var(--danger);
color: #fff;
}
.toast-message.toast-warning {
background: var(--warning);
color: #fff;
}
.toast-message.toast-info {
background: var(--accent);
color: #fff;
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateX(40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.modal.show {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
width: 90%;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transform: scale(0.95);
transition: transform 0.2s;
}
.modal.show .modal-content {
transform: scale(1);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch .slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
border-radius: 24px;
transition: 0.2s;
}
.toggle-switch .slider::before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.toggle-switch input:checked + .slider {
background: var(--accent);
}
.toggle-switch input:checked + .slider::before {
transform: translateX(20px);
}
/* Grid */
.grid {
display: grid;
gap: 1.25rem;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
/* Text Colors */
.text-success {
color: var(--success);
}
.text-warning {
color: var(--warning);
}
.text-danger {
color: var(--danger);
}
/* Flex Utilities */
.flex {
display: flex;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
/* Spacing */
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
/* Responsive */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.main {
margin-left: 0;
padding: 1rem;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
+648
View File
@@ -0,0 +1,648 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vacuum Wall{% endblock %}</title>
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--bg-card-hover: #0f3460d0;
--accent: #00b4d8;
--accent-hover: #0096c7;
--text: #e0e0e0;
--text-muted: #888;
--danger: #e63946;
--danger-hover: #c62828;
--success: #2ecc71;
--warning: #f1c40f;
--border: #1a1a3e;
--input-bg: #0d1b2a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text);
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 0;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
}
.sidebar-header {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
color: var(--accent);
letter-spacing: 1px;
}
.sidebar-header span {
color: var(--text-muted);
font-weight: 400;
font-size: 11px;
display: block;
margin-top: 2px;
letter-spacing: 0;
}
.sidebar nav {
padding: 8px 0;
}
.sidebar nav a {
display: flex;
align-items: center;
padding: 10px 20px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.sidebar nav a:hover {
color: var(--text);
background: rgba(0, 180, 216, 0.05);
}
.sidebar nav a.active {
color: var(--accent);
background: rgba(0, 180, 216, 0.08);
border-left-color: var(--accent);
}
/* Main */
.main {
margin-left: 220px;
flex: 1;
padding: 24px 32px;
min-height: 100vh;
width: calc(100vw - 220px);
}
.page-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
font-size: 22px;
font-weight: 600;
}
.page-header .subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
/* Cards */
.card {
background: var(--bg-card);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 8px;
padding: 18px 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
margin-top: 6px;
color: var(--accent);
}
.stat-card .meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.btn-outline {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-outline:hover {
background: rgba(0, 180, 216, 0.1);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover td {
background: rgba(0, 180, 216, 0.03);
}
/* Forms */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="url"],
select,
textarea {
width: 100%;
padding: 8px 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 80px;
}
select {
cursor: pointer;
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
/* Status indicator */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-up { background: var(--success); }
.status-down { background: var(--danger); }
.status-pending { background: var(--warning); }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
font-size: 16px;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
/* Toast */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
border-radius: 8px;
font-size: 13px;
min-width: 250px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateX(20px);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
gap: 0;
}
.tab {
padding: 10px 18px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
border-top: none;
border-left: none;
border-right: none;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Scrollable log */
.log-viewer {
background: #0a0a14;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
max-height: 600px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
/* Toggle switch */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch .slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 22px;
transition: 0.2s;
}
.switch .slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider {
background: var(--accent);
}
.switch input:checked + .slider:before {
transform: translateX(18px);
}
/* Flex utils */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.text-right { text-align: right; }
.w-full { width: 100%; }
/* Service status list */
.service-list {
list-style: none;
}
.service-list li {
padding: 6px 0;
display: flex;
align-items: center;
font-size: 13px;
}
.service-list .svc-name {
flex: 1;
}
/* Inline form row */
.inline-form {
display: flex;
gap: 8px;
align-items: flex-end;
flex-wrap: wrap;
}
.inline-form .form-group {
margin-bottom: 0;
}
/* Section titles */
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
margin-top: 24px;
}
.section-title:first-child { margin-top: 0; }
/* Auto-refresh indicator */
.htmx-indicator {
opacity: 0;
transition: opacity 0.3s;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main {
margin-left: 0;
width: 100vw;
padding: 16px;
}
.card-grid {
grid-template-columns: 1fr;
}
.inline-form {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
VACUUM WALL
<span>Firewall Management</span>
</div>
<nav>
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
</nav>
</aside>
<main class="main">
{% block content %}{% endblock %}
</main>
<div class="toast-container" id="toast-container"></div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
function showToast(message, type, duration) {
duration = duration || 4000;
var container = document.getElementById('toast-container');
if (!container) return;
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() { toast.classList.add('show'); });
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, duration);
}
function openModal(id) {
var el = document.getElementById(id);
if (el) el.classList.add('active');
}
function closeModal(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('tab-' + tabName).classList.add('active');
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (clickedTab) clickedTab.classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
var msg = el.getAttribute('data-success') || 'Operation successful';
var type = el.getAttribute('data-type') || 'success';
el.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail && evt.detail.successful) {
showToast(msg, type);
}
});
});
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
}
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Certificates - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Certificates</h1>
<div class="subtitle">SSL/TLS certificate management</div>
</div>
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Issuer</th>
<th>Expiry Date</th>
<th>Days Left</th>
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for cert in (certs or []) %}
<tr>
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
<td>{{ cert.get('expiry', 'N/A') }}</td>
<td>
{% set days = cert.get('days_remaining') %}
{% if cert.get('expired') or (days is not none and days <= 0) %}
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
{% elif days is not none and days <= 30 %}
<span class="badge badge-warning">{{ days }} days</span>
{% else %}
<span class="badge badge-success">{{ days }} days</span>
{% endif %}
</td>
<td>
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);">
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
</form>
</td>
</tr>
{% endfor %}
{% if not (certs or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Issue Certificate Modal -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
<div class="modal">
<h2>Issue New Certificate</h2>
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);">
<div class="form-group">
<label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="cert-email">Contact Email</label>
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Issue</button>
</div>
</form>
</div>
</div>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Dashboard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Dashboard</h1>
<div class="subtitle">System overview and status</div>
</div>
</div>
<div class="card-grid">
<div class="stat-card">
<div class="label">Zones</div>
<div class="value">{{ zones|default([])|length }}</div>
<div class="meta">Firewalld zones configured</div>
</div>
<div class="stat-card">
<div class="label">Proxy Domains</div>
<div class="value">{{ domains|default([])|length }}</div>
<div class="meta">SSL-terminated backends</div>
</div>
<div class="stat-card">
<div class="label">Certificates</div>
<div class="value">{{ certs|default([])|length }}</div>
{% set expired = certs|selectattr('expired')|list|default([])|length %}
{% set expiring = certs|selectattr('days_remaining','le',30)|rejectattr('expired')|list|default([])|length %}
<div class="meta">
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
</div>
</div>
<div class="stat-card">
<div class="label">WireGuard</div>
<div class="value" style="font-size:20px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}
</div>
<div class="meta">Tunnel state</div>
</div>
<div class="stat-card">
<div class="label">Active Leases</div>
<div class="value">{{ leases|default([])|length }}</div>
<div class="meta">DHCP clients connected</div>
</div>
</div>
<div class="section-title">Services</div>
<div class="card-grid">
{% for svc_name, svc in (services or {}).items() %}
<div class="stat-card">
<div class="label">{{ svc_name }}</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
{{ 'Running' if svc.get('running') else 'Stopped' }}
</div>
<div class="meta">
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
</div>
</div>
{% endfor %}
{% if not (services or {}) %}
<div class="stat-card">
<div class="label">Firewalld</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Dnsmasq</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Nginx</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">wg0</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
{{ 'Up' if (wg_status is defined and wg_status.get('state') == 'up') else 'Down' }}
</div>
</div>
{% endif %}
</div>
{% set warnings = [] %}
{% if certs is defined %}
{% for cert in certs %}
{% if cert.get('expired') %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
{% elif cert.get('days_remaining') is not none and cert.days_remaining <= 30 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_remaining|string + " days") %}
{% endif %}
{% endfor %}
{% endif %}
{% if warnings|length > 0 or (services is defined) %}
<div class="section-title">Warnings &amp; Activity</div>
<div class="card">
{% if warnings|length > 0 %}
<ul class="service-list">
{% for w in warnings %}
<li>
<span class="status-dot status-pending"></span>
<span class="svc-name">{{ w }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if not warnings and not (services or {}) %}
<div class="text-muted text-sm">No warnings</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>DHCP &amp; DNS</h1>
<div class="subtitle">Dnsmasq configuration and lease management</div>
</div>
</div>
<!-- DHCP Ranges -->
<div class="section-title">DHCP Ranges</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="range-interface">Interface</label>
<select id="range-interface" name="interface">
<option value="">— Global —</option>
{% for iface in (interfaces or []) %}
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="range-start">Start IP</label>
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-end">End IP</label>
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-lease">Lease Time</label>
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
</div>
<button type="submit" class="btn btn-primary">Add Range</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>Interface</th>
<th>Start</th>
<th>End</th>
<th>Lease Time</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td>{{ rng.get('interface', '(global)') }}</td>
<td>{{ rng.get('start', '') }}</td>
<td>{{ rng.get('end', '') }}</td>
<td>{{ rng.get('lease_time', '1h') }}</td>
<td>
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Static Leases -->
<div class="section-title">Static Leases</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="lease-mac">MAC Address</label>
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
</div>
<div class="form-group">
<label for="lease-ip">IP Address</label>
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
</div>
<div class="form-group">
<label for="lease-host">Hostname</label>
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
</div>
<button type="submit" class="btn btn-primary">Add Lease</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for lease in ((config or {}).get('static_leases', []) or []) %}
<tr>
<td>{{ lease.get('mac', '') }}</td>
<td>{{ lease.get('ip', '') }}</td>
<td>{{ lease.get('hostname', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('static_leases', []) or []) %}
<tr>
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Custom DNS Records -->
<div class="section-title">Custom DNS Records</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="dns-ip">IP Address</label>
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;">
</div>
<div class="form-group">
<label for="dns-hostname">Hostname / Domain</label>
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;">
</div>
<button type="submit" class="btn btn-primary">Add Record</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>IP</th>
<th>Hostname</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for rec in ((config or {}).get('dns_records', []) or []) %}
<tr>
<td>{{ rec.get('ip', '') }}</td>
<td>{{ rec.get('hostname', '') }}</td>
<td>
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dns_records', []) or []) %}
<tr>
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Current DHCP Leases -->
<div class="section-title">Current DHCP Leases</div>
<div class="card">
<table>
<thead>
<tr>
<th>Expires</th>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th>Client ID</th>
</tr>
</thead>
<tbody>
{% for lease in (leases or []) %}
<tr>
<td>{{ lease.get('expires', 'N/A') }}</td>
<td>{{ lease.get('mac', 'N/A') }}</td>
<td>{{ lease.get('ip', 'N/A') }}</td>
<td>{{ lease.get('hostname', '*') or '*' }}</td>
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
</tr>
{% endfor %}
{% if not (leases or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="mt-2 text-right">
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply &amp; Restart Dnsmasq</button>
</div>
</div>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Interfaces - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Interfaces</h1>
<div class="subtitle">Network interface to zone bindings</div>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Interface</th>
<th>MAC Address</th>
<th>IP Address</th>
<th>State</th>
<th>Zone</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for iface in (interfaces or []) %}
<tr>
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
<td>
{% for ip in iface.get('ips', []) %}
{{ ip }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% if not iface.get('ips') %}N/A{% endif %}
</td>
<td>
<span class="status-dot {{ 'status-up' if iface.get('state') == 'up' else 'status-down' }}"></span>
{{ 'Up' if iface.get('state') == 'up' else 'Down' }}
</td>
<td>
{% if zones %}
<select
class="htmx-on-success"
data-success="Zone updated for {{ iface.name }}"
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
hx-swap="none"
hx-select-oob="#toast-container *"
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
>
{% for zone in zones %}
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
{% endfor %}
</select>
{% else %}
<span class="text-muted">No zones configured</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
</td>
</tr>
{% endfor %}
{% if not (interfaces or []) %}
<tr>
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
var zoneName = selectEl.value;
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
fetch(url, { method: 'POST' })
.then(function(res) {
if (res.ok) {
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
} else {
return res.text().then(function(txt) { throw new Error(txt); });
}
})
.catch(function(err) {
showToast('Failed to assign: ' + err.message, 'error');
});
}
</script>
{% endblock %}
+106
View File
@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Logs - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>System Logs</h1>
<div class="subtitle">Service logs and journal output</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted">Auto-refresh</span>
<label class="switch">
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
<span class="slider"></span>
</label>
<span class="htmx-indicator text-sm" style="color:var(--accent);">
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
</span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
</div>
<div id="tab-journal" class="tab-content active">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-journal"
hx-get="/api/logs/journal"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
Loading journal entries...
</div>
</div>
</div>
<div id="tab-nginx-access" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-access"
hx-get="/api/logs/nginx/access"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
Loading Nginx access log...
</div>
</div>
</div>
<div id="tab-nginx-error" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-error"
hx-get="/api/logs/nginx/error"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
Loading Nginx error log...
</div>
</div>
</div>
<div id="tab-dnsmasq" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-dnsmasq"
hx-get="/api/logs/dnsmasq"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
Loading dnsmasq log...
</div>
</div>
</div>
<script>
var autoRefreshTimer = null;
function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle');
var indicators = document.querySelectorAll('.log-viewer');
if (toggle.checked) {
indicators.forEach(function(el) {
el.setAttribute('hx-trigger', 'every 15s');
hx.trigger(el, 'htmx:refresh');
});
} else {
indicators.forEach(function(el) {
el.setAttribute('hx-trigger', 'never');
});
}
}
document.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
document.getElementById('refresh-indicator').style.display = 'inline';
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
document.getElementById('refresh-indicator').style.display = 'none';
});
</script>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}NAT - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>NAT &amp; Port Forwarding</h1>
<div class="subtitle">Masquerading and destination NAT rules</div>
</div>
</div>
<div class="section-title">Masquerade (Source NAT)</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th style="width:120px;">Masquerade</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for zone in (zones or []) %}
<tr>
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
<td>
<label class="switch">
<input type="checkbox"
{% if zone.get('masquerade') %}checked{% endif %}
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
<span class="slider"></span>
</label>
</td>
<td>
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
</td>
</tr>
{% endfor %}
{% if not (zones or []) %}
<tr>
<td colspan="3" class="text-muted text-sm">No zones configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="section-title">Port Forwarding (DNAT)</div>
<div class="card mb-4">
<h3>Add Forward Rule</h3>
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="fw-zone">Zone</label>
<select id="fw-zone" name="zone" required>
<option value="">— Select —</option>
{% for zone in (zones or []) %}
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="fw-protocol">Protocol</label>
<select id="fw-protocol" name="protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="form-group">
<label for="fw-port">Port</label>
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
</div>
<div class="form-group">
<label for="fw-target">Target Address</label>
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="fw-target-port">Target Port</label>
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;">
</div>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th>Proto</th>
<th>Port</th>
<th>Target</th>
<th>Tgt Port</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% set all_forwards = [] %}
{% for zone in (zones or []) %}
{% for fwd in zone.get('forward_ports', []) %}
{% set _ = all_forwards.append({'zone': zone.get('name'), 'proxy-protocol': fwd.get('proxy-protocol'), 'port': fwd.get('port'), 'to-addr': fwd.get('to-addr'), 'to-port': fwd.get('to-port')}) %}
{% endfor %}
{% endfor %}
{% for fwd in all_forwards %}
<tr>
<td><strong>{{ fwd.zone }}</strong></td>
<td><span class="badge badge-info">{{ fwd['proxy-protocol'] }}</span></td>
<td>{{ fwd.port }}</td>
<td>{{ fwd['to-addr'] }}</td>
<td>{{ fwd['to-port'] }}</td>
<td>
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not all_forwards %}
<tr>
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
function toggleMasquerade(zoneName, enabled) {
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
var body = JSON.stringify({ enable: enabled });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function(res) {
if (res.ok) {
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
} else {
return res.text().then(function(t) { throw new Error(t); });
}
})
.catch(function(err) {
showToast('Failed: ' + err.message, 'error');
});
}
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Proxy - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>SSL Proxy Domains</h1>
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Backend Host</th>
<th>Backend Port</th>
<th>Protocol</th>
<th>Certificate</th>
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody>
{% for domain in (domains or []) %}
<tr>
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
<td>{{ domain.get('backend_host', '-') }}</td>
<td>{{ domain.get('backend_port', '-') }}</td>
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
<td>
{% set matched_cert = None %}
{% if certs %}
{% for cert in certs %}
{% if cert.get('domain') == domain.get('domain') %}
{% set matched_cert = cert %}
{% endif %}
{% endfor %}
{% endif %}
{% if matched_cert %}
{% if matched_cert.get('expired') %}
<span class="badge badge-danger">Expired</span>
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
{% else %}
<span class="badge badge-success">Valid</span>
{% endif %}
{% else %}
<span class="badge badge-danger">No cert</span>
{% endif %}
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (domains or []) %}
<tr>
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Add Domain Modal -->
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal">
<h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);">
<div class="form-group">
<label for="new-domain">Domain</label>
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="new-backend-host">Backend Host</label>
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
</div>
<div class="form-group">
<label for="new-backend-port">Backend Port</label>
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="new-protocol">Backend Protocol</label>
<select id="new-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Domain</button>
</div>
</form>
</div>
</div>
<!-- Edit Domain Modal -->
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
<div class="modal">
<h2>Edit Proxy Domain</h2>
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);">
<input type="hidden" id="edit-original-domain" name="original_domain">
<div class="form-group">
<label for="edit-domain">Domain</label>
<input type="text" id="edit-domain" name="domain" required>
</div>
<div class="form-group">
<label for="edit-backend-host">Backend Host</label>
<input type="text" id="edit-backend-host" name="backend_host" required>
</div>
<div class="form-group">
<label for="edit-backend-port">Backend Port</label>
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="edit-protocol">Backend Protocol</label>
<select id="edit-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditDomainModal(domainName, d) {
document.getElementById('edit-original-domain').value = d.domain;
document.getElementById('edit-domain').value = d.domain;
document.getElementById('edit-backend-host').value = d.backend_host || '';
document.getElementById('edit-backend-port').value = d.backend_port || '';
document.getElementById('edit-protocol').value = d.protocol || 'http';
var form = document.getElementById('edit-domain-form');
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
form.setAttribute('hx-put', target);
openModal('edit-domain-modal');
}
</script>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Rules - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Rich Rules</h1>
<div class="subtitle">Firewalld rich firewall rules per zone</div>
</div>
</div>
<div class="card mb-4">
<h3>Add Rule</h3>
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="rule-zone">Zone</label>
<select id="rule-zone" name="zone" required>
<option value="">— Select zone —</option>
{% for zone in (zones or []) %}
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="rule-text">Rule Expression</label>
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
</div>
<button type="submit" class="btn btn-primary">Add Rule</button>
</div>
</form>
<div class="text-muted text-sm mt-2">
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
</div>
</div>
{% if rules or False %}
{% for zone_name, zone_rules in rules.items() %}
<div class="card">
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
{% if zone_rules %}
<table>
<thead>
<tr>
<th>#</th>
<th>Rule</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for rule in zone_rules %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td>
<td>
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
</div>
{% endif %}
{% if not (zones or []) %}
<div class="card" style="border-color:var(--warning);">
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
</div>
{% endif %}
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}WireGuard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>WireGuard</h1>
<div class="subtitle">VPN tunnel management</div>
</div>
<div class="flex gap-2">
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/down"
hx-swap="none"
class="htmx-on-success"
data-success="Tunnel stopped"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
Stop Tunnel
</button>
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/up"
hx-swap="none"
class="htmx-on-success"
data-success="Tunnel started"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
Start Tunnel
</button>
</div>
</div>
<!-- Tunnel Status -->
<div class="card mb-4">
<div class="flex items-center justify-between">
<div>
<h3>
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
</h3>
</div>
<div class="text-sm text-muted">
{% if config %}
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> &nbsp;|&nbsp;
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> &nbsp;|&nbsp;
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
{% endif %}
</div>
</div>
</div>
<!-- Add Peer Form -->
<div class="section-title">Peers</div>
<div class="card mb-4">
<h3>Add Peer</h3>
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<div class="inline-form">
<div class="form-group">
<label for="peer-name">Name</label>
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
</div>
<div class="form-group">
<label for="peer-pubkey">Public Key</label>
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
</div>
<div class="form-group">
<label for="peer-allowed">Allowed IPs</label>
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|length + 2) %}{{ next }}/32" required style="width:160px;">
</div>
<button type="submit" class="btn btn-primary">Add Peer</button>
</div>
</form>
</div>
<!-- Peers Table -->
<div class="card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Public Key</th>
<th>Allowed IPs</th>
<th>Endpoint</th>
<th>Latest Handshake</th>
<th>Transfer</th>
<th style="width:160px;">Actions</th>
</tr>
</thead>
<tbody>
{% for peer in (peers or []) %}
<tr>
<td>
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
<strong>{{ peer.get('name', 'unnamed') }}</strong>
</td>
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
<td class="text-sm">
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (peers or []) %}
<tr>
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
function downloadPeerConfig(peerName) {
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
window.open(url, '_blank');
}
</script>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Zones - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zones</h1>
<div class="subtitle">Firewalld zone management</div>
</div>
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
</div>
<div class="card-grid">
{% for zone in (zones or []) %}
<div class="card" style="position:relative;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div>
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px;">
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
{% if zone.get('interfaces') %}
{% for iface in zone.interfaces %}
<span class="badge badge-info">{{ iface }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Services</div>
{% if zone.get('services') %}
{% for svc in zone.services %}
<span class="badge badge-success">{{ svc }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
{% if not (zones or []) %}
<div class="card">
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
</div>
{% endif %}
</div>
<!-- Create Zone Modal -->
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
<div class="modal">
<h2>Create Zone</h2>
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();">
<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>
</div>
<div class="form-group">
<label for="zone-target">Target</label>
<select id="zone-target" name="target">
<option value="default">default</option>
<option value="%%REJECT%%">%REJECT%</option>
<option value="%%DROP%%">%DROP%</option>
<option value="%%ACCEPT%%">%ACCEPT%</option>
</select>
</div>
<div class="form-group">
<label for="zone-services">Default Services (comma-separated)</label>
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
{% endblock %}