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
.venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (149 tests)
.venv/bin/python -m pytest tests/ -v # test (154 tests)
```
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"`) |
| `interfaces` | `[string, ...]` | Interfaces assigned to this 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 |
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
| `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 |
**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 |
**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
@@ -167,7 +177,12 @@ Add a firewalld rich rule to a zone.
| `zone` | `string` | Yes | Zone to add the rule to |
| `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 |
| `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 |
| `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 |
| `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 |
| `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
@@ -403,7 +440,13 @@ Add a static (reserved) DHCP lease.
| `ip` | `string` | Yes | Reserved IP address |
| `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 |
| `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_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.
@@ -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_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.
@@ -562,7 +619,11 @@ DELETE /api/proxy/domains/<domain>
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.
@@ -648,7 +709,7 @@ Each certificate object:
| Field | Type | Description |
|-------|------|-------------|
| `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 |
| `cert_path` | `string` | Path to the certificate file |
| `key_path` | `string` | Path to the private key file |
@@ -668,7 +729,7 @@ Return details for a single certificate.
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain |
| `expiry` | `string` | Expiration date (ISO 8601) |
| `expires_at` | `string` | Expiration date (ISO 8601) |
| `days_until_expiry` | `number` | Remaining days |
| `cert_path` | `string` | Certificate file path |
| `key_path` | `string` | Private key file path |
@@ -690,7 +751,6 @@ Request a new certificate for a domain.
| 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.
@@ -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 |
**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 |
**Response:** `data` is `null` on success.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
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)
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
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt ACME
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
@@ -79,9 +79,8 @@ The following file system locations are used for integration with system service
| 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/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) |
+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.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). |
| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). |
### 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. |
| `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. |
### 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. |
| `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. |
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Defaults to `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. |
+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.
- **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.
- **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:
- `vacuum-wall.service` — the Flask WebUI backend.
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
@@ -251,7 +251,7 @@ Verify that:
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`.
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`.
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
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.
@@ -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 |
| 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 |
| 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/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Directory creation | `sudo mkdir -p /etc/dnsmasq.d`, `sudo mkdir -p /etc/wireguard` | Ensure target directories exist |
| Logs | `sudo journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs |
| 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:
- 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.
- 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.
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:vacuum-wall` restricts the secure path and disables TTY requirement.
## 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.
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
@@ -84,6 +86,7 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb
| `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 |
| `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.
+22 -11
View File
@@ -45,16 +45,7 @@ apt-get install -y -qq \
nftables \
apache2-utils
# Install acme.sh under the project user's home
if [[ ! -d "$USER_HOME/.acme.sh" ]]; then
log "Installing acme.sh..."
mkdir -p "$USER_HOME"
ACME_HOME="$USER_HOME/.acme.sh" curl -sS https://get.acme.sh | sh
else
log "acme.sh already installed."
fi
# Setup Python venv with project dependencies
# --- 1b. Setup Python venv ---
log "Setting up Python virtual environment..."
python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
@@ -68,6 +59,21 @@ else
log "User $USER_NAME already exists."
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 ---
log "Creating data directories..."
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
# 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)
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
# 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 ---
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)"
# --- Done ---
+37 -58
View File
@@ -2,7 +2,8 @@
ACME certificate manager for Vacuum Wall.
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
from Let's Encrypt (or other ACME providers).
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
@@ -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:
"""Locate the acme.sh binary on the system.
@@ -58,10 +62,12 @@ def _find_acme() -> 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
validation often requires binding to privileged ports (80/443).
acme.sh does not need root for most operations. Only standalone
and TLS-ALPN validation modes require binding to privileged ports,
which are not used by Vacuum Wall (webroot validation is used
instead).
Args:
args: List of arguments to pass to acme.sh.
@@ -76,7 +82,6 @@ def _run_acme(args: list[str]) -> str:
acme_bin = _find_acme()
cmd: list[str] = [
"sudo",
acme_bin,
"--home",
str(Path.home() / ".acme.sh"),
@@ -137,13 +142,12 @@ def get_email() -> str:
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.
Args:
domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation.
standalone: If True, use standalone TCP validation (binds port 80).
Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'.
@@ -152,8 +156,6 @@ def issue(domain: str, webroot: str | None = None, standalone: bool = False) ->
if webroot:
args.extend(["--webroot", webroot])
elif standalone:
args.append("--standalone")
email = get_email()
if email:
@@ -162,6 +164,7 @@ def issue(domain: str, webroot: str | None = None, standalone: bool = False) ->
try:
output = _run_acme(args)
deploy(domain)
return {
"success": True,
"domain": domain,
@@ -399,37 +402,27 @@ def get_cert_paths(domain: str) -> dict:
}
def setup_nginx_install(domain: str) -> None:
"""Configure acme.sh to automatically install certs for nginx.
def deploy(domain: str) -> None:
"""Register the deploy hook for a domain.
Sets up a post-hook so that nginx-specific files are copied to
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed
by an nginx reload.
Tells acme.sh to run the Vacuum Wall deploy script after every
successful issue or renewal. The hook fires automatically on
future renewals as well, so this only needs to be called once per
domain.
Args:
domain: The domain name.
"""
cert_dest = f"/etc/ssl/certs/{domain}"
key_dest = f"/etc/ssl/private/{domain}.key"
args: list[str] = [
"--install-cert",
_run_acme(
[
"--deploy",
"-d",
domain,
"--cert-file",
cert_dest,
"--key-file",
key_dest,
"--ca-file",
f"/etc/ssl/certs/{domain}-ca.crt",
"--fullchain-file",
f"/etc/ssl/certs/{domain}-fullchain.crt",
"--reloadcmd",
"sudo nginx -t && sudo systemctl reload nginx",
"--deploy-hook",
_DEPLOY_HOOK,
]
_run_acme(args)
logger.info("nginx auto-install configured for %s", domain)
)
logger.info("Deploy hook registered 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."""
if not date_str:
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:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
delta = dt - datetime.now(UTC)
return delta.days
except ValueError:
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
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:
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/``
indicates the domain is being tracked by the cron job.
acme.sh tracks certificates in per-domain ``{domain}.conf`` files
under ``~/.acme.sh/``; existence of this file means the systemd
timer's ``--cron`` run will pick it up.
"""
acme_home = Path.home() / ".acme.sh"
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
# per-domain config exists, the cron will pick it up.
domain_conf = acme_home / f"{domain}.conf"
if domain_conf.is_file():
return True
# Fallback: check crontab -l for the domain.
try:
result = subprocess.run(
["sudo", "crontab", "-l"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and "--cron" in result.stdout:
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return False
return bool(domain_conf.is_file())
+35 -7
View File
@@ -491,13 +491,32 @@ def remove_forward_port(
# ---------------------------------------------------------------------------
def _parse_forward_ports(value: str) -> list[str]:
"""Parse the 'forward-ports' line into individual forward-port specifiers.
def _parse_forward_port(raw: str) -> dict[str, Any]:
"""Parse a single forward-port specifier into a structured dict.
Multiple entries are space-separated; each looks like
``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``.
Input: ``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"):
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", []):
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(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp}",
f"--add-forward-port={fp_str}",
"--permanent",
],
check=False,
+1 -1
View File
@@ -43,7 +43,7 @@ DEFAULT_SSL = {
DEFAULT_CONFIG = {
"domains": {},
"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:
f.write(conf_text)
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)
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" %}
# Certificate managed by acme.sh
{% if cert.email %} # ACME contact: {{ cert.email }}
{% endif %} ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
{% endif %} ssl_certificate /home/vacuum-wall/.acme.sh/{{ domain }}/fullchain.cer;
ssl_certificate_key /home/vacuum-wall/.acme.sh/{{ domain }}/{{ domain }}.key;
{% elif cert.type == "file" %}
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/cp -- * /etc/dnsmasq.d/
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
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
# Acme.sh (SSL cert management)
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/bash ~/.acme.sh/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/local/bin/acme.sh *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /home/*/.acme.sh/*
# Misc
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
+5
View File
@@ -22,11 +22,16 @@ ProtectHome=read-only
ReadWritePaths=/home/wall/vacuum-wall/data /tmp
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
SystemCallFilter=@system-service
PrivateDevices=yes
# Network - only loopback (nginx proxies to us)
IPAddressDeny=all
+42
View File
@@ -52,6 +52,16 @@ class TestRunAcme:
with pytest.raises(RuntimeError):
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:
def test_parses_single_entry(self):
@@ -128,3 +138,35 @@ class TestGetCertPaths:
assert paths["key"].endswith("example.com/example.com.key")
assert paths["ca"].endswith("example.com/ca.cer")
assert paths["fullchain"].endswith("example.com/fullchain.cer")
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:
@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"]}
resp = client.get("/api/firewall/zones/public")
assert resp.status_code == 200
@@ -208,11 +210,21 @@ class TestDhcpStaticLease:
assert resp.status_code == 400
@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
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
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):
resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400
@@ -340,7 +352,7 @@ class TestWireguardInitialize:
resp = client.post("/api/wireguard/initialize")
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]["interface"]
assert data["data"] is None
class TestWireguardGenerateClient:
+7 -2
View File
@@ -7,14 +7,19 @@ from lib import firewall
class TestParseForwardPorts:
def test_single_entry(self):
result = firewall._parse_forward_ports("port=443/proto=tcp")
assert result == ["port=443/proto=tcp"]
assert len(result) == 1
assert result[0]["port"] == 443
assert result[0]["proto"] == "tcp"
def test_multiple_entries(self):
result = firewall._parse_forward_ports(
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
)
assert len(result) == 2
assert result[0] == "port=443/proto=tcp"
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):
assert firewall._parse_forward_ports("") == []
+14 -16
View File
@@ -28,10 +28,7 @@ def _error(msg, code=400):
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
return jsonify({"ok": True, "data": data})
# ---------------------------------------------------------------------------
@@ -70,14 +67,11 @@ def issue_bp():
if not domain:
return _error("'domain' is required", 400)
webroot = body.get("webroot")
standalone = body.get("standalone", False)
try:
result = issue(domain, webroot=webroot, standalone=standalone)
result = issue(domain, webroot=webroot)
if result.get("success"):
return _ok(result)
return jsonify(
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
return _ok(None)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -92,10 +86,8 @@ def renew_bp(domain):
try:
result = renew(domain)
if result.get("success"):
return _ok(result)
return jsonify(
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
), 400
return _ok(None)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -107,10 +99,16 @@ def renew_bp(domain):
@bp.route("/<domain>", methods=["DELETE"])
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:
remove(domain)
return _ok({"domain": domain})
except (RuntimeError, FileNotFoundError) as exc:
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
+19 -9
View File
@@ -30,10 +30,7 @@ def _error(msg, code=400):
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
return jsonify({"ok": True, "data": data})
def _deep_merge(base, overrides):
@@ -66,7 +63,7 @@ def post_config():
return _error("Request body must be a JSON object", 400)
try:
save_config(body)
return _ok(body)
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -80,7 +77,7 @@ def patch_config():
current = get_config()
merged = _deep_merge(current, body)
save_config(merged)
return _ok(merged)
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -89,7 +86,7 @@ def patch_config():
def apply_bp():
try:
apply_config()
return _ok({"message": "dnsmasq configuration applied"})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -132,9 +129,16 @@ def remove_static_lease_bp():
mac = request.args.get("mac", "").strip()
if not mac:
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:
remove_static_lease(mac)
return _ok({"mac": mac})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -164,8 +168,14 @@ def remove_dns_record_bp():
name = request.args.get("name", "").strip()
if not name:
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:
remove_dns_record(name)
return _ok({"name": name})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
+5 -6
View File
@@ -37,10 +37,7 @@ def _error(msg, code=400):
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
return jsonify({"ok": True, "data": data})
# ---------------------------------------------------------------------------
@@ -69,6 +66,8 @@ def list_zones():
@bp.route("/zones/<name>", methods=["GET"])
def zone_details(name):
try:
if name not in get_available_zones():
return _error(f"Zone '{name}' does not exist", 404)
info = get_zone_info(name)
return jsonify({"ok": True, "data": info})
except RuntimeError as exc:
@@ -86,7 +85,7 @@ def create_zone_bp():
if zone_name in get_available_zones():
return _error(f"Zone '{zone_name}' already exists", 400)
create_zone(zone_name, target)
return _ok({"name": zone_name, "target": target})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -98,7 +97,7 @@ def delete_zone_bp(name):
if name not in available:
return _error(f"Zone '{name}' does not exist", 404)
delete_zone(name)
return _ok({"name": name})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
+7 -10
View File
@@ -30,10 +30,7 @@ def _error(msg, code=400):
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
return jsonify({"ok": True, "data": data})
# ---------------------------------------------------------------------------
@@ -120,7 +117,7 @@ def remove_domain_bp(domain):
def apply_bp():
try:
apply()
return _ok({"message": "nginx configuration applied and reloaded"})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -128,10 +125,10 @@ def apply_bp():
@bp.route("/test", methods=["POST"])
def test_bp():
try:
ok, message = test_config()
if ok:
return _ok({"passed": True, "message": message})
return jsonify({"ok": False, "error": message, "passed": False}), 400
valid, output = test_config()
if valid:
return _ok({"valid": True, "output": output})
return jsonify({"ok": False, "error": output, "valid": False}), 400
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -153,7 +150,7 @@ def management_bp():
auth_pass = body.get("auth_pass")
try:
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:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
+6 -13
View File
@@ -33,10 +33,7 @@ def _error(msg, code=400):
def _ok(data=None):
body = {"ok": True}
if data is not None:
body["data"] = data
return jsonify(body)
return jsonify({"ok": True, "data": data})
# ---------------------------------------------------------------------------
@@ -82,7 +79,7 @@ def post_config():
def apply_bp():
try:
apply()
return _ok({"message": "WireGuard configuration applied and tunnel brought up"})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -91,7 +88,7 @@ def apply_bp():
def down_bp():
try:
down()
return _ok({"message": "WireGuard tunnel brought down"})
return _ok(None)
except RuntimeError as exc:
return _error(str(exc), 500)
@@ -117,12 +114,8 @@ def status_bp():
@bp.route("/initialize", methods=["POST"])
def initialize_bp():
try:
cfg = initialize()
safe = dict(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
return _ok(safe)
initialize()
return _ok(None)
except RuntimeError as exc:
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
)
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:
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
return _error(str(exc), code)
+15
View File
@@ -123,6 +123,20 @@ def _safely(fn, default=None):
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("/")
def dashboard():
active_zones = _safely(get_active_zones, {})
@@ -140,6 +154,7 @@ def dashboard():
domains=domains,
certs=certs,
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
)
+11 -11
View File
@@ -12,7 +12,7 @@
<div class="card-grid">
<div class="stat-card">
<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>
<div class="stat-card">
@@ -23,8 +23,8 @@
<div class="stat-card">
<div class="label">Certificates</div>
<div class="value">{{ certs|default([])|length }}</div>
{% set expired = certs|selectattr('expired')|list|default([])|length %}
{% set expiring = certs|selectattr('days_remaining','le',30)|rejectattr('expired')|list|default([])|length %}
{% set expired = certs|selectattr('days_until_expiry','lt',0)|list|default([])|length %}
{% set expiring = certs|rejectattr('days_until_expiry','lt',0)|selectattr('days_until_expiry','le',30)|list|default([])|length %}
<div class="meta">
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
@@ -34,14 +34,14 @@
<div class="stat-card">
<div class="label">WireGuard</div>
<div class="value" style="font-size:20px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}
<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('up')) else 'DOWN' }}
</div>
<div class="meta">Tunnel state</div>
</div>
<div class="stat-card">
<div class="label">Active Leases</div>
<div class="value">{{ leases|default([])|length }}</div>
<div class="value">{{ dnsmasq.get('leases', [])|length }}</div>
<div class="meta">DHCP clients connected</div>
</div>
</div>
@@ -84,8 +84,8 @@
<div class="stat-card">
<div class="label">wg0</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
{{ 'Up' if (wg_status is defined and wg_status.get('state') == 'up') else 'Down' }}
<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('up')) else 'Down' }}
</div>
</div>
{% endif %}
@@ -94,10 +94,10 @@
{% set warnings = [] %}
{% if certs is defined %}
{% 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") %}
{% elif cert.get('days_remaining') is not none and cert.days_remaining <= 30 %}
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_remaining|string + " days") %}
{% 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_until_expiry|string + " days") %}
{% endif %}
{% endfor %}
{% endif %}