From 37039351be050287add5f4eeffd86db3ed4e281e Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Sun, 17 May 2026 01:15:52 +0000 Subject: [PATCH] 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 --- .gitignore | 9 +- docs/api.md | 481 ++++++++++++++++++-------------- lib/dnsmasq.py | 33 ++- lib/firewall.py | 367 ++++++++++++++++-------- lib/logging.py | 81 ++++++ lib/nginx.py | 28 +- lib/wireguard.py | 121 ++------ tests/test_api.py | 239 ++++++++++++++-- webui/api/certs.py | 22 ++ webui/api/dhcp.py | 96 ++++++- webui/api/firewall.py | 129 +++++---- webui/api/logs.py | 99 +++++++ webui/api/proxy.py | 18 +- webui/api/wireguard.py | 41 ++- webui/server.py | 74 ++++- webui/static/app.js | 402 ++++++++++++++++++-------- webui/templates/base.html | 53 +--- webui/templates/certs.html | 6 +- webui/templates/dhcp.html | 40 +-- webui/templates/interfaces.html | 31 +- webui/templates/logs.html | 105 ++++--- webui/templates/nat.html | 58 ++-- webui/templates/proxy.html | 15 +- webui/templates/rules.html | 11 +- webui/templates/wireguard.html | 20 +- webui/templates/zones.html | 6 +- 26 files changed, 1737 insertions(+), 848 deletions(-) create mode 100644 lib/logging.py create mode 100644 webui/api/logs.py diff --git a/.gitignore b/.gitignore index 4ea3644..cf28106 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/api.md b/docs/api.md index 0ac303b..0041c6f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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// ``` -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/ ``` -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/// +``` + +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/ ``` -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/ ``` -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/ 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/ ``` -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": ""}` 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/ 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//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/ ``` -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. +Returns HTTP `404` if the peer is not found. \ No newline at end of file diff --git a/lib/dnsmasq.py b/lib/dnsmasq.py index 0f82692..d8eec27 100644 --- a/lib/dnsmasq.py +++ b/lib/dnsmasq.py @@ -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() diff --git a/lib/firewall.py b/lib/firewall.py index 24f4ac6..7205fc8 100644 --- a/lib/firewall.py +++ b/lib/firewall.py @@ -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.""" - _run(["sudo", "firewall-cmd", "--reload"]) + 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: ..." 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", diff --git a/lib/logging.py b/lib/logging.py new file mode 100644 index 0000000..66786e9 --- /dev/null +++ b/lib/logging.py @@ -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) diff --git a/lib/nginx.py b/lib/nginx.py index 4f46f85..1d79dbb 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -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 = {} diff --git a/lib/wireguard.py b/lib/wireguard.py index 4d2795d..89568e9 100644 --- a/lib/wireguard.py +++ b/lib/wireguard.py @@ -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: - # 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 diff --git a/tests/test_api.py b/tests/test_api.py index 0e83ac5..e67300f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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") @@ -375,4 +574,4 @@ class TestResponseHelpers: data = resp.get_json() assert "error" in data assert "ok" in data - assert data["ok"] is False + assert data["ok"] is False \ No newline at end of file diff --git a/webui/api/certs.py b/webui/api/certs.py index 7b45fcb..fa5e734 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -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("//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) diff --git a/webui/api/dhcp.py b/webui/api/dhcp.py index a3a5e12..12822dd 100644 --- a/webui/api/dhcp.py +++ b/webui/api/dhcp.py @@ -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/", 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/", 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) diff --git a/webui/api/firewall.py b/webui/api/firewall.py index f9de628..42a308e 100644 --- a/webui/api/firewall.py +++ b/webui/api/firewall.py @@ -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//", 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///", 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) diff --git a/webui/api/logs.py b/webui/api/logs.py new file mode 100644 index 0000000..233b33d --- /dev/null +++ b/webui/api/logs.py @@ -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 --no-pager -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 %} +
{{ line | e }}
+{% 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) diff --git a/webui/api/proxy.py b/webui/api/proxy.py index ea41b85..a16ee1a 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -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) diff --git a/webui/api/wireguard.py b/webui/api/wireguard.py index a71758d..741d00c 100644 --- a/webui/api/wireguard.py +++ b/webui/api/wireguard.py @@ -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/", 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) diff --git a/webui/server.py b/webui/server.py index e1d92f9..2aecbd6 100644 --- a/webui/server.py +++ b/webui/server.py @@ -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) diff --git a/webui/static/app.js b/webui/static/app.js index ca9c24c..d46cadf 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -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 = ` - `; - 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 '
No zones configured. Create a zone to get started.
'; + return active.map(zone => + '
' + + '
' + + '

' + escHtml(zone.name) + '

' + + '
' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '
' + + '
Interfaces
' + + (zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '' + escHtml(i) + '').join('') : 'None') + + '
Services
' + + (zone.services && zone.services.length ? zone.services.map(s => '' + escHtml(s) + '').join('') : 'None') + + '
' + + '
' + + '
' + ).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 += '

Zone: ' + escHtml(zone || '(default)') + '

