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:
+952
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user