fix htmx refactor route mismatches and remaining TODO items

- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
This commit is contained in:
2026-05-17 01:15:52 +00:00
parent 0e7090a2cb
commit 37039351be
26 changed files with 1737 additions and 848 deletions
+4 -5
View File
@@ -15,8 +15,7 @@ __pycache__/
# Local AI tool config (contains internal hostnames)
opencode.json
# Runtime data configs (source-of-truth for services)
config/dnsmasq/config.json
config/nginx/config.json
config/wireguard/config.json
data/nginx/sites-enabled/
# Runtime artifacts
build/
config/
data/
+276 -203
View File
@@ -34,12 +34,79 @@ Error responses carry one of the following HTTP status codes:
| `404` | Not found — the requested resource does not exist |
| `500` | Internal server error — unexpected failure in the backend |
### Route Patterns
Resource identification uses **path parameters** whenever possible. Exceptions occur only when the identifier is inherently long (e.g., a rich rule string), in which case the body carries the identifier.
---
## Firewall API
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
### Declarative Config
The firewall supports a two-step declarative workflow: save config to `config/firewall/config.json`, then apply it to live firewalld. The config tracks `rich_rules` and `forward_ports` with auto-generated `id` fields.
#### Get Config
```
GET /api/firewall/config
```
Return the current declarative firewall config.
**Response:** `data` contains the config object with a `zones` mapping.
#### Save Config
```
POST /api/firewall/config
```
Replace the declarative config. Returns pending changes summary.
**Request Body:** Request body must contain `zones`.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config_saved` | `boolean` | Always `true` |
| `pending` | `[object, ...]` | List of pending changes |
| `needs_apply` | `boolean` | Whether changes need to be applied |
| `unmanaged_zones` | `object` | Zones active on system but not in config |
#### Apply Config
```
POST /api/firewall/config/apply
```
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**Response:** `data` contains `applied_zones` list and backup path.
#### Check Pending Changes
```
GET /api/firewall/config/pending
```
Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
**Response:** Same structure as POST /config response.
#### Partial Update Config
```
PATCH /api/firewall/config
```
Deep-merge the provided fields into the existing config.
**Response:** `data` is `null` on success.
### Zone Management
#### List All Zones
@@ -76,8 +143,8 @@ Return detailed configuration for a single zone.
| `services` | `[string, ...]` | Services allowed through the zone |
| `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 |
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
Returns HTTP `404` if the zone does not exist.
@@ -135,7 +202,7 @@ Replace all interfaces assigned to the zone with the provided list.
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned to the zone |
| `interfaces` | `[string, ...]` | List of interface names now assigned |
---
@@ -158,9 +225,9 @@ Replace all services allowed in the zone with the provided list.
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `services` | `[string, ...]` | List of services now allowed in the zone |
| `services` | `[string, ...]` | List of services now allowed |
### Firewall Rules
### Rich Rules
#### Add Rich Rule
@@ -168,7 +235,7 @@ Replace all services allowed in the zone with the provided list.
POST /api/firewall/rich-rules
```
Add a firewalld rich rule to a zone.
Add a firewalld rich rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
**Request Body:**
@@ -182,6 +249,7 @@ Add a firewalld rich rule to a zone.
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `id` | `string` | 8-character unique ID |
| `rule` | `string` | Full rich rule string |
---
@@ -189,24 +257,19 @@ Add a firewalld rich rule to a zone.
#### Remove Rich Rule
```
DELETE /api/firewall/rich-rules
DELETE /api/firewall/rich-rules/<zone>/<id>
```
Remove an existing rich rule from a zone. The `rule` string must match exactly.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `rule` | `string` | Yes | Exact rich rule string to remove |
Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `rule` | `string` | Exact rich rule string that was removed |
| `id` | `string` | ID of the removed rule |
Returns HTTP `404` if the rule ID is not found.
---
@@ -216,15 +279,64 @@ Remove an existing rich rule from a zone. The `rule` string must match exactly.
GET /api/firewall/rich-rules/<zone>
```
Return all rich rules for the specified zone.
Return all rich rules for the specified zone, each with an `id` and `rule` string.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[string, ...]` | Rich rule strings |
| `data` | `[{id, rule}, ...]` | Rich rules with IDs |
### NAT
### Port Forwarding
#### Add Port Forward
```
POST /api/firewall/forward-port
```
Add a port forwarding rule to a zone. The rule is persisted to the declarative config with an auto-generated `id`.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `id` | `string` | 8-character unique ID |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
---
#### Remove Port Forward
```
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
```
Remove a port forwarding rule. Zone, port, and protocol are all path parameters.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
Returns HTTP `404` if the forward port is not found.
### Masquerade (NAT)
#### Enable / Disable Masquerade
@@ -246,63 +358,7 @@ Toggle masquerade (source NAT) for a zone.
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled for the zone |
---
#### Add Port Forward
```
POST /api/firewall/forward-port
```
Add a port forwarding rule to a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to add the rule to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
---
#### Remove Port Forward
```
DELETE /api/firewall/forward-port
```
Remove a port forwarding rule. The body must match the original rule exactly.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone the rule belongs to |
| `port` | `number` | Yes | External port |
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
| `toaddr` | `string` | No | Internal destination address |
| `toport` | `number` | No | Internal destination port |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
| `masquerade` | `boolean` | Whether masquerade is now enabled |
### Info
@@ -406,24 +462,74 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa
**Response:** `data` is `null` on success.
### Leases
### Status
#### Get Live Leases
#### Get Service Status
```
GET /api/dhcp/leases
GET /api/dhcp/status
```
Return the current DHCP lease table from dnsmasq.
Return the current service status, config summary, and active lease count.
**Response:**
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects |
| `service_active` | `boolean` | Whether dnsmasq is running |
| `config_file_exists` | `boolean` | Whether config file exists on disk |
| `config_in_sync` | `boolean` | Whether disk config matches expected |
| `dhcp_ranges` | `number` | Number of DHCP ranges |
| `static_leases` | `number` | Number of static leases |
| `custom_dns_records` | `number` | Number of custom DNS records |
| `upstreams` | `[string, ...]` | Upstream DNS servers |
| `domain` | `string` | Local DNS domain |
| `active_leases` | `number` | Number of active leases |
| `leases` | `[object, ...]` | Active lease objects |
### DHCP Ranges
#### Add Range
```
POST /api/dhcp/ranges
```
Add or replace the DHCP range for a given interface.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | No | Interface name (empty = all interfaces) |
| `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range |
| `lease_time` | `string` | No | Lease duration; defaults to `"12h"` |
**Response:** `data` is `null` on success.
---
#### Remove Range
```
DELETE /api/dhcp/ranges
```
Remove a DHCP range. Body contains identifying fields.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | Yes | Interface name |
| `start` | `string` | Yes | Start of IP range |
| `end` | `string` | Yes | End of IP range |
**Response:** `data` is `null` on success.
### Static Leases
#### Add Static Lease
```
@@ -446,28 +552,38 @@ Add a static (reserved) DHCP lease.
|-------|------|-------------|
| `mac` | `string` | MAC address |
| `ip` | `string` | Reserved IP address |
| `hostname` | `string` | Hostname for the reservation |
| `hostname` | `string` | Hostname |
---
#### Remove Static Lease
```
DELETE /api/dhcp/static-lease?mac=aa:bb:cc:dd:ee:ff
DELETE /api/dhcp/static-lease/<mac>
```
Remove a previously configured static lease.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mac` | `string` | Yes | MAC address of the lease to remove |
Remove a static lease by MAC address.
**Response:** `data` is `null` on success.
Returns HTTP `404` if no matching lease is found.
### Live Leases
#### Get Live Leases
```
GET /api/dhcp/leases
```
Return the current DHCP lease table from dnsmasq.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects |
### DNS Records
#### Add DNS Record
@@ -484,6 +600,7 @@ Add a custom DNS A record served by dnsmasq.
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name |
| `address` | `string` | Yes | IP address to resolve to |
| `hostname` | `string` | No | Short hostname |
**Response (`data`):**
@@ -498,16 +615,10 @@ Add a custom DNS A record served by dnsmasq.
#### Remove DNS Record
```
DELETE /api/dhcp/dns-record?name=nas.lan
DELETE /api/dhcp/dns-record/<name>
```
Remove a custom DNS record.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name to remove |
Remove a custom DNS record by domain name.
**Response:** `data` is `null` on success.
@@ -553,6 +664,8 @@ Add a new reverse proxy domain.
| `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
| `cert` | `string` | No | Certificate domain |
| `extra_headers` | `object` | No | Extra proxy headers |
**Response (`data`):**
@@ -572,14 +685,7 @@ GET /api/proxy/domains/<domain>
Return the configuration for a single proxy domain.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
| `backend_host` | `string` | Backend server address |
| `backend_port` | `number` | Backend server port |
| `backend_proto` | `string` | Backend protocol |
**Response (`data`):** Domain name plus backend configuration fields.
Returns HTTP `404` if the domain is not configured.
@@ -591,15 +697,9 @@ Returns HTTP `404` if the domain is not configured.
PUT /api/proxy/domains/<domain>
```
Update one or more fields of an existing domain entry. Only the fields present in the body are modified.
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `backend_host` | `string` | No | Backend server IP or hostname |
| `backend_port` | `number` | No | Backend server port |
| `backend_proto` | `string` | No | Backend protocol |
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
**Response (`data`):**
@@ -649,15 +749,17 @@ Returns HTTP `500` if nginx config generation fails or the reload fails.
POST /api/proxy/test
```
Run `nginx -t` against the generated configuration without reloading. Useful for validating changes before applying.
Run `nginx -t` against the generated configuration without reloading.
**Response:**
**Response (valid):**
| Field | Type | Description |
|-------|------|-------------|
| `data.valid` | `boolean` | Whether the configuration syntax is valid |
| `data.valid` | `boolean` | Always `true` |
| `data.output` | `string` | Raw nginx test output |
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
### Management
#### Configure Management WebUI Proxy
@@ -675,13 +777,11 @@ Configure the nginx proxy block for the management WebUI itself, including optio
| `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
| `auth_user` | `string` | No | Username for basic auth. An `.htpasswd` entry is created when this field is present. |
| `auth_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. |
| `auth_user` | `string` | No | Username for basic auth |
| `auth_pass` | `string` | No | Password for basic auth |
**Response:** `data` is `null` on success.
If `auth_user` and `auth_pass` are provided, the endpoint creates or updates the corresponding `.htpasswd` file entry.
---
## Certificate API
@@ -704,15 +804,7 @@ Return all managed certificates with metadata.
|-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects |
Each certificate object:
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain the certificate covers |
| `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 |
Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
---
@@ -724,15 +816,7 @@ GET /api/certs/<domain>
Return details for a single certificate.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain |
| `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 |
**Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
Returns HTTP `404` if no certificate is found for the domain.
@@ -755,7 +839,7 @@ Request a new certificate for a domain.
**Response:** `data` is `null` on success.
Returns HTTP `400` if the domain is missing or the request is malformed. Returns HTTP `500` if the ACME challenge or certificate issuance fails.
Returns HTTP `400` if the domain is missing. Returns HTTP `500` if issuance fails.
---
@@ -765,7 +849,7 @@ Returns HTTP `400` if the domain is missing or the request is malformed. Returns
POST /api/certs/<domain>/renew
```
Force-renew an existing certificate, regardless of its current expiry status.
Force-renew an existing certificate.
**Response:** `data` is `null` on success.
@@ -793,7 +877,7 @@ Returns HTTP `404` if the certificate is not found.
POST /api/certs/email
```
Set or update the ACME account contact email (used by the CA for expiration and security notices).
Set or update the ACME account contact email.
**Request Body:**
@@ -801,11 +885,7 @@ Set or update the ACME account contact email (used by the CA for expiration and
|-------|------|----------|-------------|
| `email` | `string` | Yes | Contact email address |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `email` | `string` | Contact email address |
**Response (`data`):** Returns the set `email` field.
---
@@ -821,13 +901,13 @@ Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, p
GET /api/wireguard/config
```
Return the current WireGuard server configuration. The `private_key` field is stripped from the response.
Return the current WireGuard server configuration. The `private_key` field is stripped.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Full WireGuard configuration dictionary (`private_key` omitted) |
| `data` | `object` | WireGuard config (`private_key` omitted) |
---
@@ -845,11 +925,7 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
|-------|------|----------|-------------|
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Updated configuration (`private_key` omitted) |
**Response:** `data` contains the updated configuration (`private_key` omitted).
### Tunnel Control
@@ -859,11 +935,21 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped
POST /api/wireguard/apply
```
Write the current configuration to `wg0.conf` on disk and bring the WireGuard tunnel up.
Write the current configuration to `wg0.conf` and bring the tunnel up.
**Response:** `data` is `null` on success.
Returns HTTP `500` if config write or interface bring-up fails.
---
#### Start Tunnel
```
POST /api/wireguard/up
```
Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
**Response:** `data` is `null` on success.
---
@@ -885,15 +971,15 @@ Bring down the WireGuard tunnel interface (`wg0`).
GET /api/wireguard/status
```
Return live tunnel state, including interface metrics and per-peer connection statistics.
Return live tunnel state with interface metrics and per-peer connection statistics.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `up` | `boolean` | Whether the tunnel interface is up |
| `interface` | `object` | Interface info (listen port, public key, etc.) |
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) |
| `interface` | `object` | Interface info (listen port, public key) |
| `peers` | `[object, ...]` | Per-peer stats (handshake, bytes, endpoint) |
---
@@ -903,19 +989,35 @@ Return live tunnel state, including interface metrics and per-peer connection st
POST /api/wireguard/initialize
```
Perform first-time setup: generate a server key pair, write an initial configuration, and prepare for peer enrollment. This endpoint is idempotent — calling it multiple times has no additional effect.
First-time setup: generate server key pair, write initial config. Idempotent.
**Response:** `data` is `null` on success.
### Peer Management
#### List Peers
```
GET /api/wireguard/peers
```
Return all configured peers. Private keys are stripped.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Peer objects (private keys omitted) |
---
#### Add Peer
```
POST /api/wireguard/add-peer
POST /api/wireguard/peers
```
Add a new WireGuard peer. A key pair is auto-generated for the peer. The response includes peer details with the private key stripped.
Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response.
**Request Body:**
@@ -924,33 +1026,20 @@ Add a new WireGuard peer. A key pair is auto-generated for the peer. The respons
| `name` | `string` | Yes | Peer identifier name |
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
| `persistent_keepalive` | `number` | No | Persistent keepalive interval in seconds |
| `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) |
| `preshared_key` | `string` | No | Preshared key |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
| `public_key` | `string` | Peer's public key |
| `allowed_ips` | `[string, ...]` | Allowed IPs |
| `endpoint` | `string` | Allowed endpoint |
| `persistent_keepalive` | `number` | Keepalive interval |
**Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
---
#### Remove Peer
```
DELETE /api/wireguard/remove-peer?name=alice
DELETE /api/wireguard/peers/<name>
```
Remove a configured peer.
**Query Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to remove |
Remove a configured peer by name.
**Response (`data`):**
@@ -962,35 +1051,19 @@ Returns HTTP `404` if the peer is not found.
---
#### List Peers
```
GET /api/wireguard/peers
```
Return all configured peers. Private keys are stripped from the response.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of peer objects (private keys omitted) |
---
#### Peer Connection Status
```
GET /api/wireguard/peer-status
```
Return live per-peer connection status from `wg show`, including last handshake time, transfer bytes, and current endpoint.
Return live per-peer connection status from `wg show`.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of live peer status objects |
| `data` | `[object, ...]` | Live peer status (handshake time, bytes, endpoint) |
### Client Configuration
@@ -1000,21 +1073,21 @@ Return live per-peer connection status from `wg show`, including last handshake
POST /api/wireguard/generate-client
```
Generate a complete WireGuard client configuration file for provisioning a device. The returned config includes the peer's private key for the client to use.
Generate a complete WireGuard client configuration file. The returned config includes the peer's private key for provisioning.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to generate config for |
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) for the client's `[Peer]` section |
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config` | `string` | Complete WireGuard client config text (`[Interface]` + `[Peer]` block) |
| `config` | `string` | Complete client config text (`[Interface]` + `[Peer]`) |
The client config includes the generated private key so the client can be provisioned directly. Note that this is the only endpoint that returns a WireGuard private key — all other endpoints strip private keys from responses.
This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
Returns HTTP `404` if the peer is not found.
+30 -3
View File
@@ -6,6 +6,7 @@ static leases, and custom DNS records through sudo.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
@@ -15,6 +16,8 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
@@ -101,6 +104,7 @@ def save_config(cfg: dict) -> None:
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
def apply_config() -> None:
@@ -118,6 +122,7 @@ def apply_config() -> None:
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
@@ -187,6 +192,23 @@ def set_dhcp_range(
ranges.append(entry)
save_config(cfg)
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
"""Remove a DHCP range by interface + IP range."""
cfg = get_config()
cfg["dhcp"]["ranges"] = [
r
for r in cfg["dhcp"]["ranges"]
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
save_config(cfg)
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
@@ -200,6 +222,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
if hostname:
leases[i]["hostname"] = hostname
save_config(cfg)
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
@@ -207,6 +230,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
def remove_static_lease(mac: str) -> None:
@@ -218,6 +242,7 @@ def remove_static_lease(mac: str) -> None:
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
logger.info("Static DHCP lease removed for MAC %s", mac)
# ───────── dns record management ─────────────────────────────────────
@@ -234,6 +259,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
if hostname:
records[i]["hostname"] = hostname
save_config(cfg)
logger.info("DNS record updated: %s -> %s", name, address)
return
entry: dict[str, Any] = {"name": name, "address": address}
@@ -241,6 +267,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
logger.info("DNS record added: %s -> %s", name, address)
def remove_dns_record(name: str) -> None:
@@ -250,6 +277,7 @@ def remove_dns_record(name: str) -> None:
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
logger.info("DNS record removed: %s", name)
# ───────── lease table ───────────────────────────────────────────────
@@ -299,6 +327,7 @@ def set_upstreams(servers: list[str]) -> None:
cfg = get_config()
cfg["dns"]["upstreams"] = list(servers)
save_config(cfg)
logger.info("DNS upstreams set to %s", servers)
def set_domain(domain: str | None) -> None:
@@ -306,6 +335,7 @@ def set_domain(domain: str | None) -> None:
cfg = get_config()
cfg["dns"]["domain"] = domain if domain else None
save_config(cfg)
logger.info("DNS domain set to '%s'", domain)
# ───────── status / info ─────────────────────────────────────────────
@@ -315,7 +345,6 @@ def get_status() -> dict:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
# dnsmasq process check
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
@@ -326,7 +355,6 @@ def get_status() -> dict:
except Exception:
active = False
# config on disk
conf_exists = os.path.isfile(DNSMASQ_CONF)
if conf_exists:
try:
@@ -337,7 +365,6 @@ def get_status() -> dict:
else:
conf_on_disk = ""
# current expected config
expected = generate_conf(cfg)
leases = get_lease_table()
+247 -118
View File
@@ -9,12 +9,16 @@ Flask UI can inspect or restore previous configurations.
"""
import json
import logging
import os
import subprocess
from contextlib import suppress
from datetime import UTC
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall")
@@ -45,7 +49,12 @@ def _run(cmd: list[str], check: bool = True) -> str:
def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately."""
try:
_run(["sudo", "firewall-cmd", "--reload"])
logger.info("firewalld reloaded")
except RuntimeError as exc:
logger.error("firewalld reload failed: %s", exc)
raise
def _ensure_data_dir() -> None:
@@ -54,6 +63,11 @@ def _ensure_data_dir() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def _gen_id() -> str:
"""Generate a short unique identifier (8 hex characters)."""
return uuid4().hex[:8]
# ---------------------------------------------------------------------------
# Read-only queries
# ---------------------------------------------------------------------------
@@ -66,15 +80,7 @@ def get_available_zones() -> list[str]:
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces.
Example return value::
{
"public": ["eth0"],
"internal": ["eth1"],
}
"""
"""Return a dict mapping active zone names to their assigned interfaces."""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
zones: dict[str, list[str]] = {}
current_zone: str | None = None
@@ -82,7 +88,6 @@ def get_active_zones() -> dict[str, list[str]]:
stripped = raw_line.strip()
if not stripped:
continue
# Indented lines belong to the current zone section.
if raw_line.startswith(" "):
current_ifaces = (
zones[current_zone]
@@ -99,13 +104,7 @@ def get_active_zones() -> dict[str, list[str]]:
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*.
Keys in the returned dict include:
``name``, ``target``, ``interfaces``, ``sources``, ``services``,
``ports``, ``protocols``, ``forward-ports``, ``masquerade``,
``rich-rules``, ``ics``, ``icmp-blocks``, ``module``.
"""
"""Return detailed information for *zone*."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
@@ -117,7 +116,6 @@ def get_zone_info(zone: str) -> dict[str, Any]:
value = value.strip()
if not value:
# Lines like "interfaces: " or "masquerade: " when disabled
if key in ("masquerade", "ics"):
info[key] = False
else:
@@ -138,12 +136,10 @@ def get_zone_info(zone: str) -> dict[str, Any]:
elif key in ("masquerade", "ics"):
info[key] = value.lower() == "yes"
elif key == "rich-rules":
# rich-rules can span multiple lines; we'll parse below.
info[key] = [value] if value else []
else:
info[key] = value
# rich-rules may already have been set; if not, default to empty.
info.setdefault("rich-rules", [])
info.setdefault("interfaces", [])
info.setdefault("sources", [])
@@ -177,7 +173,6 @@ def get_interfaces() -> list[str]:
ifaces: list[str] = []
for line in output.splitlines():
if line:
# Format: "NUM: NAME: <FLAGS> ..."
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
@@ -212,14 +207,7 @@ def get_rich_rules(zone: str) -> list[str]:
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld.
Args:
zone: Name of the zone to create.
Raises:
RuntimeError: If the zone already exists or creation fails.
"""
"""Create a new permanent zone in firewalld."""
_run(
[
"sudo",
@@ -230,16 +218,14 @@ def create_zone(zone: str, target: str = "default") -> None:
]
)
_reload()
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
def delete_zone(zone: str) -> None:
"""Delete an existing zone.
Raises:
RuntimeError: If the zone does not exist or the deletion fails.
"""
"""Delete an existing zone."""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
_reload()
logger.info("Firewall zone '%s' deleted", zone)
# ---------------------------------------------------------------------------
@@ -248,12 +234,7 @@ def delete_zone(zone: str) -> None:
def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
"""Assign *interfaces* to *zone*, replacing any existing assignments.
Existing interfaces on the zone are removed first so only the
provided list remains.
"""
# Remove current permanent interfaces for this zone.
"""Assign *interfaces* to *zone*, replacing any existing assignments."""
try:
current = get_zone_info(zone).get("interfaces", [])
except Exception:
@@ -270,7 +251,6 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
check=False,
)
# Add the desired set.
for iface in interfaces:
_run(
[
@@ -282,6 +262,7 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
]
)
_reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
def add_zone_interface(zone: str, iface: str) -> None:
@@ -296,6 +277,7 @@ def add_zone_interface(zone: str, iface: str) -> None:
]
)
_reload()
logger.info("Interface '%s' added to zone '%s'", iface, zone)
def remove_zone_interface(zone: str, iface: str) -> None:
@@ -310,6 +292,7 @@ def remove_zone_interface(zone: str, iface: str) -> None:
]
)
_reload()
logger.info("Interface '%s' removed from zone '%s'", iface, zone)
# ---------------------------------------------------------------------------
@@ -319,7 +302,6 @@ def remove_zone_interface(zone: str, iface: str) -> None:
def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
# Remove all current services.
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
@@ -344,6 +326,7 @@ def set_zone_services(zone: str, services: list[str]) -> None:
]
)
_reload()
logger.info("Zone '%s' services set to %s", zone, services)
def add_zone_service(zone: str, service: str) -> None:
@@ -358,6 +341,7 @@ def add_zone_service(zone: str, service: str) -> None:
]
)
_reload()
logger.info("Service '%s' added to zone '%s'", service, zone)
def remove_zone_service(zone: str, service: str) -> None:
@@ -372,6 +356,7 @@ def remove_zone_service(zone: str, service: str) -> None:
]
)
_reload()
logger.info("Service '%s' removed from zone '%s'", service, zone)
# ---------------------------------------------------------------------------
@@ -379,12 +364,8 @@ def remove_zone_service(zone: str, service: str) -> None:
# ---------------------------------------------------------------------------
def add_rich_rule(zone: str, rule: str) -> None:
"""Add a rich rule to *zone*.
The *rule* argument should be a fully-formed rich-rule expression,
e.g. ``rule family="ipv4" port protocol="tcp" port="443" accept``.
"""
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to *zone* and persist to declarative config."""
_run(
[
"sudo",
@@ -395,13 +376,14 @@ def add_rich_rule(zone: str, rule: str) -> None:
]
)
_reload()
_persist_rich_rule(zone, rule)
rule_entry = _get_rich_rule_entry(zone, rule)
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return rule_entry
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*.
The rule string must match exactly what was added.
"""
"""Remove a rich rule from *zone*."""
_run(
[
"sudo",
@@ -412,6 +394,55 @@ def remove_rich_rule(zone: str, rule: str) -> None:
]
)
_reload()
_unpersist_rich_rule(zone, rule)
logger.info("Rich rule removed from zone '%s': %s", zone, rule[:80])
def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to the declarative config with a generated id."""
cfg = config_get()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("rich_rules", [])
existing_rules = cfg["zones"][zone]["rich_rules"]
rule_id = _gen_id()
entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry)
config_set(cfg)
return entry
def _unpersist_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from the declarative config by rule string."""
cfg = config_get()
zone_cfg = cfg.get("zones", {}).get(zone, {})
rules = zone_cfg.get("rich_rules", [])
zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule]
config_set(cfg)
def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
"""Look up a rich rule entry in the declarative config."""
cfg = config_get()
for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []):
if r.get("rule") == rule:
return r
return {"rule": rule}
def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
"""Remove a rich rule from *zone* by its config id."""
cfg = config_get()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for r in zone_cfg.get("rich_rules", []):
if r.get("id") == rule_id:
entry = r
break
if entry is None:
raise ValueError(f"Rich rule '{rule_id}' not found in zone '{zone}'")
rule = entry["rule"]
remove_rich_rule(zone, rule)
# ---------------------------------------------------------------------------
@@ -424,6 +455,7 @@ def set_masquerade(zone: str, enable: bool) -> None:
action = "--add-masquerade" if enable else "--remove-masquerade"
_run(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
_reload()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
# ---------------------------------------------------------------------------
@@ -437,12 +469,8 @@ def add_forward_port(
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Add a port forwarding rule to *zone*.
Forward traffic arriving on ``port/protocol`` to
``toaddr:toport`` (or just ``toport`` when *toaddr* is omitted).
"""
) -> dict[str, Any]:
"""Add a port forwarding rule to *zone* and persist to declarative config."""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
@@ -461,6 +489,10 @@ def add_forward_port(
]
)
_reload()
_persist_forward_port(zone, port, protocol, toaddr, toport)
fp_entry = _get_forward_port_entry(zone, port, protocol)
logger.info("Port forward added to zone '%s': %s", zone, fwd)
return fp_entry
def remove_forward_port(
@@ -470,10 +502,7 @@ def remove_forward_port(
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Remove a previously added port-forwarding rule from *zone*.
All parameters must match the original rule exactly.
"""
"""Remove a previously added port-forwarding rule from *zone*."""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
@@ -492,6 +521,80 @@ def remove_forward_port(
]
)
_reload()
_unpersist_forward_port(zone, port, protocol)
logger.info("Port forward removed from zone '%s': %s", zone, fwd)
def _persist_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id."""
cfg = config_get()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", [])
fp_id = _gen_id()
entry: dict[str, Any] = {
"id": fp_id,
"port": port,
"proto": protocol,
}
if toaddr:
entry["toaddr"] = toaddr
if toport:
entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry)
config_set(cfg)
return entry
def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from the declarative config by port+proto."""
cfg = config_get()
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
cfg.setdefault("zones", {}).setdefault(zone, {})
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol)
]
config_set(cfg)
def _get_forward_port_entry(
zone: str, port: int, protocol: str
) -> dict[str, Any]:
"""Look up a forward port entry in the declarative config."""
cfg = config_get()
for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol:
return fp
entry: dict[str, Any] = {"port": port, "proto": protocol}
return entry
def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from *zone* by port+proto (id used by API layer)."""
cfg = config_get()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for fp in zone_cfg.get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol:
entry = fp
break
if entry is None:
raise ValueError(
f"Forward port {port}/{protocol} not found in zone '{zone}'"
)
remove_forward_port(
zone,
port,
protocol,
toaddr=entry.get("toaddr"),
toport=entry.get("toport"),
)
# ---------------------------------------------------------------------------
@@ -500,10 +603,7 @@ def remove_forward_port(
def _parse_forward_port(raw: str) -> dict[str, Any]:
"""Parse a single forward-port specifier into a structured dict.
Input: ``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``
"""
"""Parse a single forward-port specifier into a structured dict."""
result: dict[str, Any] = {}
for piece in raw.split("/"):
if "=" not in piece:
@@ -533,12 +633,7 @@ def _parse_forward_ports(value: str) -> list[dict[str, Any]]:
def get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld as a Python dict.
The dict contains all zones with their per-zone configuration, all
rich rules, masquerade settings, forward-port rules, and the set of
active interfaces.
"""
"""Return the complete current state of firewalld as a Python dict."""
zones: dict[str, dict[str, Any]] = {}
for name in get_available_zones():
try:
@@ -558,70 +653,43 @@ def get_state() -> dict[str, Any]:
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
from datetime import datetime
return datetime.now(UTC).isoformat()
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk.
Returns:
Absolute path to the written file.
"""
"""Capture the full state and write it to RULES_FILE on disk."""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
logger.info("Firewall state backup saved to %s", RULES_FILE)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict.
Use :func:`restore_backup` to actually apply the loaded state.
Raises:
FileNotFoundError: When no backup file exists at RULES_FILE.
json.JSONDecodeError: When the file is not valid JSON.
Returns:
The loaded state dict.
"""
"""Read the JSON backup file and return the state dict."""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
def restore_backup(state: dict[str, Any]) -> None:
"""Apply the zone configuration described in *state*.
Walks every zone in *state*["zones"] and re-creates services,
interfaces, forward ports, masquerade, and rich rules.
This is a *merge*: zones not present in the snapshot are **not**
touched.
"""
"""Apply the zone configuration described in *state*."""
zones_cfg = state.get("zones", {})
for zone_name, zinfo in zones_cfg.items():
# Ensure the zone exists.
if zone_name not in get_available_zones():
target = zinfo.get("target", "default")
create_zone(zone_name, target)
# Services
services = zinfo.get("services", [])
set_zone_services(zone_name, services)
# Interfaces
interfaces = zinfo.get("interfaces", [])
set_zone_interfaces(zone_name, interfaces)
# Masquerade
if zinfo.get("masquerade"):
set_masquerade(zone_name, True)
# 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
@@ -643,7 +711,6 @@ def restore_backup(state: dict[str, Any]) -> None:
check=False,
)
# Rich rules
for rule in zinfo.get("rich-rules", []):
_run(
[
@@ -657,6 +724,7 @@ def restore_backup(state: dict[str, Any]) -> None:
)
_reload()
logger.info("Firewall backup restored, %d zones processed", len(zones_cfg))
# ---------------------------------------------------------------------------
@@ -688,6 +756,7 @@ def config_set(cfg: dict[str, Any]) -> None:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
logger.info("Firewall declarative config saved")
def _normalize_target(target: str) -> str:
@@ -713,11 +782,7 @@ def _live_target_to_config(target: str) -> str:
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff.
Returns a dict with ``pending`` (list of change dicts), ``needs_apply``
(bool), and ``live_zones`` (dict of zones not yet in config).
"""
"""Compare declarative config against live firewalld state, return diff."""
cfg = config_get()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
@@ -779,6 +844,38 @@ def config_pending() -> dict[str, Any]:
}
)
cfg_rules = {
r.get("rule") for r in zone_cfg.get("rich_rules", [])
}
live_rules = set(live_zone.get("rich-rules", []))
if cfg_rules != live_rules:
changes.append(
{
"zone": zone_name,
"type": "rich_rules",
"config_count": len(cfg_rules),
"live_count": len(live_rules),
}
)
cfg_fps = {
(fp.get("port"), fp.get("proto"))
for fp in zone_cfg.get("forward_ports", [])
}
live_fps = {
(fp.get("port"), fp.get("proto"))
for fp in live_zone.get("forward-ports", [])
}
if cfg_fps != live_fps:
changes.append(
{
"zone": zone_name,
"type": "forward_ports",
"config_count": len(cfg_fps),
"live_count": len(live_fps),
}
)
for zone_name in live_zones:
if zone_name not in cfg_zones:
unknown_live[zone_name] = {
@@ -793,14 +890,7 @@ def config_pending() -> dict[str, Any]:
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld.
Takes a snapshot via ``save_backup()`` first, then reconciles each zone
in the config (create/update, interfaces, services, masquerade), reloads,
and takes another snapshot.
Returns a dict with ``applied_zones`` and a ``backup`` path.
"""
"""Apply the declarative config to live firewalld."""
cfg = config_get()
cfg_zones = cfg.get("zones", {})
@@ -836,11 +926,48 @@ def config_apply() -> dict[str, Any]:
if mq is not None:
set_masquerade(zone_name, mq)
for rule_entry in zone_cfg.get("rich_rules", []):
rule_str = rule_entry.get("rule", "") if isinstance(rule_entry, dict) else str(rule_entry)
if rule_str:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule_str}",
"--permanent",
],
check=False,
)
for fp_entry in zone_cfg.get("forward_ports", []):
if isinstance(fp_entry, str):
fp_str = fp_entry
else:
parts = [f"port={fp_entry['port']}", f"proto={fp_entry['proto']}"]
if "toaddr" in fp_entry:
parts.append(f"toaddr={fp_entry['toaddr']}")
if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts)
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
check=False,
)
applied.append(zone_name)
_reload()
backup_path = save_backup()
logger.info("Firewall config applied to %d zones", len(applied))
return {
"applied_zones": applied,
"backup": backup_path,
@@ -875,7 +1002,9 @@ __all__ = [
"get_zone_info",
"load_backup",
"remove_forward_port",
"remove_forward_port_by_id",
"remove_rich_rule",
"remove_rich_rule_by_id",
"remove_zone_interface",
"remove_zone_service",
"restore_backup",
+81
View File
@@ -0,0 +1,81 @@
"""
logging - Centralized logging configuration for Vacuum Wall.
Call :func:`setup_logging` once at application startup. All other
modules obtain a logger via ``logging.getLogger(__name__)``.
Output:
* **stderr** (StreamHandler) - captured by systemd journald
* **data/logs/vacuum-wall.log** (RotatingFileHandler) - persisted for
viewing via the WebUI ``/logs`` page.
"""
import logging
import os
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent.parent
_LOG_DIR = PROJECT_DIR / "data" / "logs"
_LOG_FILE = _LOG_DIR / "vacuum-wall.log"
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
_BACKUP_COUNT = 3
_LOG_FMT = "[%(asctime)s] %(levelname)-8s %(name)s %(message)s"
_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_initialized = False
def setup_logging(level: str | None = None) -> None:
"""Configure and enable root-level logging for the application.
Safe to call multiple times; subsequent calls are no-ops.
Args:
level: Override log level string (e.g. ``"DEBUG"``). If ``None``,
reads ``VACUUM_WALL_LOG_LEVEL`` from the environment, defaulting
to ``"INFO"``.
"""
global _initialized
if _initialized:
return
_initialized = True
if level is None:
level = os.environ.get("VACUUM_WALL_LOG_LEVEL", "INFO").upper()
valid_levels = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
numeric = valid_levels.get(level, logging.INFO)
root = logging.getLogger()
root.setLevel(numeric)
fmt = logging.Formatter(_LOG_FMT, datefmt=_DATE_FMT)
# stderr handler — feeds systemd journal
sh = logging.StreamHandler(sys.stderr)
sh.setFormatter(fmt)
root.addHandler(sh)
# rotating file handler
_LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
)
fh.setFormatter(fmt)
root.addHandler(fh)
# Silence noisy third-party loggers in production
for name in ("werkzeug", "urllib3"):
logging.getLogger(name).setLevel(logging.WARNING)
+21 -7
View File
@@ -6,12 +6,15 @@ bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import json
import logging
import os
import subprocess
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
DATA_DIR = PROJECT_DIR / "data" / "nginx"
@@ -141,6 +144,13 @@ def add_domain(
entry["headers"] = extra_headers
cfg["domains"][domain] = entry
save_config(cfg)
logger.info(
"Proxy domain '%s' added -> %s:%d (%s)",
domain,
backend_host,
backend_port,
backend_proto,
)
def remove_domain(domain) -> None:
@@ -150,6 +160,7 @@ def remove_domain(domain) -> None:
site = SITES_DIR / f"{domain}.conf"
if site.exists():
site.unlink()
logger.info("Proxy domain '%s' removed", domain)
def update_domain(domain, **kwargs) -> None:
@@ -163,6 +174,7 @@ def update_domain(domain, **kwargs) -> None:
else:
entry[key] = val
save_config(cfg)
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
# ------------------------------------------------------------------
@@ -240,6 +252,8 @@ def write_all_sites() -> None:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
logger.info("All nginx site configs written (%d sites)", len(written))
def write_include_file() -> None:
tmpl = ENV.get_template("nginx/include.conf")
@@ -282,6 +296,10 @@ def test_config() -> tuple[bool, str]:
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
output = "nginx configuration test passed"
if ok:
logger.info("nginx config test passed")
else:
logger.error("nginx config test failed: %s", output)
return ok, output
@@ -293,6 +311,7 @@ def apply() -> None:
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
logger.info("nginx configuration applied and reloaded")
# ------------------------------------------------------------------
@@ -321,6 +340,7 @@ def set_management_proxy(
save_config(cfg)
if auth_user and auth_pass:
write_htpasswd(auth_user, auth_pass)
logger.info("Management proxy set to '%s'", domain)
# ------------------------------------------------------------------
@@ -329,13 +349,7 @@ def set_management_proxy(
def write_htpasswd(user, password) -> None:
"""
Append (or create) an htpasswd entry for *user*.
Uses passlib's apache_passwd hash so the file remains portable.
If passlib is unavailable falls back to Python's built-in crypt.
If the user already exists the line is replaced in-place.
"""
"""Append (or create) an htpasswd entry for *user*."""
_ensure_dirs()
hashed = _hash_password(password)
existing = {}
+26 -95
View File
@@ -6,6 +6,7 @@ the WireGuard tunnel interface.
"""
import json
import logging
import os
import subprocess
from datetime import UTC, datetime
@@ -13,6 +14,8 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json")
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
@@ -65,15 +68,10 @@ def _default_config() -> dict:
def get_config() -> dict:
"""Load the current WireGuard configuration from the JSON store.
Returns the full config dict. If the file does not exist or is
unreadable, returns the default (empty) config skeleton.
"""
"""Load the current WireGuard configuration from the JSON store."""
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
# Backfill keys that might be missing from older snapshots.
defaults = _default_config()
cfg.setdefault("interface", defaults["interface"])
cfg["interface"].setdefault("name", defaults["interface"]["name"])
@@ -90,11 +88,7 @@ def get_config() -> dict:
def save_config(cfg: dict) -> None:
"""Persist *cfg* to the JSON store atomically.
Writes to a temporary file in the same directory and then renames
to avoid partial reads on crash.
"""
"""Persist *cfg* to the JSON store atomically."""
_ensure_dir(CONFIG_PATH)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
@@ -107,11 +101,7 @@ def save_config(cfg: dict) -> None:
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
Returns:
``(private_key, public_key)`` as two 43-character base64 strings.
"""
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key)
@@ -139,7 +129,7 @@ def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick."""
cfg = get_config()
conf_text = generate_conf(cfg)
save_config(cfg) # ensure latest state persisted
save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
@@ -152,6 +142,7 @@ def apply() -> None:
local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
def down() -> None:
@@ -159,19 +150,14 @@ def down() -> None:
cfg = get_config()
name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name])
logger.info("WireGuard tunnel '%s' brought down", name)
# --- Status ---
def status() -> dict:
"""Query the live tunnel state via ``wg show``.
Returns a dict with keys:
- ``up`` (bool) - whether the interface is currently up.
- ``interface`` (dict) - name, public key, listen port, fwmark.
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
"""
"""Query the live tunnel state via ``wg show``."""
cfg = get_config()
name = cfg["interface"]["name"]
result = {
@@ -189,17 +175,6 @@ def status() -> dict:
except Exception:
return result
# Parse the wg show output.
# Format (multi-section, separated by blank lines or interleaved):
# interface:
# public key: ...
# listening port: ...
# peer: <key>
# endpoint: ...
# allowed ips: ...
# latest handshake: ...
# transfer: ...
# persistent-keepalive: ...
current_peer = None
peers: list[dict] = []
@@ -287,24 +262,7 @@ def add_peer(
persistent_keepalive: int | None = None,
preshared_key: str | None = None,
) -> dict:
"""Add (or update) a peer in the configuration.
If the peer has no public key yet, one will be generated
together with a matching private key (useful for client provi-
sioning). The returned dict mirrors the stored peer record
with an additional ``private_key`` field so the caller can
distribute the client credentials.
Args:
name: Human-readable identifier (dict key in config).
endpoint: e.g. ``203.0.113.1:51820``.
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
persistent_keepalive: Interval in seconds (or ``None``).
preshared_key: Optional PSK (base64 string).
Returns:
The peer dict as stored, plus ``private_key`` for client use.
"""
"""Add (or update) a peer in the configuration."""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
@@ -316,22 +274,21 @@ def add_peer(
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
logger.info("WireGuard peer '%s' updated", name)
else:
# Generate a key pair for the new peer.
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv, # stored so we can hand it to the client
"private_key": priv,
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key,
}
peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer)
return peer_out
@@ -341,33 +298,23 @@ def remove_peer(name: str) -> None:
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
def get_peers() -> list[dict]:
"""List all configured peers (from the JSON store, *not* live).
Returns a list of dicts. Each dict includes ``name`` and all
stored fields **except** ``private_key`` (not exposed here).
"""
"""List all configured peers (from the JSON store, *not* live)."""
cfg = get_config()
peers = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
# Strip private key from the public listing.
entry.pop("private_key", None)
peers.append(entry)
return peers
def get_peer_status() -> list[dict]:
"""Return live peer status from ``wg show``.
Each element contains:
- ``public_key``, ``endpoint``, ``allowed_ips``,
``latest_handshake``, ``transfer_received``,
``transfer_sent``, ``persistent_keepalive``.
"""
"""Return live peer status from ``wg show``."""
st = status()
return st.get("peers", [])
@@ -401,7 +348,7 @@ def generate_client_conf(
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
return tmpl.render(
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
@@ -412,29 +359,25 @@ def generate_client_conf(
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
)
logger.info("Client config generated for peer '%s'", peer_name)
return conf
# --- Interface-level setters ---
def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration.
Does **not** hot-reload; call :func:`apply` afterwards to
activate the change.
"""
"""Update the server listen port in the stored configuration."""
if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535")
cfg = get_config()
cfg["interface"]["listen_port"] = port
save_config(cfg)
logger.info("WireGuard listen port set to %d", port)
def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command.
The command is passed verbatim to the generated wg0.conf.
"""
"""Set (or clear) the PostUp hook command."""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
@@ -451,25 +394,17 @@ def set_post_down(cmd: str | None) -> None:
def initialize() -> dict:
"""Perform first-time WireGuard setup.
Generates a fresh server key pair, writes the initial config
to disk, and returns the full config dict.
Call this once at appliance bootstrapping time. It will
**not** overwrite an existing config that already has a
non-empty private key.
"""
"""Perform first-time WireGuard setup."""
cfg = get_config()
if cfg["interface"].get("private_key"):
# Already initialised — return existing config.
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg
@@ -477,11 +412,7 @@ def initialize() -> dict:
def _parse_wg_show(output: str) -> dict:
"""Internal parser for ``wg show`` multiline output.
Returns a dict keyed by peer public key with parsed values.
Used internally; ``status()`` is the public interface.
"""
"""Internal parser for ``wg show`` multiline output."""
peers: dict = {}
current = None
+218 -19
View File
@@ -1,3 +1,7 @@
"""
API integration tests — all blueprints tested via a single Flask app fixture.
"""
from unittest.mock import patch
import pytest
@@ -24,6 +28,11 @@ def client():
return app.test_client()
# ============================================================================
# Firewall
# ============================================================================
class TestFirewallListZones:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
@@ -100,7 +109,7 @@ class TestFirewallDeleteZone:
class TestFirewallRichRules:
@patch("webui.api.firewall.add_rich_rule")
def test_add(self, mock_add, client):
mock_add.return_value = None
mock_add.return_value = {"id": "abc123", "rule": "rule accept"}
resp = client.post(
"/api/firewall/rich-rules",
json={
@@ -111,18 +120,35 @@ class TestFirewallRichRules:
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["id"] == "abc123"
def test_missing_fields(self, client):
resp = client.post("/api/firewall/rich-rules", json={})
assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules")
def test_list(self, mock_list, client):
mock_list.return_value = ["rule1", "rule2"]
@patch("webui.api.firewall.config_get")
def test_list(self, mock_cfg, mock_list, client):
mock_list.return_value = ["rule1"]
mock_cfg.return_value = {"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}}
resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"] == ["rule1", "rule2"]
assert isinstance(data["data"], list)
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 404
class TestFirewallServices:
@@ -161,12 +187,14 @@ class TestFirewallMasquerade:
class TestFirewallForwardPort:
@patch("webui.api.firewall.add_forward_port")
def test_add(self, mock_add, client):
mock_add.return_value = None
mock_add.return_value = {"id": "fp1", "port": 443, "proto": "tcp"}
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public", "port": 443, "proto": "tcp"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["id"] == "fp1"
def test_missing_fields(self, client):
resp = client.post(
@@ -175,6 +203,25 @@ class TestFirewallForwardPort:
)
assert resp.status_code == 400
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/forward-port/public/443/tcp")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/forward-port/public/999/tcp")
assert resp.status_code == 404
# ============================================================================
# DHCP
# ============================================================================
class TestDhcpConfig:
@patch("webui.api.dhcp.get_config")
@@ -192,6 +239,67 @@ class TestDhcpConfig:
assert data is not None
class TestDhcpApply:
@patch("webui.api.dhcp.apply_config")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/dhcp/apply")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
class TestDhcpStatus:
@patch("lib.dnsmasq.get_status")
def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["service_active"] is True
class TestDhcpRanges:
@patch("webui.api.dhcp.set_dhcp_range")
def test_add_range(self, mock_set, client):
mock_set.return_value = None
resp = client.post(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
"lease_time": "2h",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_add_range_missing_fields(self, client):
resp = client.post("/api/dhcp/ranges", json={"start": "192.168.1.100"})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dhcp_range")
def test_remove_range(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_remove_range_missing_fields(self, client):
resp = client.delete("/api/dhcp/ranges", json={})
assert resp.status_code == 400
class TestDhcpStaticLease:
@patch("webui.api.dhcp.add_static_lease")
def test_add(self, mock_add, client):
@@ -213,19 +321,15 @@ class TestDhcpStaticLease:
"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")
resp = client.delete("/api/dhcp/static-lease/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")
resp = client.delete("/api/dhcp/static-lease/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
class TestDhcpDnsRecord:
@patch("webui.api.dhcp.add_dns_record")
@@ -241,6 +345,27 @@ class TestDhcpDnsRecord:
resp = client.post("/api/dhcp/dns-record", json={})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dns_record")
@patch("webui.api.dhcp.get_config")
def test_remove(self, mock_get, mock_remove, client):
mock_get.return_value = {
"dns": {"custom_records": [{"name": "host.local", "address": "10.0.0.10"}]}
}
mock_remove.return_value = None
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 200
@patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dns": {"custom_records": []}}
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 404
# ============================================================================
# Proxy
# ============================================================================
class TestProxyDomains:
@patch("webui.api.proxy.get_domains")
@@ -271,6 +396,30 @@ class TestProxyApply:
assert resp.status_code == 200
class TestProxyTest:
@patch("webui.api.proxy.test_config")
def test_valid(self, mock_test, client):
mock_test.return_value = (True, "syntax ok")
resp = client.post("/api/proxy/test")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["valid"] is True
@patch("webui.api.proxy.test_config")
def test_invalid(self, mock_test, client):
mock_test.return_value = (False, "error msg")
resp = client.post("/api/proxy/test")
assert resp.status_code == 400
data = resp.get_json()
assert data["ok"] is False
assert data["error"] == "error msg"
# ============================================================================
# Certs
# ============================================================================
class TestCertsList:
@patch("webui.api.certs.list_certs")
def test_list(self, mock_list, client):
@@ -297,6 +446,11 @@ class TestCertsEmail:
assert resp.status_code == 400
# ============================================================================
# WireGuard
# ============================================================================
class TestWireguardConfig:
@patch("webui.api.wireguard.get_config")
def test_get(self, mock_get, client):
@@ -325,9 +479,9 @@ class TestWireguardPeers:
@patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client):
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
mock_add.return_value = {"name": "client1", "public_key": "pub", "private_key": "priv"}
resp = client.post(
"/api/wireguard/add-peer",
"/api/wireguard/peers",
json={"name": "client1"},
)
data = resp.get_json()
@@ -335,21 +489,31 @@ class TestWireguardPeers:
assert "private_key" not in data["data"]
def test_add_missing_name(self, client):
resp = client.post("/api/wireguard/add-peer", json={})
resp = client.post("/api/wireguard/peers", json={})
assert resp.status_code == 400
@patch("webui.api.wireguard.remove_peer")
@patch("webui.api.wireguard.get_config")
def test_remove_by_name(self, mock_get, mock_remove, client):
mock_get.return_value = {"peers": {"client1": {}}}
mock_remove.return_value = None
resp = client.delete("/api/wireguard/peers/client1")
assert resp.status_code == 200
@patch("webui.api.wireguard.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"peers": {}}
resp = client.delete("/api/wireguard/peers/unknown")
assert resp.status_code == 404
class TestWireguardInitialize:
@patch("webui.api.wireguard.initialize")
def test_initialize(self, mock_init, client):
mock_init.return_value = {
"interface": {"name": "wg0", "private_key": "priv"},
"peers": {},
}
mock_init.return_value = None
resp = client.post("/api/wireguard/initialize")
data = resp.get_json()
assert data["ok"] is True
assert data["data"] is None
class TestWireguardGenerateClient:
@@ -366,6 +530,41 @@ class TestWireguardStatus:
assert resp.status_code == 200
class TestWireguardUp:
@patch("webui.api.wireguard.apply")
def test_up_starts_tunnel(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/up")
assert resp.status_code == 200
@patch("webui.api.wireguard.apply")
def test_up_error(self, mock_apply, client):
mock_apply.side_effect = RuntimeError("interface down")
resp = client.post("/api/wireguard/up")
assert resp.status_code == 500
class TestWireguardDown:
@patch("webui.api.wireguard.down")
def test_down_stops_tunnel(self, mock_down, client):
mock_down.return_value = None
resp = client.post("/api/wireguard/down")
assert resp.status_code == 200
class TestWireguardApply:
@patch("webui.api.wireguard.apply")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/apply")
assert resp.status_code == 200
# ============================================================================
# Helpers
# ============================================================================
class TestResponseHelpers:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
+22
View File
@@ -4,6 +4,8 @@ webui/api/certs.py - ACME certificate management API blueprint.
Exposed at /api/certs/* and delegates to lib.acme.
"""
import logging
from flask import Blueprint, jsonify, request
from lib.acme import (
@@ -15,6 +17,7 @@ from lib.acme import (
set_email,
)
logger = logging.getLogger(__name__)
bp = Blueprint("certs", __name__)
@@ -41,6 +44,7 @@ def list_certs_bp():
try:
return _ok(list_certs())
except RuntimeError as exc:
logger.error("Failed to list certificates: %s", exc)
return _error(str(exc), 500)
@@ -52,6 +56,7 @@ def cert_details(domain):
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get cert info for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -68,11 +73,17 @@ def issue_bp():
return _error("'domain' is required", 400)
webroot = body.get("webroot")
try:
logger.info("Certificate issuance requested for '%s' via API", domain)
result = issue(domain, webroot=webroot)
if result.get("success"):
logger.info("Certificate issued for '%s'", domain)
return _ok(None)
logger.error(
"Certificate issuance failed for '%s': %s", domain, result.get("error")
)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
logger.error("Exception issuing cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -84,11 +95,17 @@ def issue_bp():
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain):
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
result = renew(domain)
if result.get("success"):
logger.info("Certificate renewed for '%s'", domain)
return _ok(None)
logger.error(
"Certificate renewal failed for '%s': %s", domain, result.get("error")
)
return _error(result.get("error", "Unknown error"), 500)
except RuntimeError as exc:
logger.error("Exception renewing cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -104,11 +121,14 @@ def remove_bp(domain):
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to verify cert '%s': %s", domain, exc)
return _error(str(exc), 500)
try:
remove(domain)
logger.info("Certificate removed for '%s' via API", domain)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to remove cert '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -125,6 +145,8 @@ def set_email_bp():
return _error("'email' is required", 400)
try:
set_email(email)
logger.info("ACME email set via API: %s", email)
return _ok({"email": email})
except RuntimeError as exc:
logger.error("Failed to set ACME email: %s", exc)
return _error(str(exc), 500)
+85 -11
View File
@@ -4,6 +4,8 @@ webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint.
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
"""
import logging
from flask import Blueprint, jsonify, request
from lib.dnsmasq import (
@@ -12,11 +14,14 @@ from lib.dnsmasq import (
apply_config,
get_config,
get_lease_table,
remove_dhcp_range,
remove_dns_record,
remove_static_lease,
save_config,
set_dhcp_range,
)
logger = logging.getLogger(__name__)
bp = Blueprint("dhcp", __name__)
@@ -53,6 +58,7 @@ def get_config_bp():
try:
return _ok(get_config())
except RuntimeError as exc:
logger.error("Failed to read DHCP config: %s", exc)
return _error(str(exc), 500)
@@ -65,6 +71,7 @@ def post_config():
save_config(body)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to save DHCP config: %s", exc)
return _error(str(exc), 500)
@@ -79,6 +86,7 @@ def patch_config():
save_config(merged)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to patch DHCP config: %s", exc)
return _error(str(exc), 500)
@@ -86,13 +94,76 @@ def patch_config():
def apply_bp():
try:
apply_config()
logger.info("dnsmasq config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply dnsmasq config: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Leases
# Status
# ---------------------------------------------------------------------------
@bp.route("/status", methods=["GET"])
def status_bp():
try:
from lib.dnsmasq import get_status as dnsmasq_status
return _ok(dnsmasq_status())
except RuntimeError as exc:
logger.error("Failed to get DHCP status: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DHCP ranges
# ---------------------------------------------------------------------------
@bp.route("/ranges", methods=["POST"])
def add_range_bp():
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or None
start = body.get("start", "").strip()
end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h")
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
set_dhcp_range(
iface if iface else "",
start,
end,
lease_time=lease_time,
)
logger.info("DHCP range added via API: %s-%s", start, end)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to add DHCP range: %s", exc)
return _error(str(exc), 500)
@bp.route("/ranges", methods=["DELETE"])
def remove_range_bp():
body = request.get_json(silent=True) or {}
iface = body.get("interface", "").strip() or ""
start = body.get("start", "").strip()
end = body.get("end", "").strip()
if not start or not end:
return _error("'start' and 'end' are required", 400)
try:
remove_dhcp_range(iface, start, end)
logger.info("DHCP range removed via API: %s-%s", start, end)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to remove DHCP range: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Static leases
# ---------------------------------------------------------------------------
@@ -101,6 +172,7 @@ def leases_bp():
try:
return _ok(get_lease_table())
except RuntimeError as exc:
logger.error("Failed to read lease table: %s", exc)
return _error(str(exc), 500)
@@ -119,16 +191,15 @@ def add_static_lease_bp():
return _error("'mac' and 'ip' are required", 400)
try:
add_static_lease(mac, ip, hostname)
logger.info("Static lease added via API: %s -> %s", mac, ip)
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
except RuntimeError as exc:
logger.error("Failed to add static lease: %s", exc)
return _error(str(exc), 500)
@bp.route("/static-lease", methods=["DELETE"])
def remove_static_lease_bp():
mac = request.args.get("mac", "").strip()
if not mac:
return _error("Query parameter 'mac' is required", 400)
@bp.route("/static-lease/<mac>", methods=["DELETE"])
def remove_static_lease_bp(mac):
current = get_config()
found = any(
lease["mac"].lower() == mac.lower()
@@ -138,8 +209,10 @@ def remove_static_lease_bp():
return _error(f"No static lease found for MAC '{mac}'", 404)
try:
remove_static_lease(mac)
logger.info("Static lease removed via API: %s", mac)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to remove static lease: %s", exc)
return _error(str(exc), 500)
@@ -158,16 +231,15 @@ def add_dns_record_bp():
return _error("'name' and 'address' are required", 400)
try:
add_dns_record(name, address, hostname)
logger.info("DNS record added via API: %s -> %s", name, address)
return _ok({"name": name, "address": address, "hostname": hostname})
except RuntimeError as exc:
logger.error("Failed to add DNS record: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record", methods=["DELETE"])
def remove_dns_record_bp():
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
current = get_config()
found = any(
r["name"] == name for r in current.get("dns", {}).get("custom_records", [])
@@ -176,6 +248,8 @@ def remove_dns_record_bp():
return _error(f"No DNS record found for '{name}'", 404)
try:
remove_dns_record(name)
logger.info("DNS record removed via API: %s", name)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to remove DNS record: %s", exc)
return _error(str(exc), 500)
+77 -52
View File
@@ -4,6 +4,8 @@ webui/api/firewall.py - Firewall (firewalld) management API blueprint.
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
"""
import logging
from flask import Blueprint, jsonify, request
from lib.firewall import (
@@ -20,13 +22,14 @@ from lib.firewall import (
get_rich_rules,
get_services,
get_zone_info,
remove_forward_port,
remove_rich_rule,
remove_forward_port_by_id,
remove_rich_rule_by_id,
set_masquerade,
set_zone_interfaces,
set_zone_services,
)
logger = logging.getLogger(__name__)
bp = Blueprint("firewall", __name__)
@@ -53,6 +56,7 @@ def config_get_bp():
try:
return _ok(config_get())
except Exception as exc:
logger.error("Failed to read firewall config: %s", exc)
return _error(str(exc), 500)
@@ -66,6 +70,7 @@ def config_set_bp():
try:
config_set(body)
pending_info = config_pending()
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
return _ok(
{
"config_saved": True,
@@ -75,6 +80,7 @@ def config_set_bp():
}
)
except Exception as exc:
logger.error("Failed to save firewall config: %s", exc)
return _error(str(exc), 500)
@@ -84,8 +90,10 @@ def config_apply_bp():
from lib.firewall import config_apply as _config_apply
result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
return _ok(result)
except Exception as exc:
logger.error("Failed to apply firewall config: %s", exc)
return _error(str(exc), 500)
@@ -94,6 +102,7 @@ def config_pending_bp():
try:
return _ok(config_pending())
except Exception as exc:
logger.error("Failed to check pending config: %s", exc)
return _error(str(exc), 500)
@@ -107,16 +116,9 @@ def list_zones():
try:
active = get_active_zones()
available = get_available_zones()
return jsonify(
{
"ok": True,
"data": {
"active": active,
"available": available,
},
}
)
return _ok({"active": active, "available": available})
except RuntimeError as exc:
logger.error("Failed to list zones: %s", exc)
return _error(str(exc), 500)
@@ -126,8 +128,9 @@ def zone_details(name):
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})
return _ok(info)
except RuntimeError as exc:
logger.error("Failed to get zone '%s' info: %s", name, exc)
return _error(str(exc), 500)
@@ -142,8 +145,10 @@ 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)
logger.info("Zone '%s' created via API", zone_name)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to create zone '%s': %s", zone_name, exc)
return _error(str(exc), 500)
@@ -154,8 +159,10 @@ def delete_zone_bp(name):
if name not in available:
return _error(f"Zone '{name}' does not exist", 404)
delete_zone(name)
logger.info("Zone '%s' deleted via API", name)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to delete zone '%s': %s", name, exc)
return _error(str(exc), 500)
@@ -172,8 +179,10 @@ def set_zone_interfaces_bp(name):
return _error("'interfaces' must be a list", 400)
try:
set_zone_interfaces(name, interfaces)
logger.info("Zone '%s' interfaces updated: %s", name, interfaces)
return _ok({"zone": name, "interfaces": interfaces})
except RuntimeError as exc:
logger.error("Failed to set interfaces for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@@ -192,6 +201,7 @@ def set_zone_services_bp(name):
set_zone_services(name, services)
return _ok({"zone": name, "services": services})
except RuntimeError as exc:
logger.error("Failed to set services for zone '%s': %s", name, exc)
return _error(str(exc), 500)
@@ -205,6 +215,7 @@ def list_services():
try:
return _ok(get_services())
except RuntimeError as exc:
logger.error("Failed to list services: %s", exc)
return _error(str(exc), 500)
@@ -213,6 +224,7 @@ def list_interfaces():
try:
return _ok(get_interfaces())
except RuntimeError as exc:
logger.error("Failed to list interfaces: %s", exc)
return _error(str(exc), 500)
@@ -229,23 +241,11 @@ def add_rich_rule_bp():
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
add_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule})
except RuntimeError as exc:
return _error(str(exc), 500)
@bp.route("/rich-rules", methods=["DELETE"])
def remove_rich_rule_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
rule = body.get("rule", "").strip()
if not zone or not rule:
return _error("Both 'zone' and 'rule' are required", 400)
try:
remove_rich_rule(zone, rule)
return _ok({"zone": zone, "rule": rule})
entry = add_rich_rule(zone, rule)
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return _ok({"zone": zone, "id": entry["id"], "rule": rule})
except RuntimeError as exc:
logger.error("Failed to add rich rule to zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@@ -253,8 +253,35 @@ def remove_rich_rule_bp():
def list_rich_rules(zone):
try:
rules = get_rich_rules(zone)
return _ok(rules)
from lib.firewall import config_get as firewall_config_get
cfg = firewall_config_get()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result = []
for rule_str in rules:
matched = next(
(e for e in cfg_entries if e.get("rule") == rule_str), None
)
if matched:
result.append({"id": matched["id"], "rule": rule_str})
else:
result.append({"rule": rule_str})
return _ok(result)
except RuntimeError as exc:
logger.error("Failed to get rich rules for zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@bp.route("/rich-rules/<zone>/<rule_id>", methods=["DELETE"])
def remove_rich_rule_bp(zone, rule_id):
try:
remove_rich_rule_by_id(zone, rule_id)
logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone)
return _ok({"zone": zone, "id": rule_id})
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@@ -272,8 +299,14 @@ def set_masquerade_bp():
return _error("'zone' and 'enable' (bool) are required", 400)
try:
set_masquerade(zone, bool(enable))
logger.info(
"Masquerade %s on zone '%s' via API",
"enabled" if enable else "disabled",
zone,
)
return _ok({"zone": zone, "masquerade": bool(enable)})
except RuntimeError as exc:
logger.error("Failed to set masquerade on zone '%s': %s", zone, exc)
return _error(str(exc), 500)
@@ -293,38 +326,30 @@ def add_forward_port_bp():
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
try:
add_forward_port(
entry = add_forward_port(
zone,
int(port),
proto,
toaddr=str(toaddr) if toaddr else None,
toport=int(toport) if toport else None,
)
return _ok({"zone": zone, "port": int(port), "proto": proto})
return _ok(
{"zone": zone, "id": entry["id"], "port": int(port), "proto": proto}
)
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
logger.error("Failed to add forward port: %s", exc)
return _error(str(exc), code)
@bp.route("/forward-port", methods=["DELETE"])
def remove_forward_port_bp():
body = request.get_json(silent=True) or {}
zone = body.get("zone", "").strip()
port = body.get("port")
proto = body.get("proto", "").strip()
toaddr = body.get("toaddr")
toport = body.get("toport")
if not zone or port is None or not proto:
return _error("'zone', 'port', and 'proto' are required", 400)
@bp.route("/forward-port/<zone>/<int:port>/<proto>", methods=["DELETE"])
def remove_forward_port_bp(zone, port, proto):
try:
remove_forward_port(
zone,
int(port),
proto,
toaddr=str(toaddr) if toaddr else None,
toport=int(toport) if toport else None,
)
return _ok({"zone": zone, "port": int(port), "proto": proto})
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
return _error(str(exc), code)
remove_forward_port_by_id(zone, port, proto)
logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone)
return _ok({"zone": zone, "port": port, "proto": proto})
except ValueError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to remove forward port from zone '%s': %s", zone, exc)
return _error(str(exc), 500)
+99
View File
@@ -0,0 +1,99 @@
"""
webui/api/logs.py - Log viewing API blueprint.
Serves log content to the /logs page via HTMX endpoints:
/api/logs/journal systemd journal for vacuum-wall
/api/logs/nginx/access nginx access log tail
/api/logs/nginx/error nginx error log tail
/api/logs/dnsmasq systemd journal for dnsmasq
/api/logs/app Vacuum Wall application log file
"""
import logging
import subprocess
from pathlib import Path
from flask import Blueprint, render_template_string
logger = logging.getLogger(__name__)
bp = Blueprint("logs", __name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
_MAX_LINES = 200
def _tail_file(path: str, n: int = _MAX_LINES) -> str:
"""Return the last *n* lines of a file."""
try:
with open(path) as f:
lines = f.readlines()
return "".join(lines[-n:])
except FileNotFoundError:
return "(log file not found)\n"
except PermissionError:
return "(permission denied)\n"
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
"""Run ``sudo journalctl -u <unit> --no-pager -n <n>`` and return output."""
try:
result = subprocess.run(
["sudo", "journalctl", "-u", unit, "--no-pager", "-n", str(n)],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout.strip()
return output if output else f"(no journal entries for {unit})\n"
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
return f"(error reading journal: {exc})\n"
_LOG_LINE_TEMPLATE = """\
{% for line in lines %}
<div class="log-line{% if 'ERROR' in line %} log-error{% elif 'WARN' in line %} log-warn{% endif %}">{{ line | e }}</div>
{% endfor %}"""
def _render_log_lines(text: str) -> str:
"""Render raw log text into HTML fragment with line-by-line coloring."""
lines = text.rstrip("\n").split("\n") if text.strip() else []
return render_template_string(_LOG_LINE_TEMPLATE, lines=lines)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@bp.route("/journal")
def journal():
text = _sudo_journalctl("vacuum-wall")
return _render_log_lines(text)
@bp.route("/nginx/access")
def nginx_access():
text = _tail_file("/var/log/nginx/access.log")
return _render_log_lines(text)
@bp.route("/nginx/error")
def nginx_error():
text = _tail_file("/var/log/nginx/error.log")
return _render_log_lines(text)
@bp.route("/dnsmasq")
def dnsmasq():
text = _sudo_journalctl("dnsmasq")
return _render_log_lines(text)
@bp.route("/app")
def app_log():
text = _tail_file(str(APP_LOG_FILE))
return _render_log_lines(text)
+17 -1
View File
@@ -4,6 +4,8 @@ webui/api/proxy.py - Nginx proxy domain management API blueprint.
Exposed at /api/proxy/* and delegates to lib.nginx.
"""
import logging
from flask import Blueprint, jsonify, request
from lib.nginx import (
@@ -17,6 +19,7 @@ from lib.nginx import (
update_domain,
)
logger = logging.getLogger(__name__)
bp = Blueprint("proxy", __name__)
@@ -43,6 +46,7 @@ def list_domains():
try:
return _ok(get_domains())
except RuntimeError as exc:
logger.error("Failed to list proxy domains: %s", exc)
return _error(str(exc), 500)
@@ -65,8 +69,10 @@ def add_domain_bp():
add_domain(
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
)
logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain})
except (ValueError, RuntimeError) as exc:
logger.error("Failed to add proxy domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -79,6 +85,7 @@ def domain_details(domain):
return _error(f"Domain '{domain}' not found", 404)
return _ok({"domain": domain, **entry})
except RuntimeError as exc:
logger.error("Failed to get domain details: %s", exc)
return _error(str(exc), 500)
@@ -89,10 +96,12 @@ def update_domain_bp(domain):
return _error("Request body must be a JSON object with fields to update", 400)
try:
update_domain(domain, **body)
logger.info("Proxy domain '%s' updated via API", domain)
return _ok({"domain": domain})
except KeyError as exc:
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -103,8 +112,10 @@ def remove_domain_bp(domain):
if domain not in cfg.get("domains", {}):
return _error(f"Domain '{domain}' not found", 404)
remove_domain(domain)
logger.info("Proxy domain removed via API: %s", domain)
return _ok({"domain": domain})
except RuntimeError as exc:
logger.error("Failed to remove domain '%s': %s", domain, exc)
return _error(str(exc), 500)
@@ -117,8 +128,10 @@ def remove_domain_bp(domain):
def apply_bp():
try:
apply()
logger.info("nginx config applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply nginx config: %s", exc)
return _error(str(exc), 500)
@@ -128,8 +141,9 @@ def test_bp():
valid, output = test_config()
if valid:
return _ok({"valid": True, "output": output})
return jsonify({"ok": False, "error": output, "valid": False}), 400
return _error(output, 400)
except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500)
@@ -150,7 +164,9 @@ def management_bp():
auth_pass = body.get("auth_pass")
try:
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
logger.info("Management proxy configured via API: %s", domain)
return _ok(None)
except (ValueError, RuntimeError) as exc:
code = 400 if isinstance(exc, ValueError) else 500
logger.error("Failed to set management proxy: %s", exc)
return _error(str(exc), code)
+34 -7
View File
@@ -4,6 +4,8 @@ webui/api/wireguard.py - WireGuard tunnel management API blueprint.
Exposed at /api/wireguard/* and delegates to lib.wireguard.
"""
import logging
from flask import Blueprint, jsonify, request
from lib.wireguard import (
@@ -20,6 +22,7 @@ from lib.wireguard import (
status,
)
logger = logging.getLogger(__name__)
bp = Blueprint("wireguard", __name__)
@@ -51,6 +54,7 @@ def get_config_bp():
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
logger.error("Failed to read WireGuard config: %s", exc)
return _error(str(exc), 500)
@@ -67,6 +71,7 @@ def post_config():
safe["interface"].pop("private_key", None)
return _ok(safe)
except RuntimeError as exc:
logger.error("Failed to save WireGuard config: %s", exc)
return _error(str(exc), 500)
@@ -79,8 +84,21 @@ def post_config():
def apply_bp():
try:
apply()
logger.info("WireGuard tunnel applied via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to apply WireGuard config: %s", exc)
return _error(str(exc), 500)
@bp.route("/up", methods=["POST"])
def up_bp():
try:
apply()
logger.info("WireGuard tunnel started via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to start WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@@ -88,8 +106,10 @@ def apply_bp():
def down_bp():
try:
down()
logger.info("WireGuard tunnel brought down via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down WireGuard tunnel: %s", exc)
return _error(str(exc), 500)
@@ -103,6 +123,7 @@ def status_bp():
try:
return _ok(status())
except RuntimeError as exc:
logger.error("Failed to get WireGuard status: %s", exc)
return _error(str(exc), 500)
@@ -115,8 +136,10 @@ def status_bp():
def initialize_bp():
try:
initialize()
logger.info("WireGuard initialized via API")
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to initialize WireGuard: %s", exc)
return _error(str(exc), 500)
@@ -125,7 +148,7 @@ def initialize_bp():
# ---------------------------------------------------------------------------
@bp.route("/add-peer", methods=["POST"])
@bp.route("/peers", methods=["POST"])
def add_peer_bp():
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
@@ -141,23 +164,24 @@ def add_peer_bp():
)
safe = dict(peer)
safe.pop("private_key", None)
logger.info("WireGuard peer '%s' added via API", name)
return _ok(safe)
except RuntimeError as exc:
logger.error("Failed to add peer '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/remove-peer", methods=["DELETE"])
def remove_peer_bp():
name = request.args.get("name", "").strip()
if not name:
return _error("Query parameter 'name' is required", 400)
@bp.route("/peers/<name>", methods=["DELETE"])
def remove_peer_bp(name):
try:
cfg = get_config()
if name not in cfg.get("peers", {}):
return _error(f"Peer '{name}' not found", 404)
remove_peer(name)
logger.info("WireGuard peer '%s' removed via API", name)
return _ok({"name": name})
except RuntimeError as exc:
logger.error("Failed to remove peer '%s': %s", name, exc)
return _error(str(exc), 500)
@@ -166,6 +190,7 @@ def peers_bp():
try:
return _ok(get_peers())
except RuntimeError as exc:
logger.error("Failed to list WireGuard peers: %s", exc)
return _error(str(exc), 500)
@@ -174,6 +199,7 @@ def peer_status_bp():
try:
return _ok(get_peer_status())
except RuntimeError as exc:
logger.error("Failed to get WireGuard peer status: %s", exc)
return _error(str(exc), 500)
@@ -196,12 +222,13 @@ def generate_client_bp():
server_pubkey = cfg["interface"].get("public_key", "")
if not server_endpoint:
_ = cfg["interface"].get("listen_port", 51820)
# Can't auto-derive public IP; ask user to provide it
return _error(
"Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400
)
conf_text = generate_client_conf(name, server_endpoint, server_pubkey)
logger.info("Client config generated for peer '%s' via API", name)
return _ok({"config": conf_text})
except (KeyError, ValueError, RuntimeError) as exc:
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), code)
+70 -4
View File
@@ -7,9 +7,12 @@ and enforces basic authentication before proxying to this port.
import logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path
from flask import Flask, render_template
from flask import Flask, render_template, request
from lib.acme import get_email, list_certs
from lib.dnsmasq import get_config as dnsmasq_config
@@ -22,6 +25,7 @@ from lib.firewall import (
get_interfaces,
get_zone_info,
)
from lib.logging import setup_logging
from lib.nginx import get_config as nginx_config
from lib.nginx import get_domains
from lib.wireguard import get_config as wg_config
@@ -29,9 +33,28 @@ from lib.wireguard import status as wg_status
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
# Logging — must be first so subsequent modules inherit the config
# ---------------------------------------------------------------------------
PROJECT_DIR = Path(__file__).resolve().parent.parent
setup_logging()
logger = logging.getLogger(__name__)
logger.info(
"Python %s.%s.%s",
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
)
logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid())
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
@@ -44,6 +67,44 @@ app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
BLUEPRINTS = [
("firewall", firewall_bp),
("dhcp", dhcp_bp),
("proxy", proxy_bp),
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
]
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
@app.before_request
def _log_request_start():
request._start_time = time.monotonic()
@app.after_request
def _log_request_finish(response):
elapsed_ms = (
time.monotonic() - getattr(request, "_start_time", time.monotonic())
) * 1000
logger.info(
"%s %s -> %d (%.1f ms)",
request.method,
request.path,
response.status_code,
elapsed_ms,
)
return response
# ---------------------------------------------------------------------------
@@ -117,8 +178,6 @@ def json_pretty_filter(value):
# Page routes
# ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
def _safely(fn, default=None):
"""Call *fn* and return *default* on any exception."""
@@ -204,7 +263,13 @@ def zones_page():
@app.route("/rules")
def rules_page():
zones = list(_safely(get_active_zones, {}).keys())
return render_template("rules.html", zones=zones)
raw = _safely(config_get, {})
rules = {}
for zname, zcfg in raw.get("zones", {}).items():
rr = zcfg.get("rich_rules", [])
if rr:
rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None)
@app.route("/nat")
@@ -252,4 +317,5 @@ def logs_page():
if __name__ == "__main__":
logger.info("Starting Flask on 127.0.0.1:9090")
app.run(host="127.0.0.1", port=9090)
+289 -113
View File
@@ -1,137 +1,86 @@
// Toast notification system
function showToast(message, type = "info") {
const container = document.querySelector(".toast") || createToastContainer();
const toast = document.createElement("div");
toast.className = `toast-message toast-${type}`;
// Toast notifications
const showToast = (message, type, duration = 4000) => {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateX(40px)";
toast.style.transition = "all 0.3s ease";
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
}, duration);
};
function createToastContainer() {
const el = document.createElement("div");
el.className = "toast";
document.body.appendChild(el);
return el;
}
const showSuccessToast = (msg) => showToast(msg, 'success');
const showErrorToast = (msg) => showToast(msg, 'error');
// Modal helpers
function openModal(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.add("show");
}
const openModal = (id) => {
const el = document.getElementById(id);
if (el) el.classList.add('active');
};
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.remove("show");
}
const closeModal = (id) => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
};
// Confirm dialog
function confirmAction(message, onConfirm) {
const existing = document.getElementById("confirm-modal");
if (existing) existing.remove();
// Tab switching
const switchTab = (tabName) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tabName).classList.add('active');
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (clickedTab) clickedTab.classList.add('active');
};
const modal = document.createElement("div");
modal.id = "confirm-modal";
modal.className = "modal";
modal.innerHTML = `
<div class="modal-content">
<p class="mb-2">${message}</p>
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
</div>
</div>`;
document.body.appendChild(modal);
openModal("confirm-modal");
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal("confirm-modal");
});
}
function setupConfirmCallback(callback) {
document.getElementById("confirm-ok")?.addEventListener("click", () => {
closeModal("confirm-modal");
callback();
});
}
// Auto-refresh with HTMX
function startAutoRefresh(endpoint, target, interval) {
const el = document.createElement("div");
el.setAttribute("hx-get", endpoint);
el.setAttribute("hx-target", `#${target}`);
el.setAttribute("hx-swap", "innerHTML");
el.setAttribute("hx-trigger", `every ${interval}s`);
el.setAttribute("hx-swap-oob", "true");
document.body.appendChild(el);
}
// Time formatting
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// Bytes formatting
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
// Form helpers
function resetForm(formId) {
const form = document.getElementById(formId);
if (form) form.reset();
}
function fillForm(formId, data) {
const form = document.getElementById(formId);
if (!form) return;
for (const [key, value] of Object.entries(data)) {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = value;
}
}
// Refresh a container from a JSON GET endpoint using a renderer callback
const refreshTable = (url, container, renderer) => {
fetch(url)
.then(r => r.json())
.then(data => {
const json = data.ok ? data.data : data;
container.innerHTML = renderer(json);
htmx.process(container);
})
.catch(() => {});
};
// HTMX event handlers
document.body.addEventListener("htmx:afterSwap", (evt) => {
const toastHeader = evt.detail.xhr?.getResponseHeader("X-Toast");
document.body.addEventListener('htmx:afterSwap', (evt) => {
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
if (toastHeader) {
const parts = toastHeader.split(":");
const msg = parts.slice(1).join(":").trim();
showToast(msg, parts[0]?.trim() || "info");
const parts = toastHeader.split(':');
const msg = parts.slice(1).join(':').trim();
showToast(msg, parts[0]?.trim() || 'info');
}
});
document.body.addEventListener("htmx:responseError", (evt) => {
document.body.addEventListener('htmx:responseError', (evt) => {
const status = evt.detail.xhr?.status || 0;
showToast(`Request failed (${status})`, "error");
const json = evt.detail.xhr?.response;
let msg = 'Request failed (' + status + ')';
try {
const parsed = JSON.parse(json);
if (parsed.error) msg = parsed.error;
} catch (e) {}
showToast(msg, 'error');
});
document.body.addEventListener("htmx:beforeRequest", (evt) => {
const target = evt.target;
const btn = target.closest(".btn");
document.body.addEventListener('htmx:beforeRequest', (evt) => {
const btn = evt.target.closest('.btn');
if (btn) {
btn.dataset.originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Loading...";
btn.textContent = 'Loading...';
}
});
document.body.addEventListener("htmx:afterRequest", (evt) => {
const target = evt.target;
const btn = target.closest(".btn");
document.body.addEventListener('htmx:afterRequest', (evt) => {
const btn = evt.target.closest('.btn');
if (btn && btn.dataset.originalText !== undefined) {
btn.disabled = false;
btn.textContent = btn.dataset.originalText;
@@ -139,9 +88,236 @@ document.body.addEventListener("htmx:afterRequest", (evt) => {
}
});
// Close on escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
// Keyboard: Escape closes all modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
}
});
// -------- Renderer helpers for htmx-driven DOM updates --------
const renderZones = (data) => {
const active = Array.isArray(data) ? data : (data.active || []);
if (!active.length) return '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
return active.map(zone =>
'<div class="card" style="position:relative;">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
).join('');
};
const renderRules = (data) => {
let html = '';
let zoneRules = {};
const cfgZones = data && data.zones ? data.zones : null;
if (cfgZones) {
Object.keys(cfgZones).forEach(zname => {
const rr = cfgZones[zname].rich_rules || [];
if (rr.length) zoneRules[zname] = rr;
});
} else {
zoneRules = data || {};
}
Object.keys(zoneRules).forEach(zone => {
let entries = zoneRules[zone];
if (!Array.isArray(entries)) entries = [];
html += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
if (entries.length) {
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
entries.forEach((entry, i) => {
let ruleId, ruleText;
if (typeof entry === 'object' && entry.rule) {
ruleId = entry.id;
ruleText = entry.rule;
} else {
ruleId = null;
ruleText = String(entry);
}
html += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
}
html += '</div>';
});
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
};
const renderForwards = (forwards) => {
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
return forwards.map(fwd => {
const proto = fwd['proxy-protocol'] || fwd.proto;
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
}).join('');
};
const renderForwardsFromConfig = (data) => {
const zones = data.zones || {};
const forwards = [];
Object.keys(zones).forEach(name => {
zones[name].forward_ports = zones[name].forward_ports || [];
zones[name].forward_ports.forEach(fwd => {
forwards.push({
zone: name,
'proxy-protocol': fwd['proxy-protocol'] || fwd.proto,
port: fwd.port,
'to-addr': fwd['to-addr'] || fwd.toaddr,
'to-port': fwd['to-port'] || fwd.toport
});
});
});
return renderForwards(forwards);
};
const renderRanges = (ranges) => {
if (!ranges.length) return '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
return ranges.map(rng =>
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderStaticLeases = (leases) => {
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
return leases.map(lease =>
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDnsRecords = (records) => {
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
return records.map(rec =>
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDomains = (domains) => {
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
return domains.map(d => {
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
else if (typeof d.days_remaining === 'number') {
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
else certHtml = '<span class="badge badge-success">Valid</span>';
}
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
'<td>' + (d.backend_port || '-') + '</td>' +
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
'<td>' + certHtml + '</td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
}).join('');
};
const renderPeers = (peers) => {
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
return peers.map(peer =>
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
).join('');
};
const renderCerts = (certs) => {
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
return certs.map(cert => {
const days = cert.days_remaining;
let badgeHtml;
if (cert.expired || (days !== undefined && days <= 0)) {
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined ? ' (' + days + 'd ago)' : '') + '</span>';
} else if (days !== undefined && days <= 30) {
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
} else {
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
}
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
'<td>' + badgeHtml + '</td>' +
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
}).join('');
};
const renderInterfaces = (interfaces) => {
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
return interfaces.map(iface => {
const zoneOptions = (iface.zones || []).map(z =>
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
).join('');
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
}).join('');
};
const assignZone = (ifaceName, selectEl) => {
fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ interfaces: [ifaceName] })
})
.then(r => {
if (r.ok) {
showSuccessToast(ifaceName + ' assigned to ' + selectEl.value);
refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces);
}
else return r.json().then(j => { throw new Error(j.error || r.statusText); });
})
.catch(e => { showErrorToast(e.message); });
};
const escHtml = (s) => {
const div = document.createElement('div');
div.appendChild(document.createTextNode(s));
return div.innerHTML;
};
const escAttr = (s) => {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
+1 -52
View File
@@ -592,57 +592,6 @@
<div class="toast-container" id="toast-container"></div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
function showToast(message, type, duration) {
duration = duration || 4000;
var container = document.getElementById('toast-container');
if (!container) return;
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() { toast.classList.add('show'); });
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, duration);
}
function openModal(id) {
var el = document.getElementById(id);
if (el) el.classList.add('active');
}
function closeModal(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('tab-' + tabName).classList.add('active');
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (clickedTab) clickedTab.classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
var msg = el.getAttribute('data-success') || 'Operation successful';
var type = el.getAttribute('data-type') || 'success';
el.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail && evt.detail.successful) {
showToast(msg, type);
}
});
});
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
}
});
</script>
<script src="/static/app.js"></script>
</body>
</html>
+3 -3
View File
@@ -21,7 +21,7 @@
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody>
<tbody id="cert-rows">
{% for cert in (certs or []) %}
<tr>
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
@@ -38,7 +38,7 @@
{% endif %}
</td>
<td>
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);">
<form hx-post="/api/certs/{{ cert.get('domain', '') }}/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Renewal started for {{ cert.domain }}'); }">
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
</form>
</td>
@@ -57,7 +57,7 @@
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
<div class="modal">
<h2>Issue New Certificate</h2>
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);">
<form hx-post="/api/certs/issue" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
<div class="form-group">
<label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
+19 -19
View File
@@ -13,7 +13,7 @@
<div class="section-title">DHCP Ranges</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/dhcp/ranges" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }">
<div class="inline-form">
<div class="form-group">
<label for="range-interface">Interface</label>
@@ -50,7 +50,7 @@
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
<tbody id="range-rows">
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td>{{ rng.get('interface', '(global)') }}</td>
@@ -58,8 +58,8 @@
<td>{{ rng.get('end', '') }}</td>
<td>{{ rng.get('lease_time', '1h') }}</td>
<td>
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
</form>
</td>
</tr>
@@ -78,7 +78,7 @@
<div class="section-title">Static Leases</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/dhcp/static-lease" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }">
<div class="inline-form">
<div class="form-group">
<label for="lease-mac">MAC Address</label>
@@ -105,15 +105,15 @@
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
<tbody id="lease-rows">
{% for lease in ((config or {}).get('static_leases', []) or []) %}
<tr>
<td>{{ lease.get('mac', '') }}</td>
<td>{{ lease.get('ip', '') }}</td>
<td>{{ lease.get('hostname', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
</form>
</td>
</tr>
@@ -132,15 +132,15 @@
<div class="section-title">Custom DNS Records</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/dhcp/dns-record" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }">
<div class="inline-form">
<div class="form-group">
<label for="dns-ip">IP Address</label>
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;">
<input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
</div>
<div class="form-group">
<label for="dns-hostname">Hostname / Domain</label>
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;">
<input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
</div>
<button type="submit" class="btn btn-primary">Add Record</button>
</div>
@@ -149,19 +149,19 @@
<table>
<thead>
<tr>
<th>IP</th>
<th>Hostname</th>
<th>Name</th>
<th>Address</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
<tbody id="dns-rows">
{% for rec in ((config or {}).get('dns_records', []) or []) %}
<tr>
<td>{{ rec.get('ip', '') }}</td>
<td>{{ rec.get('hostname', '') }}</td>
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
<td class="text-sm">{{ rec.get('address', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
</form>
</td>
</tr>
@@ -208,7 +208,7 @@
</tbody>
</table>
<div class="mt-2 text-right">
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply &amp; Restart Dnsmasq</button>
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Dnsmasq configuration reloaded'); }">Apply &amp; Restart Dnsmasq</button>
</div>
</div>
{% endblock %}
+2 -29
View File
@@ -18,10 +18,9 @@
<th>IP Address</th>
<th>State</th>
<th>Zone</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tbody id="interface-list">
{% for iface in (interfaces or []) %}
<tr>
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
@@ -39,12 +38,7 @@
<td>
{% if zones %}
<select
class="htmx-on-success"
data-success="Zone updated for {{ iface.name }}"
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
hx-swap="none"
hx-select-oob="#toast-container *"
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
>
{% for zone in zones %}
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
@@ -54,9 +48,6 @@
<span class="text-muted">No zones configured</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
</td>
</tr>
{% endfor %}
{% if not (interfaces or []) %}
@@ -67,22 +58,4 @@
</tbody>
</table>
</div>
<script>
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
var zoneName = selectEl.value;
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
fetch(url, { method: 'POST' })
.then(function(res) {
if (res.ok) {
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
} else {
return res.text().then(function(txt) { throw new Error(txt); });
}
})
.catch(function(err) {
showToast('Failed to assign: ' + err.message, 'error');
});
}
</script>
{% endblock %}
+61 -28
View File
@@ -13,9 +13,7 @@
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
<span class="slider"></span>
</label>
<span class="htmx-indicator text-sm" style="color:var(--accent);">
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
</span>
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
</div>
</div>
@@ -24,15 +22,16 @@
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
</div>
<div id="tab-journal" class="tab-content active">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-journal"
hx-get="/api/logs/journal"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
hx-indicator=".page-header .htmx-indicator">
Loading journal entries...
</div>
</div>
@@ -42,9 +41,9 @@
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-access"
hx-get="/api/logs/nginx/access"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
hx-indicator=".page-header .htmx-indicator">
Loading Nginx access log...
</div>
</div>
@@ -54,9 +53,9 @@
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-error"
hx-get="/api/logs/nginx/error"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
hx-indicator=".page-header .htmx-indicator">
Loading Nginx error log...
</div>
</div>
@@ -66,41 +65,75 @@
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-dnsmasq"
hx-get="/api/logs/dnsmasq"
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator="#refresh-indicator">
hx-indicator=".page-header .htmx-indicator">
Loading dnsmasq log...
</div>
</div>
</div>
<div id="tab-app" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-app"
hx-get="/api/logs/app"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading app log...
</div>
</div>
</div>
<script>
var autoRefreshTimer = null;
var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal';
function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
}
}
function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
htmx.ajax('GET', activeEl);
}
}
var origSwitchTab = switchTab;
switchTab = function(tabName) {
currentTab = tabName;
if (typeof origSwitchTab === 'function') {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle');
var indicators = document.querySelectorAll('.log-viewer');
if (toggle.checked) {
indicators.forEach(function(el) {
el.setAttribute('hx-trigger', 'every 15s');
hx.trigger(el, 'htmx:refresh');
});
setActivePolling();
loadActiveTab();
} else {
indicators.forEach(function(el) {
el.setAttribute('hx-trigger', 'never');
document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
}
}
document.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
document.getElementById('refresh-indicator').style.display = 'inline';
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
document.getElementById('refresh-indicator').style.display = 'none';
document.addEventListener('DOMContentLoaded', function() {
loadActiveTab();
});
</script>
{% endblock %}
+14 -34
View File
@@ -17,7 +17,6 @@
<tr>
<th>Zone</th>
<th style="width:120px;">Masquerade</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
@@ -25,21 +24,23 @@
<tr>
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
<td>
<form hx-post="/api/firewall/masquerade" hx-encoding="json" hx-vals='{"zone": "{{ zone.get('name', '') }}", "enable": JSON.stringify(this.checked)}' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Masquerade '+(this.checked?'enabled':'disabled')+' for {{ zone.get('name', '') }}') } else { this.checked=!this.checked; }">
<label class="switch">
<input type="checkbox"
{% if zone.get('masquerade') %}checked{% endif %}
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
id="masq-{{ zone.get('name', '') }}"
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
disabled>
<span class="slider"></span>
</label>
</td>
<td>
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
<button type="submit" style="display:none"></button>
</form>
</td>
</tr>
{% endfor %}
{% if not (zones or []) %}
<tr>
<td colspan="3" class="text-muted text-sm">No zones configured</td>
<td colspan="2" class="text-muted text-sm">No zones configured</td>
</tr>
{% endif %}
</tbody>
@@ -50,7 +51,7 @@
<div class="card mb-4">
<h3>Add Forward Rule</h3>
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/firewall/forward-port" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="fw-zone">Zone</label>
@@ -63,7 +64,7 @@
</div>
<div class="form-group">
<label for="fw-protocol">Protocol</label>
<select id="fw-protocol" name="protocol">
<select id="fw-protocol" name="proto">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
@@ -74,11 +75,11 @@
</div>
<div class="form-group">
<label for="fw-target">Target Address</label>
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;">
<input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="fw-target-port">Target Port</label>
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;">
<input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
</div>
<button type="submit" class="btn btn-primary">Add</button>
</div>
@@ -97,7 +98,7 @@
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
<tbody id="forward-rows">
{% set all_forwards = [] %}
{% for zone in (zones or []) %}
{% for fwd in zone.get('forward_ports', []) %}
@@ -112,8 +113,8 @@
<td>{{ fwd['to-addr'] }}</td>
<td>{{ fwd['to-port'] }}</td>
<td>
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
<form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd['proxy-protocol'] }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd['proxy-protocol'] }} → {{ fwd['to-addr'] }}:{{ fwd['to-port'] }}?">Remove</button>
</form>
</td>
</tr>
@@ -127,25 +128,4 @@
</table>
</div>
<script>
function toggleMasquerade(zoneName, enabled) {
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
var body = JSON.stringify({ enable: enabled });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function(res) {
if (res.ok) {
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
} else {
return res.text().then(function(t) { throw new Error(t); });
}
})
.catch(function(err) {
showToast('Failed: ' + err.message, 'error');
});
}
</script>
{% endblock %}
+6 -9
View File
@@ -9,7 +9,7 @@
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</button>
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
</div>
</div>
@@ -25,7 +25,7 @@
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody>
<tbody id="domain-rows">
{% for domain in (domains or []) %}
<tr>
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
@@ -56,8 +56,8 @@
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</td>
@@ -76,7 +76,7 @@
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal">
<h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);">
<form hx-post="/api/proxy/domains" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }">
<div class="form-group">
<label for="new-domain">Domain</label>
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
@@ -108,7 +108,7 @@
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
<div class="modal">
<h2>Edit Proxy Domain</h2>
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);">
<form id="edit-domain-form" hx-post="/api/proxy/domains" hx-swap="none" hx-encoding="json" hx-on::after-request="if(evt.detail.successful){ closeModal('edit-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain updated'); }">
<input type="hidden" id="edit-original-domain" name="original_domain">
<div class="form-group">
<label for="edit-domain">Domain</label>
@@ -144,9 +144,6 @@ function openEditDomainModal(domainName, d) {
document.getElementById('edit-backend-host').value = d.backend_host || '';
document.getElementById('edit-backend-port').value = d.backend_port || '';
document.getElementById('edit-protocol').value = d.protocol || 'http';
var form = document.getElementById('edit-domain-form');
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
form.setAttribute('hx-put', target);
openModal('edit-domain-modal');
}
</script>
+7 -4
View File
@@ -11,7 +11,7 @@
<div class="card mb-4">
<h3>Add Rule</h3>
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/firewall/rich-rules" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="rule-zone">Zone</label>
@@ -34,6 +34,7 @@
</div>
</div>
<div id="rules-container">
{% if rules or False %}
{% for zone_name, zone_rules in rules.items() %}
<div class="card">
@@ -49,12 +50,13 @@
</thead>
<tbody>
{% for rule in zone_rules %}
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td>
<td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
<td>
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</td>
</tr>
@@ -71,6 +73,7 @@
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
</div>
{% endif %}
</div>
{% if not (zones or []) %}
<div class="card" style="border-color:var(--warning);">
+8 -12
View File
@@ -11,17 +11,13 @@
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/down"
hx-swap="none"
class="htmx-on-success"
data-success="Tunnel stopped"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
Stop Tunnel
</button>
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/up"
hx-post="/api/wireguard/apply"
hx-swap="none"
class="htmx-on-success"
data-success="Tunnel started"
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
Start Tunnel
</button>
</div>
@@ -51,7 +47,7 @@
<div class="card mb-4">
<h3>Add Peer</h3>
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<form hx-post="/api/wireguard/peers" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer added'); }">
<div class="inline-form">
<div class="form-group">
<label for="peer-name">Name</label>
@@ -63,7 +59,7 @@
</div>
<div class="form-group">
<label for="peer-allowed">Allowed IPs</label>
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|length + 2) %}{{ next }}/32" required style="width:160px;">
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers or []|length + 2) %}{{ next }}/32" required style="width:160px;">
</div>
<button type="submit" class="btn btn-primary">Add Peer</button>
</div>
@@ -84,7 +80,7 @@
<th style="width:160px;">Actions</th>
</tr>
</thead>
<tbody>
<tbody id="peer-rows">
{% for peer in (peers or []) %}
<tr>
<td>
@@ -102,8 +98,8 @@
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</div>
</td>
+3 -3
View File
@@ -10,7 +10,7 @@
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
</div>
<div class="card-grid">
<div id="zone-grid" class="card-grid">
{% for zone in (zones or []) %}
<div class="card" style="position:relative;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
@@ -45,7 +45,7 @@
</div>
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted">
<form hx-delete="/api/firewall/zones/{{ zone.get('name', '') }}" hx-swap="none" hx-confirm="Delete zone {{ zone.name }}? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone deleted'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
@@ -62,7 +62,7 @@
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
<div class="modal">
<h2>Create Zone</h2>
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();">
<form hx-post="/api/firewall/zones" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }">
<div class="form-group">
<label for="zone-name">Zone Name</label>
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>