'; + if (entries.length) { + html += ''; + 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 += '' + + '' + + ''; + }); + html += '
#RuleAction
' + (i + 1) + '' + escHtml(ruleText) + '
' + + '
'; + } else { + html += '
No rich rules configured for this zone.
'; + } + html += '
'; + }); + return html || '
No rules loaded.
'; +}; + +const renderForwards = (forwards) => { + if (!forwards.length) return 'No port forwarding rules configured'; + return forwards.map(fwd => { + const proto = fwd['proxy-protocol'] || fwd.proto; + return '' + escHtml(fwd.zone) + '' + + '' + escHtml(proto) + '' + + '' + fwd.port + '' + escHtml(fwd['to-addr'] || fwd.toaddr) + '' + + '' + (fwd['to-port'] || fwd.toport || '-') + '' + + '
' + + '
'; + }).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 'No DHCP ranges configured'; + return ranges.map(rng => + '' + escHtml(rng.interface || '(global)') + '' + + '' + escHtml(rng.start) + '' + escHtml(rng.end) + '' + + '' + escHtml(rng.lease_time || '1h') + '' + + '
' + + '
' + ).join(''); +}; + +const renderStaticLeases = (leases) => { + if (!leases.length) return 'No static leases configured'; + return leases.map(lease => + '' + escHtml(lease.mac) + '' + escHtml(lease.ip) + '' + + '' + escHtml(lease.hostname || '-') + '' + + '
' + + '
' + ).join(''); +}; + +const renderDnsRecords = (records) => { + if (!records.length) return 'No custom DNS records'; + return records.map(rec => + '' + escHtml(rec.name || 'unnamed') + '' + + '' + escHtml(rec.address || '-') + '' + + '
' + + '
' + ).join(''); +}; + +const renderDomains = (domains) => { + if (!domains.length) return 'No proxy domains configured. Add a domain to start terminating SSL.'; + return domains.map(d => { + let certHtml = '' + (d.cert_status || 'No cert') + ''; + if (d.cert_status === 'expired') certHtml = 'Expired'; + else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = 'Valid'; + else if (typeof d.days_remaining === 'number') { + if (d.days_remaining <= 0) certHtml = 'Expired'; + else if (d.days_remaining <= 30) certHtml = '' + d.days_remaining + 'd'; + else certHtml = 'Valid'; + } + return '' + escHtml(d.domain) + '' + + '' + escHtml(d.backend_host || '-') + '' + + '' + (d.backend_port || '-') + '' + + '' + escHtml(d.protocol || 'http') + '' + + '' + certHtml + '' + + '
' + + '' + + '
' + + '
'; + }).join(''); +}; + +const renderPeers = (peers) => { + if (!peers.length) return 'No peers configured. Add a peer above.'; + return peers.map(peer => + '' + + '' + escHtml(peer.name || 'unnamed') + '' + + '' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...' + + '' + escHtml(peer.allowed_ips || '-') + '' + + '' + escHtml(peer.endpoint || '-') + '' + + '' + escHtml(peer.latest_handshake || 'Never') + '' + + '
Recv: ' + escHtml(peer.transfer_recv || '0') + '
Sent: ' + escHtml(peer.transfer_sent || '0') + '
' + + '
' + + '' + + '
' + + '
' + ).join(''); +}; + +const renderCerts = (certs) => { + if (!certs.length) return 'No certificates found. Issue a certificate to get started.'; + return certs.map(cert => { + const days = cert.days_remaining; + let badgeHtml; + if (cert.expired || (days !== undefined && days <= 0)) { + badgeHtml = 'Expired' + (days !== undefined ? ' (' + days + 'd ago)' : '') + ''; + } else if (days !== undefined && days <= 30) { + badgeHtml = '' + days + ' days'; + } else { + badgeHtml = '' + (days !== undefined ? days + ' days' : 'N/A') + ''; + } + return '' + escHtml(cert.domain || 'unknown') + '' + + '' + escHtml(cert.issuer || '-') + '' + + '' + escHtml(cert.expiry || 'N/A') + '' + + '' + badgeHtml + '' + + '
' + + '
'; + }).join(''); +}; + +const renderInterfaces = (interfaces) => { + if (!interfaces.length) return 'No interfaces found'; + return interfaces.map(iface => { + const zoneOptions = (iface.zones || []).map(z => + '' + ).join(''); + return '' + escHtml(iface.name) + '' + + '' + escHtml(iface.mac || 'N/A') + '' + + '' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '' + + '' + + (iface.state === 'up' ? 'Up' : 'Down') + '' + + ''; + }).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,'&').replace(/"/g,'"').replace(/'/g,''').replace(//g,'>'); +}; diff --git a/webui/templates/base.html b/webui/templates/base.html index f560569..c96714c 100644 --- a/webui/templates/base.html +++ b/webui/templates/base.html @@ -592,57 +592,6 @@
- + diff --git a/webui/templates/certs.html b/webui/templates/certs.html index 3b6c2f0..513a9ce 100644 --- a/webui/templates/certs.html +++ b/webui/templates/certs.html @@ -21,7 +21,7 @@ Actions - + {% for cert in (certs or []) %} {{ cert.get('domain', 'unknown') }} @@ -38,7 +38,7 @@ {% endif %} -
+
@@ -57,7 +57,7 @@