refactor: introduce two-user daemon architecture with socket-based communication

- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
This commit is contained in:
2026-05-27 23:38:23 +00:00
parent 5ac69dfa7e
commit 200e078bc5
39 changed files with 4671 additions and 1810 deletions
+139 -2
View File
@@ -174,6 +174,8 @@ Create a new firewalld zone.
**Response:** `data` is `null` on success.
Returns HTTP `400` if the zone already exists.
---
#### Delete Zone
@@ -637,6 +639,74 @@ Returns HTTP `404` if no matching record is found.
Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy.
### Configuration
#### Get Proxy Configuration
```
GET /api/proxy/config
```
Return the current proxy configuration object.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Full proxy configuration dictionary |
---
#### Replace Proxy Configuration
```
POST /api/proxy/config
```
Replace the entire proxy configuration with the provided JSON object.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete proxy configuration object |
**Response:** `data` is `null` on success.
---
#### Partial Update Proxy Configuration
```
PATCH /api/proxy/config
```
Deep-merge the provided fields into the existing proxy configuration.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| *(any subset)* | `any` | Yes | Fields to merge into the config |
**Response:** `data` is `null` on success.
---
### SSL
#### Apply SSL Snippet
```
POST /api/proxy/ssl-apply
```
Write the global nginx SSL snippet configuration.
**Response:** `data` is `null` on success.
---
### Domain Management
#### List All Domains
@@ -842,6 +912,7 @@ Request a new certificate for a domain.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain to issue the certificate for |
| `email` | `string` | No | ACME contact email |
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
**Response:** `data` is `null` on success.
@@ -932,7 +1003,7 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
**Response:** `data` contains the updated configuration (`private_key` omitted).
**Response:** `data` is `null` on success.
---
@@ -1115,4 +1186,70 @@ Generate a complete WireGuard client configuration file. The returned config inc
This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
Returns HTTP `404` if the peer is not found.
Returns HTTP `404` if the peer is not found.
---
## Logs API
Endpoints prefixed with `/api/logs/...`. Serve rendered HTML log line fragments for HTMX consumption. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
### System Journal
#### Get Journal Entries
```
GET /api/logs/journal
```
Return recent system journal entries as rendered HTML log lines.
**Response:** HTML fragment of `<div class="log-line">` elements.
### Nginx Logs
#### Nginx Access Log
```
GET /api/logs/nginx/access
```
Return recent nginx access log entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements.
---
#### Nginx Error Log
```
GET /api/logs/nginx/error
```
Return recent nginx error log entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements.
### Dnsmasq Log
#### Dnsmasq Entries
```
GET /api/logs/dnsmasq
```
Return recent dnsmasq journal entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements.
### Application Log
#### App Log Entries
```
GET /api/logs/app
```
Return recent application log entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements.
+27 -11
View File
@@ -22,8 +22,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
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.
4. The Flask application processes the request and communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
6. Flask renders an HTML or JSON response, which nginx returns 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.
@@ -33,14 +34,24 @@ The following diagram summarizes how the Flask WebUI communicates with each mana
```
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 local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
Flask WebUI ──→ lib/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
Flask WebUI ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp server)
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
```
Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `config/` and runtime artifacts from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged service user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
### Two-User Model with Shared Group
Vacuum Wall uses two distinct system users bridged by a shared group:
- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Owns the project directory and data files. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process).
- **`vacuum-wall`** (web UI user): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`.
- **`vacuum-wall`** (shared group): Both users belong to this group. The daemon socket is owned by `vacuum-walld:vacuum-wall` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by `vacuum-walld:vacuum-wall` with group-read+execute, giving the web UI user read access to configs and shared files.
This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`.
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
@@ -48,8 +59,9 @@ The `lib/` modules auto-discover the project root at runtime via `Path(__file__)
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
- **`sudoers.d/vacuum-wall`** — `{{ USER_NAME }}` is substituted to produce the sudoers whitelist.
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user.
- **`sudoers.d/vacuum-wall`** — Reserved for the WebUI user; currently contains no sudo rules (privilege escalation is handled entirely by the daemon).
- The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is.
Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data.
@@ -60,7 +72,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|---|---|---|---|---|
| firewalld | N/A (firewalld manages own state) | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as an automated backup snapshot. |
| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` serves as an automated backup snapshot. |
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
@@ -76,6 +88,8 @@ Config files are persistent, user-editable JSON that defines the desired state f
config/
├── dnsmasq/
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
├── firewall/
│ └── config.json # Firewall zones, rich rules, forward ports
├── nginx/
│ └── config.json # Proxy domain definitions, management domain, SSL settings
└── wireguard/
@@ -96,6 +110,8 @@ data/
├── firewall/
│ └── rules.json # Auto-generated firewall rule state backup
├── acme/ # ACME certificate files (acme.sh home)
├── logs/
│ └── vacuum-wall.log # Application log file
└── wireguard/ # WireGuard runtime artifacts
```
+101 -51
View File
@@ -47,14 +47,14 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| Field | Type | Required | Description |
|---|---|---|---|
| `ranges` | array | Yes | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. |
| `ranges` | array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: `[]`. |
| `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[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `12h`. |
| `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` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: `[]`. |
| `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. |
@@ -63,9 +63,9 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| 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. |
| `upstreams` | array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. Default: `["8.8.8.8", "1.1.1.1"]`. |
| `domain` | string | No | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., `printer` becomes `printer.lan`). Default: `null`. |
| `custom_records` | array | No | Static DNS A records for internal services and devices. Default: `[]`. |
| `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. |
@@ -88,18 +88,15 @@ This file defines reverse proxy domains, the management interface, and global SS
"proto": "http"
},
"force_ssl": true,
"cert": "acme",
"headers": {
"X-Forwarded-Proto": "https",
"X-Real-IP": "$remote_addr"
},
"cert": {
"type": "acme",
"email": "admin@example.com"
}
}
},
"management": {
"domain": "<hostname>.local",
"domain": "vacuum-wall.local",
"backend": {
"host": "127.0.0.1",
"port": 9090,
@@ -107,12 +104,12 @@ This file defines reverse proxy domains, the management interface, and global SS
},
"auth": {
"user": "admin",
"htpasswd": "data/nginx/.htpasswd"
"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",
"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
}
}
@@ -127,36 +124,34 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
| `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`. |
| `backend.proto` | string | No | Protocol for the backend connection: `http` or `https`. Default: `http`. |
| `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 the CA provider. |
| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
### Certificate Types
| Type | Description |
The `cert` field is a string that selects the provisioning method:
| Value | Description |
|---|---|
| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
| `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at `/.well-known/acme-challenge/`. |
| `file` | Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. |
### 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.
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an `auth` block for HTTP Basic Authentication.
| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
| `auth` | object | No | HTTP Basic Authentication configuration. Only created if `auth_user` is provided when setting the management proxy. |
| `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:
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's `apache_passwd` with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible:
```bash
htpasswd -bc data/nginx/.htpasswd admin yourpassword
@@ -176,23 +171,23 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th
**File**: `config/wireguard/config.json`
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair via `wg genkey` / `wg pubkey`.
```json
{
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "kOv8lK...',
"public_key": "YzP3xI...',
"private_key": "<generated>",
"public_key": "<generated>",
"addresses": ["10.137.0.1/24"],
"post_up": null,
"post_down": null
},
"peers": {
"alice": {
"public_key": "nR7mQ2...',
"private_key": "xLpDgF...',
"public_key": "<auto-generated>",
"private_key": "<auto-generated>",
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25,
@@ -206,39 +201,94 @@ This file defines the WireGuard server interface and all connected peers. The ap
| 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. |
| `name` | string | No | WireGuard interface name. Default: `wg0`. |
| `listen_port` | integer | No | Port the WireGuard interface listens on. Default: `51820`. Must be opened in the firewall. |
| `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. |
| `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. |
| `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["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. Default: `null`. |
| `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. Default: `null`. |
### 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.
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. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses.
| 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 | No | CIDR blocks that traffic from this peer is allowed to route. Defaults to `[]` (no routing restrictions from the server side). `["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. |
| `public_key` | string | Yes | The peer's public key. Auto-generated when the peer is added. |
| `private_key` | string | No | The peer's private key, stored for generating downloadable client configuration files. Auto-generated when the peer is added. 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. Default: `null`. |
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["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. Default: `null`. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
### 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.
When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), 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. `generate_client_conf()` computes the client IP address from the server's subnet and the peer's sorted index position.
### 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.
1. Renders the `wg0.conf` file from the JSON configuration.
2. Writes the file to `/etc/wireguard/wg0.conf` with `600` permissions via `sudo cp`.
3. Runs `sudo wg-quick up <name>` to apply the configuration.
4. Returns success or error status to the caller.
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
## Firewall Configuration
**File**: `config/firewall/config.json`
This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via `_compute_pending_changes()` and applies incremental changes. Runtime state backups are stored in `data/firewall/rules.json`.
```json
{
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["dhcp", "dns", "https", "ssh"],
"target": "DEFAULT",
"masquerade": true,
"forward_ports": [
{
"id": "abc123",
"port": 443,
"proto": "tcp",
"toaddr": "192.168.2.50",
"toport": 8080
}
],
"rich_rules": [
{
"rule": "rule family=\"ipv4\" source address=\"10.0.0.0/8\" reject"
}
]
}
}
}
```
### Zone Fields
The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`.
| Field | Type | Required | Description |
|---|---|---|---|
| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. |
| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. |
| `target` | string | No | Zone target policy. One of: `DEFAULT`, `ACCEPT`, `DROP`, `REJECT`. The code maps these to firewalld's canonical target values (`default`, `ACCEPT`, `DROP`, `REJECT`). Default: `DEFAULT`. |
| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. |
| `forward_ports` | array | No | Port forwarding rules. Each entry has an auto-generated `id` field and the standard firewalld forward-port fields. Default: `[]`. |
| `forward_ports[].id` | string | No | Auto-generated unique identifier for the port forwarding rule. Not user-settable. |
| `forward_ports[].port` | integer | Yes | Destination port to forward. |
| `forward_ports[].proto` | string | Yes | Protocol: `tcp` or `udp`. |
| `forward_ports[].toaddr` | string | No | Internal IP address to forward to. Omit for broadcast forwarding. |
| `forward_ports[].toport` | integer | No | Internal port to forward to. Omit to keep the same port. |
| `rich_rules` | array | No | Rich rule entries for advanced firewall policies. Default: `[]`. |
| `rich_rules[].rule` | string | Yes | The full firewalld rich rule string, e.g., `rule family="ipv4" source address="10.0.0.0/8" reject`. |
### Applying Firewall Configuration
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
+19 -14
View File
@@ -51,6 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag
| `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. |
| `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
| `--force-venv` | — | No | Force recreation of the Python virtual environment. |
Run `./install.sh --help` for full usage.
@@ -75,13 +76,15 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
- **System user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with a nologin shell that owns the project data and runs the WebUI service.
- **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies.
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils.
- **Shared group creation**: Creates a shared system group (`vacuum-wall`) both service users belong to.
- **Daemon user creation**: Creates `vacuum-walld` (derived from WebUI user name) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the project directory and daemon socket.
- **WebUI user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with zero sudo access. Communicates with the daemon via Unix socket.
- **Python venv**: Creates the Python virtual environment and installs project dependencies. Skips if already present (use `--force-venv` to recreate).
- **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed.
- **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config).
- **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`. The WebUI user's sudoers file (`/etc/sudoers.d/vacuum-wall`) is empty — it has no sudo access.
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present.
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
@@ -91,22 +94,23 @@ The installer performs the following steps automatically:
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
- **Systemd units**: Installs three units (rendered from Jinja2 templates):
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
- `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/restarts nginx and the WebUI service, and enables the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes.
- **Service startup**: Enables and starts/restarts nginx, the daemon (`vacuum-walld`), the WebUI (`vacuum-wall`), and the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes.
- **ACME registration**: Registers the ACME account with the provided email via acme.sh.
### Idempotent Re-Runs
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
- Rebuilds the Python venv and reinstalls dependencies
- Restarts `vacuum-wall` and reloads `nginx` to pick up changes
- Skips the Python venv (use `--force-venv` to rebuild)
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
- Preserves existing `config.json` files (skips initial write if file exists)
- Safely updates `htpasswd` (uses update mode instead of create mode)
@@ -122,7 +126,7 @@ This makes it safe for development workflows: simply run `bash install.sh` again
After the installer completes, confirm all services are running:
```bash
systemctl status vacuum-wall nginx firewalld dnsmasq
systemctl status vacuum-walld vacuum-wall nginx firewalld dnsmasq
```
Each should be active (running). The `vacuum-wall-acme.timer` should also be active (waiting).
@@ -249,8 +253,8 @@ Vacuum Wall includes integrated WireGuard server support for VPN access.
Check service logs and configuration:
```bash
journalctl -u vacuum-walld --no-pager -n 50
journalctl -u vacuum-wall --no-pager -n 50
journalctl -u nginx --no-pager -n 50
nginx -t
```
@@ -268,7 +272,7 @@ 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
visudo -cf /etc/sudoers.d/vacuum-walld
```
### Certificate Issuance Fails
@@ -280,7 +284,7 @@ ACME validation via the ACME provider requires:
- The ACME email was registered correctly. Check with:
```bash
su -s /bin/bash "$USER_NAME" -c "~/.acme.sh/acme.sh --list"
su -s /bin/bash "$USER_DAEMON_NAME" -c "~/data/acme/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.
@@ -308,10 +312,11 @@ Verify that:
| Component | Service | Config Location |
|---|---|---|
| Daemon (privileged) | `vacuum-walld.service` | `daemon/` |
| WebUI backend | `vacuum-wall.service` | `webui/` |
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
| VPN | wireguard-tools | `config/wireguard/` |
| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` |
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
| Certificates | `vacuum-wall-acme.timer` | `$PROJECT_DIR/data/acme/` |
| Sudoers (daemon) | — | `/etc/sudoers.d/vacuum-walld` |
+26 -11
View File
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
## Architecture Overview
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic ACME certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
Vacuum Wall is built around four integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and 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
@@ -60,13 +60,20 @@ After installation, access the management interface at `https://<hostname>.local
├── .venv/ # Python virtual environment
├── config/ # Declarative JSON configuration (source of truth)
│ ├── dnsmasq/ # DHCP/DNS config
│ ├── firewall/ # Firewall zone & rule config
│ ├── nginx/ # Proxy domain & SSL config
│ └── wireguard/ # VPN interface & peer config
├── data/ # Runtime artifacts & generated files
│ ├── nginx/sites-enabled/ # Generated server blocks
│ ├── dnsmasq/fragments/ # User config fragments
│ ├── acme/ # ACME certificates
── firewall/ # Firewall rule backup
── firewall/ # Firewall rule backup
│ ├── logs/ # Application logs
│ └── wireguard/ # Generated WireGuard configs
├── daemon/ # Privileged background daemon
│ ├── server.py # aiohttp server, cache, batch routing, handler registry
│ ├── client.py # Sync HTTP client over Unix socket
│ └── handlers/ # Privileged operation handlers (all sudo calls)
├── system/ # System file templates (all Jinja2)
│ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
@@ -86,17 +93,25 @@ After installation, access the management interface at `https://<hostname>.local
│ └── wireguard.py # VPN tunnel management
├── webui/ # Flask web application
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules
│ │ ── common.py # Shared API response helpers (_ok, _error)
│ ├── api/ # REST API route modules (blueprints)
│ │ ── common.py # Shared API response helpers (_ok, _error)
│ │ ├── firewall.py # Firewall API
│ │ ├── dhcp.py # DHCP/DNS API
│ │ ├── proxy.py # Nginx proxy API
│ │ ├── certs.py # Certificate API
│ │ ├── wireguard.py # WireGuard API
│ │ └── logs.py # Logs API
│ ├── 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
── docs/ # Documentation
├── overview.md # This file
├── deployment.md
├── api.md
├── security.md
├── architecture.md
└── config.md
└── scripts/ # Utility scripts
└── update-vendor.sh # Vendor frontend library updates
```
## Documentation
+47 -27
View File
@@ -2,31 +2,46 @@
## Privilege Model
The Vacuum Wall management WebUI (Flask application) runs as an unprivileged system user (default name: `vacuum-wall`, configurable via the `USER_NAME` environment variable at install time). The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the application user without sudo escalation, using webroot validation that doesn't require binding to privileged ports.
Vacuum Wall uses two distinct system users bridged by a shared group (`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 dedicated service user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`.
- **`vacuum-wall`** (WebUI user): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.
ACME certificate operations via `acme.sh` run as the WebUI user (`vacuum-wall`) — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports.
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
## Communication Between WebUI and Daemon
The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:vacuum-wall` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands.
## Sudo Whitelist
The file `/etc/sudoers.d/vacuum-wall` grants the configured system user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) passwordless sudo access to a strict set of commands. The WebUI user (`/etc/sudoers.d/vacuum-wall`) has no sudo entries. Each daemon entry is scoped to a single binary with allowed arguments:
| 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 |
| Nginx file ops | `cp -- * /etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of SSL snippet |
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/` | Copy rendered config files |
| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management |
| Certificates | (none) | acme.sh runs as the unprivileged service user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) |
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Directory creation | `sudo mkdir -p /etc/dnsmasq.d`, `sudo mkdir -p /etc/wireguard` | Ensure target directories exist |
| Logs | `sudo journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs |
| Leases | `sudo cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
| WireGuard file ops | `cp -- * /etc/wireguard/` | Copy rendered config files |
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
| Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) |
| Network queries | `ip -o link show` | List network interfaces |
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
Key safety properties:
@@ -41,41 +56,41 @@ Key safety properties:
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary.
### 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.
- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age and `includeSubDomains` 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.
Additional proxy headers (`extra_headers` in the domain config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
### 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.
- **Cipher suites**: A curated set of AEAD ciphers using ECDHE key exchange (ECDHE-ECDSA and ECDHE-RSA with AES-GCM and CHACHA20-POLY1305). No non-ECDHE ciphers are included.
- **ssl_prefer_server_ciphers** defaults to `off` (client chooses).
- **Session settings**: `ssl_session_timeout 1d`, `ssl_session_cache shared:TLS:10m`, `ssl_session_tickets off`.
## 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:
Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system:
| Directive | Value | Effect |
|---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ReadWritePaths` | `$INSTALL_DIR`, `$INSTALL_DIR/config`, `$INSTALL_DIR/data`, and `/tmp` | The project directory, config directory, data directory, and `/tmp` are writable (required by `ProtectSystem=strict`). The project path is templated at install time. |
| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths 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 |
@@ -87,10 +102,13 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| `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 |
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
| `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` | Restricts available address families |
| `IPAddressDeny` | `any` | Drops all network traffic by default |
| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach the other process at 127.0.0.1) |
The `User`, `Group`, `WorkingDirectory`, `ExecStart`, and `ReadWritePaths` directives in the service unit are rendered from a Jinja2 template at install time with the configured `USER_NAME` and `INSTALL_DIR`.
The WebUI unit additionally restricts address families and denies all IP traffic except to localhost — it cannot reach any external network interface. Both units use template variables (`{{ USER_NAME }}`, `{{ USER_GROUP }}`, `{{ USER_DAEMON_NAME }}`, `{{ PROJECT_DIR }}`) rendered at install time.
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the config and data directories, and no ability to escalate privileges through kernel interfaces.
This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the project directory, and no ability to escalate privileges through kernel interfaces.
## Network Security
@@ -100,6 +118,8 @@ The firewalld default zone policy is set to deny all incoming traffic. Only expl
### Zone-Based Traffic Isolation
The `lib/firewall` module is a generic firewalld parser with no hardcoded zone definitions. Zone structure is defined declaratively in `config/firewall/config.json` at runtime. A typical deployment uses:
| Zone | Interface | Purpose | Behavior |
|---|---|---|---|
| `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo is rate-limited. |
@@ -116,16 +136,16 @@ IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routin
### acme.sh Integration
Certificate management is handled by acme.sh, which stores all certificates and private keys in the service user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the service user.
Certificate management is handled by acme.sh, which stores all certificates and private keys under `PROJECT_DIR/data/acme/` (set via the `ACME_HOME` environment variable). The directory is owned by and writable only by the service users.
### 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.
Private key material is never exposed through the WebUI API. The API returns certificate metadata such as domain names, validity dates, file paths, and renewal status. File paths are returned so downstream tooling (nginx, cert management) can reference them. When a domain's certificate is needed by nginx, the rendered nginx configuration references the acme.sh file paths directly via `ssl_certificate` and `ssl_certificate_key` directives — no symlinks are created.
### 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.
All HTTPS proxy domains (excluding the management interface) 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 proxied domains and their 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.
As noted in the Web Security section, the default SSL snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites using ECDHE key exchange. The cipher suite list excludes weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers.