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
+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`.