Fix dashboard template bugs, acme date parsing, wireguard sudoers match, and stale docs

- dashboard.html: Fix zones, leases, wg_status, cert key names, add services var
- server.py: Pass services to dashboard template via _get_service_status()
- lib/acme.py: Fix dead third date format (%Y%m%d%H%M%z) using astimezone(UTC)
- lib/wireguard.py: Add -- separator to cp command to match sudoers rule
- lib/nginx.py: Replace shallow dict.copy() with {**...} for DEFAULT_SSL
- AGENTS.md: Update test count 149 -> 154
- docs/api.md: Rename cert field expiry -> expires_at
This commit is contained in:
2026-05-08 19:11:54 +00:00
parent e2f56b8cc8
commit 65741644a3
26 changed files with 384 additions and 200 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code
```bash ```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint .venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format .venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (149 tests) .venv/bin/python -m pytest tests/ -v # test (154 tests)
``` ```
Install dev tooling with `pip install -e ".[dev]"`. Install dev tooling with `pip install -e ".[dev]"`.
+15
View File
@@ -0,0 +1,15 @@
# Documentation MCP Server Rules
## Context
This project uses the `docs-mcp-server` to provide grounded, real-time access to technical documentation. These rules ensure the AI prioritizes this live data over internal training knowledge.
## Rules for Documentation Retrieval
1. **Mandatory Documentation Check**: Before answering any question regarding API signatures, library configurations, or framework versions, you MUST use the `docs-mcp-server` tools (e.g., `search_docs` or `ask_question`).
2. **Preference for Retrived Content**: If the information retrieved from the MCP server contradicts your internal training data, the MCP data is the "source of truth." You must follow it strictly.
3. **No Hallucinations**: If the MCP server returns no results for a specific query, do not guess. State "No official documentation found via MCP" and ask the user if they would like you to proceed based on general knowledge or if they have a local file to reference.
4. **Citation Requirement**: When providing an answer based on MCP data, include a brief mention that the information was retrieved from the live documentation server.
## Operational Workflow
- **Search First**: Start by searching for broad keywords related to the user's request.
- **Drill Down**: If multiple results are found, use specific queries to narrow down the relevant documentation sections.
- **Verify Config**: Use the server to check for `opencode.json` schema requirements or tool-specific parameters before suggesting configuration changes.
+86 -18
View File
@@ -74,7 +74,7 @@ Return detailed configuration for a single zone.
| `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) | | `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) |
| `interfaces` | `[string, ...]` | Interfaces assigned to this zone | | `interfaces` | `[string, ...]` | Interfaces assigned to this zone |
| `services` | `[string, ...]` | Services allowed through the zone | | `services` | `[string, ...]` | Services allowed through the zone |
| `ports` | `[{port: number, proto: string}, ...]` | Explicit port rules | | `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) |
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled | | `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules | | `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
| `rich_rules` | `[string, ...]` | Rich rule definitions | | `rich_rules` | `[string, ...]` | Rich rule definitions |
@@ -130,7 +130,12 @@ Replace all interfaces assigned to the zone with the provided list.
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `interfaces` | `[string, ...]` | Yes | List of interface names | | `interfaces` | `[string, ...]` | Yes | List of interface names |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned to the zone |
--- ---
@@ -148,7 +153,12 @@ Replace all services allowed in the zone with the provided list.
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names | | `services` | `[string, ...]` | Yes | List of firewalld service names |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `services` | `[string, ...]` | List of services now allowed in the zone |
### Firewall Rules ### Firewall Rules
@@ -167,7 +177,12 @@ Add a firewalld rich rule to a zone.
| `zone` | `string` | Yes | Zone to add the rule to | | `zone` | `string` | Yes | Zone to add the rule to |
| `rule` | `string` | Yes | Full rich rule string | | `rule` | `string` | Yes | Full rich rule string |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `rule` | `string` | Full rich rule string |
--- ---
@@ -186,7 +201,12 @@ Remove an existing rich rule from a zone. The `rule` string must match exactly.
| `zone` | `string` | Yes | Zone the rule belongs to | | `zone` | `string` | Yes | Zone the rule belongs to |
| `rule` | `string` | Yes | Exact rich rule string to remove | | `rule` | `string` | Yes | Exact rich rule string to remove |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `rule` | `string` | Exact rich rule string that was removed |
--- ---
@@ -221,7 +241,12 @@ Toggle masquerade (source NAT) for a zone.
| `zone` | `string` | Yes | Zone to configure | | `zone` | `string` | Yes | Zone to configure |
| `enable` | `boolean` | Yes | `true` to enable, `false` to disable | | `enable` | `boolean` | Yes | `true` to enable, `false` to disable |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled for the zone |
--- ---
@@ -243,7 +268,13 @@ Add a port forwarding rule to a zone.
| `toaddr` | `string` | No | Internal destination address | | `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port | | `toport` | `number` | No | Internal destination port |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
--- ---
@@ -265,7 +296,13 @@ Remove a port forwarding rule. The body must match the original rule exactly.
| `toaddr` | `string` | No | Internal destination address | | `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port | | `toport` | `number` | No | Internal destination port |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
### Info ### Info
@@ -403,7 +440,13 @@ Add a static (reserved) DHCP lease.
| `ip` | `string` | Yes | Reserved IP address | | `ip` | `string` | Yes | Reserved IP address |
| `hostname` | `string` | No | Hostname for the reservation | | `hostname` | `string` | No | Hostname for the reservation |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `mac` | `string` | MAC address |
| `ip` | `string` | Reserved IP address |
| `hostname` | `string` | Hostname for the reservation |
--- ---
@@ -442,7 +485,13 @@ Add a custom DNS A record served by dnsmasq.
| `name` | `string` | Yes | Fully qualified domain name | | `name` | `string` | Yes | Fully qualified domain name |
| `address` | `string` | Yes | IP address to resolve to | | `address` | `string` | Yes | IP address to resolve to |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Fully qualified domain name |
| `address` | `string` | IP address |
| `hostname` | `string` | Short hostname |
--- ---
@@ -505,7 +554,11 @@ Add a new reverse proxy domain.
| `backend_port` | `number` | Yes | Backend server port | | `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` | | `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `400` if the domain is already configured. Returns HTTP `400` if the domain is already configured.
@@ -548,7 +601,11 @@ Update one or more fields of an existing domain entry. Only the fields present i
| `backend_port` | `number` | No | Backend server port | | `backend_port` | `number` | No | Backend server port |
| `backend_proto` | `string` | No | Backend protocol | | `backend_proto` | `string` | No | Backend protocol |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `404` if the domain is not configured. Returns HTTP `404` if the domain is not configured.
@@ -562,7 +619,11 @@ DELETE /api/proxy/domains/<domain>
Remove a proxy domain and its nginx configuration. Remove a proxy domain and its nginx configuration.
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `404` if the domain is not configured. Returns HTTP `404` if the domain is not configured.
@@ -648,7 +709,7 @@ Each certificate object:
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `domain` | `string` | Domain the certificate covers | | `domain` | `string` | Domain the certificate covers |
| `expiry` | `string` | Expiration date (ISO 8601) | | `expires_at` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days until expiration | | `days_until_expiry` | `number` | Remaining days until expiration |
| `cert_path` | `string` | Path to the certificate file | | `cert_path` | `string` | Path to the certificate file |
| `key_path` | `string` | Path to the private key file | | `key_path` | `string` | Path to the private key file |
@@ -668,7 +729,7 @@ Return details for a single certificate.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `domain` | `string` | Domain | | `domain` | `string` | Domain |
| `expiry` | `string` | Expiration date (ISO 8601) | | `expires_at` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days | | `days_until_expiry` | `number` | Remaining days |
| `cert_path` | `string` | Certificate file path | | `cert_path` | `string` | Certificate file path |
| `key_path` | `string` | Private key file path | | `key_path` | `string` | Private key file path |
@@ -690,7 +751,6 @@ Request a new certificate for a domain.
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain to issue the certificate for | | `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 | | `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
**Response:** `data` is `null` on success. **Response:** `data` is `null` on success.
@@ -741,7 +801,11 @@ Set or update the ACME account contact email (used by Let's Encrypt for expirati
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `email` | `string` | Yes | Contact email address | | `email` | `string` | Yes | Contact email address |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `email` | `string` | Contact email address |
--- ---
@@ -888,7 +952,11 @@ Remove a configured peer.
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to remove | | `name` | `string` | Yes | Peer name to remove |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
Returns HTTP `404` if the peer is not found. Returns HTTP `404` if the peer is not found.
+6 -7
View File
@@ -34,13 +34,13 @@ The following diagram summarizes how the Flask WebUI communicates with each mana
``` ```
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090) 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/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/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /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/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt ACME
Flask WebUI ──→ lib/wireguard.py ──→ render /etc/wireguard/wg0.conf ──→ sudo wg-quick up wg0 ──→ kernel module Flask WebUI ──→ lib/wireguard.py ──→ render /home/wall/vacuum-wall/data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
``` ```
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. Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), 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. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged `vacuum-wall` user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
## State Management ## State Management
@@ -79,9 +79,8 @@ The following file system locations are used for integration with system service
| Path | Purpose | Managed By | | 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/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. | 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/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/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/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) | | `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the `vacuum-wall` user. Defines all permitted privilege escalations. | Install script (manual edits not required) |
+4 -5
View File
@@ -133,16 +133,15 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. | | `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.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.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` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
| `cert.path.certificate` | 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.path.key` | string | Yes (if `file`) | Full path to the private key file (PEM). |
### Certificate Types ### Certificate Types
| Type | Description | | 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. | | `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. | | `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored alongside other acme-managed files in `~/.acme.sh/` with a `.selfsigned` marker. | | `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 ### Management Domain
@@ -224,7 +223,7 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
| `public_key` | string | Yes | The peer's public key. | | `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. | | `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. | | `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. | | `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. | | `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. | | `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. |
+2 -2
View File
@@ -55,7 +55,7 @@ The installer performs the following steps automatically:
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces. - **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. - **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. - **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. - **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Copies it to both `$USER_HOME/vacuum-wall/.htpasswd` (used by install.sh's initial nginx config) and `data/nginx/.htpasswd` (used by the running app).
- **Systemd units**: Installs three units: - **Systemd units**: Installs three units:
- `vacuum-wall.service` — the Flask WebUI backend. - `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot. - `vacuum-wall-acme.service` — the certificate renewal oneshot.
@@ -251,7 +251,7 @@ Verify that:
1. Verify nginx is running: `systemctl status nginx`. 1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`. 2. Test nginx configuration: `nginx -t`.
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf`. 2. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
4. Ensure the `vacuum-wall` WebUI service is listening on port 9090: `ss -tlnp | grep 9090`. 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. 5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real Let's Encrypt certificate.
+12 -9
View File
@@ -2,7 +2,7 @@
## Privilege 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`. 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, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the `vacuum-wall` user without sudo escalation, using webroot validation that doesn't require binding to privileged ports.
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. 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.
@@ -19,24 +19,26 @@ The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status | | Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) | | WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management | | WireGuard | `wg *` | WireGuard status and peer management |
| Certificates | `acme.sh` (via `bash -c`) | Let's Encrypt certificate issuance and renewal | | Certificates | (none) | acme.sh runs as the unprivileged `vacuum-wall` user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) |
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths | | File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
| Logs | `sudo journalctl --unit=*` | Query systemd journal for managed services | | File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Directory creation | `sudo mkdir -p /etc/dnsmasq.d`, `sudo mkdir -p /etc/wireguard` | Ensure target directories exist |
| Logs | `sudo journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs | | Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs |
| File writes | `sudo tee` | Write configuration data to protected paths | | Leases | `sudo cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
Key safety properties: Key safety properties:
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`). - 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. - Wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`), but none 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. - `NOPASSWD` is used so the application never prompts for a password. `Defaults:vacuum-wall` restricts the secure path and disables TTY requirement.
- `DEFAULT!/usr/bin/sudo` and `NOPASSWD` are used so the application never prompts for a password and cannot chain sudo calls.
## Web Security ## Web Security
### Management Interface ### 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. The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
### Proxy Domains ### Proxy Domains
@@ -84,6 +86,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| `LockPersonality` | `yes` | Prevents changing the execution domain | | `LockPersonality` | `yes` | Prevents changing the execution domain |
| `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable | | `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 | | `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
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. 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.
+22 -11
View File
@@ -45,16 +45,7 @@ apt-get install -y -qq \
nftables \ nftables \
apache2-utils apache2-utils
# Install acme.sh under the project user's home # --- 1b. Setup Python venv ---
if [[ ! -d "$USER_HOME/.acme.sh" ]]; then
log "Installing acme.sh..."
mkdir -p "$USER_HOME"
ACME_HOME="$USER_HOME/.acme.sh" curl -sS https://get.acme.sh | sh
else
log "acme.sh already installed."
fi
# Setup Python venv with project dependencies
log "Setting up Python virtual environment..." log "Setting up Python virtual environment..."
python3 -m venv "${PROJECT_DIR}/.venv" python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}" "${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
@@ -68,6 +59,21 @@ else
log "User $USER_NAME already exists." log "User $USER_NAME already exists."
fi fi
# --- 2b. Install acme.sh as the vacuum-wall user ---
if [[ ! -d "$USER_HOME/.acme.sh" ]]; then
log "Installing acme.sh for $USER_NAME..."
su -s /bin/sh "$USER_NAME" -c \
"ACME_HOME='$USER_HOME/.acme.sh' curl -sS https://get.acme.sh | sh"
else
log "acme.sh already installed."
fi
# Ensure the vacuum-wall user owns the acme.sh directory
chown -R "$USER_NAME:$USER_NAME" "$USER_HOME/.acme.sh"
# Ensure the acme deploy hook script has correct permissions
chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh"
# --- 3. Setup directories --- # --- 3. Setup directories ---
log "Creating data directories..." log "Creating data directories..."
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard}
@@ -138,6 +144,10 @@ with open('$USER_HOME/vacuum-wall/.htpasswd', 'w') as f:
chown "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall/.htpasswd" 2>/dev/null chown "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall/.htpasswd" 2>/dev/null
# Copy .htpasswd to the path expected by the running app (data/nginx/.htpasswd)
cp "$USER_HOME/vacuum-wall/.htpasswd" "${PROJECT_DIR}/data/nginx/.htpasswd" 2>/dev/null || true
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd" 2>/dev/null || true
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context) # Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF' cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
# Vacuum Wall - WebSocket upgrade map # Vacuum Wall - WebSocket upgrade map
@@ -223,7 +233,8 @@ systemctl start vacuum-wall 2>/dev/null || warn "Could not start vacuum-wall Web
# --- 12. Configure acme.sh default email --- # --- 12. Configure acme.sh default email ---
log "Configuring acme.sh default email..." log "Configuring acme.sh default email..."
"$USER_HOME/.acme.sh/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \ su -s /bin/sh "$USER_NAME" -c \
"$USER_HOME/.acme.sh/acme.sh --register-account -m '$ACME_EMAIL'" 2>/dev/null || \
warn "Could not register acme.sh account (will be done from WebUI)" warn "Could not register acme.sh account (will be done from WebUI)"
# --- Done --- # --- Done ---
+40 -61
View File
@@ -2,7 +2,8 @@
ACME certificate manager for Vacuum Wall. ACME certificate manager for Vacuum Wall.
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
from Let's Encrypt (or other ACME providers). from Let's Encrypt (or other ACME providers). acme.sh runs as the
vacuum-wall system user; nginx is reloaded via a deploy hook script.
""" """
import logging import logging
@@ -22,6 +23,9 @@ _ACME_ENVIRON = {
), ),
} }
PROJECT_DIR = Path("/home/wall/vacuum-wall")
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
def _find_acme() -> str: def _find_acme() -> str:
"""Locate the acme.sh binary on the system. """Locate the acme.sh binary on the system.
@@ -58,10 +62,12 @@ def _find_acme() -> str:
def _run_acme(args: list[str]) -> str: def _run_acme(args: list[str]) -> str:
"""Execute acme.sh with the given arguments. """Execute acme.sh with the given arguments (as the current user).
Runs the command as root via sudo because standalone / webroot acme.sh does not need root for most operations. Only standalone
validation often requires binding to privileged ports (80/443). and TLS-ALPN validation modes require binding to privileged ports,
which are not used by Vacuum Wall (webroot validation is used
instead).
Args: Args:
args: List of arguments to pass to acme.sh. args: List of arguments to pass to acme.sh.
@@ -76,7 +82,6 @@ def _run_acme(args: list[str]) -> str:
acme_bin = _find_acme() acme_bin = _find_acme()
cmd: list[str] = [ cmd: list[str] = [
"sudo",
acme_bin, acme_bin,
"--home", "--home",
str(Path.home() / ".acme.sh"), str(Path.home() / ".acme.sh"),
@@ -137,13 +142,12 @@ def get_email() -> str:
return "" return ""
def issue(domain: str, webroot: str | None = None, standalone: bool = False) -> dict: def issue(domain: str, webroot: str | None = None) -> dict:
"""Issue a new SSL certificate for a domain. """Issue a new SSL certificate for a domain.
Args: Args:
domain: The primary domain name. domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation. webroot: Path to the web root directory for HTTP-01 validation.
standalone: If True, use standalone TCP validation (binds port 80).
Returns: Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'. A dict with 'success', 'domain', 'message', 'output', and 'error'.
@@ -152,8 +156,6 @@ def issue(domain: str, webroot: str | None = None, standalone: bool = False) ->
if webroot: if webroot:
args.extend(["--webroot", webroot]) args.extend(["--webroot", webroot])
elif standalone:
args.append("--standalone")
email = get_email() email = get_email()
if email: if email:
@@ -162,6 +164,7 @@ def issue(domain: str, webroot: str | None = None, standalone: bool = False) ->
try: try:
output = _run_acme(args) output = _run_acme(args)
deploy(domain)
return { return {
"success": True, "success": True,
"domain": domain, "domain": domain,
@@ -399,37 +402,27 @@ def get_cert_paths(domain: str) -> dict:
} }
def setup_nginx_install(domain: str) -> None: def deploy(domain: str) -> None:
"""Configure acme.sh to automatically install certs for nginx. """Register the deploy hook for a domain.
Sets up a post-hook so that nginx-specific files are copied to Tells acme.sh to run the Vacuum Wall deploy script after every
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed successful issue or renewal. The hook fires automatically on
by an nginx reload. future renewals as well, so this only needs to be called once per
domain.
Args: Args:
domain: The domain name. domain: The domain name.
""" """
cert_dest = f"/etc/ssl/certs/{domain}" _run_acme(
key_dest = f"/etc/ssl/private/{domain}.key" [
"--deploy",
args: list[str] = [ "-d",
"--install-cert", domain,
"-d", "--deploy-hook",
domain, _DEPLOY_HOOK,
"--cert-file", ]
cert_dest, )
"--key-file", logger.info("Deploy hook registered for %s", domain)
key_dest,
"--ca-file",
f"/etc/ssl/certs/{domain}-ca.crt",
"--fullchain-file",
f"/etc/ssl/certs/{domain}-fullchain.crt",
"--reloadcmd",
"sudo nginx -t && sudo systemctl reload nginx",
]
_run_acme(args)
logger.info("nginx auto-install configured for %s", domain)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -468,43 +461,29 @@ def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now.""" """Parse an ISO date string and return days until that date from now."""
if not date_str: if not date_str:
return None return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y%m%d%H%M%z"): for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
try: try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC) dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
delta = dt - datetime.now(UTC) delta = dt - datetime.now(UTC)
return delta.days return delta.days
except ValueError: except ValueError:
continue continue
try:
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
delta = dt - datetime.now(UTC)
return delta.days
except ValueError:
pass
return None return None
def _has_auto_renew(domain: str) -> bool: def _has_auto_renew(domain: str) -> bool:
"""Check whether a domain has a scheduled cron renewal. """Check whether a domain has automatic renewal configured.
The README states the cron entry format is: acme.sh tracks certificates in per-domain ``{domain}.conf`` files
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null under ``~/.acme.sh/``; existence of this file means the systemd
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/`` timer's ``--cron`` run will pick it up.
indicates the domain is being tracked by the cron job.
""" """
acme_home = Path.home() / ".acme.sh" acme_home = Path.home() / ".acme.sh"
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
# per-domain config exists, the cron will pick it up.
domain_conf = acme_home / f"{domain}.conf" domain_conf = acme_home / f"{domain}.conf"
if domain_conf.is_file(): return bool(domain_conf.is_file())
return True
# Fallback: check crontab -l for the domain.
try:
result = subprocess.run(
["sudo", "crontab", "-l"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and "--cron" in result.stdout:
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return False
+35 -7
View File
@@ -491,13 +491,32 @@ def remove_forward_port(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_forward_ports(value: str) -> list[str]: def _parse_forward_port(raw: str) -> dict[str, Any]:
"""Parse the 'forward-ports' line into individual forward-port specifiers. """Parse a single forward-port specifier into a structured dict.
Multiple entries are space-separated; each looks like Input: ``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``
``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``.
""" """
return value.split() if value else [] result: dict[str, Any] = {}
for piece in raw.split("/"):
if "=" not in piece:
continue
key, _, val = piece.partition("=")
if key == "port":
result["port"] = int(val)
elif key == "proto":
result["proto"] = val
elif key == "toaddr":
result["toaddr"] = val
elif key == "toport":
result["toport"] = int(val)
return result
def _parse_forward_ports(value: str) -> list[dict[str, Any]]:
"""Parse the 'forward-ports' line into a list of structured dicts."""
if not value:
return []
return [_parse_forward_port(raw) for raw in value.split()]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -594,14 +613,23 @@ def restore_backup(state: dict[str, Any]) -> None:
if zinfo.get("masquerade"): if zinfo.get("masquerade"):
set_masquerade(zone_name, True) set_masquerade(zone_name, True)
# Forward ports (stored as raw strings in zinfo) # Forward ports (stored as dicts, or raw strings from old backups)
for fp in zinfo.get("forward-ports", []): for fp in zinfo.get("forward-ports", []):
if isinstance(fp, str):
fp_str = fp
else:
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts)
_run( _run(
[ [
"sudo", "sudo",
"firewall-cmd", "firewall-cmd",
f"--zone={zone_name}", f"--zone={zone_name}",
f"--add-forward-port={fp}", f"--add-forward-port={fp_str}",
"--permanent", "--permanent",
], ],
check=False, check=False,
+1 -1
View File
@@ -43,7 +43,7 @@ DEFAULT_SSL = {
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"domains": {}, "domains": {},
"management": None, "management": None,
"ssl": DEFAULT_SSL.copy(), "ssl": {**DEFAULT_SSL},
} }
+1 -1
View File
@@ -147,7 +147,7 @@ def apply() -> None:
with open(local_tmp, "w") as f: with open(local_tmp, "w") as f:
f.write(conf_text) f.write(conf_text)
os.chmod(local_tmp, 0o600) os.chmod(local_tmp, 0o600)
_run(["cp", str(local_tmp), WG_CONF_PATH]) _run(["cp", "--", str(local_tmp), WG_CONF_PATH])
_run(["chown", "root:root", WG_CONF_PATH], check=False) _run(["chown", "root:root", WG_CONF_PATH], check=False)
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# acme-deploy.sh — Deploy hook for acme.sh (Vacuum Wall)
#
# Called by acme.sh after every successful certificate issue or renewal.
# The nginx config reads certs directly from ~/.acme.sh/, so we only
# need to reload nginx. If the config is invalid (e.g. apply hasn't
# been called yet) we exit silently — the user will reload via WebUI.
sudo nginx -t 2>/dev/null && sudo nginx -s reload 2>/dev/null
exit 0
+2 -2
View File
@@ -21,8 +21,8 @@ server {
{% if cert.type == "acme" %} {% if cert.type == "acme" %}
# Certificate managed by acme.sh # Certificate managed by acme.sh
{% if cert.email %} # ACME contact: {{ cert.email }} {% if cert.email %} # ACME contact: {{ cert.email }}
{% endif %} ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem; {% endif %} ssl_certificate /home/vacuum-wall/.acme.sh/{{ domain }}/fullchain.cer;
ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem; ssl_certificate_key /home/vacuum-wall/.acme.sh/{{ domain }}/{{ domain }}.key;
{% elif cert.type == "file" %} {% elif cert.type == "file" %}
ssl_certificate {{ cert.path }}; ssl_certificate {{ cert.path }};
+1 -5
View File
@@ -19,17 +19,13 @@ vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/ vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
# WireGuard management # WireGuard management
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick * vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg * vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/ vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
# Acme.sh (SSL cert management)
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/bash ~/.acme.sh/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/local/bin/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /home/*/.acme.sh/*
# Misc # Misc
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n * vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/* vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
+5
View File
@@ -22,11 +22,16 @@ ProtectHome=read-only
ReadWritePaths=/home/wall/vacuum-wall/data /tmp ReadWritePaths=/home/wall/vacuum-wall/data /tmp
PrivateTmp=yes PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes MemoryDenyWriteExecute=yes
RestrictRealtime=yes RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes LockPersonality=yes
SystemCallFilter=@system-service
PrivateDevices=yes
# Network - only loopback (nginx proxies to us) # Network - only loopback (nginx proxies to us)
IPAddressDeny=all IPAddressDeny=all
+42
View File
@@ -52,6 +52,16 @@ class TestRunAcme:
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
acme._run_acme(["--list"]) acme._run_acme(["--list"])
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_no_sudo_prefix(self, mock_run, mock_find):
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
acme._run_acme(["--list"])
cmd = mock_run.call_args[0][0]
assert cmd[0] == "/usr/local/bin/acme.sh"
assert "sudo" not in cmd
class TestParseListOutput: class TestParseListOutput:
def test_parses_single_entry(self): def test_parses_single_entry(self):
@@ -128,3 +138,35 @@ class TestGetCertPaths:
assert paths["key"].endswith("example.com/example.com.key") assert paths["key"].endswith("example.com/example.com.key")
assert paths["ca"].endswith("example.com/ca.cer") assert paths["ca"].endswith("example.com/ca.cer")
assert paths["fullchain"].endswith("example.com/fullchain.cer") assert paths["fullchain"].endswith("example.com/fullchain.cer")
class TestDeployHook:
@patch("lib.acme._run_acme")
def test_deploy_registers_hook(self, mock_run):
acme.deploy("example.com")
args = mock_run.call_args[0][0]
assert "--deploy" in args
assert "-d" in args
assert "example.com" in args
assert "--deploy-hook" in args
assert any("acme-deploy.sh" in arg for arg in args)
class TestHasAutoRenew:
@patch("lib.acme.Path.home")
def test_true_when_conf_exists(self, mock_home):
tmpdir = tempfile.mkdtemp()
acme_dir = Path(tmpdir) / ".acme.sh"
acme_dir.mkdir()
conf = acme_dir / "example.com.conf"
conf.touch()
mock_home.return_value = Path(tmpdir)
result = acme._has_auto_renew("example.com")
assert result is True
conf.unlink()
@patch("lib.acme.Path.home")
def test_false_when_conf_missing(self, mock_home):
mock_home.return_value = Path(tempfile.mkdtemp())
result = acme._has_auto_renew("nonexistent.com")
assert result is False
+15 -3
View File
@@ -50,7 +50,9 @@ class TestFirewallListZones:
class TestFirewallZoneDetails: class TestFirewallZoneDetails:
@patch("webui.api.firewall.get_zone_info") @patch("webui.api.firewall.get_zone_info")
def test_success(self, mock_info, client): @patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_available, mock_info, client):
mock_available.return_value = ["public", "internal"]
mock_info.return_value = {"name": "public", "services": ["ssh"]} mock_info.return_value = {"name": "public", "services": ["ssh"]}
resp = client.get("/api/firewall/zones/public") resp = client.get("/api/firewall/zones/public")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -208,11 +210,21 @@ class TestDhcpStaticLease:
assert resp.status_code == 400 assert resp.status_code == 400
@patch("webui.api.dhcp.remove_static_lease") @patch("webui.api.dhcp.remove_static_lease")
def test_remove(self, mock_remove, client): @patch("webui.api.dhcp.get_config")
def test_remove(self, mock_get, mock_remove, client):
mock_get.return_value = {
"dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]}
}
mock_remove.return_value = None mock_remove.return_value = None
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC") resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
assert resp.status_code == 200 assert resp.status_code == 200
@patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dhcp": {"static_leases": []}}
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
assert resp.status_code == 404
def test_remove_missing_mac(self, client): def test_remove_missing_mac(self, client):
resp = client.delete("/api/dhcp/static-lease") resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400 assert resp.status_code == 400
@@ -340,7 +352,7 @@ class TestWireguardInitialize:
resp = client.post("/api/wireguard/initialize") resp = client.post("/api/wireguard/initialize")
data = resp.get_json() data = resp.get_json()
assert data["ok"] is True assert data["ok"] is True
assert "private_key" not in data["data"]["interface"] assert data["data"] is None
class TestWireguardGenerateClient: class TestWireguardGenerateClient:
+7 -2
View File
@@ -7,14 +7,19 @@ from lib import firewall
class TestParseForwardPorts: class TestParseForwardPorts:
def test_single_entry(self): def test_single_entry(self):
result = firewall._parse_forward_ports("port=443/proto=tcp") result = firewall._parse_forward_ports("port=443/proto=tcp")
assert result == ["port=443/proto=tcp"] assert len(result) == 1
assert result[0]["port"] == 443
assert result[0]["proto"] == "tcp"
def test_multiple_entries(self): def test_multiple_entries(self):
result = firewall._parse_forward_ports( result = firewall._parse_forward_ports(
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080" "port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
) )
assert len(result) == 2 assert len(result) == 2
assert result[0] == "port=443/proto=tcp" assert result[0]["port"] == 443
assert result[1]["port"] == 80
assert result[1]["toaddr"] == "10.0.0.1"
assert result[1]["toport"] == 8080
def test_empty_string(self): def test_empty_string(self):
assert firewall._parse_forward_ports("") == [] assert firewall._parse_forward_ports("") == []
+14 -16
View File
@@ -28,10 +28,7 @@ def _error(msg, code=400):
def _ok(data=None): def _ok(data=None):
body = {"ok": True} return jsonify({"ok": True, "data": data})
if data is not None:
body["data"] = data
return jsonify(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,14 +67,11 @@ def issue_bp():
if not domain: if not domain:
return _error("'domain' is required", 400) return _error("'domain' is required", 400)
webroot = body.get("webroot") webroot = body.get("webroot")
standalone = body.get("standalone", False)
try: try:
result = issue(domain, webroot=webroot, standalone=standalone) result = issue(domain, webroot=webroot)
if result.get("success"): if result.get("success"):
return _ok(result) return _ok(None)
return jsonify( return _error(result.get("error", "Unknown error"), 500)
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -92,10 +86,8 @@ def renew_bp(domain):
try: try:
result = renew(domain) result = renew(domain)
if result.get("success"): if result.get("success"):
return _ok(result) return _ok(None)
return jsonify( return _error(result.get("error", "Unknown error"), 500)
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -107,10 +99,16 @@ def renew_bp(domain):
@bp.route("/<domain>", methods=["DELETE"]) @bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain): def remove_bp(domain):
try:
get_cert_info(domain)
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
return _error(str(exc), 500)
try: try:
remove(domain) remove(domain)
return _ok({"domain": domain}) return _ok(None)
except (RuntimeError, FileNotFoundError) as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
+19 -9
View File
@@ -30,10 +30,7 @@ def _error(msg, code=400):
def _ok(data=None): def _ok(data=None):
body = {"ok": True} return jsonify({"ok": True, "data": data})
if data is not None:
body["data"] = data
return jsonify(body)
def _deep_merge(base, overrides): def _deep_merge(base, overrides):
@@ -66,7 +63,7 @@ def post_config():
return _error("Request body must be a JSON object", 400) return _error("Request body must be a JSON object", 400)
try: try:
save_config(body) save_config(body)
return _ok(body) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -80,7 +77,7 @@ def patch_config():
current = get_config() current = get_config()
merged = _deep_merge(current, body) merged = _deep_merge(current, body)
save_config(merged) save_config(merged)
return _ok(merged) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -89,7 +86,7 @@ def patch_config():
def apply_bp(): def apply_bp():
try: try:
apply_config() apply_config()
return _ok({"message": "dnsmasq configuration applied"}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -132,9 +129,16 @@ def remove_static_lease_bp():
mac = request.args.get("mac", "").strip() mac = request.args.get("mac", "").strip()
if not mac: if not mac:
return _error("Query parameter 'mac' is required", 400) return _error("Query parameter 'mac' is required", 400)
current = get_config()
found = any(
lease["mac"].lower() == mac.lower()
for lease in current.get("dhcp", {}).get("static_leases", [])
)
if not found:
return _error(f"No static lease found for MAC '{mac}'", 404)
try: try:
remove_static_lease(mac) remove_static_lease(mac)
return _ok({"mac": mac}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -164,8 +168,14 @@ def remove_dns_record_bp():
name = request.args.get("name", "").strip() name = request.args.get("name", "").strip()
if not name: if not name:
return _error("Query parameter 'name' is required", 400) return _error("Query parameter 'name' is required", 400)
current = get_config()
found = any(
r["name"] == name for r in current.get("dns", {}).get("custom_records", [])
)
if not found:
return _error(f"No DNS record found for '{name}'", 404)
try: try:
remove_dns_record(name) remove_dns_record(name)
return _ok({"name": name}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
+5 -6
View File
@@ -37,10 +37,7 @@ def _error(msg, code=400):
def _ok(data=None): def _ok(data=None):
body = {"ok": True} return jsonify({"ok": True, "data": data})
if data is not None:
body["data"] = data
return jsonify(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -69,6 +66,8 @@ def list_zones():
@bp.route("/zones/<name>", methods=["GET"]) @bp.route("/zones/<name>", methods=["GET"])
def zone_details(name): def zone_details(name):
try: try:
if name not in get_available_zones():
return _error(f"Zone '{name}' does not exist", 404)
info = get_zone_info(name) info = get_zone_info(name)
return jsonify({"ok": True, "data": info}) return jsonify({"ok": True, "data": info})
except RuntimeError as exc: except RuntimeError as exc:
@@ -86,7 +85,7 @@ def create_zone_bp():
if zone_name in get_available_zones(): if zone_name in get_available_zones():
return _error(f"Zone '{zone_name}' already exists", 400) return _error(f"Zone '{zone_name}' already exists", 400)
create_zone(zone_name, target) create_zone(zone_name, target)
return _ok({"name": zone_name, "target": target}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -98,7 +97,7 @@ def delete_zone_bp(name):
if name not in available: if name not in available:
return _error(f"Zone '{name}' does not exist", 404) return _error(f"Zone '{name}' does not exist", 404)
delete_zone(name) delete_zone(name)
return _ok({"name": name}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
+7 -10
View File
@@ -30,10 +30,7 @@ def _error(msg, code=400):
def _ok(data=None): def _ok(data=None):
body = {"ok": True} return jsonify({"ok": True, "data": data})
if data is not None:
body["data"] = data
return jsonify(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,7 +117,7 @@ def remove_domain_bp(domain):
def apply_bp(): def apply_bp():
try: try:
apply() apply()
return _ok({"message": "nginx configuration applied and reloaded"}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -128,10 +125,10 @@ def apply_bp():
@bp.route("/test", methods=["POST"]) @bp.route("/test", methods=["POST"])
def test_bp(): def test_bp():
try: try:
ok, message = test_config() valid, output = test_config()
if ok: if valid:
return _ok({"passed": True, "message": message}) return _ok({"valid": True, "output": output})
return jsonify({"ok": False, "error": message, "passed": False}), 400 return jsonify({"ok": False, "error": output, "valid": False}), 400
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -153,7 +150,7 @@ def management_bp():
auth_pass = body.get("auth_pass") auth_pass = body.get("auth_pass")
try: try:
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass) set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
return _ok({"domain": domain}) return _ok(None)
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500 code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code) return _error(str(exc), code)
+6 -13
View File
@@ -33,10 +33,7 @@ def _error(msg, code=400):
def _ok(data=None): def _ok(data=None):
body = {"ok": True} return jsonify({"ok": True, "data": data})
if data is not None:
body["data"] = data
return jsonify(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -82,7 +79,7 @@ def post_config():
def apply_bp(): def apply_bp():
try: try:
apply() apply()
return _ok({"message": "WireGuard configuration applied and tunnel brought up"}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -91,7 +88,7 @@ def apply_bp():
def down_bp(): def down_bp():
try: try:
down() down()
return _ok({"message": "WireGuard tunnel brought down"}) return _ok(None)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -117,12 +114,8 @@ def status_bp():
@bp.route("/initialize", methods=["POST"]) @bp.route("/initialize", methods=["POST"])
def initialize_bp(): def initialize_bp():
try: try:
cfg = initialize() initialize()
safe = dict(cfg) return _ok(None)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc: except RuntimeError as exc:
return _error(str(exc), 500) return _error(str(exc), 500)
@@ -208,7 +201,7 @@ def generate_client_bp():
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400 "Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
) )
conf_text = generate_client_conf(name, server_endpoint, server_pubkey) conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
return _ok({"config": conf_text, "name": name}) return _ok({"config": conf_text})
except (KeyError, ValueError, RuntimeError) as exc: except (KeyError, ValueError, RuntimeError) as exc:
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500 code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
return _error(str(exc), code) return _error(str(exc), code)
+15
View File
@@ -123,6 +123,20 @@ def _safely(fn, default=None):
return default return default
def _get_service_status(dnsmasq_info, wg_info):
"""Build a service status dict for the dashboard template."""
services = {}
if dnsmasq_info:
services["Dnsmasq"] = {
"running": dnsmasq_info.get("service_active", False),
}
if wg_info:
services["WireGuard"] = {
"running": wg_info.get("up", False),
}
return services
@app.route("/") @app.route("/")
def dashboard(): def dashboard():
active_zones = _safely(get_active_zones, {}) active_zones = _safely(get_active_zones, {})
@@ -140,6 +154,7 @@ def dashboard():
domains=domains, domains=domains,
certs=certs, certs=certs,
wg_status=wg, wg_status=wg,
services=_get_service_status(dnsmasq, wg),
) )
+11 -11
View File
@@ -12,7 +12,7 @@
<div class="card-grid"> <div class="card-grid">
<div class="stat-card"> <div class="stat-card">
<div class="label">Zones</div> <div class="label">Zones</div>
<div class="value">{{ zones|default([])|length }}</div> <div class="value">{{ active_zones|default({})|length }}</div>
<div class="meta">Firewalld zones configured</div> <div class="meta">Firewalld zones configured</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -23,8 +23,8 @@
<div class="stat-card"> <div class="stat-card">
<div class="label">Certificates</div> <div class="label">Certificates</div>
<div class="value">{{ certs|default([])|length }}</div> <div class="value">{{ certs|default([])|length }}</div>
{% set expired = certs|selectattr('expired')|list|default([])|length %} {% set expired = certs|selectattr('days_until_expiry','lt',0)|list|default([])|length %}
{% set expiring = certs|selectattr('days_remaining','le',30)|rejectattr('expired')|list|default([])|length %} {% set expiring = certs|rejectattr('days_until_expiry','lt',0)|selectattr('days_until_expiry','le',30)|list|default([])|length %}
<div class="meta"> <div class="meta">
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %} {% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %} {% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
@@ -34,14 +34,14 @@
<div class="stat-card"> <div class="stat-card">
<div class="label">WireGuard</div> <div class="label">WireGuard</div>
<div class="value" style="font-size:20px;"> <div class="value" style="font-size:20px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span> <span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }} {{ 'UP' if (wg_status is defined and wg_status.get('up')) else 'DOWN' }}
</div> </div>
<div class="meta">Tunnel state</div> <div class="meta">Tunnel state</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Active Leases</div> <div class="label">Active Leases</div>
<div class="value">{{ leases|default([])|length }}</div> <div class="value">{{ dnsmasq.get('leases', [])|length }}</div>
<div class="meta">DHCP clients connected</div> <div class="meta">DHCP clients connected</div>
</div> </div>
</div> </div>
@@ -84,8 +84,8 @@
<div class="stat-card"> <div class="stat-card">
<div class="label">wg0</div> <div class="label">wg0</div>
<div class="value" style="font-size:16px;"> <div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span> <span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'Up' if (wg_status is defined and wg_status.get('state') == 'up') else 'Down' }} {{ 'Up' if (wg_status is defined and wg_status.get('up')) else 'Down' }}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -94,10 +94,10 @@
{% set warnings = [] %} {% set warnings = [] %}
{% if certs is defined %} {% if certs is defined %}
{% for cert in certs %} {% for cert in certs %}
{% if cert.get('expired') %} {% if cert.get('days_until_expiry') is not none and cert.days_until_expiry < 0 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %} {% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
{% elif cert.get('days_remaining') is not none and cert.days_remaining <= 30 %} {% elif cert.get('days_until_expiry') is not none and cert.days_until_expiry <= 30 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_remaining|string + " days") %} {% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_until_expiry|string + " days") %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}