Initial commit: SSL proxy / firewall appliance
Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP, WireGuard, and ACME certificate management.
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
# Virtual environment
|
||||
.venv/
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Package build
|
||||
*.egg-info/
|
||||
|
||||
# Tool caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local AI tool config (contains internal hostnames)
|
||||
opencode.json
|
||||
|
||||
# Runtime data configs (source-of-truth for services)
|
||||
data/dnsmasq/config.json
|
||||
data/nginx/sites-enabled/
|
||||
@@ -0,0 +1,83 @@
|
||||
# Vacuum Wall — Agent Instructions
|
||||
|
||||
## What This Is
|
||||
|
||||
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
|
||||
Deploys on Debian 13 (trixie). Target system: `/home/wall/vacuum-wall`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
|
||||
Flask ──→ lib/*.py ──→ sudo <cmd> ──→ system service
|
||||
```
|
||||
|
||||
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
|
||||
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`.
|
||||
- `lib/*.py` — Backend modules. Wrap system commands via `subprocess.run(["sudo", ...])`.
|
||||
- `data/` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`.
|
||||
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
|
||||
|
||||
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
|
||||
|
||||
## Fixed Path
|
||||
|
||||
Every module hardcodes `/home/wall/vacuum-wall`. Changing it requires updating `lib/*.py`, `system/systemd/*.service`, `install.sh`, and `system/sudoers.d/vacuum-wall`.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
|
||||
```
|
||||
|
||||
In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking).
|
||||
|
||||
## Blueprint ↔ lib Mapping (Naming Is Not 1:1)
|
||||
|
||||
| Blueprint | URL prefix | Backend module |
|
||||
|-----------------------|-------------------|------------------|
|
||||
| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` |
|
||||
| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` |
|
||||
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
|
||||
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
||||
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
||||
|
||||
## Privileged Operations
|
||||
|
||||
`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`.
|
||||
|
||||
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
|
||||
Adding a new privileged command requires a sudoers entry **and** the `lib/` code.
|
||||
|
||||
## API Response Contract
|
||||
|
||||
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)`
|
||||
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)`
|
||||
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
|
||||
- Full spec: `docs/api.md`
|
||||
|
||||
## Page Routes vs API
|
||||
|
||||
`server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead.
|
||||
|
||||
## Deploy
|
||||
|
||||
`install.sh` is the single deploy script. Run as root, requires `MGMT_DOMAIN`, `MGMT_PASS`, `ACME_EMAIL` env vars.
|
||||
|
||||
## Lint and Tests
|
||||
|
||||
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
|
||||
|
||||
**Tests:** pytest in `tests/`. Run with `python -m pytest`. Tests mock out subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required.
|
||||
|
||||
```bash
|
||||
.venv/bin/ruff check lib/ webui/ tests/ # lint
|
||||
.venv/bin/ruff format lib/ webui/ tests/ # format
|
||||
.venv/bin/python -m pytest tests/ -v # test (149 tests)
|
||||
```
|
||||
|
||||
Install dev tooling with `pip install -e ".[dev]"`.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, and data directory layout in detail.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"dhcp": {
|
||||
"ranges": [],
|
||||
"static_leases": []
|
||||
},
|
||||
"dns": {
|
||||
"upstreams": [
|
||||
"8.8.8.8",
|
||||
"1.1.1.1"
|
||||
],
|
||||
"domain": null,
|
||||
"custom_records": []
|
||||
}
|
||||
}
|
||||
+952
@@ -0,0 +1,952 @@
|
||||
# REST API Reference
|
||||
|
||||
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://wall.lan/api/...`).
|
||||
|
||||
Every request and response uses `Content-Type: application/json`.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Success Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": <value>
|
||||
}
|
||||
```
|
||||
|
||||
The `data` field contains the payload, which may be an object, array, string, or `null`.
|
||||
|
||||
### Error Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "<human-readable message>"
|
||||
}
|
||||
```
|
||||
|
||||
Error responses carry one of the following HTTP status codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `400` | Bad request — invalid body, missing required field, or malformed value |
|
||||
| `404` | Not found — the requested resource does not exist |
|
||||
| `500` | Internal server error — unexpected failure in the backend |
|
||||
|
||||
---
|
||||
|
||||
## Firewall API
|
||||
|
||||
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
|
||||
|
||||
### Zone Management
|
||||
|
||||
#### List All Zones
|
||||
|
||||
```
|
||||
GET /api/firewall/zones
|
||||
```
|
||||
|
||||
Returns active zone-to-interface mappings and all available zone definitions.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data.active` | `object<name, [interface, ...]>` | Currently assigned interfaces per zone |
|
||||
| `data.available` | `[string, ...]` | All zones known to firewalld |
|
||||
|
||||
---
|
||||
|
||||
#### Get Zone Details
|
||||
|
||||
```
|
||||
GET /api/firewall/zones/<name>
|
||||
```
|
||||
|
||||
Return detailed configuration for a single zone.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) |
|
||||
| `interfaces` | `[string, ...]` | Interfaces assigned to this zone |
|
||||
| `services` | `[string, ...]` | Services allowed through the zone |
|
||||
| `ports` | `[{port: number, proto: string}, ...]` | Explicit port rules |
|
||||
| `masquerade` | `boolean` | Whether masquerade (NAT) is enabled |
|
||||
| `forward_ports` | `[{port: number, proto: string, toaddr: string, toport: number}, ...]` | Port forward rules |
|
||||
| `rich_rules` | `[string, ...]` | Rich rule definitions |
|
||||
|
||||
Returns HTTP `404` if the zone does not exist.
|
||||
|
||||
---
|
||||
|
||||
#### Create Zone
|
||||
|
||||
```
|
||||
POST /api/firewall/zones
|
||||
```
|
||||
|
||||
Create a new firewalld zone.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Zone name |
|
||||
| `target` | `string` | No | Zone target; defaults to `"default"` |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Delete Zone
|
||||
|
||||
```
|
||||
DELETE /api/firewall/zones/<name>
|
||||
```
|
||||
|
||||
Remove a zone from firewalld.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the zone does not exist.
|
||||
|
||||
### Zone Configuration
|
||||
|
||||
#### Set Zone Interfaces
|
||||
|
||||
```
|
||||
POST /api/firewall/zones/<name>/interfaces
|
||||
```
|
||||
|
||||
Replace all interfaces assigned to the zone with the provided list.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `interfaces` | `[string, ...]` | Yes | List of interface names |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Set Zone Services
|
||||
|
||||
```
|
||||
POST /api/firewall/zones/<name>/services
|
||||
```
|
||||
|
||||
Replace all services allowed in the zone with the provided list.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `services` | `[string, ...]` | Yes | List of firewalld service names |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Firewall Rules
|
||||
|
||||
#### Add Rich Rule
|
||||
|
||||
```
|
||||
POST /api/firewall/rich-rules
|
||||
```
|
||||
|
||||
Add a firewalld rich rule to a zone.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone to add the rule to |
|
||||
| `rule` | `string` | Yes | Full rich rule string |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Remove Rich Rule
|
||||
|
||||
```
|
||||
DELETE /api/firewall/rich-rules
|
||||
```
|
||||
|
||||
Remove an existing rich rule from a zone. The `rule` string must match exactly.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone the rule belongs to |
|
||||
| `rule` | `string` | Yes | Exact rich rule string to remove |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### List Rich Rules
|
||||
|
||||
```
|
||||
GET /api/firewall/rich-rules/<zone>
|
||||
```
|
||||
|
||||
Return all rich rules for the specified zone.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[string, ...]` | Rich rule strings |
|
||||
|
||||
### NAT
|
||||
|
||||
#### Enable / Disable Masquerade
|
||||
|
||||
```
|
||||
POST /api/firewall/masquerade
|
||||
```
|
||||
|
||||
Toggle masquerade (source NAT) for a zone.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone to configure |
|
||||
| `enable` | `boolean` | Yes | `true` to enable, `false` to disable |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Add Port Forward
|
||||
|
||||
```
|
||||
POST /api/firewall/forward-port
|
||||
```
|
||||
|
||||
Add a port forwarding rule to a zone.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone to add the rule to |
|
||||
| `port` | `number` | Yes | External port |
|
||||
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
|
||||
| `toaddr` | `string` | No | Internal destination address |
|
||||
| `toport` | `number` | No | Internal destination port |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Remove Port Forward
|
||||
|
||||
```
|
||||
DELETE /api/firewall/forward-port
|
||||
```
|
||||
|
||||
Remove a port forwarding rule. The body must match the original rule exactly.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `zone` | `string` | Yes | Zone the rule belongs to |
|
||||
| `port` | `number` | Yes | External port |
|
||||
| `proto` | `string` | Yes | Protocol (`"tcp"` or `"udp"`) |
|
||||
| `toaddr` | `string` | No | Internal destination address |
|
||||
| `toport` | `number` | No | Internal destination port |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Info
|
||||
|
||||
#### Available Services
|
||||
|
||||
```
|
||||
GET /api/firewall/services
|
||||
```
|
||||
|
||||
List all service names known to firewalld.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[string, ...]` | Service names |
|
||||
|
||||
---
|
||||
|
||||
#### Available Interfaces
|
||||
|
||||
```
|
||||
GET /api/firewall/interfaces
|
||||
```
|
||||
|
||||
List all network interfaces currently available on the system.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[string, ...]` | Interface names |
|
||||
|
||||
---
|
||||
|
||||
## DHCP / DNS API
|
||||
|
||||
Endpoints prefixed with `/api/dhcp/...`. Manage dnsmasq configuration, DHCP leases, and custom DNS records.
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Get Configuration
|
||||
|
||||
```
|
||||
GET /api/dhcp/config
|
||||
```
|
||||
|
||||
Return the current DHCP/DNS configuration object.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `object` | Full dnsmasq configuration dictionary |
|
||||
|
||||
---
|
||||
|
||||
#### Replace Configuration
|
||||
|
||||
```
|
||||
POST /api/dhcp/config
|
||||
```
|
||||
|
||||
Replace the entire configuration with the provided JSON object.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| *(entire body)* | `object` | Yes | Complete configuration object |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Partial Update Configuration
|
||||
|
||||
```
|
||||
PATCH /api/dhcp/config
|
||||
```
|
||||
|
||||
Deep-merge the provided fields into the existing configuration. Useful for targeted updates (e.g., changing DNS upstream servers without replacing the full config).
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| *(any subset)* | `any` | Yes | Fields to merge into the existing config |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Apply Configuration
|
||||
|
||||
```
|
||||
POST /api/dhcp/apply
|
||||
```
|
||||
|
||||
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Leases
|
||||
|
||||
#### Get Live Leases
|
||||
|
||||
```
|
||||
GET /api/dhcp/leases
|
||||
```
|
||||
|
||||
Return the current DHCP lease table from dnsmasq.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of lease objects |
|
||||
|
||||
---
|
||||
|
||||
#### Add Static Lease
|
||||
|
||||
```
|
||||
POST /api/dhcp/static-lease
|
||||
```
|
||||
|
||||
Add a static (reserved) DHCP lease.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `mac` | `string` | Yes | MAC address (`"aa:bb:cc:dd:ee:ff"`) |
|
||||
| `ip` | `string` | Yes | Reserved IP address |
|
||||
| `hostname` | `string` | No | Hostname for the reservation |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Remove Static Lease
|
||||
|
||||
```
|
||||
DELETE /api/dhcp/static-lease?mac=aa:bb:cc:dd:ee:ff
|
||||
```
|
||||
|
||||
Remove a previously configured static lease.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `mac` | `string` | Yes | MAC address of the lease to remove |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if no matching lease is found.
|
||||
|
||||
### DNS Records
|
||||
|
||||
#### Add DNS Record
|
||||
|
||||
```
|
||||
POST /api/dhcp/dns-record
|
||||
```
|
||||
|
||||
Add a custom DNS A record served by dnsmasq.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Fully qualified domain name |
|
||||
| `address` | `string` | Yes | IP address to resolve to |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
#### Remove DNS Record
|
||||
|
||||
```
|
||||
DELETE /api/dhcp/dns-record?name=nas.lan
|
||||
```
|
||||
|
||||
Remove a custom DNS record.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Fully qualified domain name to remove |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if no matching record is found.
|
||||
|
||||
---
|
||||
|
||||
## Proxy API
|
||||
|
||||
Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy.
|
||||
|
||||
### Domain Management
|
||||
|
||||
#### List All Domains
|
||||
|
||||
```
|
||||
GET /api/proxy/domains
|
||||
```
|
||||
|
||||
Return all configured proxy domains.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of domain configuration objects |
|
||||
|
||||
---
|
||||
|
||||
#### Add Domain
|
||||
|
||||
```
|
||||
POST /api/proxy/domains
|
||||
```
|
||||
|
||||
Add a new reverse proxy domain.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Domain name to proxy |
|
||||
| `backend_host` | `string` | Yes | Backend server IP or hostname |
|
||||
| `backend_port` | `number` | Yes | Backend server port |
|
||||
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `400` if the domain is already configured.
|
||||
|
||||
---
|
||||
|
||||
#### Get Domain Details
|
||||
|
||||
```
|
||||
GET /api/proxy/domains/<domain>
|
||||
```
|
||||
|
||||
Return the configuration for a single proxy domain.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `domain` | `string` | Domain name |
|
||||
| `backend_host` | `string` | Backend server address |
|
||||
| `backend_port` | `number` | Backend server port |
|
||||
| `backend_proto` | `string` | Backend protocol |
|
||||
|
||||
Returns HTTP `404` if the domain is not configured.
|
||||
|
||||
---
|
||||
|
||||
#### Update Domain
|
||||
|
||||
```
|
||||
PUT /api/proxy/domains/<domain>
|
||||
```
|
||||
|
||||
Update one or more fields of an existing domain entry. Only the fields present in the body are modified.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `backend_host` | `string` | No | Backend server IP or hostname |
|
||||
| `backend_port` | `number` | No | Backend server port |
|
||||
| `backend_proto` | `string` | No | Backend protocol |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the domain is not configured.
|
||||
|
||||
---
|
||||
|
||||
#### Remove Domain
|
||||
|
||||
```
|
||||
DELETE /api/proxy/domains/<domain>
|
||||
```
|
||||
|
||||
Remove a proxy domain and its nginx configuration.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the domain is not configured.
|
||||
|
||||
### Apply / Test
|
||||
|
||||
#### Apply Configuration
|
||||
|
||||
```
|
||||
POST /api/proxy/apply
|
||||
```
|
||||
|
||||
Regenerate nginx configuration files for all proxy domains and reload the nginx service.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `500` if nginx config generation fails or the reload fails.
|
||||
|
||||
---
|
||||
|
||||
#### Test Configuration
|
||||
|
||||
```
|
||||
POST /api/proxy/test
|
||||
```
|
||||
|
||||
Run `nginx -t` against the generated configuration without reloading. Useful for validating changes before applying.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data.valid` | `boolean` | Whether the configuration syntax is valid |
|
||||
| `data.output` | `string` | Raw nginx test output |
|
||||
|
||||
### Management
|
||||
|
||||
#### Configure Management WebUI Proxy
|
||||
|
||||
```
|
||||
POST /api/proxy/management
|
||||
```
|
||||
|
||||
Configure the nginx proxy block for the management WebUI itself, including optional HTTP basic authentication.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Management domain (e.g., `"wall.lan"`) |
|
||||
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
|
||||
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
|
||||
| `auth_user` | `string` | No | Username for basic auth. An `.htpasswd` entry is created when this field is present. |
|
||||
| `auth_pass` | `string` | No | Password for basic auth. Used together with `auth_user`. |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
If `auth_user` and `auth_pass` are provided, the endpoint creates or updates the corresponding `.htpasswd` file entry.
|
||||
|
||||
---
|
||||
|
||||
## Certificate API
|
||||
|
||||
Endpoints prefixed with `/api/certs/...`. Manage TLS certificates via ACME (Let's Encrypt / certbot).
|
||||
|
||||
### Listing & Details
|
||||
|
||||
#### List All Certificates
|
||||
|
||||
```
|
||||
GET /api/certs/list
|
||||
```
|
||||
|
||||
Return all managed certificates with metadata.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of certificate objects |
|
||||
|
||||
Each certificate object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `domain` | `string` | Domain the certificate covers |
|
||||
| `expiry` | `string` | Expiration date (ISO 8601) |
|
||||
| `days_until_expiry` | `number` | Remaining days until expiration |
|
||||
| `cert_path` | `string` | Path to the certificate file |
|
||||
| `key_path` | `string` | Path to the private key file |
|
||||
|
||||
---
|
||||
|
||||
#### Get Certificate Details
|
||||
|
||||
```
|
||||
GET /api/certs/<domain>
|
||||
```
|
||||
|
||||
Return details for a single certificate.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `domain` | `string` | Domain |
|
||||
| `expiry` | `string` | Expiration date (ISO 8601) |
|
||||
| `days_until_expiry` | `number` | Remaining days |
|
||||
| `cert_path` | `string` | Certificate file path |
|
||||
| `key_path` | `string` | Private key file path |
|
||||
|
||||
Returns HTTP `404` if no certificate is found for the domain.
|
||||
|
||||
### Operations
|
||||
|
||||
#### Issue Certificate
|
||||
|
||||
```
|
||||
POST /api/certs/issue
|
||||
```
|
||||
|
||||
Request a new certificate for a domain.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | `string` | Yes | Domain to issue the certificate for |
|
||||
| `standalone` | `boolean` | No | Use standalone (TCP) validation; defaults to `false` (HTTP-01 via existing webroot) |
|
||||
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `400` if the domain is missing or the request is malformed. Returns HTTP `500` if the ACME challenge or certificate issuance fails.
|
||||
|
||||
---
|
||||
|
||||
#### Renew Certificate
|
||||
|
||||
```
|
||||
POST /api/certs/<domain>/renew
|
||||
```
|
||||
|
||||
Force-renew an existing certificate, regardless of its current expiry status.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails.
|
||||
|
||||
---
|
||||
|
||||
#### Remove Certificate
|
||||
|
||||
```
|
||||
DELETE /api/certs/<domain>
|
||||
```
|
||||
|
||||
Delete a certificate and remove it from auto-renewal tracking.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the certificate is not found.
|
||||
|
||||
### Account
|
||||
|
||||
#### Set ACME Contact Email
|
||||
|
||||
```
|
||||
POST /api/certs/email
|
||||
```
|
||||
|
||||
Set or update the ACME account contact email (used by Let's Encrypt for expiration and security notices).
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `email` | `string` | Yes | Contact email address |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
---
|
||||
|
||||
## WireGuard API
|
||||
|
||||
Endpoints prefixed with `/api/wireguard/...`. Manage the WireGuard VPN server, peers, and client configuration.
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Get Configuration
|
||||
|
||||
```
|
||||
GET /api/wireguard/config
|
||||
```
|
||||
|
||||
Return the current WireGuard server configuration. The `private_key` field is stripped from the response.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `object` | Full WireGuard configuration dictionary (`private_key` omitted) |
|
||||
|
||||
---
|
||||
|
||||
#### Replace Configuration
|
||||
|
||||
```
|
||||
POST /api/wireguard/config
|
||||
```
|
||||
|
||||
Replace the entire WireGuard configuration. The `private_key` field is stripped from the response.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| *(entire body)* | `object` | Yes | Complete WireGuard configuration object |
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `object` | Updated configuration (`private_key` omitted) |
|
||||
|
||||
### Tunnel Control
|
||||
|
||||
#### Apply Configuration
|
||||
|
||||
```
|
||||
POST /api/wireguard/apply
|
||||
```
|
||||
|
||||
Write the current configuration to `wg0.conf` on disk and bring the WireGuard tunnel up.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `500` if config write or interface bring-up fails.
|
||||
|
||||
---
|
||||
|
||||
#### Bring Tunnel Down
|
||||
|
||||
```
|
||||
POST /api/wireguard/down
|
||||
```
|
||||
|
||||
Bring down the WireGuard tunnel interface (`wg0`).
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Status
|
||||
|
||||
#### Tunnel Status
|
||||
|
||||
```
|
||||
GET /api/wireguard/status
|
||||
```
|
||||
|
||||
Return live tunnel state, including interface metrics and per-peer connection statistics.
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `up` | `boolean` | Whether the tunnel interface is up |
|
||||
| `interface` | `object` | Interface info (listen port, public key, etc.) |
|
||||
| `peers` | `[object, ...]` | Per-peer connection stats (handshake time, transfer bytes, endpoint, etc.) |
|
||||
|
||||
---
|
||||
|
||||
#### Initialize
|
||||
|
||||
```
|
||||
POST /api/wireguard/initialize
|
||||
```
|
||||
|
||||
Perform first-time setup: generate a server key pair, write an initial configuration, and prepare for peer enrollment. This endpoint is idempotent — calling it multiple times has no additional effect.
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
### Peer Management
|
||||
|
||||
#### Add Peer
|
||||
|
||||
```
|
||||
POST /api/wireguard/add-peer
|
||||
```
|
||||
|
||||
Add a new WireGuard peer. A key pair is auto-generated for the peer. The response includes peer details with the private key stripped.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Peer identifier name |
|
||||
| `endpoint` | `string` | No | Allowed endpoint address (`"ip:port"`) |
|
||||
| `allowed_ips` | `[string, ...]` | No | Allowed IPs; defaults to `["0.0.0.0/0"]` |
|
||||
| `persistent_keepalive` | `number` | No | Persistent keepalive interval in seconds |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Peer name |
|
||||
| `public_key` | `string` | Peer's public key |
|
||||
| `allowed_ips` | `[string, ...]` | Allowed IPs |
|
||||
| `endpoint` | `string` | Allowed endpoint |
|
||||
| `persistent_keepalive` | `number` | Keepalive interval |
|
||||
|
||||
---
|
||||
|
||||
#### Remove Peer
|
||||
|
||||
```
|
||||
DELETE /api/wireguard/remove-peer?name=alice
|
||||
```
|
||||
|
||||
Remove a configured peer.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Peer name to remove |
|
||||
|
||||
**Response:** `data` is `null` on success.
|
||||
|
||||
Returns HTTP `404` if the peer is not found.
|
||||
|
||||
---
|
||||
|
||||
#### List Peers
|
||||
|
||||
```
|
||||
GET /api/wireguard/peers
|
||||
```
|
||||
|
||||
Return all configured peers. Private keys are stripped from the response.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of peer objects (private keys omitted) |
|
||||
|
||||
---
|
||||
|
||||
#### Peer Connection Status
|
||||
|
||||
```
|
||||
GET /api/wireguard/peer-status
|
||||
```
|
||||
|
||||
Return live per-peer connection status from `wg show`, including last handshake time, transfer bytes, and current endpoint.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `data` | `[object, ...]` | Array of live peer status objects |
|
||||
|
||||
### Client Configuration
|
||||
|
||||
#### Generate Client Config
|
||||
|
||||
```
|
||||
POST /api/wireguard/generate-client
|
||||
```
|
||||
|
||||
Generate a complete WireGuard client configuration file for provisioning a device. The returned config includes the peer's private key for the client to use.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Peer name to generate config for |
|
||||
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) for the client's `[Peer]` section |
|
||||
|
||||
**Response (`data`):**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `config` | `string` | Complete WireGuard client config text (`[Interface]` + `[Peer]` block) |
|
||||
|
||||
The client config includes the generated private key so the client can be provisioned directly. Note that this is the only endpoint that returns a WireGuard private key — all other endpoints strip private keys from responses.
|
||||
|
||||
Returns HTTP `404` if the peer is not found.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Architecture
|
||||
|
||||
## Request Flow
|
||||
|
||||
The following describes the path a request takes from an external client to a backend service and back:
|
||||
|
||||
### Proxied Service (e.g., `app.example.com`)
|
||||
|
||||
1. An external client sends an HTTP request to `app.example.com`.
|
||||
2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the `external` firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
|
||||
3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
|
||||
4. nginx evaluates the `server_name` against the configured server blocks. The matching block is generated from the domain entry in `data/nginx/config.json`.
|
||||
5. The request is forwarded to the backend service (e.g., `192.168.2.50:8080`) via an `proxy_pass` directive.
|
||||
6. The backend service processes the request and returns an HTTP response.
|
||||
7. nginx adds security headers (`X-Content-Type-Options`, `X-Frame-Options`, HSTS, etc.) to the response.
|
||||
8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface.
|
||||
|
||||
For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.
|
||||
|
||||
### Management WebUI Access (e.g., `wall.lan`)
|
||||
|
||||
1. A client sends an HTTPS request to the management domain.
|
||||
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
|
||||
3. If authentication succeeds, the request is proxied to `127.0.0.1:9090` where the Flask WebUI is listening.
|
||||
4. The Flask application processes the request, performs any necessary privileged operations through the sudo whitelist, and returns an HTML or JSON response.
|
||||
5. nginx returns the response to the client over the encrypted connection.
|
||||
|
||||
Because Flask binds only to `127.0.0.1`, it is unreachable directly from any external interface. The nginx reverse proxy is the sole entry point.
|
||||
|
||||
## Subsystem Communication
|
||||
|
||||
The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:
|
||||
|
||||
```
|
||||
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
|
||||
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||
Flask WebUI ──→ lib/nginx.py ──→ write rendered .conf files ──→ sudo nginx -s reload
|
||||
Flask WebUI ──→ lib/dnsmasq.py ──→ render /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
||||
Flask WebUI ──→ lib/acme.py ──→ sudo acme.sh ──→ acme.sh CLI ──→ Let's Encrypt ACME
|
||||
Flask WebUI ──→ lib/wireguard.py ──→ render /etc/wireguard/wg0.conf ──→ sudo wg-quick up wg0 ──→ kernel module
|
||||
```
|
||||
|
||||
Each `lib/` module encapsulates the command construction, sudo invocation, and error handling for its subsystem. The modules read declarative configuration from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation through the sudo whitelist.
|
||||
|
||||
## State Management
|
||||
|
||||
Vacuum Wall uses a declarative configuration model. The source of truth for each subsystem is a JSON file in the `data/` directory. The application renders these declarations into the format expected by the underlying system service.
|
||||
|
||||
| Subsystem | Declarative Config | Rendered Target | State Persistence |
|
||||
|---|---|---|---|
|
||||
| firewalld | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as a declarative backup and can be used to restore firewall rules. |
|
||||
| dnsmasq | `data/dnsmasq/config.json` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||
| nginx | `data/nginx/config.json` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
|
||||
| WireGuard | `data/wireguard/config.json` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||
| ACME | `~/.acme.sh/` (managed by acme.sh) | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
|
||||
|
||||
## Data Directory Structure
|
||||
|
||||
```
|
||||
data/
|
||||
├── nginx/
|
||||
│ ├── config.json # Proxy domain definitions, management domain, SSL settings
|
||||
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
||||
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
||||
├── dnsmasq/
|
||||
│ ├── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
||||
├── firewall/
|
||||
│ └── rules.json # Declarative firewall rule state backup
|
||||
└── wireguard/
|
||||
└── config.json # WireGuard interface and peer configuration
|
||||
```
|
||||
|
||||
The `data/` directory resides within the `vacuum-wall` user's project directory (`/home/wall/vacuum-wall/data/`). The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to this directory, while keeping the rest of the filesystem read-only.
|
||||
|
||||
## File System Layout
|
||||
|
||||
The following file system locations are used for integration with system services:
|
||||
|
||||
| Path | Purpose | Managed By |
|
||||
|---|---|---|
|
||||
| `/etc/nginx/conf.d/vacuum-wall.conf` | Include directive that pulls in `data/nginx/sites-enabled/*.conf`. Also contains the WebSocket proxy map shared by all server blocks. | Vacuum Wall (lib/nginx.py) |
|
||||
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
|
||||
| `/etc/nginx/sites-enabled/` | Symlinks or config files for Vacuum Wall-managed domains (if used alongside other sites). | Vacuum Wall / system |
|
||||
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `data/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
||||
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `data/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the `vacuum-wall` user. Defines all permitted privilege escalations. | Install script (manual edits not required) |
|
||||
|
||||
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
|
||||
|
||||
## Zone Model
|
||||
|
||||
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
|
||||
|
||||
| Zone | Interfaces | Trust Level | Description |
|
||||
|---|---|---|---|
|
||||
| `public` / `external` | WAN (e.g., `eth0`) | Untrusted | Internet-facing. Only explicitly allowed inbound services (HTTPS/443, WireGuard/51820, ICMP echo rate-limited) are accessible. All other inbound traffic is dropped. |
|
||||
| `internal` | LAN (e.g., `eth1`) | Trusted | Local area network. DHCP (UDP 67/68) and DNS (UDP/TCP 53) are served. Masquerade (NAT) is enabled for outbound Internet access from LAN clients. Inbound from WAN to this zone is not directly accessible. |
|
||||
| `vpn` | WireGuard (`wg0`) | Semi-trusted | WireGuard tunnel interface. Firewall rules determine which internal services and subnets VPN peers can reach. By default, VPN peers can access the Internet but may be restricted from accessing management interfaces or sensitive LAN services. |
|
||||
| `trusted` | Management interface | Administrative | Used for management traffic. The `loopback` zone covers localhost communication, enabling the Flask WebUI to receive proxied requests from nginx on `127.0.0.1:9090`. |
|
||||
|
||||
### Custom Zones
|
||||
|
||||
Additional zones can be created for specialized network segments:
|
||||
|
||||
- **DMZ zone**: For hosting public-facing services that need to be isolated from the internal LAN. Traffic from the DMZ to the `internal` zone is denied by default.
|
||||
- **Guest zone**: For visitor Wi-Fi or untrusted devices. Access is limited to outbound Internet traffic only, with no access to `internal` or `vpn` zones.
|
||||
- **IoT zone**: For devices requiring restricted outbound access (e.g., blocking telemetry domains).
|
||||
|
||||
Each custom zone can define its own source rules, port forwardings, and inter-zone traffic policies. The Flask WebUI provides interfaces to create, modify, and assign interfaces to zones at runtime.
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
# Configuration Reference
|
||||
|
||||
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All configuration is stored in the `data/` directory as declarative JSON. The application renders these declarations into the format expected by each underlying service.
|
||||
|
||||
## DHCP/DNS Configuration
|
||||
|
||||
**File**: `data/dnsmasq/config.json`
|
||||
|
||||
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into `/etc/dnsmasq.d/vacuum-wall.conf`.
|
||||
|
||||
```json
|
||||
{
|
||||
"dhcp": {
|
||||
"ranges": [
|
||||
{
|
||||
"interface": "eth1",
|
||||
"start": "192.168.2.100",
|
||||
"end": "192.168.2.200",
|
||||
"lease_time": "12h",
|
||||
"gateway": "192.168.2.1",
|
||||
"dns": "192.168.2.1"
|
||||
}
|
||||
],
|
||||
"static_leases": [
|
||||
{
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"ip": "192.168.2.50",
|
||||
"hostname": "printer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dns": {
|
||||
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
||||
"domain": "lan",
|
||||
"custom_records": [
|
||||
{
|
||||
"name": "nas.lan",
|
||||
"address": "192.168.2.10",
|
||||
"hostname": "nas"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DHCP Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `ranges` | array | Yes | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. |
|
||||
| `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). |
|
||||
| `ranges[].start` | string | Yes | First IP address in the pool. |
|
||||
| `ranges[].end` | string | Yes | Last IP address in the pool. |
|
||||
| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `1h`. |
|
||||
| `ranges[].gateway` | string | No | Default gateway advertised to DHCP clients. Typically the router's LAN IP. |
|
||||
| `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. |
|
||||
| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. |
|
||||
| `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). |
|
||||
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. |
|
||||
| `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
|
||||
|
||||
### DNS Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `upstreams` | array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. |
|
||||
| `domain` | string | Yes | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., `printer` becomes `printer.lan`). |
|
||||
| `custom_records` | array | No | Static DNS A records for internal services and devices. |
|
||||
| `custom_records[].name` | string | Yes | Fully qualified domain name (e.g., `nas.lan`). |
|
||||
| `custom_records[].address` | string | Yes | The IP address to resolve the name to. |
|
||||
| `custom_records[].hostname` | string | No | Short hostname without the domain suffix. Adds a reverse DNS entry as well. |
|
||||
|
||||
Additional dnsmasq directives can be appended verbatim by placing plain-text files in `data/dnsmasq/fragments/`. Each file's contents are concatenated into the generated config. This is useful for advanced options not covered by the JSON schema (e.g., `bogus-priv`, `cache-size`, `log-queries`).
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
**File**: `data/nginx/config.json`
|
||||
|
||||
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
|
||||
|
||||
```json
|
||||
{
|
||||
"domains": {
|
||||
"app.example.com": {
|
||||
"backend": {
|
||||
"host": "192.168.2.50",
|
||||
"port": 8080,
|
||||
"proto": "http"
|
||||
},
|
||||
"force_ssl": true,
|
||||
"headers": {
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Real-IP": "$remote_addr"
|
||||
},
|
||||
"cert": {
|
||||
"type": "acme",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"management": {
|
||||
"domain": "wall.lan",
|
||||
"backend": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9090,
|
||||
"proto": "http"
|
||||
},
|
||||
"auth": {
|
||||
"user": "admin",
|
||||
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"prefer_server_ciphers": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Entries
|
||||
|
||||
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `backend` | object | Yes | The upstream service that receives proxied traffic. |
|
||||
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
|
||||
| `backend.port` | integer | Yes | Port the backend service is listening on. |
|
||||
| `backend.proto` | string | Yes | Protocol for the backend connection: `http` or `https`. |
|
||||
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
|
||||
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
|
||||
| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. |
|
||||
| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. |
|
||||
| `cert.email` | string | Yes (if `acme`) | ACME account email used by Let's Encrypt. |
|
||||
| `cert.path` | object | Yes (if `file`) | Paths to certificate files. |
|
||||
| `cert.path.certificate` | string | Yes (if `file`) | Full path to the public certificate file (PEM). |
|
||||
| `cert.path.key` | string | Yes (if `file`) | Full path to the private key file (PEM). |
|
||||
|
||||
### Certificate Types
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `acme` | Vacuum Wall uses acme.sh to request and renew a Let's Encrypt certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. |
|
||||
| `file` | Use a pre-existing certificate and private key from the local file system. The `path.certificate` and `path.key` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. |
|
||||
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored alongside other acme-managed files in `~/.acme.sh/` with a `.selfsigned` marker. |
|
||||
|
||||
### Management Domain
|
||||
|
||||
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but includes an `auth` block for HTTP Basic Authentication.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `wall.lan`). |
|
||||
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
|
||||
| `auth` | object | Yes | HTTP Basic Authentication configuration. |
|
||||
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
|
||||
| `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. |
|
||||
|
||||
The `.htpasswd` file can be created with the `htpasswd` utility:
|
||||
|
||||
```bash
|
||||
htpasswd -bc /home/wall/vacuum-wall/data/nginx/.htpasswd admin yourpassword
|
||||
```
|
||||
|
||||
### Global SSL Settings
|
||||
|
||||
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `protocols` | string | No | nginx `ssl_protocols` directive value. Default: `TLSv1.2 TLSv1.3`. |
|
||||
| `ciphers` | string | No | nginx `ssl_ciphers` directive value. Default is a curated AEAD-only cipher string. |
|
||||
| `prefer_server_ciphers` | boolean | No | Whether to prefer server cipher order. Default: `false`. |
|
||||
|
||||
## WireGuard Configuration
|
||||
|
||||
**File**: `data/wireguard/config.json`
|
||||
|
||||
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`.
|
||||
|
||||
```json
|
||||
{
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "kOv8lK...',
|
||||
"public_key": "YzP3xI...',
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": null,
|
||||
"post_down": null
|
||||
},
|
||||
"peers": {
|
||||
"alice": {
|
||||
"public_key": "nR7mQ2...',
|
||||
"private_key": "xLpDgF...',
|
||||
"endpoint": "203.0.113.1:51820",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
"persistent_keepalive": 25,
|
||||
"preshared_key": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interface Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `name` | string | Yes | WireGuard interface name. Default: `wg0`. |
|
||||
| `listen_port` | integer | Yes | Port the WireGuard interface listens on. Default: `51820`. Must be opened in the firewall. |
|
||||
| `private_key` | string | Yes | Base64-encoded private key for the server interface. Use `wg genkey` to generate. |
|
||||
| `public_key` | string | Yes | Corresponding public key. Use `wg pubkey` to derive from the private key. |
|
||||
| `addresses` | array | Yes | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). |
|
||||
| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. |
|
||||
| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. |
|
||||
|
||||
### Peer Fields
|
||||
|
||||
Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `public_key` | string | Yes | The peer's public key. |
|
||||
| `private_key` | string | No | The peer's private key, stored for generating downloadable client configuration files. This value is stripped from all API responses — the WebUI never exposes peer private keys over the network. |
|
||||
| `endpoint` | string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or `null` for peer-initiated connections where the peer connects to the server. |
|
||||
| `allowed_ips` | array | Yes | CIDR blocks that traffic from this peer is allowed to route. `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
|
||||
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. |
|
||||
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. |
|
||||
|
||||
### Client Configuration Generation
|
||||
|
||||
When a peer's `private_key` is set, the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API.
|
||||
|
||||
### Applying Configuration
|
||||
|
||||
When configuration is saved through the WebUI or API, the application:
|
||||
|
||||
1. Validates all key pairs and IP ranges.
|
||||
2. Renders the `wg0.conf` file from the JSON configuration.
|
||||
3. Copies the rendered file to `/etc/wireguard/wg0.conf` using the sudo whitelist.
|
||||
4. Runs `sudo wg-quick up wg0` to apply the configuration.
|
||||
5. Returns success or error status to the caller.
|
||||
|
||||
If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Vacuum Wall Deployment Guide
|
||||
|
||||
This guide walks through deploying Vacuum Wall on a real appliance or server. Vacuum Wall is an SSL proxy firewall appliance that combines edge proxying, firewall management, DHCP, DNS, and WireGuard in a single device.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OS**: Clean Debian 13 (Trixie) system. Also works on Debian 12 with backports for firewalld.
|
||||
- **git**: Required for cloning the repository.
|
||||
- **Access**: Root access to the machine.
|
||||
- **Networking**:
|
||||
- One public-facing network interface (external/edge). This receives inbound traffic and serves the management UI.
|
||||
- At least one LAN network interface (internal). This connects to your downstream network and will serve DHCP/DNS.
|
||||
- **DNS**: A DNS record pointing to the appliance's public IP for the management domain (e.g., `wall.example.com`).
|
||||
- **Minimum hardware**: 1 CPU, 512 MB RAM, 4 GB disk.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the Vacuum Wall repository onto the target machine, then run the installer with the required environment variables:
|
||||
|
||||
```bash
|
||||
MGMT_DOMAIN=wall.example.com \
|
||||
MGMT_PASS="strongpassword" \
|
||||
MGMT_USER="admin" \
|
||||
ACME_EMAIL="admin@example.com" \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `MGMT_DOMAIN` | Yes | The public-facing domain for the management WebUI. A DNS A record must point to the appliance's IP. |
|
||||
| `MGMT_PASS` | Yes | The password for HTTP basic auth protecting the WebUI. Use a strong, randomly generated password. |
|
||||
| `MGMT_USER` | No | The username for WebUI access. Defaults to `admin`. |
|
||||
| `ACME_EMAIL` | Yes | The email address registered with Let's Encrypt for certificate issuance and expiry notifications. |
|
||||
|
||||
---
|
||||
|
||||
## What install.sh Does
|
||||
|
||||
The installer performs the following steps automatically:
|
||||
|
||||
- **Package installation**: Installs firewalld, nginx, dnsmasq, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils.
|
||||
- **acme.sh installation**: Downloads and installs the acme.sh client to the project user's home directory for Let's Encrypt certificate management.
|
||||
- **Flask installation**: Ensures the Flask Python package is available via pip for the WebUI backend.
|
||||
- **System user creation**: Creates a dedicated `vacuum-wall` system user (nologin shell) that owns the project data and runs the WebUI service.
|
||||
- **Directory setup**: Creates data directories under `/home/wall/vacuum-wall/data/` for nginx sites, dnsmasq config, firewall rules, and WireGuard config. Sets ownership to the `vacuum-wall` user.
|
||||
- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the `vacuum-wall` user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`.
|
||||
- **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones.
|
||||
- **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access.
|
||||
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
|
||||
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert.
|
||||
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
|
||||
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth.
|
||||
- **Systemd units**: Installs three units:
|
||||
- `vacuum-wall.service` — the Flask WebUI backend.
|
||||
- `vacuum-wall-acme.service` — the certificate renewal oneshot.
|
||||
- `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals.
|
||||
- **Firewalld zones**: Creates initial zones:
|
||||
- `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed.
|
||||
- `vpn` — WireGuard tunnel zone.
|
||||
- **Service startup**: Enables and starts nginx, the vacuum-wall WebUI, and the ACME renewal timer.
|
||||
- **ACME registration**: Registers the Let's Encrypt account with the provided email via acme.sh.
|
||||
|
||||
---
|
||||
|
||||
## Post-Installation
|
||||
|
||||
### Verify Services
|
||||
|
||||
After the installer completes, confirm all services are running:
|
||||
|
||||
```bash
|
||||
systemctl status vacuum-wall nginx firewalld dnsmasq
|
||||
```
|
||||
|
||||
Each should be active (running). The `vacuum-wall-acme.timer` should also be active (waiting).
|
||||
|
||||
### Access the WebUI
|
||||
|
||||
Open a browser and navigate to:
|
||||
|
||||
```
|
||||
https://wall.example.com
|
||||
```
|
||||
|
||||
Log in with the username and password you provided during installation.
|
||||
|
||||
### Certificate Note
|
||||
|
||||
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real Let's Encrypt certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
|
||||
|
||||
---
|
||||
|
||||
## Configuring Your First Network
|
||||
|
||||
After installation, the appliance has no interfaces assigned to zones and no DHCP ranges configured. Use the WebUI to set up your LAN.
|
||||
|
||||
### 1. Assign a LAN Interface to the Internal Zone
|
||||
|
||||
1. Navigate to the **Interfaces** tab.
|
||||
2. From the interface list, select your LAN interface (e.g., `eth1`).
|
||||
3. Assign it to the `internal` zone.
|
||||
4. Click **Apply** to update the firewall configuration.
|
||||
|
||||
### 2. Enable NAT/Masquerade
|
||||
|
||||
1. Go to the **NAT** tab.
|
||||
2. Enable masquerade on the `internal` zone. This allows devices on your LAN to reach the internet through the appliance's external interface.
|
||||
3. Click **Apply**.
|
||||
|
||||
### 3. Configure DHCP
|
||||
|
||||
1. Go to the **DHCP** tab.
|
||||
2. Click **Add Range**.
|
||||
3. Specify:
|
||||
- Address range: e.g., `192.168.2.100-192.168.2.200`
|
||||
- Lease time: e.g., `12h`
|
||||
- Interface: `eth1` (or whichever interface you assigned to internal)
|
||||
4. Click **Apply**. This writes the dnsmasq configuration and reloads the service.
|
||||
|
||||
DNS resolution will also be provided on this interface by dnsmasq, which forwards queries upstream.
|
||||
|
||||
---
|
||||
|
||||
## Adding a Proxy Domain
|
||||
|
||||
Vacuum Wall's primary function is proxying incoming HTTPS traffic to internal backend services.
|
||||
|
||||
### 1. Add the Domain
|
||||
|
||||
1. Navigate to the **Proxy** tab.
|
||||
2. Click **Add Domain**.
|
||||
3. Fill in:
|
||||
- **Domain**: The public domain name (e.g., `app.example.com`).
|
||||
- **Backend Host**: The internal IP address of the service (e.g., `192.168.2.50`).
|
||||
- **Backend Port**: The port the service listens on (e.g., `8080`).
|
||||
|
||||
### 2. Issue a Certificate
|
||||
|
||||
1. Go to the **Certs** tab.
|
||||
2. Click **Issue Certificate** and enter the domain name.
|
||||
3. ACME validation requires that port 80 on the appliance is reachable from the internet and that the domain's DNS A record points to the appliance's public IP.
|
||||
|
||||
### 3. Reload Nginx
|
||||
|
||||
1. Return to the **Proxy** tab.
|
||||
2. Click **Apply** to write the nginx configuration and reload the service.
|
||||
|
||||
The proxied domain is now accessible via HTTPS at the configured domain name.
|
||||
|
||||
---
|
||||
|
||||
## Setting up WireGuard
|
||||
|
||||
Vacuum Wall includes integrated WireGuard server support for VPN access.
|
||||
|
||||
### 1. Initialize the Server
|
||||
|
||||
1. Navigate to the **WireGuard** tab.
|
||||
2. Click **Initialize**. This generates the server's private and public keys and creates the `wg0` interface configuration.
|
||||
|
||||
### 2. Add a Peer
|
||||
|
||||
1. Click **Add Peer**.
|
||||
2. Enter a peer name (e.g., `alice`).
|
||||
3. Optionally set a specific AllowedIPs range for this peer (defaults to `0.0.0.0/0`).
|
||||
4. Optionally set an **Endpoint** if you know the peer's static public IP (restricts incoming connections to that IP).
|
||||
5. Click **Add**. The peer's public key and preshared key are generated automatically.
|
||||
|
||||
### 3. Activate the Tunnel
|
||||
|
||||
1. Click **Apply** to write the WireGuard configuration and bring up the `wg0` interface.
|
||||
|
||||
### 4. Download Client Configuration
|
||||
|
||||
1. In the peer list, use the peer actions menu to download the client configuration file for the peer.
|
||||
2. Install this configuration on the client device.
|
||||
|
||||
### 5. Assign WireGuard to a Firewall Zone
|
||||
|
||||
1. Navigate to the **Interfaces** tab.
|
||||
2. Assign `wg0` to the `vpn` zone.
|
||||
3. The `vpn` zone allows all traffic by default (target ACCEPT). Adjust firewall rules as needed to restrict VPN access to specific services.
|
||||
|
||||
### 6. Configure Firewall Rules for VPN Traffic
|
||||
|
||||
1. Go to the **Firewall** tab or use the **NAT** tab.
|
||||
2. Add rules as needed to control what VPN peers can access. For example, you can restrict VPN peers to only reach specific internal services rather than the entire LAN.
|
||||
3. Optionally enable masquerade on the `vpn` zone to allow VPN clients to reach the internet through the appliance.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Services Won't Start
|
||||
|
||||
Check service logs and configuration:
|
||||
|
||||
```bash
|
||||
journalctl -u vacuum-wall --no-pager -n 50
|
||||
journalctl -u nginx --no-pager -n 50
|
||||
nginx -t
|
||||
```
|
||||
|
||||
Common causes include port conflicts (another service on port 80/443), missing dependencies, or file permission issues on `/home/wall/vacuum-wall/data/`.
|
||||
|
||||
### Firewall Rules Not Applying
|
||||
|
||||
Verify that firewalld is running:
|
||||
|
||||
```bash
|
||||
firewall-cmd --state
|
||||
systemctl status firewalld
|
||||
```
|
||||
|
||||
If firewalld is not running, start it with `systemctl start firewalld`. Check that the sudoers whitelist is valid:
|
||||
|
||||
```bash
|
||||
visudo -cf /etc/sudoers.d/vacuum-wall
|
||||
```
|
||||
|
||||
### Certificate Issuance Fails
|
||||
|
||||
Let's Encrypt ACME validation requires:
|
||||
|
||||
- The domain's DNS A record points to the appliance's public IP.
|
||||
- Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface.
|
||||
- The ACME email was registered correctly. Check with:
|
||||
|
||||
```bash
|
||||
su -s /bin/bash vacuum-wall -c "~/.acme.sh/acme.sh --list"
|
||||
```
|
||||
|
||||
If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically.
|
||||
|
||||
### DHCP Not Working
|
||||
|
||||
Verify that:
|
||||
|
||||
- The LAN interface is assigned to a firewalld zone (check the **Interfaces** tab or `firewall-cmd --get-active-zones`).
|
||||
- Dnsmasq is running: `systemctl status dnsmasq`.
|
||||
- A DHCP range is configured for the correct interface. Check dnsmasq config at `/home/wall/vacuum-wall/data/dnsmasq/`.
|
||||
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
|
||||
|
||||
### WebUI Not Accessible
|
||||
|
||||
1. Verify nginx is running: `systemctl status nginx`.
|
||||
2. Test nginx configuration: `nginx -t`.
|
||||
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf`.
|
||||
4. Ensure the `vacuum-wall` WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
|
||||
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real Let's Encrypt certificate.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
| Component | Service | Config Location |
|
||||
|---|---|---|
|
||||
| WebUI backend | `vacuum-wall.service` | `/home/wall/vacuum-wall/webui/` |
|
||||
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
|
||||
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
|
||||
| DHCP/DNS | `dnsmasq` | `/home/wall/vacuum-wall/data/dnsmasq/` |
|
||||
| VPN | wireguard-tools | `/home/wall/vacuum-wall/data/wireguard/` |
|
||||
| Certificates | `vacuum-wall-acme.timer` | `/home/vacuum-wall/.acme.sh/` |
|
||||
| Sudoers | — | `/etc/sudoers.d/vacuum-wall` |
|
||||
@@ -0,0 +1,90 @@
|
||||
# Vacuum Wall
|
||||
|
||||
## What is Vacuum Wall?
|
||||
|
||||
Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic Let's Encrypt certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
|
||||
|
||||
## Subsystems
|
||||
|
||||
### Firewall
|
||||
|
||||
The firewall uses firewalld's zone model for traffic control. Network interfaces are assigned to zones such as external, internal, VPN, and trusted. Rules and services define which traffic is allowed between zones. Source NAT (masquerade) enables RFC 1918 networks to reach the internet through the external interface. Destination NAT rules provide port forwarding, exposing internal services to external networks on configurable ports.
|
||||
|
||||
### DHCP/DNS
|
||||
|
||||
dnsmasq serves as both the DHCP server and local DNS resolver. It is configured to serve address pools on specified LAN interfaces, with support for dynamic allocation ranges and static MAC-based reservations. Custom DNS records can be defined for local name resolution, and upstream DNS forwarding passes external queries to configurable resolvers.
|
||||
|
||||
### SSL Proxy
|
||||
|
||||
The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and Let's Encrypt. Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention.
|
||||
|
||||
### WireGuard
|
||||
|
||||
WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Debian 13 (trixie) target platform
|
||||
- Python 3, Flask 3.x for web management
|
||||
- firewalld (nftables backend)
|
||||
- nginx 1.26+
|
||||
- dnsmasq
|
||||
- WireGuard tools (wireguard-tools)
|
||||
- acme.sh for ACME/Let's Encrypt certificate management
|
||||
- HTMX for dynamic UI updates
|
||||
- Jinja2 for server-side templating
|
||||
|
||||
## Quick Start
|
||||
|
||||
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with the required environment variables:
|
||||
|
||||
```bash
|
||||
MGMT_DOMAIN=wall.lan MGMT_PASS=yourpassword ACME_EMAIL=admin@example.com \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
After installation, access the management interface at `https://wall.lan` using the credentials you configured. The `install.sh` script provisions nginx, sets up authentication, obtains an initial Let's Encrypt certificate, and starts all services.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── install.sh # Deployment script
|
||||
├── pyproject.toml # Project metadata + dependencies
|
||||
├── .venv/ # Python virtual environment
|
||||
├── system/ # System file templates
|
||||
│ ├── systemd/ # Service and timer unit files
|
||||
│ │ ├── vacuum-wall.service # Web UI service
|
||||
│ │ ├── vacuum-wall-acme.service # Certificate renewal service
|
||||
│ │ └── vacuum-wall-acme.timer # Renewal schedule
|
||||
│ └── sudoers.d/ # Sudo whitelist for service account
|
||||
├── lib/ # Subsystem abstraction layer
|
||||
│ ├── firewall.py # firewalld bindings
|
||||
│ ├── dnsmasq.py # DHCP/DNS configuration
|
||||
│ ├── nginx.py # Reverse proxy configuration
|
||||
│ ├── acme.py # Certificate management
|
||||
│ └── wireguard.py # VPN tunnel management
|
||||
├── webui/ # Flask web application
|
||||
│ ├── server.py # Application entry point
|
||||
│ ├── api/ # REST API route modules
|
||||
│ ├── templates/ # Jinja2/HTMX templates
|
||||
│ └── static/ # CSS and client-side JS
|
||||
└── docs/ # Documentation
|
||||
├── overview.md # This file
|
||||
├── deployment.md
|
||||
├── api.md
|
||||
├── security.md
|
||||
├── architecture.md
|
||||
└── config.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Deployment Guide](deployment.md) - Full installation and configuration
|
||||
- [API Reference](api.md) - REST API endpoints
|
||||
- [Security Model](security.md) - Privilege model and sudo whitelist
|
||||
- [Architecture](architecture.md) - Detailed subsystem design
|
||||
- [Configuration](config.md) - Config file formats and locations
|
||||
@@ -0,0 +1,126 @@
|
||||
# Security Model
|
||||
|
||||
## Privilege Model
|
||||
|
||||
The Vacuum Wall management WebUI (Flask application) runs as the unprivileged `vacuum-wall` system user. The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, TLS certificate issuance — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`.
|
||||
|
||||
This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the `vacuum-wall` user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access.
|
||||
|
||||
## Sudo Whitelist
|
||||
|
||||
The file `/etc/sudoers.d/vacuum-wall` grants the `vacuum-wall` user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are:
|
||||
|
||||
| Category | Whitelisted Command | Purpose |
|
||||
|---|---|---|
|
||||
| Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) |
|
||||
| Nginx | `nginx -s reload` | Graceful nginx configuration reload |
|
||||
| Nginx | `nginx -t` | Nginx configuration syntax validation |
|
||||
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
|
||||
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
||||
| WireGuard | `wg *` | WireGuard status and peer management |
|
||||
| Certificates | `acme.sh` (via `bash -c`) | Let's Encrypt certificate issuance and renewal |
|
||||
| File writes | `sudo cp` to `/etc/nginx/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths |
|
||||
| Logs | `sudo journalctl --unit=*` | Query systemd journal for managed services |
|
||||
| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs |
|
||||
| File writes | `sudo tee` | Write configuration data to protected paths |
|
||||
|
||||
Key safety properties:
|
||||
|
||||
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
|
||||
- No wildcard entries grant shell access or arbitrary command execution.
|
||||
- The `acme.sh` entry is restricted to certificate operations through an explicit `bash -c` wrapper that only passes acme-related arguments.
|
||||
- `DEFAULT!/usr/bin/sudo` and `NOPASSWD` are used so the application never prompts for a password and cannot chain sudo calls.
|
||||
|
||||
## Web Security
|
||||
|
||||
### Management Interface
|
||||
|
||||
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication using an `.htpasswd` file.
|
||||
|
||||
### Proxy Domains
|
||||
|
||||
Every proxied domain configured in Vacuum Wall enforces:
|
||||
|
||||
- **HTTP-to-HTTPS redirect** — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent.
|
||||
- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age to prevent downgrade attacks.
|
||||
- **Security headers** on all proxied responses:
|
||||
- `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing.
|
||||
- `X-Frame-Options: DENY` — Prevents clickjacking via iframes.
|
||||
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
|
||||
- `Content-Security-Policy` rules can be customized per-domain via the configuration.
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
The default nginx SSL configuration enforces modern TLS only:
|
||||
|
||||
- **Protocols**: TLSv1.2 and TLSv1.3. Older protocols (SSLv3, TLSv1.0, TLSv1.1) are disabled.
|
||||
- **Cipher suites**: A curated set of AEAD ciphers (ECDHE-ECDSA and ECDHE-RSA key exchange with AES-GCM and CHACHA20-POLY1305).
|
||||
- **DH parameters**: 2048-bit generated Diffie-Hellman parameters are used when ECDHE is not selected.
|
||||
- **OCSP stapling** is enabled for faster certificate validation.
|
||||
- **ssl_prefer_server_ciphers** can be toggled per-domain; the default is to let the client choose.
|
||||
|
||||
## Systemd Hardening
|
||||
|
||||
The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandboxing directives to isolate the WebUI process from the rest of the system:
|
||||
|
||||
| Directive | Value | Effect |
|
||||
|---|---|---|
|
||||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||||
| `ProtectHome` | `read-only` | Makes `/home`, `/root`, and `/run/user` inaccessible |
|
||||
| `ReadWritePaths` | `/home/wall/vacuum-wall/data /tmp` | Only the application data directory and `/tmp` are writable |
|
||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||
| `IPAddressDeny` | `all` | Drops all network traffic |
|
||||
| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach nginx upstream at 127.0.0.1:9090) |
|
||||
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
|
||||
| `ProtectKernelTunables` | `yes` | Makes `/proc/sys`, `/sys`, and `/proc/sysrq-trigger` read-only |
|
||||
| `ProtectKernelModules` | `yes` | Disables `init_module` and `finit_module` syscalls |
|
||||
| `ProtectControlGroups` | `yes` | Mounts `/sys/fs/cgroup` as read-only |
|
||||
| `ProtectHostname` | `yes` | Prevents the process from changing the system hostname |
|
||||
| `RestrictNamespaces` | `yes` | Prevents creating new namespaces |
|
||||
| `RestrictSUIDSGID` | `yes` | Removes setuid/setgid bits from newly created files |
|
||||
| `LockPersonality` | `yes` | Prevents changing the execution domain |
|
||||
| `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable |
|
||||
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
|
||||
|
||||
This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the data directory, and no ability to escalate privileges through kernel interfaces.
|
||||
|
||||
## Network Security
|
||||
|
||||
### Default Deny
|
||||
|
||||
The firewalld default zone policy is set to deny all incoming traffic. Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default.
|
||||
|
||||
### Zone-Based Traffic Isolation
|
||||
|
||||
| Zone | Interface | Purpose | Behavior |
|
||||
|---|---|---|---|
|
||||
| `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo is rate-limited. |
|
||||
| `internal` | LAN (e.g., `eth1`) | Trusted local network | DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed. |
|
||||
| `vpn` | WireGuard (`wg0`) | WireGuard tunnel traffic | Semi-trusted. Firewall rules control which internal services VPN peers can reach. Traffic to the LAN is restricted to specific services and ports. |
|
||||
| `trusted` / `loopback` | `lo` | Localhost communication | unrestricted; used for the Flask-to-nginx management proxy. |
|
||||
| Custom zones | — | DMZ, guest networks, etc. | Additional zones can be created to isolate specific network segments with their own rule sets. |
|
||||
|
||||
### IP Forwarding and NAT
|
||||
|
||||
IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the `internal` zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.
|
||||
|
||||
## Certificate Security
|
||||
|
||||
### acme.sh Integration
|
||||
|
||||
Certificate management is handled by acme.sh, which stores all certificates and private keys in the `vacuum-wall` user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the `vacuum-wall` user.
|
||||
|
||||
### Private Key Protection
|
||||
|
||||
Private keys are never exposed through the WebUI API or returned in API responses. The API only returns certificate metadata such as domain names, validity dates, and renewal status. When a domain's certificate is needed by nginx, the rendered nginx configuration references the file paths managed by acme.sh (`~/.acme.sh/<domain>/fullchain.cer` and `~/.acme.sh/<domain>/<domain>.key`), and nginx reads them directly through symbolic links or includes.
|
||||
|
||||
### HSTS Enforcement
|
||||
|
||||
All HTTPS proxy domains have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the `includeSubDomains` directive. This ensures browsers always use HTTPS for the domain and all subdomains, preventing SSL stripping attacks.
|
||||
|
||||
### Modern TLS Only
|
||||
|
||||
As noted in the Web Security section, the default ssl snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites. Weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers are explicitly excluded.
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Vacuum Wall - SSL Proxy Firewall Appliance Installer
|
||||
# Run as root on a fresh Debian 13 (trixie) system
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[!!]${NC} $*"; exit 1; }
|
||||
|
||||
PROJECT_DIR="/home/wall/vacuum-wall"
|
||||
USER_NAME="vacuum-wall"
|
||||
USER_HOME="/home/$USER_NAME"
|
||||
DOMAIN="${MGMT_DOMAIN:?ERROR: Set MGMT_DOMAIN env var (e.g., wall.lan)}"
|
||||
MGMT_USER="${MGMT_USER:-admin}"
|
||||
MGMT_PASS="${MGMT_PASS:?ERROR: Set MGMT_PASS env var for WebUI basic auth}"
|
||||
ACME_EMAIL="${ACME_EMAIL:?ERROR: Set ACME_EMAIL env var for Let's Encrypt}"
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
[[ $EUID -eq 0 ]] || err "This script must be run as root."
|
||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||
|
||||
echo "============================================"
|
||||
echo " Vacuum Wall Appliance Installer"
|
||||
echo " Management domain: $DOMAIN"
|
||||
echo "============================================"
|
||||
|
||||
# --- 1. Install packages ---
|
||||
log "Installing system packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
firewalld \
|
||||
nginx \
|
||||
dnsmasq \
|
||||
wireguard-tools \
|
||||
python3 \
|
||||
python3-pip \
|
||||
jq \
|
||||
curl \
|
||||
iptables \
|
||||
nftables \
|
||||
apache2-utils
|
||||
|
||||
# Install acme.sh under the project user's home
|
||||
if [[ ! -d "$USER_HOME/.acme.sh" ]]; then
|
||||
log "Installing acme.sh..."
|
||||
mkdir -p "$USER_HOME"
|
||||
ACME_HOME="$USER_HOME/.acme.sh" curl -sS https://get.acme.sh | sh
|
||||
else
|
||||
log "acme.sh already installed."
|
||||
fi
|
||||
|
||||
# Setup Python venv with project dependencies
|
||||
log "Setting up Python virtual environment..."
|
||||
python3 -m venv "${PROJECT_DIR}/.venv"
|
||||
"${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}"
|
||||
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
|
||||
|
||||
# --- 2. Create system user ---
|
||||
if ! id "$USER_NAME" &>/dev/null; then
|
||||
log "Creating system user $USER_NAME..."
|
||||
useradd --system --home-dir "$USER_HOME" --shell /usr/sbin/nologin "$USER_NAME"
|
||||
else
|
||||
log "User $USER_NAME already exists."
|
||||
fi
|
||||
|
||||
# --- 3. Setup directories ---
|
||||
log "Creating data directories..."
|
||||
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard}
|
||||
mkdir -p "$USER_HOME/vacuum-wall"
|
||||
mkdir -p /etc/wireguard
|
||||
mkdir -p /etc/dnsmasq
|
||||
|
||||
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data"
|
||||
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/lib"
|
||||
chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/webui"
|
||||
chown -R "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall"
|
||||
|
||||
# --- 4. Install sudoers ---
|
||||
log "Installing sudoers whitelist..."
|
||||
install -m 0440 "${PROJECT_DIR}/system/sudoers.d/vacuum-wall" /etc/sudoers.d/vacuum-wall
|
||||
|
||||
# Validate sudoers syntax
|
||||
visudo -cf /etc/sudoers.d/vacuum-wall || err "Invalid sudoers file!"
|
||||
|
||||
# --- 5. Enable IP forwarding ---
|
||||
log "Enabling IP forwarding..."
|
||||
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
|
||||
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
|
||||
fi
|
||||
sysctl -w net.ipv4.ip_forward=1 2>/dev/null || warn "Could not enable IP forwarding (may need kernel access)"
|
||||
|
||||
# --- 6. Start and configure firewalld ---
|
||||
log "Enabling firewalld..."
|
||||
systemctl enable firewalld || warn "Could not enable firewalld (already running?)"
|
||||
systemctl start firewalld || warn "Could not start firewalld (may need D-Bus)"
|
||||
|
||||
# Allow management access (HTTP/HTTPS for the proxy)
|
||||
firewall-cmd --permanent --add-service=http 2>/dev/null || true
|
||||
firewall-cmd --permanent --add-service=https 2>/dev/null || true
|
||||
firewall-cmd --permanent --add-service=ssh 2>/dev/null || true
|
||||
firewall-cmd --reload 2>/dev/null || true
|
||||
|
||||
# --- 7. Configure dnsmasq ---
|
||||
log "Configuring dnsmasq..."
|
||||
systemctl enable dnsmasq || true
|
||||
systemctl start dnsmasq || warn "Could not start dnsmasq (no interfaces configured yet)"
|
||||
|
||||
# --- 8. Setup nginx management proxy ---
|
||||
log "Generating self-signed certificate for management domain..."
|
||||
mkdir -p "$USER_HOME/.acme.sh/$DOMAIN"
|
||||
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout "$USER_HOME/.acme.sh/$DOMAIN/$DOMAIN.key" \
|
||||
-out "$USER_HOME/.acme.sh/$DOMAIN/fullchain.cer" \
|
||||
-subj "/CN=$DOMAIN" \
|
||||
-addext "subjectAltName=DNS:$DOMAIN"
|
||||
|
||||
chown -R "$USER_NAME:$USER_NAME" "$USER_HOME/.acme.sh"
|
||||
|
||||
# Generate htpasswd (credentials passed via environment to avoid shell injection)
|
||||
mkdir -p "$USER_HOME/vacuum-wall"
|
||||
htpasswd -cb "$USER_HOME/vacuum-wall/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
|
||||
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" python3 -c "
|
||||
import os, crypt, base64
|
||||
password = os.environ['MGMT_PASS']
|
||||
user = os.environ['MGMT_USER']
|
||||
salt = '\$6\$' + base64.b64encode(os.urandom(16)).decode().rstrip('=')[:16]
|
||||
hashed = crypt.crypt(password, salt)
|
||||
with open('$USER_HOME/vacuum-wall/.htpasswd', 'w') as f:
|
||||
f.write(user + ':' + hashed + '\n')
|
||||
" 2>/dev/null || \
|
||||
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
|
||||
|
||||
chown "$USER_NAME:$USER_NAME" "$USER_HOME/vacuum-wall/.htpasswd" 2>/dev/null
|
||||
|
||||
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
|
||||
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
|
||||
# Vacuum Wall - WebSocket upgrade map
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
MAPEOF
|
||||
|
||||
# Write the management site block (conf.d/ is inside http {}, no extra http {} needed)
|
||||
cat > /etc/nginx/conf.d/vacuum-wall-mgmt.conf <<MGMTSITEEOF
|
||||
# Vacuum Wall - Management Proxy
|
||||
# Auto-generated by install.sh
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $DOMAIN;
|
||||
|
||||
ssl_certificate $USER_HOME/.acme.sh/$DOMAIN/fullchain.cer;
|
||||
ssl_certificate_key $USER_HOME/.acme.sh/$DOMAIN/$DOMAIN.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
auth_basic "Vacuum Wall";
|
||||
auth_basic_user_file $USER_HOME/vacuum-wall/.htpasswd;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection \$connection_upgrade;
|
||||
}
|
||||
}
|
||||
MGMTSITEEOF
|
||||
|
||||
# --- 9. Install systemd units ---
|
||||
log "Installing systemd units..."
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall.service" /etc/systemd/system/vacuum-wall.service
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" /etc/systemd/system/vacuum-wall-acme.service
|
||||
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- 10. Setup initial firewalld zones ---
|
||||
log "Setting up default firewalld zones..."
|
||||
|
||||
# Internal zone (LAN)
|
||||
firewall-cmd --permanent --new-zone=internal 2>/dev/null || true
|
||||
firewall-cmd --permanent --zone=internal --set-target=ACCEPT 2>/dev/null || true
|
||||
firewall-cmd --permanent --zone=internal --add-service=dhcp 2>/dev/null || true
|
||||
firewall-cmd --permanent --zone=internal --add-service=dns 2>/dev/null || true
|
||||
firewall-cmd --permanent --zone=internal --add-service=ntp 2>/dev/null || true
|
||||
|
||||
# VPN zone
|
||||
firewall-cmd --permanent --new-zone=vpn 2>/dev/null || true
|
||||
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT 2>/dev/null || true
|
||||
|
||||
# Public/external zone defaults are fine
|
||||
|
||||
firewall-cmd --reload 2>/dev/null || true
|
||||
|
||||
# --- 11. Enable and start services ---
|
||||
log "Enabling services..."
|
||||
systemctl enable nginx
|
||||
systemctl enable vacuum-wall
|
||||
systemctl enable vacuum-wall-acme.timer
|
||||
|
||||
systemctl start nginx 2>/dev/null || warn "Could not start nginx (check config)"
|
||||
systemctl start vacuum-wall 2>/dev/null || warn "Could not start vacuum-wall WebUI"
|
||||
|
||||
# --- 12. Configure acme.sh default email ---
|
||||
log "Configuring acme.sh default email..."
|
||||
"$USER_HOME/.acme.sh/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
||||
|
||||
# --- Done ---
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " Management UI: https://$DOMAIN"
|
||||
echo " User: $MGMT_USER"
|
||||
echo " WebUI service: vacuum-wall.service"
|
||||
echo " ACME renewal: vacuum-wall-acme.timer"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Assign interfaces to zones from the WebUI"
|
||||
echo " 2. Configure DHCP ranges for your LAN"
|
||||
echo " 3. Add proxy domains with Let's Encrypt certs"
|
||||
echo " 4. Set up WireGuard tunnel (optional)"
|
||||
echo ""
|
||||
echo " NOTE: A self-signed certificate was generated."
|
||||
echo " From the WebUI, issue a real certificate for $DOMAIN"
|
||||
echo " when DNS points to this appliance."
|
||||
echo ""
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
ACME certificate manager for Vacuum Wall.
|
||||
|
||||
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
|
||||
from Let's Encrypt (or other ACME providers).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACME_ENVIRON = {
|
||||
"HOME": str(Path.home()),
|
||||
"PATH": os.environ.get(
|
||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
"""Locate the acme.sh binary on the system.
|
||||
|
||||
Checks:
|
||||
1. ~/.acme.sh/acme.sh
|
||||
2. /usr/local/bin/acme.sh
|
||||
|
||||
Returns:
|
||||
Absolute path to the acme.sh binary.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If acme.sh cannot be found.
|
||||
"""
|
||||
candidates = [
|
||||
Path.home() / ".acme.sh" / "acme.sh",
|
||||
Path("/usr/local/bin/acme.sh"),
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
if path.is_file() and os.access(path, os.X_OK):
|
||||
logger.info("Found acme.sh at %s", path)
|
||||
return str(path)
|
||||
|
||||
acme = shutil.which("acme.sh")
|
||||
if acme:
|
||||
logger.info("Found acme.sh via PATH at %s", acme)
|
||||
return acme
|
||||
|
||||
raise FileNotFoundError(
|
||||
"acme.sh not found in any standard location. "
|
||||
"Install it with: curl -sSL https://get.acme.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
"""Execute acme.sh with the given arguments.
|
||||
|
||||
Runs the command as root via sudo because standalone / webroot
|
||||
validation often requires binding to privileged ports (80/443).
|
||||
|
||||
Args:
|
||||
args: List of arguments to pass to acme.sh.
|
||||
|
||||
Returns:
|
||||
Combined stdout + stderr from the command, since acme.sh writes
|
||||
meaningful output to both streams.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the acme.sh command exits with a non-zero code.
|
||||
"""
|
||||
acme_bin = _find_acme()
|
||||
|
||||
cmd: list[str] = [
|
||||
"sudo",
|
||||
acme_bin,
|
||||
"--home",
|
||||
str(Path.home() / ".acme.sh"),
|
||||
"--config-home",
|
||||
str(Path.home() / ".acme.sh"),
|
||||
*args,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, **_ACME_ENVIRON},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"acme.sh command timed out after 120s: {' '.join(cmd)}"
|
||||
) from exc
|
||||
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output = output + result.stderr if output else result.stderr
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
|
||||
raise RuntimeError(
|
||||
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def set_email(email: str) -> None:
|
||||
"""Configure the default ACME contact email.
|
||||
|
||||
Registers or updates the ACME account with the given email address.
|
||||
|
||||
Args:
|
||||
email: The contact email for the ACME account.
|
||||
"""
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
logger.info("ACME contact email set to %s", email)
|
||||
|
||||
|
||||
def get_email() -> str:
|
||||
"""Return the ACME contact email, or '' if none is configured."""
|
||||
try:
|
||||
account_conf = Path.home() / ".acme.sh" / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read account.conf: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def issue(domain: str, webroot: str | None = None, standalone: bool = False) -> dict:
|
||||
"""Issue a new SSL certificate for a domain.
|
||||
|
||||
Args:
|
||||
domain: The primary domain name.
|
||||
webroot: Path to the web root directory for HTTP-01 validation.
|
||||
standalone: If True, use standalone TCP validation (binds port 80).
|
||||
|
||||
Returns:
|
||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
||||
"""
|
||||
args: list[str] = ["--issue", "-d", domain]
|
||||
|
||||
if webroot:
|
||||
args.extend(["--webroot", webroot])
|
||||
elif standalone:
|
||||
args.append("--standalone")
|
||||
|
||||
email = get_email()
|
||||
if email:
|
||||
args.extend(["-m", email])
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"message": f"Certificate for {domain} issued successfully",
|
||||
"output": output.strip(),
|
||||
"error": None,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"domain": domain,
|
||||
"message": f"Failed to issue certificate for {domain}",
|
||||
"output": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def renew(domain: str, force: bool = False) -> dict:
|
||||
"""Renew an existing SSL certificate.
|
||||
|
||||
Args:
|
||||
domain: The domain whose certificate should be renewed.
|
||||
force: If True, renew even if the certificate isn't close to expiry.
|
||||
|
||||
Returns:
|
||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
||||
"""
|
||||
args: list[str] = ["--renew", "-d", domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"message": f"Certificate for {domain} renewed successfully",
|
||||
"output": output.strip(),
|
||||
"error": None,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"domain": domain,
|
||||
"message": f"Failed to renew certificate for {domain}",
|
||||
"output": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def remove(domain: str) -> str:
|
||||
"""Stop auto-renewal for a domain.
|
||||
|
||||
Runs ``acme.sh --remove`` which stops the cron job from renewing
|
||||
the certificate. Per the acme.sh README the cert/key files are
|
||||
**not** deleted from disk after ``--remove``; remove them with
|
||||
the ``--ecc`` flag if needed, or delete the ``~/.acme.sh/{domain}``
|
||||
directory manually.
|
||||
|
||||
Args:
|
||||
domain: The domain to remove from the renewal list.
|
||||
|
||||
Returns:
|
||||
The combined stdout from the acme.sh command.
|
||||
"""
|
||||
output = _run_acme(["--remove", "-d", domain])
|
||||
logger.info("Certificate for %s removed", domain)
|
||||
return output
|
||||
|
||||
|
||||
def list_certs() -> list[dict]:
|
||||
"""List all managed certificates with expiry information.
|
||||
|
||||
Returns:
|
||||
A list of dicts, one per certificate, with keys matching
|
||||
the cert-info schema (domain, ca, cert_path, etc.).
|
||||
"""
|
||||
raw = _run_acme(["--list"])
|
||||
certs: list[dict] = []
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home = Path.home() / ".acme.sh"
|
||||
|
||||
for entry in entries:
|
||||
main = entry["main_domain"]
|
||||
if not main:
|
||||
continue
|
||||
|
||||
san_domains = [
|
||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
||||
]
|
||||
|
||||
cert_dir = acme_home / main
|
||||
cert_path = str(cert_dir / "fullchain.cer")
|
||||
key_path = str(cert_dir / f"{main}.key")
|
||||
ca_path = str(cert_dir / "ca.cer")
|
||||
|
||||
days = _days_until(entry.get("certificate_expires", ""))
|
||||
auto = _has_auto_renew(main)
|
||||
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"ca": entry.get("CA", ""),
|
||||
"cert_path": cert_path,
|
||||
"key_path": key_path,
|
||||
"ca_path": ca_path,
|
||||
"issued_at": entry.get("certificate_date", ""),
|
||||
"expires_at": entry.get("certificate_expires", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": auto,
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
|
||||
return certs
|
||||
|
||||
|
||||
def get_cert_info(domain: str) -> dict:
|
||||
"""Return detailed information about a certificate.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
A dict matching the cert-info schema.
|
||||
|
||||
Raises:
|
||||
ValueError: If no certificate is found for the domain.
|
||||
"""
|
||||
certs = list_certs()
|
||||
for c in certs:
|
||||
if c["domain"] == domain or domain in c["san_domains"]:
|
||||
return c
|
||||
|
||||
raise ValueError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
def get_expiry(domain: str) -> str | None:
|
||||
"""Return the certificate expiry date as an ISO string, or None.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Expiry date string (e.g. '2026-04-15') or None.
|
||||
"""
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return info.get("expires_at")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_expired(domain: str) -> bool:
|
||||
"""Check whether a certificate has expired.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
True if the certificate is expired or not found, False otherwise.
|
||||
"""
|
||||
days = days_until_expiry(domain)
|
||||
if days is None:
|
||||
return True
|
||||
return days < 0
|
||||
|
||||
|
||||
def days_until_expiry(domain: str) -> int | None:
|
||||
"""Calculate the number of days until a certificate expires.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Integer days remaining (negative if expired), or None if cert not found.
|
||||
"""
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return _days_until(info.get("expires_at", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def copy_cert(domain: str, dest_dir: str) -> dict:
|
||||
"""Copy certificate files to a target directory.
|
||||
|
||||
Copies the fullchain, key, and CA certificate files.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
dest_dir: Destination directory path.
|
||||
|
||||
Returns:
|
||||
A dict with paths to the copied files.
|
||||
"""
|
||||
paths = get_cert_paths(domain)
|
||||
target = Path(dest_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied = {}
|
||||
for label, src in paths.items():
|
||||
src_path = Path(src)
|
||||
if src_path.is_file():
|
||||
dst = target / src_path.name
|
||||
shutil.copy2(str(src_path), str(dst))
|
||||
copied[label] = str(dst)
|
||||
else:
|
||||
logger.warning("Source %s (%s) not found, skipping", label, src)
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"dest_dir": str(target),
|
||||
"copied": copied,
|
||||
"failed": [k for k in paths if k not in copied],
|
||||
}
|
||||
|
||||
|
||||
def get_cert_paths(domain: str) -> dict:
|
||||
"""Return the file paths for all certificate components.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
||||
"""
|
||||
acme_home = str(Path.home() / ".acme.sh" / domain)
|
||||
return {
|
||||
"cert": f"{acme_home}/{domain}.cert",
|
||||
"key": f"{acme_home}/{domain}.key",
|
||||
"ca": f"{acme_home}/ca.cer",
|
||||
"fullchain": f"{acme_home}/fullchain.cer",
|
||||
}
|
||||
|
||||
|
||||
def setup_nginx_install(domain: str) -> None:
|
||||
"""Configure acme.sh to automatically install certs for nginx.
|
||||
|
||||
Sets up a post-hook so that nginx-specific files are copied to
|
||||
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed
|
||||
by an nginx reload.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
"""
|
||||
cert_dest = f"/etc/ssl/certs/{domain}"
|
||||
key_dest = f"/etc/ssl/private/{domain}.key"
|
||||
|
||||
args: list[str] = [
|
||||
"--install-cert",
|
||||
"-d",
|
||||
domain,
|
||||
"--cert-file",
|
||||
cert_dest,
|
||||
"--key-file",
|
||||
key_dest,
|
||||
"--ca-file",
|
||||
f"/etc/ssl/certs/{domain}-ca.crt",
|
||||
"--fullchain-file",
|
||||
f"/etc/ssl/certs/{domain}-fullchain.crt",
|
||||
"--reloadcmd",
|
||||
"sudo nginx -t && sudo systemctl reload nginx",
|
||||
]
|
||||
|
||||
_run_acme(args)
|
||||
logger.info("nginx auto-install configured for %s", domain)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_list_output(raw: str) -> list[dict]:
|
||||
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
|
||||
|
||||
Each line in the output contains ``Key:Value`` tokens separated by
|
||||
whitespace, e.g.::
|
||||
|
||||
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
|
||||
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
|
||||
|
||||
Keys are converted to lowercase in the returned dicts.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
for line in raw.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
for token in line.split():
|
||||
if ":" not in token:
|
||||
continue
|
||||
key, _, value = token.partition(":")
|
||||
entry[key.lower()] = value
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
"""Parse an ISO date string and return days until that date from now."""
|
||||
if not date_str:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y%m%d%H%M%z"):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
|
||||
delta = dt - datetime.now(UTC)
|
||||
return delta.days
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _has_auto_renew(domain: str) -> bool:
|
||||
"""Check whether a domain has a scheduled cron renewal.
|
||||
|
||||
The README states the cron entry format is:
|
||||
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null
|
||||
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/``
|
||||
indicates the domain is being tracked by the cron job.
|
||||
"""
|
||||
acme_home = Path.home() / ".acme.sh"
|
||||
|
||||
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
|
||||
# per-domain config exists, the cron will pick it up.
|
||||
domain_conf = acme_home / f"{domain}.conf"
|
||||
if domain_conf.is_file():
|
||||
return True
|
||||
|
||||
# Fallback: check crontab -l for the domain.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "crontab", "-l"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and "--cron" in result.stdout:
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return False
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
|
||||
|
||||
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
|
||||
static leases, and custom DNS records through sudo.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||
CONFIG_PATH = DATA_DIR / "config.json"
|
||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
# --- defaults ---
|
||||
DEFAULT_CFG: dict[str, Any] = {
|
||||
"dhcp": {
|
||||
"ranges": [],
|
||||
"static_leases": [],
|
||||
},
|
||||
"dns": {
|
||||
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
||||
"domain": None,
|
||||
"custom_records": [],
|
||||
},
|
||||
}
|
||||
|
||||
# ───────── helpers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ensure_dirs() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["sudo", *list(cmd)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_json(path: Path, data: dict) -> None:
|
||||
_ensure_dirs()
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
|
||||
def _deep_merge(base: dict, overrides: dict) -> dict:
|
||||
result = deepcopy(base)
|
||||
for k, v in overrides.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = _deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = deepcopy(v)
|
||||
return result
|
||||
|
||||
|
||||
# ───────── config lifecycle ──────────────────────────────────────────
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Load current dnsmasq config from JSON state file."""
|
||||
_ensure_dirs()
|
||||
raw = _load_json(CONFIG_PATH)
|
||||
if not raw:
|
||||
return deepcopy(DEFAULT_CFG)
|
||||
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
|
||||
_ensure_dirs()
|
||||
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||
_save_json(CONFIG_PATH, merged)
|
||||
|
||||
|
||||
def apply_config() -> None:
|
||||
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
|
||||
cfg = get_config()
|
||||
conf_text = generate_conf(cfg)
|
||||
|
||||
_ensure_dirs()
|
||||
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
|
||||
subprocess.run(
|
||||
["sudo", "tee", DNSMASQ_CONF, "--"],
|
||||
input=conf_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
_sudo("systemctl", "reload", "dnsmasq")
|
||||
|
||||
|
||||
# ───────── config generation ─────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_conf(cfg: dict) -> str:
|
||||
"""Render a complete dnsmasq.conf text block from the config dict."""
|
||||
dhcp_cfg = cfg.get("dhcp", {})
|
||||
dns_cfg = cfg.get("dns", {})
|
||||
|
||||
interfaces = [
|
||||
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
|
||||
]
|
||||
|
||||
tmpl = ENV.get_template("dnsmasq.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interfaces=interfaces,
|
||||
dhcp=dhcp_cfg,
|
||||
dns=dns_cfg,
|
||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
||||
)
|
||||
|
||||
|
||||
# ───────── dhcp management ───────────────────────────────────────────
|
||||
|
||||
|
||||
def set_dhcp_range(
|
||||
iface: str,
|
||||
start: str,
|
||||
end: str,
|
||||
lease_time: str = "12h",
|
||||
gateway: str | None = None,
|
||||
dns: str | None = None,
|
||||
) -> None:
|
||||
"""Add or replace the DHCP range for a given interface."""
|
||||
cfg = get_config()
|
||||
ranges = cfg["dhcp"]["ranges"]
|
||||
|
||||
found = False
|
||||
for i, r in enumerate(ranges):
|
||||
if r.get("interface") == iface:
|
||||
ranges[i] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if gateway:
|
||||
ranges[i]["gateway"] = gateway
|
||||
if dns:
|
||||
ranges[i]["dns"] = dns
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
entry: dict[str, Any] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if gateway:
|
||||
entry["gateway"] = gateway
|
||||
if dns:
|
||||
entry["dns"] = dns
|
||||
ranges.append(entry)
|
||||
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
|
||||
"""Add (or update) a static DHCP lease by MAC address."""
|
||||
cfg = get_config()
|
||||
leases = cfg["dhcp"]["static_leases"]
|
||||
|
||||
for i, lease in enumerate(leases):
|
||||
if lease["mac"].lower() == mac.lower():
|
||||
leases[i] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
leases[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def remove_static_lease(mac: str) -> None:
|
||||
"""Remove a static DHCP lease by MAC address."""
|
||||
cfg = get_config()
|
||||
cfg["dhcp"]["static_leases"] = [
|
||||
lease
|
||||
for lease in cfg["dhcp"]["static_leases"]
|
||||
if lease["mac"].lower() != mac.lower()
|
||||
]
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# ───────── dns record management ─────────────────────────────────────
|
||||
|
||||
|
||||
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
|
||||
"""Add or update a custom DNS A record."""
|
||||
cfg = get_config()
|
||||
records = cfg["dns"]["custom_records"]
|
||||
|
||||
for i, r in enumerate(records):
|
||||
if r["name"] == name:
|
||||
records[i] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
records[i]["hostname"] = hostname
|
||||
save_config(cfg)
|
||||
return
|
||||
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def remove_dns_record(name: str) -> None:
|
||||
"""Remove a custom DNS record by name."""
|
||||
cfg = get_config()
|
||||
cfg["dns"]["custom_records"] = [
|
||||
r for r in cfg["dns"]["custom_records"] if r["name"] != name
|
||||
]
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# ───────── lease table ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_lease_line(line: str) -> dict[str, Any] | None:
|
||||
"""Parse one line from dnsmasq.leases into a dict."""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
ts = None
|
||||
|
||||
return {
|
||||
"expires_at": ts,
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
|
||||
|
||||
def get_lease_table() -> list[dict]:
|
||||
"""Read and parse the current dnsmasq lease file."""
|
||||
leases: list[dict] = []
|
||||
try:
|
||||
result = _sudo("cat", LEASE_FILE)
|
||||
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
||||
if entry is not None:
|
||||
leases.append(entry)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
return leases
|
||||
|
||||
|
||||
# ───────── upstream / domain helpers ─────────────────────────────────
|
||||
|
||||
|
||||
def set_upstreams(servers: list[str]) -> None:
|
||||
"""Set the list of upstream DNS forwarders."""
|
||||
cfg = get_config()
|
||||
cfg["dns"]["upstreams"] = list(servers)
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def set_domain(domain: str | None) -> None:
|
||||
"""Set (or clear) the local DNS domain."""
|
||||
cfg = get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# ───────── status / info ─────────────────────────────────────────────
|
||||
|
||||
|
||||
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"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
active = proc.stdout.strip() == "active"
|
||||
except Exception:
|
||||
active = False
|
||||
|
||||
# config on disk
|
||||
conf_exists = os.path.isfile(DNSMASQ_CONF)
|
||||
if conf_exists:
|
||||
try:
|
||||
with open(DNSMASQ_CONF) as f:
|
||||
conf_on_disk = f.read()
|
||||
except PermissionError:
|
||||
conf_on_disk = ""
|
||||
else:
|
||||
conf_on_disk = ""
|
||||
|
||||
# current expected config
|
||||
expected = generate_conf(cfg)
|
||||
|
||||
leases = get_lease_table()
|
||||
|
||||
return {
|
||||
"service_active": active,
|
||||
"config_file_exists": conf_exists,
|
||||
"config_in_sync": conf_on_disk == expected,
|
||||
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
|
||||
"static_leases": len(cfg["dhcp"]["static_leases"]),
|
||||
"custom_dns_records": len(cfg["dns"]["custom_records"]),
|
||||
"upstreams": cfg["dns"]["upstreams"],
|
||||
"domain": cfg["dns"].get("domain"),
|
||||
"active_leases": len(leases),
|
||||
"leases": leases,
|
||||
}
|
||||
+655
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance.
|
||||
|
||||
Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT,
|
||||
and port-forwarding. All mutations are --permanent followed by --reload.
|
||||
|
||||
A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the
|
||||
Flask UI can inspect or restore previous configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
DATA_DIR: str = "/home/wall/vacuum-wall/data/firewall"
|
||||
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(cmd: list[str], check: bool = True) -> str:
|
||||
"""Run a command via subprocess and return its stdout.
|
||||
|
||||
Callers must include ``"sudo"`` as the first argument when the
|
||||
command requires elevated privileges.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When ``check=True`` and the process exits non-zero.
|
||||
"""
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _reload() -> None:
|
||||
"""Reload firewalld so permanent changes take effect immediately."""
|
||||
_run(["sudo", "firewall-cmd", "--reload"])
|
||||
|
||||
|
||||
def _ensure_data_dir() -> None:
|
||||
"""Create the data directory tree if it does not exist."""
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read-only queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_available_zones() -> list[str]:
|
||||
"""Return the list of all built-in (available) firewalld zone names."""
|
||||
output = _run(["sudo", "firewall-cmd", "--get-zones"])
|
||||
return output.split()
|
||||
|
||||
|
||||
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"],
|
||||
}
|
||||
"""
|
||||
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
|
||||
zones: dict[str, list[str]] = {}
|
||||
current_zone: str | None = None
|
||||
for raw_line in output.splitlines():
|
||||
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]
|
||||
if current_zone
|
||||
else zones.get(list(zones.keys())[-1], [])
|
||||
)
|
||||
for piece in stripped.split():
|
||||
if current_zone and piece not in current_ifaces:
|
||||
current_ifaces.append(piece)
|
||||
else:
|
||||
current_zone = stripped
|
||||
zones[current_zone] = []
|
||||
return zones
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
|
||||
info: dict[str, Any] = {"name": zone}
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if not value:
|
||||
# Lines like "interfaces: " or "masquerade: " when disabled
|
||||
if key in ("masquerade", "ics"):
|
||||
info[key] = False
|
||||
else:
|
||||
info[key] = []
|
||||
else:
|
||||
if key in (
|
||||
"interfaces",
|
||||
"sources",
|
||||
"services",
|
||||
"ports",
|
||||
"protocols",
|
||||
"icmp-blocks",
|
||||
"module",
|
||||
):
|
||||
info[key] = value.split()
|
||||
elif key == "forward-ports":
|
||||
info[key] = _parse_forward_ports(value)
|
||||
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", [])
|
||||
info.setdefault("services", [])
|
||||
info.setdefault("ports", [])
|
||||
info.setdefault("protocols", [])
|
||||
info.setdefault("forward-ports", [])
|
||||
info.setdefault("masquerade", False)
|
||||
info.setdefault("ics", False)
|
||||
info.setdefault("icmp-blocks", [])
|
||||
info.setdefault("module", [])
|
||||
info.setdefault("target", "default")
|
||||
return info
|
||||
|
||||
|
||||
def get_services() -> list[str]:
|
||||
"""Return the list of available service names known to firewalld."""
|
||||
output = _run(["sudo", "firewall-cmd", "--get-services"])
|
||||
return output.split()
|
||||
|
||||
|
||||
def get_icmp_blocks() -> list[str]:
|
||||
"""Return the list of available ICMP block names."""
|
||||
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"])
|
||||
return output.split()
|
||||
|
||||
|
||||
def get_interfaces() -> list[str]:
|
||||
"""Return the list of network interfaces visible via iproute2."""
|
||||
output = _run(["ip", "-o", "link", "show"])
|
||||
ifaces: list[str] = []
|
||||
for line in output.splitlines():
|
||||
if line:
|
||||
# Format: "NUM: NAME: <FLAGS> ..."
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
name = parts[1].rstrip(":")
|
||||
ifaces.append(name)
|
||||
return ifaces
|
||||
|
||||
|
||||
def get_rich_rules(zone: str) -> list[str]:
|
||||
"""Return the rich rules defined for *zone* as a list of raw strings."""
|
||||
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-rich-rules"])
|
||||
output = output.strip()
|
||||
if not output:
|
||||
return []
|
||||
rules: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in output.splitlines():
|
||||
raw = line.rstrip()
|
||||
if not raw.endswith(";"):
|
||||
current.append(raw)
|
||||
else:
|
||||
current.append(raw)
|
||||
rules.append(" ".join(current))
|
||||
current = []
|
||||
if current:
|
||||
rules.append(" ".join(current))
|
||||
return rules
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def delete_zone(zone: str) -> None:
|
||||
"""Delete an existing zone.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the zone does not exist or the deletion fails.
|
||||
"""
|
||||
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
try:
|
||||
current = get_zone_info(zone).get("interfaces", [])
|
||||
except Exception:
|
||||
current = []
|
||||
for iface in current:
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Add the desired set.
|
||||
for iface in interfaces:
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def add_zone_interface(zone: str, iface: str) -> None:
|
||||
"""Add a single interface to *zone*."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def remove_zone_interface(zone: str, iface: str) -> None:
|
||||
"""Remove a single interface from *zone*."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--remove-service={svc}",
|
||||
"--permanent",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
for svc in services:
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--add-service={svc}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def add_zone_service(zone: str, service: str) -> None:
|
||||
"""Add a single service to *zone*."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--add-service={service}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def remove_zone_service(zone: str, service: str) -> None:
|
||||
"""Remove a single service from *zone*."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--remove-service={service}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--add-rich-rule=" + rule,
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def remove_rich_rule(zone: str, rule: str) -> None:
|
||||
"""Remove a rich rule from *zone*.
|
||||
|
||||
The rule string must match exactly what was added.
|
||||
"""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-rich-rule=" + rule,
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masquerade (NAT)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_masquerade(zone: str, enable: bool) -> None:
|
||||
"""Enable or disable masquerade (source-NAT) on *zone*."""
|
||||
action = "--add-masquerade" if enable else "--remove-masquerade"
|
||||
_run(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_forward_port(
|
||||
zone: str,
|
||||
port: int,
|
||||
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).
|
||||
"""
|
||||
fwd = f"port={port}/proto={protocol}"
|
||||
if toaddr and toport:
|
||||
fwd += f"/toaddr={toaddr}/toport={toport}"
|
||||
elif toport:
|
||||
fwd += f"/toport={toport}"
|
||||
else:
|
||||
fwd += f"/toaddr={toaddr}" if toaddr else ""
|
||||
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--add-forward-port={fwd}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
def remove_forward_port(
|
||||
zone: str,
|
||||
port: int,
|
||||
protocol: str,
|
||||
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.
|
||||
"""
|
||||
fwd = f"port={port}/proto={protocol}"
|
||||
if toaddr and toport:
|
||||
fwd += f"/toaddr={toaddr}/toport={toport}"
|
||||
elif toport:
|
||||
fwd += f"/toport={toport}"
|
||||
else:
|
||||
fwd += f"/toaddr={toaddr}" if toaddr else ""
|
||||
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--remove-forward-port={fwd}",
|
||||
"--permanent",
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for parsing forward-port lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_forward_ports(value: str) -> list[str]:
|
||||
"""Parse the 'forward-ports' line into individual forward-port specifiers.
|
||||
|
||||
Multiple entries are space-separated; each looks like
|
||||
``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``.
|
||||
"""
|
||||
return value.split() if value else []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State snapshot / backup helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for name in get_available_zones():
|
||||
try:
|
||||
zones[name] = get_zone_info(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"active_zones": get_active_zones(),
|
||||
"interfaces": get_interfaces(),
|
||||
"available_services": get_services(),
|
||||
"zones": zones,
|
||||
"rich_rules": {name: get_rich_rules(name) for name in zones},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
_ensure_data_dir()
|
||||
state = get_state()
|
||||
with open(RULES_FILE, "w") as fh:
|
||||
json.dump(state, fh, indent=2, default=str)
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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 raw strings in zinfo)
|
||||
for fp in zinfo.get("forward-ports", []):
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--add-forward-port={fp}",
|
||||
"--permanent",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Rich rules
|
||||
for rule in zinfo.get("rich-rules", []):
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--add-rich-rule={rule}",
|
||||
"--permanent",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
_reload()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DATA_DIR",
|
||||
"RULES_FILE",
|
||||
"_reload",
|
||||
"_run",
|
||||
"add_forward_port",
|
||||
"add_rich_rule",
|
||||
"add_zone_interface",
|
||||
"add_zone_service",
|
||||
"create_zone",
|
||||
"delete_zone",
|
||||
"get_active_zones",
|
||||
"get_available_zones",
|
||||
"get_icmp_blocks",
|
||||
"get_interfaces",
|
||||
"get_rich_rules",
|
||||
"get_services",
|
||||
"get_state",
|
||||
"get_zone_info",
|
||||
"load_backup",
|
||||
"remove_forward_port",
|
||||
"remove_rich_rule",
|
||||
"remove_zone_interface",
|
||||
"remove_zone_service",
|
||||
"restore_backup",
|
||||
"save_backup",
|
||||
"set_masquerade",
|
||||
"set_zone_interfaces",
|
||||
"set_zone_services",
|
||||
]
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Nginx server-block generator for Vacuum Wall SSL proxy firewall.
|
||||
|
||||
Manages per-domain SSL reverse proxy configurations, certificate
|
||||
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
||||
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
||||
SITES_DIR = DATA_DIR / "sites-enabled"
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
|
||||
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
|
||||
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_SSL = {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": (
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-RSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
),
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": DEFAULT_SSL.copy(),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_dirs():
|
||||
SITES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _run(cmd, **kw):
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
|
||||
|
||||
|
||||
def _json_load(path):
|
||||
_ensure_dirs()
|
||||
if not path.exists():
|
||||
return DEFAULT_CONFIG.copy()
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if "ssl" not in data:
|
||||
data["ssl"] = DEFAULT_SSL.copy()
|
||||
return data
|
||||
|
||||
|
||||
def _json_dump(path, data):
|
||||
_ensure_dirs()
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
return _json_load(CONFIG_FILE)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
_json_dump(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
def get_domains() -> list[dict]:
|
||||
cfg = get_config()
|
||||
result = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
result.append(
|
||||
{
|
||||
"domain": name,
|
||||
"backend": dom.get("backend", {}),
|
||||
"online": site.exists(),
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Domain CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_domain(
|
||||
domain,
|
||||
backend_host,
|
||||
backend_port,
|
||||
backend_proto="http",
|
||||
cert=None,
|
||||
extra_headers=None,
|
||||
) -> None:
|
||||
cfg = get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
entry = {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
if extra_headers is not None:
|
||||
entry["headers"] = extra_headers
|
||||
cfg["domains"][domain] = entry
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def remove_domain(domain) -> None:
|
||||
cfg = get_config()
|
||||
cfg["domains"].pop(domain, None)
|
||||
save_config(cfg)
|
||||
site = SITES_DIR / f"{domain}.conf"
|
||||
if site.exists():
|
||||
site.unlink()
|
||||
|
||||
|
||||
def update_domain(domain, **kwargs) -> None:
|
||||
cfg = get_config()
|
||||
if domain not in cfg["domains"]:
|
||||
raise KeyError(f"Domain {domain!r} not configured")
|
||||
entry = cfg["domains"][domain]
|
||||
for key, val in kwargs.items():
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
else:
|
||||
entry[key] = val
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nginx config generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_server_conf(domain_cfg: dict) -> str:
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
return tmpl.render(
|
||||
domain=domain_cfg["domain"],
|
||||
backend=domain_cfg.get("backend", {}),
|
||||
headers=domain_cfg.get("headers", {}),
|
||||
force_ssl=domain_cfg.get("force_ssl", True),
|
||||
cert=domain_cfg.get("cert"),
|
||||
auth=domain_cfg.get("auth"),
|
||||
is_management=False,
|
||||
)
|
||||
|
||||
|
||||
def _generate_management_conf(management: dict) -> str:
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
return tmpl.render(
|
||||
domain=management.get("domain", "wall.lan"),
|
||||
backend=dict(
|
||||
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
||||
),
|
||||
headers={},
|
||||
force_ssl=True,
|
||||
cert=None,
|
||||
auth=management.get("auth"),
|
||||
is_management=True,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File writers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_site(domain, conf_text) -> None:
|
||||
_ensure_dirs()
|
||||
path = SITES_DIR / f"{domain}.conf"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def write_all_sites() -> None:
|
||||
_ensure_dirs()
|
||||
cfg = get_config()
|
||||
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||
|
||||
written = set()
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
dom_copy = dict(dom, domain=name)
|
||||
conf = generate_server_conf(dom_copy)
|
||||
write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
|
||||
if cfg.get("management"):
|
||||
mgmt_conf = _generate_management_conf(cfg["management"])
|
||||
write_site("management", mgmt_conf)
|
||||
written.add("management.conf")
|
||||
|
||||
for old in existing:
|
||||
if old.suffix == ".conf" and old.name not in written:
|
||||
old.unlink()
|
||||
|
||||
|
||||
def write_include_file() -> None:
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = INCLUDE_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_ssl_snippet() -> None:
|
||||
cfg = get_config()
|
||||
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
|
||||
ssl_cfg.setdefault("prefer_server_ciphers", False)
|
||||
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
|
||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||
|
||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||
content = tmpl.render(ssl=ssl_cfg)
|
||||
tmp = SSL_SNIPPET.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
|
||||
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# nginx lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config() -> tuple[bool, str]:
|
||||
result = _run(["sudo", "nginx", "-t"])
|
||||
ok = result.returncode == 0
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
if not output and ok:
|
||||
output = "nginx configuration test passed"
|
||||
return ok, output
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
write_ssl_snippet()
|
||||
write_all_sites()
|
||||
write_include_file()
|
||||
ok, msg = test_config()
|
||||
if not ok:
|
||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||
_run(["sudo", "nginx", "-s", "reload"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Management WebUI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_management_proxy(
|
||||
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
|
||||
) -> None:
|
||||
cfg = get_config()
|
||||
entry = {
|
||||
"domain": domain,
|
||||
"backend": {
|
||||
"host": flask_host,
|
||||
"port": int(flask_port),
|
||||
"proto": "http",
|
||||
},
|
||||
}
|
||||
if auth_user:
|
||||
entry["auth"] = {
|
||||
"user": auth_user,
|
||||
"htpasswd": str(HTPASSWD_FILE),
|
||||
}
|
||||
cfg["management"] = entry
|
||||
save_config(cfg)
|
||||
if auth_user and auth_pass:
|
||||
write_htpasswd(auth_user, auth_pass)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# htpasswd
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
_ensure_dirs()
|
||||
hashed = _hash_password(password)
|
||||
existing = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
existing[parts[0]] = line
|
||||
|
||||
existing[user] = f"{user}:{hashed}"
|
||||
|
||||
tmp = HTPASSWD_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
for _uname, entry in existing.items():
|
||||
f.write(entry + "\n")
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
def _hash_password(password):
|
||||
try:
|
||||
from passlib.hash import apache_passwd
|
||||
|
||||
return apache_passwd.using(rounds=12).hash(password)
|
||||
except Exception:
|
||||
import crypt as _crypt
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
return _crypt.crypt(password, f"$5${salt}")
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
|
||||
|
||||
Generates wg-quick configurations, manages peers, and controls
|
||||
the WireGuard tunnel interface.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
||||
CONFIG_PATH = str(PROJECT_DIR / "data" / "wireguard" / "config.json")
|
||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||
WG_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a command via sudo and return the completed process."""
|
||||
return subprocess.run(
|
||||
["sudo", *cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_dir(path: str) -> None:
|
||||
"""Create parent directories for *path* if they don't exist."""
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _default_config() -> dict:
|
||||
"""Return the skeleton config with no keys and no peers."""
|
||||
return {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
|
||||
# --- Core config persistence ---
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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"])
|
||||
cfg["interface"].setdefault("listen_port", defaults["interface"]["listen_port"])
|
||||
cfg["interface"].setdefault("private_key", defaults["interface"]["private_key"])
|
||||
cfg["interface"].setdefault("public_key", defaults["interface"]["public_key"])
|
||||
cfg["interface"].setdefault("addresses", defaults["interface"]["addresses"])
|
||||
cfg["interface"].setdefault("post_up", defaults["interface"]["post_up"])
|
||||
cfg["interface"].setdefault("post_down", defaults["interface"]["post_down"])
|
||||
cfg.setdefault("peers", {})
|
||||
return cfg
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return _default_config()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
_ensure_dir(CONFIG_PATH)
|
||||
tmp = CONFIG_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(cfg, f, indent=4)
|
||||
f.write("\n")
|
||||
os.replace(tmp, CONFIG_PATH)
|
||||
|
||||
|
||||
# --- Key generation ---
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
res = _run([WG_BIN, "genkey"])
|
||||
private_key = res.stdout.strip()
|
||||
res2 = _run([WG_BIN, "pubkey"], input=private_key)
|
||||
public_key = res2.stdout.strip()
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
# --- wg0.conf generation ---
|
||||
|
||||
|
||||
def generate_conf(cfg: dict) -> str:
|
||||
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=cfg["interface"],
|
||||
peers=cfg.get("peers", {}),
|
||||
)
|
||||
|
||||
|
||||
# --- Apply / down ---
|
||||
|
||||
|
||||
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
|
||||
|
||||
local_dir = Path("/home/wall/vacuum-wall/data/wireguard")
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / "wg0.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
_run(["cp", str(local_tmp), WG_CONF_PATH])
|
||||
_run(["chown", "root:root", WG_CONF_PATH], check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
|
||||
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
|
||||
|
||||
|
||||
def down() -> None:
|
||||
"""Bring the WireGuard tunnel interface down."""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
_run([WG_QUICK_BIN, "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``.
|
||||
"""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
|
||||
try:
|
||||
proc = _run([WG_BIN, "show", name], check=False)
|
||||
if proc.returncode != 0:
|
||||
return result
|
||||
|
||||
raw = proc.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
# Parse the wg show output.
|
||||
# Format (multi-section, separated by blank lines or interleaved):
|
||||
# interface:
|
||||
# public key: ...
|
||||
# listening port: ...
|
||||
# peer: <key>
|
||||
# endpoint: ...
|
||||
# allowed ips: ...
|
||||
# latest handshake: ...
|
||||
# transfer: ...
|
||||
# persistent-keepalive: ...
|
||||
current_peer = None
|
||||
peers: list[dict] = []
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("interface:"):
|
||||
result["up"] = True
|
||||
result["interface"] = {}
|
||||
current_peer = None
|
||||
continue
|
||||
|
||||
if line.startswith("public key:"):
|
||||
result["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
if line.startswith("listening port:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
result["interface"]["listen_port"] = int(val)
|
||||
continue
|
||||
|
||||
if line.startswith("fwmark:"):
|
||||
result["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
current_peer = {
|
||||
"public_key": cur_key,
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": 0,
|
||||
"transfer_sent": 0,
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
peers.append(current_peer)
|
||||
continue
|
||||
|
||||
if current_peer is None:
|
||||
continue
|
||||
|
||||
if line.startswith("endpoint:"):
|
||||
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
if line.startswith("allowed ips:"):
|
||||
vals = line.split(":", 1)[1].strip().split(", ")
|
||||
current_peer["allowed_ips"] = vals
|
||||
continue
|
||||
|
||||
if line.startswith("latest handshake:"):
|
||||
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
|
||||
if line.startswith("transfer:"):
|
||||
rest = line.split(":", 1)[1].strip()
|
||||
parts = rest.split(", ")
|
||||
if parts:
|
||||
current_peer["transfer_received"] = parts[0].strip()
|
||||
if len(parts) > 1:
|
||||
current_peer["transfer_sent"] = parts[1].strip()
|
||||
continue
|
||||
|
||||
if line.startswith("persistent-keepalive:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
try:
|
||||
current_peer["persistent_keepalive"] = int(val)
|
||||
except ValueError:
|
||||
current_peer["persistent_keepalive"] = None
|
||||
|
||||
result["peers"] = peers
|
||||
return result
|
||||
|
||||
|
||||
# --- Peer management ---
|
||||
|
||||
|
||||
def add_peer(
|
||||
name: str,
|
||||
endpoint: str | None = None,
|
||||
allowed_ips: list[str] | None = None,
|
||||
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.
|
||||
"""
|
||||
cfg = get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = allowed_ips or []
|
||||
|
||||
if name in peers:
|
||||
peer = peers[name]
|
||||
peer["endpoint"] = endpoint
|
||||
peer["allowed_ips"] = allowed_ips
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if preshared_key is not None:
|
||||
peer["preshared_key"] = preshared_key
|
||||
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
|
||||
"endpoint": endpoint,
|
||||
"allowed_ips": allowed_ips,
|
||||
"persistent_keepalive": persistent_keepalive,
|
||||
"preshared_key": preshared_key,
|
||||
}
|
||||
peers[name] = peer
|
||||
|
||||
save_config(cfg)
|
||||
|
||||
# Return a copy that includes the private key (safe — used for provisioning).
|
||||
peer_out = dict(peer)
|
||||
return peer_out
|
||||
|
||||
|
||||
def remove_peer(name: str) -> None:
|
||||
"""Remove a peer from the configuration by name."""
|
||||
cfg = get_config()
|
||||
cfg.setdefault("peers", {}).pop(name, None)
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
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``.
|
||||
"""
|
||||
st = status()
|
||||
return st.get("peers", [])
|
||||
|
||||
|
||||
# --- Client config generation ---
|
||||
|
||||
|
||||
def generate_client_conf(
|
||||
peer_name: str,
|
||||
server_endpoint: str,
|
||||
server_pubkey: str,
|
||||
) -> str:
|
||||
"""Build a client-side wg-quick config snippet for *peer_name*."""
|
||||
cfg = get_config()
|
||||
iface = cfg["interface"]
|
||||
peer = cfg["peers"].get(peer_name)
|
||||
if peer is None:
|
||||
raise KeyError(f"Peer '{peer_name}' not found in configuration")
|
||||
|
||||
client_priv = peer.get("private_key", "")
|
||||
if not client_priv:
|
||||
raise ValueError(
|
||||
f"Peer '{peer_name}' has no private key — cannot generate client config."
|
||||
)
|
||||
|
||||
sorted_peers = sorted(cfg.get("peers", {}).keys())
|
||||
peer_index = sorted_peers.index(peer_name) + 2
|
||||
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
|
||||
addr_part, prefix = srv_addr.rsplit("/", 1)
|
||||
prefix_base = addr_part.rsplit(".", 1)[0]
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
peer_name=peer_name,
|
||||
client_priv=client_priv,
|
||||
client_addr=client_addr,
|
||||
server_pubkey=server_pubkey,
|
||||
server_endpoint=server_endpoint,
|
||||
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
)
|
||||
|
||||
|
||||
# --- 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.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
cfg = get_config()
|
||||
cfg["interface"]["post_up"] = cmd
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def set_post_down(cmd: str | None) -> None:
|
||||
"""Set (or clear) the PostDown hook command."""
|
||||
cfg = get_config()
|
||||
cfg["interface"]["post_down"] = cmd
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# --- Initialise ---
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
return cfg
|
||||
|
||||
|
||||
# --- Utility: parse wg show into structured peer map ---
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
peers: dict = {}
|
||||
current = None
|
||||
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
key = line.split(":", 1)[1].strip()
|
||||
current = {"_key": key}
|
||||
peers[key] = current
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
if line.startswith("endpoint:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
current["endpoint"] = val
|
||||
elif line.startswith("allowed ips:"):
|
||||
current["allowed_ips"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("latest handshake:"):
|
||||
current["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
current["transfer_raw"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
||||
|
||||
return peers
|
||||
@@ -0,0 +1,54 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "vacuum-wall"
|
||||
version = "0.0.1"
|
||||
description = "SSL proxy / firewall appliance with zone-based policies"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"Flask>=3.0,<4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.4.0",
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.1",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["lib*", "webui*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
src = ["."]
|
||||
exclude = [".venv"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"SIM", # flake8-simplify
|
||||
"RUF", # ruff-specific
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long – handled by formatter
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -0,0 +1,47 @@
|
||||
# ---- vacuum-wall managed dnsmasq configuration ----
|
||||
# generated {{ timestamp }}
|
||||
|
||||
{% if interfaces %}
|
||||
interface={{ interfaces | join(',') }}
|
||||
bind-interfaces
|
||||
{% endif %}
|
||||
{% for srv in dns.upstreams %}
|
||||
server={{ srv }}
|
||||
{% else %}
|
||||
no-resolv
|
||||
{% endfor %}
|
||||
{% if dns.domain %}
|
||||
domain={{ dns.domain }}
|
||||
expand-hosts
|
||||
{% endif %}
|
||||
{% for rng in dhcp.ranges %}
|
||||
{% if rng.interface %}
|
||||
dhcp-range=set:{{ rng.interface }},{{ rng.start }},{{ rng.end }},{{ rng.lease_time }}
|
||||
{% else %}
|
||||
dhcp-range={{ rng.start }},{{ rng.end }},{{ rng.lease_time }}
|
||||
{% endif %}
|
||||
{% if rng.gateway %}
|
||||
dhcp-option=tag:{{ rng.interface }},3,{{ rng.gateway }}
|
||||
{% endif %}
|
||||
{% if rng.dns %}
|
||||
dhcp-option=tag:{{ rng.interface }},6,{{ rng.dns }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for lease in dhcp.static_leases %}
|
||||
{% if lease.hostname %}
|
||||
dhcp-host={{ lease.mac }},{{ lease.ip }},{{ lease.hostname }}
|
||||
{% else %}
|
||||
dhcp-host={{ lease.mac }},{{ lease.ip }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for rec in dns.custom_records %}
|
||||
{% if rec.hostname %}
|
||||
host-record={{ rec.name }},{{ rec.address }}
|
||||
addr/{{ rec.name }}/{{ rec.address }}
|
||||
{% else %}
|
||||
addr/{{ rec.name }}/{{ rec.address }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if fragments_dir %}
|
||||
conf-dir={{ fragments_dir }},optional
|
||||
{% endif %}# ---- end vacuum-wall config ----
|
||||
@@ -0,0 +1,11 @@
|
||||
# Vacuum Wall — auto-generated include file
|
||||
# Regenerated on every config change — do not edit manually
|
||||
|
||||
# WebSocket upgrade mapping
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Domain server blocks
|
||||
include {{ sites_glob }};
|
||||
@@ -0,0 +1,99 @@
|
||||
# Auto-generated by Vacuum Wall — do not edit manually
|
||||
# Domain: {{ domain }}
|
||||
|
||||
{% if force_ssl %}
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name {{ domain }};
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name {{ domain }};
|
||||
|
||||
{% if cert %}
|
||||
{% if cert.type == "acme" %}
|
||||
# Certificate managed by acme.sh
|
||||
{% if cert.email %} # ACME contact: {{ cert.email }}
|
||||
{% endif %} ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
|
||||
|
||||
{% elif cert.type == "file" %}
|
||||
ssl_certificate {{ cert.path }};
|
||||
ssl_certificate_key {{ cert.key_path }};
|
||||
|
||||
{% elif cert.type == "selfsigned" %}
|
||||
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
|
||||
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
|
||||
|
||||
{% endif %}
|
||||
{% elif is_management %}
|
||||
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
|
||||
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
|
||||
|
||||
{% endif %}
|
||||
# Shared SSL settings
|
||||
include snippets/vacuum-wall-ssl.conf;
|
||||
|
||||
{% if auth %}
|
||||
# HTTP basic authentication
|
||||
auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}";
|
||||
auth_basic_user_file {{ auth.htpasswd }};
|
||||
|
||||
{% endif %}
|
||||
{% if not is_management %}
|
||||
# Security hardening headers
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
{% for hname, hval in headers.items() %}
|
||||
proxy_set_header {{ hname }} {{ hval }};
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
# Proxy pass to backend
|
||||
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
|
||||
proxy_http_version 1.1;
|
||||
|
||||
{% if not is_management %}
|
||||
# Timeouts
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
|
||||
# Access / error logs
|
||||
access_log /var/log/nginx/{{ domain }}_access.log;
|
||||
error_log /var/log/nginx/{{ domain }}_error.log warn;
|
||||
{% else %}
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
|
||||
access_log /var/log/nginx/wall_mgmt_access.log;
|
||||
error_log /var/log/nginx/wall_mgmt_error.log warn;
|
||||
{% endif %}
|
||||
location / {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Vacuum Wall — shared SSL settings
|
||||
# Regenerated automatically — do not edit manually
|
||||
|
||||
ssl_protocols {{ ssl.protocols }};
|
||||
ssl_prefer_server_ciphers {{ "on" if ssl.prefer_server_ciphers else "off" }};
|
||||
ssl_ciphers {{ ssl.ciphers }};
|
||||
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:TLS:10m;
|
||||
ssl_session_tickets off;
|
||||
@@ -0,0 +1,37 @@
|
||||
# Defaults directives
|
||||
Defaults:vacuum-wall !requiretty
|
||||
Defaults:vacuum-wall secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
# Firewall management
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/firewall-cmd *
|
||||
|
||||
# Nginx management
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||
|
||||
# Dnsmasq management
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
|
||||
|
||||
# WireGuard management
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick *
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg *
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
|
||||
|
||||
# Acme.sh (SSL cert management)
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/bash ~/.acme.sh/acme.sh *
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/local/bin/acme.sh *
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /home/*/.acme.sh/*
|
||||
|
||||
# Misc
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
|
||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/wireguard
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Vacuum Wall ACME Certificate Renewal
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=vacuum-wall
|
||||
WorkingDirectory=/home/wall/vacuum-wall
|
||||
ExecStart=/usr/local/bin/acme.sh --cron --home /home/vacuum-wall/.acme.sh
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Vacuum Wall ACME Certificate Renewal Timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
OnCalendar=*-*-* 12:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=Vacuum Wall Management WebUI
|
||||
Documentation=https://github.com/wall/vacuum-wall
|
||||
After=network.target firewalld.service nginx.service dnsmasq.service
|
||||
Wants=firewalld.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vacuum-wall
|
||||
Group=vacuum-wall
|
||||
WorkingDirectory=/home/wall/vacuum-wall
|
||||
ExecStart=/home/wall/vacuum-wall/.venv/bin/python webui/server.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=PATH=/usr/local/bin:/usr/bin
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/wall/vacuum-wall/data /tmp
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictRealtime=yes
|
||||
LockPersonality=yes
|
||||
|
||||
# Network - only loopback (nginx proxies to us)
|
||||
IPAddressDeny=all
|
||||
IPAddressAllow=localhost
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
# vacuum-wall client config for {{ peer_name }} — generated {{ timestamp }}
|
||||
|
||||
[Interface]
|
||||
PrivateKey = {{ client_priv }}
|
||||
Address = {{ client_addr }}
|
||||
|
||||
[Peer]
|
||||
PublicKey = {{ server_pubkey }}
|
||||
Endpoint = {{ server_endpoint }}
|
||||
AllowedIPs = {{ allowed_ips | join(',') }}
|
||||
{% if preshared_key %}
|
||||
PresharedKey = {{ preshared_key }}
|
||||
{% endif %}
|
||||
{% if persistent_keepalive is not none %}
|
||||
PersistentKeepalive = {{ persistent_keepalive }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Auto-generated by vacuum-wall at {{ timestamp }}
|
||||
|
||||
[Interface]
|
||||
PrivateKey = {{ interface.private_key }}
|
||||
Address = {{ interface.addresses | join(',') }}
|
||||
ListenPort = {{ interface.listen_port }}
|
||||
{% if interface.post_up %}
|
||||
PostUp = {{ interface.post_up }}
|
||||
{% endif %}
|
||||
{% if interface.post_down %}
|
||||
PostDown = {{ interface.post_down }}
|
||||
{% endif %}
|
||||
{% for peer_name, peer in peers.items() %}
|
||||
|
||||
[Peer] # {{ peer_name }}
|
||||
PublicKey = {{ peer.public_key }}
|
||||
{% if peer.preshared_key %}
|
||||
PresharedKey = {{ peer.preshared_key }}
|
||||
{% endif %}
|
||||
{% if peer.endpoint %}
|
||||
Endpoint = {{ peer.endpoint }}
|
||||
{% endif %}
|
||||
{% if peer.allowed_ips %}
|
||||
AllowedIPs = {{ peer.allowed_ips | join(',') }}
|
||||
{% endif %}
|
||||
{% if peer.persistent_keepalive is not none %}
|
||||
PersistentKeepalive = {{ peer.persistent_keepalive }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,3 @@
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
@@ -0,0 +1,130 @@
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import acme
|
||||
|
||||
|
||||
class TestFindAcme:
|
||||
@patch("lib.acme.shutil.which")
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_finds_in_home(self, mock_home, mock_which):
|
||||
mock_home.return_value = Path("/tmp/fakehome")
|
||||
acme_path = mock_home.return_value / ".acme.sh" / "acme.sh"
|
||||
acme_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
acme_path.write_text("#!/bin/sh\n")
|
||||
acme_path.chmod(0o755)
|
||||
try:
|
||||
result = acme._find_acme()
|
||||
assert "acme.sh" in result
|
||||
finally:
|
||||
acme_path.unlink()
|
||||
|
||||
@patch("lib.acme.shutil.which")
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_raises_when_not_found(self, mock_home, mock_which):
|
||||
mock_home.return_value = Path("/tmp/nonexistent-acme-dir")
|
||||
mock_which.return_value = None
|
||||
with pytest.raises(FileNotFoundError):
|
||||
acme._find_acme()
|
||||
|
||||
|
||||
class TestRunAcme:
|
||||
@patch("lib.acme._find_acme")
|
||||
@patch("lib.acme.subprocess.run")
|
||||
def test_success(self, mock_run, mock_find):
|
||||
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="success\n", stderr="")
|
||||
result = acme._run_acme(["--list"])
|
||||
assert result == "success\n"
|
||||
|
||||
@patch("lib.acme._find_acme")
|
||||
@patch("lib.acme.subprocess.run")
|
||||
def test_failure(self, mock_run, mock_find):
|
||||
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error\n")
|
||||
with pytest.raises(RuntimeError):
|
||||
acme._run_acme(["--list"])
|
||||
|
||||
|
||||
class TestParseListOutput:
|
||||
def test_parses_single_entry(self):
|
||||
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["main_domain"] == "example.com"
|
||||
assert result[0]["ca"] == "LetsEncrypt"
|
||||
|
||||
def test_parses_multiple_entries(self):
|
||||
raw = (
|
||||
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
|
||||
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
|
||||
)
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty_input(self):
|
||||
result = acme._parse_list_output("")
|
||||
assert result == []
|
||||
|
||||
def test_skips_lines_without_colons(self):
|
||||
raw = "some random line\nMain_Domain:a.com"
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestDaysUntil:
|
||||
def test_future_date(self):
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
future = datetime.now(UTC) + timedelta(days=365)
|
||||
result = acme._days_until(future.strftime("%Y-%m-%d"))
|
||||
assert result >= 364
|
||||
|
||||
def test_empty_string(self):
|
||||
assert acme._days_until("") is None
|
||||
assert acme._days_until(None) is None
|
||||
|
||||
def test_invalid_format(self):
|
||||
assert acme._days_until("not-a-date") is None
|
||||
|
||||
def test_expired_date(self):
|
||||
result = acme._days_until("2020-01-01")
|
||||
assert result is not None
|
||||
assert result < 0
|
||||
|
||||
|
||||
class TestGetEmail:
|
||||
def test_returns_empty_when_no_account_conf(self):
|
||||
with patch("lib.acme.Path.home") as mock_home:
|
||||
mock_home.return_value = Path("/tmp/no-acme-email")
|
||||
result = acme.get_email()
|
||||
assert result == ""
|
||||
|
||||
def test_parses_email_from_account_conf(self):
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
acme_dir = Path(tmpdir) / ".acme.sh"
|
||||
acme_dir.mkdir(exist_ok=True)
|
||||
conf = acme_dir / "account.conf"
|
||||
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
|
||||
|
||||
with patch("lib.acme.Path.home", return_value=Path(tmpdir)):
|
||||
result = acme.get_email()
|
||||
assert result == "test@example.com"
|
||||
|
||||
|
||||
class TestGetCertPaths:
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_returns_paths(self, mock_home):
|
||||
mock_home.return_value = Path("/home/user")
|
||||
paths = acme.get_cert_paths("example.com")
|
||||
assert paths["cert"].endswith("example.com/example.com.cert")
|
||||
assert paths["key"].endswith("example.com/example.com.key")
|
||||
assert paths["ca"].endswith("example.com/ca.cer")
|
||||
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
||||
@@ -0,0 +1,369 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wg_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
app.register_blueprint(bp, url_prefix="/api/firewall")
|
||||
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(wg_bp, url_prefix="/api/wireguard")
|
||||
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestFirewallListZones:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_available, mock_active, client):
|
||||
mock_active.return_value = {"public": ["eth0"]}
|
||||
mock_available.return_value = ["public", "internal"]
|
||||
resp = client.get("/api/firewall/zones")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "public" in data["data"]["active"]
|
||||
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
def test_runtime_error(self, mock_active, client):
|
||||
mock_active.side_effect = RuntimeError("no sudo")
|
||||
resp = client.get("/api/firewall/zones")
|
||||
assert resp.status_code == 500
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is False
|
||||
|
||||
|
||||
class TestFirewallZoneDetails:
|
||||
@patch("webui.api.firewall.get_zone_info")
|
||||
def test_success(self, mock_info, client):
|
||||
mock_info.return_value = {"name": "public", "services": ["ssh"]}
|
||||
resp = client.get("/api/firewall/zones/public")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["data"]["name"] == "public"
|
||||
|
||||
|
||||
class TestFirewallCreateZone:
|
||||
@patch("webui.api.firewall.create_zone")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_zones, mock_create, client):
|
||||
mock_zones.return_value = ["public", "internal"]
|
||||
mock_create.return_value = None
|
||||
resp = client.post(
|
||||
"/api/firewall/zones",
|
||||
json={"name": "dmz", "target": "default"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_missing_name(self, client):
|
||||
resp = client.post(
|
||||
"/api/firewall/zones",
|
||||
json={"target": "default"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is False
|
||||
|
||||
|
||||
class TestFirewallDeleteZone:
|
||||
@patch("webui.api.firewall.delete_zone")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_zones, mock_delete, client):
|
||||
mock_zones.return_value = ["public", "dmz"]
|
||||
mock_delete.return_value = None
|
||||
resp = client.delete("/api/firewall/zones/dmz")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_not_found(self, mock_zones, client):
|
||||
mock_zones.return_value = ["public"]
|
||||
resp = client.delete("/api/firewall/zones/dmz")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestFirewallRichRules:
|
||||
@patch("webui.api.firewall.add_rich_rule")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/firewall/rich-rules",
|
||||
json={
|
||||
"zone": "public",
|
||||
"rule": 'rule family="ipv4" port protocol="tcp" port="443" accept',
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
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"]
|
||||
resp = client.get("/api/firewall/rich-rules/public")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["data"] == ["rule1", "rule2"]
|
||||
|
||||
|
||||
class TestFirewallServices:
|
||||
@patch("webui.api.firewall.get_services")
|
||||
def test_list(self, mock_services, client):
|
||||
mock_services.return_value = ["ssh", "http", "dns"]
|
||||
resp = client.get("/api/firewall/services")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["data"] == ["ssh", "http", "dns"]
|
||||
|
||||
|
||||
class TestFirewallInterfaces:
|
||||
@patch("webui.api.firewall.get_interfaces")
|
||||
def test_list(self, mock_ifaces, client):
|
||||
mock_ifaces.return_value = ["eth0", "eth1"]
|
||||
resp = client.get("/api/firewall/interfaces")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["data"] == ["eth0", "eth1"]
|
||||
|
||||
|
||||
class TestFirewallMasquerade:
|
||||
@patch("webui.api.firewall.set_masquerade")
|
||||
def test_enable(self, mock_set, client):
|
||||
mock_set.return_value = None
|
||||
resp = client.post(
|
||||
"/api/firewall/masquerade",
|
||||
json={"zone": "internal", "enable": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post("/api/firewall/masquerade", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestFirewallForwardPort:
|
||||
@patch("webui.api.firewall.add_forward_port")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/firewall/forward-port",
|
||||
json={"zone": "public", "port": 443, "proto": "tcp"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post(
|
||||
"/api/firewall/forward-port",
|
||||
json={"zone": "public"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestDhcpConfig:
|
||||
@patch("webui.api.dhcp.get_config")
|
||||
def test_get(self, mock_get, client):
|
||||
mock_get.return_value = {"dhcp": {}, "dns": {}}
|
||||
resp = client.get("/api/dhcp/config")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
def test_post_invalid_body(self, client):
|
||||
resp = client.post(
|
||||
"/api/dhcp/config", data="not json", content_type="text/plain"
|
||||
)
|
||||
data = resp.get_json()
|
||||
assert data is not None
|
||||
|
||||
|
||||
class TestDhcpStaticLease:
|
||||
@patch("webui.api.dhcp.add_static_lease")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/dhcp/static-lease",
|
||||
json={"mac": "AA:BB:CC", "ip": "10.0.0.5"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_mac(self, client):
|
||||
resp = client.post("/api/dhcp/static-lease", json={"ip": "10.0.0.5"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("webui.api.dhcp.remove_static_lease")
|
||||
def test_remove(self, mock_remove, client):
|
||||
mock_remove.return_value = None
|
||||
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
|
||||
assert resp.status_code == 200
|
||||
|
||||
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")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/dhcp/dns-record",
|
||||
json={"name": "host.local", "address": "10.0.0.10"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post("/api/dhcp/dns-record", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestProxyDomains:
|
||||
@patch("webui.api.proxy.get_domains")
|
||||
def test_list(self, mock_get, client):
|
||||
mock_get.return_value = []
|
||||
resp = client.get("/api/proxy/domains")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.proxy.add_domain")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/proxy/domains",
|
||||
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_add_missing_domain(self, client):
|
||||
resp = client.post("/api/proxy/domains", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestProxyApply:
|
||||
@patch("webui.api.proxy.apply")
|
||||
def test_apply(self, mock_apply, client):
|
||||
mock_apply.return_value = None
|
||||
resp = client.post("/api/proxy/apply")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestCertsList:
|
||||
@patch("webui.api.certs.list_certs")
|
||||
def test_list(self, mock_list, client):
|
||||
mock_list.return_value = []
|
||||
resp = client.get("/api/certs/list")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.certs.get_cert_info")
|
||||
def test_details_not_found(self, mock_info, client):
|
||||
mock_info.side_effect = ValueError("not found")
|
||||
resp = client.get("/api/certs/example.com")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCertsIssue:
|
||||
def test_missing_domain(self, client):
|
||||
resp = client.post("/api/certs/issue", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestCertsEmail:
|
||||
def test_missing_email(self, client):
|
||||
resp = client.post("/api/certs/email", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestWireguardConfig:
|
||||
@patch("webui.api.wireguard.get_config")
|
||||
def test_get(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"interface": {"name": "wg0", "private_key": "secret"},
|
||||
"peers": {},
|
||||
}
|
||||
resp = client.get("/api/wireguard/config")
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "private_key" not in data["data"]["interface"]
|
||||
|
||||
@patch("webui.api.wireguard.save_config")
|
||||
def test_post(self, mock_save, client):
|
||||
mock_save.return_value = None
|
||||
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestWireguardPeers:
|
||||
@patch("webui.api.wireguard.get_peers")
|
||||
def test_list(self, mock_get, client):
|
||||
mock_get.return_value = []
|
||||
resp = client.get("/api/wireguard/peers")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.wireguard.add_peer")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
|
||||
resp = client.post(
|
||||
"/api/wireguard/add-peer",
|
||||
json={"name": "client1"},
|
||||
)
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "private_key" not in data["data"]
|
||||
|
||||
def test_add_missing_name(self, client):
|
||||
resp = client.post("/api/wireguard/add-peer", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
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": {},
|
||||
}
|
||||
resp = client.post("/api/wireguard/initialize")
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "private_key" not in data["data"]["interface"]
|
||||
|
||||
|
||||
class TestWireguardGenerateClient:
|
||||
def test_missing_name(self, client):
|
||||
resp = client.post("/api/wireguard/generate-client", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestWireguardStatus:
|
||||
@patch("webui.api.wireguard.status")
|
||||
def test_get(self, mock_status, client):
|
||||
mock_status.return_value = {"up": True, "interface": {}, "peers": []}
|
||||
resp = client.get("/api/wireguard/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestResponseHelpers:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_error_response_format(self, mock_a, mock_b, client):
|
||||
mock_a.side_effect = RuntimeError("fail")
|
||||
resp = client.get("/api/firewall/zones")
|
||||
data = resp.get_json()
|
||||
assert "error" in data
|
||||
assert "ok" in data
|
||||
assert data["ok"] is False
|
||||
@@ -0,0 +1,166 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import dnsmasq
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(tmp_path):
|
||||
original = dnsmasq.DATA_DIR
|
||||
original_config = dnsmasq.CONFIG_PATH
|
||||
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
|
||||
dnsmasq.CONFIG_PATH = dnsmasq.DATA_DIR / "config.json"
|
||||
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
|
||||
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
yield tmp_path
|
||||
dnsmasq.DATA_DIR = original
|
||||
dnsmasq.CONFIG_PATH = original_config
|
||||
|
||||
|
||||
class TestDeepMerge:
|
||||
def test_merge_flat_dicts(self):
|
||||
base = {"a": 1, "b": 2}
|
||||
override = {"b": 3, "c": 4}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": 1, "b": 3, "c": 4}
|
||||
|
||||
def test_merge_nested_dicts(self):
|
||||
base = {"a": {"x": 1, "y": 2}}
|
||||
override = {"a": {"y": 3, "z": 4}}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
||||
|
||||
def test_merge_non_dict_override(self):
|
||||
base = {"a": {"x": 1}}
|
||||
override = {"a": "flat"}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": "flat"}
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
@patch("lib.dnsmasq._load_json")
|
||||
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
|
||||
mock_load.return_value = {}
|
||||
result = dnsmasq.get_config()
|
||||
assert "dhcp" in result
|
||||
assert "dns" in result
|
||||
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
||||
|
||||
@patch("lib.dnsmasq._load_json")
|
||||
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
|
||||
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
||||
result = dnsmasq.get_config()
|
||||
assert result["dns"]["upstreams"] == ["9.9.9.9"]
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
cfg = {"dns": {"upstreams": ["1.2.3.4"], "domain": "test.lan"}}
|
||||
dnsmasq.save_config(cfg)
|
||||
loaded = dnsmasq.get_config()
|
||||
assert loaded["dns"]["upstreams"] == ["1.2.3.4"]
|
||||
assert loaded["dns"]["domain"] == "test.lan"
|
||||
|
||||
|
||||
class TestSetDhcpRange:
|
||||
def test_add_new_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
|
||||
|
||||
def test_replace_existing_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
|
||||
|
||||
|
||||
class TestStaticLeases:
|
||||
def test_add_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
|
||||
|
||||
def test_update_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
|
||||
|
||||
def test_remove_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
|
||||
dnsmasq.remove_static_lease("aa:bb:cc")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
|
||||
|
||||
|
||||
class TestDnsRecords:
|
||||
def test_add_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
|
||||
def test_remove_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
dnsmasq.add_dns_record("other", "10.0.0.101")
|
||||
dnsmasq.remove_dns_record("host")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
assert cfg["dns"]["custom_records"][0]["name"] == "other"
|
||||
|
||||
|
||||
class TestParseLeaseLine:
|
||||
def test_valid_line(self):
|
||||
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert result["ip"] == "10.0.0.50"
|
||||
assert result["hostname"] == "printer"
|
||||
|
||||
def test_empty_line(self):
|
||||
assert dnsmasq._parse_lease_line("") is None
|
||||
|
||||
def test_comment_line(self):
|
||||
assert dnsmasq._parse_lease_line("# comment") is None
|
||||
|
||||
def test_short_line(self):
|
||||
assert dnsmasq._parse_lease_line("incomplete") is None
|
||||
|
||||
def test_minimal_fields(self):
|
||||
line = "1700000000 AA:BB:CC 10.0.0.50"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["hostname"] == ""
|
||||
assert result["interface"] == ""
|
||||
|
||||
|
||||
class TestUpstreamsAndDomain:
|
||||
def test_set_upstreams(self, temp_data_dir):
|
||||
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["upstreams"] == ["1.1.1.1", "9.9.9.9"]
|
||||
|
||||
def test_set_domain(self, temp_data_dir):
|
||||
dnsmasq.set_domain("internal.lan")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["domain"] == "internal.lan"
|
||||
|
||||
def test_clear_domain(self, temp_data_dir):
|
||||
dnsmasq.set_domain("internal.lan")
|
||||
dnsmasq.set_domain(None)
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["domain"] is None
|
||||
@@ -0,0 +1,156 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib import firewall
|
||||
|
||||
|
||||
class TestParseForwardPorts:
|
||||
def test_single_entry(self):
|
||||
result = firewall._parse_forward_ports("port=443/proto=tcp")
|
||||
assert result == ["port=443/proto=tcp"]
|
||||
|
||||
def test_multiple_entries(self):
|
||||
result = firewall._parse_forward_ports(
|
||||
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "port=443/proto=tcp"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert firewall._parse_forward_ports("") == []
|
||||
|
||||
|
||||
class TestGetActiveZones:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_active_zones(self, mock_run):
|
||||
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {
|
||||
"public": ["eth0"],
|
||||
"internal": ["eth1", "eth2"],
|
||||
}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_empty_output(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_zone_with_no_interfaces(self, mock_run):
|
||||
mock_run.return_value = "dmz"
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {"dmz": []}
|
||||
|
||||
|
||||
class TestGetZoneInfo:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_zone_info(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"target: default\n"
|
||||
"interfaces: eth0\n"
|
||||
"sources: \n"
|
||||
"services: ssh dhcp\n"
|
||||
"ports: 8080/tcp\n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: yes\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
result = firewall.get_zone_info("public")
|
||||
assert result["name"] == "public"
|
||||
assert result["services"] == ["ssh", "dhcp"]
|
||||
assert result["ports"] == ["8080/tcp"]
|
||||
assert result["masquerade"] is True
|
||||
assert result["interfaces"] == ["eth0"]
|
||||
assert result["sources"] == []
|
||||
assert result["rich-rules"] == []
|
||||
|
||||
|
||||
class TestGetInterfaces:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_interfaces(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
||||
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
)
|
||||
result = firewall.get_interfaces()
|
||||
assert result == ["lo", "eth0", "eth1"]
|
||||
|
||||
|
||||
class TestGetRichRules:
|
||||
@patch("lib.firewall._run")
|
||||
def test_single_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
||||
)
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_empty_rules(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert result == []
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_multiline_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
||||
)
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert len(result) == 1
|
||||
assert "10.0.0.0/24" in result[0]
|
||||
|
||||
|
||||
class TestNowIso:
|
||||
def test_returns_iso_string(self):
|
||||
result = firewall._now_iso()
|
||||
datetime.fromisoformat(result)
|
||||
assert "+" in result
|
||||
|
||||
|
||||
class TestAddForwardPort:
|
||||
@patch("lib.firewall._run")
|
||||
def test_forward_port_basic(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
|
||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert any("--add-forward-port=" in str(c) for c in calls)
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_forward_port_port_only(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
||||
|
||||
|
||||
class TestGetState:
|
||||
@patch("lib.firewall.get_available_zones")
|
||||
@patch("lib.firewall.get_zone_info")
|
||||
@patch("lib.firewall.get_active_zones")
|
||||
@patch("lib.firewall.get_interfaces")
|
||||
@patch("lib.firewall.get_services")
|
||||
@patch("lib.firewall.get_rich_rules")
|
||||
def test_returns_full_state(
|
||||
self,
|
||||
mock_rich,
|
||||
mock_services,
|
||||
mock_ifaces,
|
||||
mock_active,
|
||||
mock_zone_info,
|
||||
mock_available,
|
||||
):
|
||||
mock_available.return_value = ["public", "internal"]
|
||||
mock_active.return_value = {"public": ["eth0"]}
|
||||
mock_ifaces.return_value = ["eth0", "eth1"]
|
||||
mock_services.return_value = ["ssh", "http"]
|
||||
mock_zone_info.return_value = {"name": "public", "services": []}
|
||||
mock_rich.return_value = []
|
||||
result = firewall.get_state()
|
||||
assert "zones" in result
|
||||
assert "active_zones" in result
|
||||
assert "timestamp" in result
|
||||
@@ -0,0 +1,246 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import nginx
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_default_config():
|
||||
"""Reset the shared mutable DEFAULT_CONFIG before each test."""
|
||||
original = nginx.DEFAULT_CONFIG.copy()
|
||||
yield
|
||||
# Reset the shared "domains" dict that leaks due to shallow copy in _json_load
|
||||
nginx.DEFAULT_CONFIG = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(tmp_path):
|
||||
original_config = nginx.CONFIG_FILE
|
||||
original_sites = nginx.SITES_DIR
|
||||
original_htpasswd = nginx.HTPASSWD_FILE
|
||||
original_ssl_snippet = nginx.SSL_SNIPPET
|
||||
original_include = nginx.INCLUDE_FILE
|
||||
|
||||
nginx.DATA_DIR = tmp_path / "nginx"
|
||||
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
|
||||
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
|
||||
nginx.HTPASSWD_FILE = tmp_path / "nginx" / ".htpasswd"
|
||||
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
|
||||
nginx.INCLUDE_FILE = tmp_path / "include.conf"
|
||||
|
||||
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yield tmp_path
|
||||
|
||||
nginx.CONFIG_FILE = original_config
|
||||
nginx.SITES_DIR = original_sites
|
||||
nginx.HTPASSWD_FILE = original_htpasswd
|
||||
nginx.SSL_SNIPPET = original_ssl_snippet
|
||||
nginx.INCLUDE_FILE = original_include
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_returns_default_when_no_file(self, temp_data_dir):
|
||||
cfg = nginx.get_config()
|
||||
assert "domains" in cfg
|
||||
assert "ssl" in cfg
|
||||
assert cfg["domains"] == {}
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
loaded = nginx.get_config()
|
||||
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
|
||||
|
||||
|
||||
class TestGetDomains:
|
||||
def test_empty_domains(self, temp_data_dir):
|
||||
result = nginx.get_domains()
|
||||
assert result == []
|
||||
|
||||
def test_returns_domain_list(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {
|
||||
"example.com": {
|
||||
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
assert len(result) == 1
|
||||
assert result[0]["domain"] == "example.com"
|
||||
|
||||
|
||||
class TestAddDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_add_domain(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
cfg = nginx.get_config()
|
||||
assert "example.com" in cfg["domains"]
|
||||
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
|
||||
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
|
||||
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
|
||||
|
||||
class TestRemoveDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_remove_existing_domain(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.remove_domain("example.com")
|
||||
cfg = nginx.get_config()
|
||||
assert "example.com" not in cfg["domains"]
|
||||
|
||||
def test_remove_nonexistent_domain(self, temp_data_dir):
|
||||
nginx.remove_domain("nonexistent.com")
|
||||
cfg = nginx.get_config()
|
||||
assert "nonexistent.com" not in cfg["domains"]
|
||||
|
||||
|
||||
class TestUpdateDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_update_existing_domain(self, mock_get, temp_data_dir):
|
||||
entry = {
|
||||
"backend": {"host": "10.0.0.5", "port": 8080, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
}
|
||||
mock_get.return_value = {
|
||||
"domains": {"example.com": entry},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.update_domain("example.com", force_ssl=False)
|
||||
cfg = nginx.get_config()
|
||||
assert cfg["domains"]["example.com"]["force_ssl"] is False
|
||||
|
||||
def test_update_nonexistent_raises(self, temp_data_dir):
|
||||
with pytest.raises(KeyError):
|
||||
nginx.update_domain("nonexistent.com", force_ssl=False)
|
||||
|
||||
|
||||
class TestWriteSite:
|
||||
def test_write_creates_file(self, temp_data_dir):
|
||||
nginx.write_site("example.com", "server { listen 443; }")
|
||||
path = nginx.SITES_DIR / "example.com.conf"
|
||||
assert path.exists()
|
||||
content = Path(path).read_text()
|
||||
assert "server { listen 443; }" in content
|
||||
|
||||
|
||||
class TestWriteAllSites:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_writes_all_domains(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"a.com": {
|
||||
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
},
|
||||
"b.com": {
|
||||
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
},
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.write_all_sites()
|
||||
assert (nginx.SITES_DIR / "a.com.conf").exists()
|
||||
assert (nginx.SITES_DIR / "b.com.conf").exists()
|
||||
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_removes_old_sites(self, mock_get, temp_data_dir):
|
||||
# Pre-create an old site
|
||||
nginx.write_site("old.com", "server {}")
|
||||
assert (nginx.SITES_DIR / "old.com.conf").exists()
|
||||
|
||||
mock_get.return_value = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.write_all_sites()
|
||||
assert not (nginx.SITES_DIR / "old.com.conf").exists()
|
||||
|
||||
|
||||
class TestTestConfig:
|
||||
@patch("lib.nginx._run")
|
||||
def test_passes(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout="", stderr="test passed\n"
|
||||
)
|
||||
ok, _msg = nginx.test_config()
|
||||
assert ok is True
|
||||
|
||||
@patch("lib.nginx._run")
|
||||
def test_fails(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
|
||||
)
|
||||
ok, _msg = nginx.test_config()
|
||||
assert ok is False
|
||||
|
||||
|
||||
class TestWriteHtpasswd:
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_creates_file(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash"
|
||||
nginx.write_htpasswd("admin", "secret")
|
||||
assert nginx.HTPASSWD_FILE.exists()
|
||||
content = nginx.HTPASSWD_FILE.read_text()
|
||||
assert "admin:" in content
|
||||
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_replaces_existing_user(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash1"
|
||||
nginx.write_htpasswd("admin", "old")
|
||||
mock_hash.return_value = "$apr1$hash2"
|
||||
nginx.write_htpasswd("admin", "new")
|
||||
lines = [
|
||||
line
|
||||
for line in nginx.HTPASSWD_FILE.read_text().strip().splitlines()
|
||||
if line
|
||||
]
|
||||
assert len([line for line in lines if line.startswith("admin:")]) == 1
|
||||
|
||||
|
||||
class TestHashPasswordFallback:
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_hash_returns_string(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash"
|
||||
result = nginx._hash_password("test")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
@@ -0,0 +1,119 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from webui.server import app
|
||||
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestTemplateFilters:
|
||||
@pytest.fixture
|
||||
def env(self):
|
||||
from webui.server import app
|
||||
|
||||
return app.jinja_env
|
||||
|
||||
def test_timestamp_filter_valid(self, env):
|
||||
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
|
||||
assert "2026-04-01" in result
|
||||
|
||||
def test_timestamp_filter_empty(self, env):
|
||||
assert env.filters["timestamp"]("") == ""
|
||||
assert env.filters["timestamp"](None) == ""
|
||||
|
||||
def test_timestamp_filter_invalid(self, env):
|
||||
result = env.filters["timestamp"]("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
def test_bytes_filter_zero(self, env):
|
||||
assert env.filters["bytes"](0) == "0.0 B"
|
||||
|
||||
def test_bytes_filter_kb(self, env):
|
||||
result = env.filters["bytes"](1536)
|
||||
assert "KB" in result
|
||||
|
||||
def test_bytes_filter_mb(self, env):
|
||||
result = env.filters["bytes"](1500000)
|
||||
assert "MB" in result
|
||||
|
||||
def test_bytes_filter_negative(self, env):
|
||||
assert env.filters["bytes"](-1) == "0 B"
|
||||
|
||||
def test_bytes_filter_invalid(self, env):
|
||||
assert env.filters["bytes"]("not-a-number") == "not-a-number"
|
||||
|
||||
def test_duration_filter_zero(self, env):
|
||||
assert env.filters["duration"](0) == "0s"
|
||||
|
||||
def test_duration_filter_seconds(self, env):
|
||||
assert env.filters["duration"](65) == "1m 5s"
|
||||
|
||||
def test_duration_filter_hours(self, env):
|
||||
result = env.filters["duration"](3661)
|
||||
assert "1h" in result
|
||||
|
||||
def test_duration_filter_days(self, env):
|
||||
result = env.filters["duration"](90000)
|
||||
assert "1d" in result
|
||||
|
||||
def test_duration_filter_invalid(self, env):
|
||||
assert env.filters["duration"]("bad") == "bad"
|
||||
|
||||
def test_json_pretty_filter(self, env):
|
||||
result = env.filters["json_pretty"]({"key": "value"})
|
||||
assert '{"key": "value"}' in result or "key" in result
|
||||
|
||||
|
||||
class TestSafelyHelper:
|
||||
def test_returns_result(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 42)
|
||||
assert result == 42
|
||||
|
||||
def test_returns_default_on_exception(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default=None)
|
||||
assert result is None
|
||||
|
||||
def test_returns_custom_default(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default="fallback")
|
||||
assert result == "fallback"
|
||||
|
||||
|
||||
class TestPageRoutes:
|
||||
@patch("webui.server.get_active_zones")
|
||||
@patch("webui.server.get_interfaces")
|
||||
@patch("webui.server.dnsmasq_status")
|
||||
@patch("webui.server.get_domains")
|
||||
@patch("webui.server.list_certs")
|
||||
@patch("webui.server.wg_status")
|
||||
def test_dashboard_no_crash(
|
||||
self,
|
||||
mock_wg,
|
||||
mock_certs,
|
||||
mock_domains,
|
||||
mock_dnsmasq,
|
||||
mock_ifaces,
|
||||
mock_zones,
|
||||
client,
|
||||
):
|
||||
mock_zones.return_value = {}
|
||||
mock_ifaces.return_value = []
|
||||
mock_dnsmasq.return_value = {}
|
||||
mock_domains.return_value = []
|
||||
mock_certs.return_value = []
|
||||
mock_wg.return_value = {}
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,248 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import wireguard
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
original = wireguard.CONFIG_PATH
|
||||
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
|
||||
yield tmp_path
|
||||
wireguard.CONFIG_PATH = original
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
def test_returns_skeleton(self):
|
||||
cfg = wireguard._default_config()
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
assert cfg["interface"]["private_key"] == ""
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_returns_default_when_no_file(self, temp_config):
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
def test_loads_existing_config(self, temp_config):
|
||||
path = Path(wireguard.CONFIG_PATH)
|
||||
expected = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "existing-key",
|
||||
"public_key": "existing-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
path.write_text(json.dumps(expected))
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["private_key"] == "existing-key"
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_save_and_reload(self, temp_config):
|
||||
cfg = wireguard._default_config()
|
||||
cfg["interface"]["listen_port"] = 51821
|
||||
wireguard.save_config(cfg)
|
||||
loaded = wireguard.get_config()
|
||||
assert loaded["interface"]["listen_port"] == 51821
|
||||
|
||||
|
||||
class TestGenerateKeyPair:
|
||||
@patch("lib.wireguard._run")
|
||||
def test_returns_keypair(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="private-key\n"),
|
||||
MagicMock(returncode=0, stdout="public-key\n"),
|
||||
]
|
||||
private, public = wireguard.generate_keypair()
|
||||
assert private == "private-key"
|
||||
assert public == "public-key"
|
||||
|
||||
|
||||
class TestGetPeers:
|
||||
def test_empty_peers(self, temp_config):
|
||||
peers = wireguard.get_peers()
|
||||
assert peers == []
|
||||
|
||||
def test_lists_peers_without_private_keys(self, temp_config):
|
||||
cfg = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {
|
||||
"client1": {
|
||||
"public_key": "pub1",
|
||||
"private_key": "priv1",
|
||||
"endpoint": "203.0.113.1:51820",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
"persistent_keepalive": None,
|
||||
"preshared_key": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg))
|
||||
peers = wireguard.get_peers()
|
||||
assert len(peers) == 1
|
||||
assert peers[0]["name"] == "client1"
|
||||
assert "private_key" not in peers[0]
|
||||
|
||||
|
||||
class TestAddPeer:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_adds_new_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
|
||||
assert result["public_key"] == "pub"
|
||||
assert result["private_key"] == "priv"
|
||||
assert result["allowed_ips"] == ["10.0.0.0/24"]
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_updates_existing_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
wireguard.add_peer("client1")
|
||||
wireguard.add_peer("client1", endpoint="203.0.113.1:51820")
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["peers"]["client1"]["endpoint"] == "203.0.113.1:51820"
|
||||
|
||||
|
||||
class TestRemovePeer:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_removes_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
wireguard.add_peer("client1")
|
||||
wireguard.remove_peer("client1")
|
||||
cfg = wireguard.get_config()
|
||||
assert "client1" not in cfg["peers"]
|
||||
|
||||
|
||||
class TestSetListenPort:
|
||||
def test_set_valid_port(self, temp_config):
|
||||
wireguard.set_listen_port(12345)
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["listen_port"] == 12345
|
||||
|
||||
def test_set_invalid_port_raises(self, temp_config):
|
||||
with pytest.raises(ValueError):
|
||||
wireguard.set_listen_port(0)
|
||||
with pytest.raises(ValueError):
|
||||
wireguard.set_listen_port(70000)
|
||||
|
||||
|
||||
class TestSetPostHooks:
|
||||
def test_set_post_up(self, temp_config):
|
||||
wireguard.set_post_up("iptables -I FORWARD -i wg0 -j ACCEPT")
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
|
||||
|
||||
def test_clear_post_up(self, temp_config):
|
||||
wireguard.set_post_up("some-cmd")
|
||||
wireguard.set_post_up(None)
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["post_up"] is None
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_initializes_once(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
cfg = wireguard.initialize()
|
||||
assert cfg["interface"]["private_key"] == "priv"
|
||||
assert cfg["interface"]["public_key"] == "pub"
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_does_not_overwrite_existing(self, mock_gen, temp_config):
|
||||
existing = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "original-private",
|
||||
"public_key": "original-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
|
||||
cfg = wireguard.initialize()
|
||||
assert cfg["interface"]["private_key"] == "original-private"
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
|
||||
class TestStatus:
|
||||
@patch("lib.wireguard._run")
|
||||
def test_returns_down_when_interface_down(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="interface not found"
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert result["up"] is False
|
||||
|
||||
@patch("lib.wireguard._run")
|
||||
def test_parses_interface_info(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=("interface:\n public key: ABCDEF\n listening port: 51820\n"),
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert result["up"] is True
|
||||
assert result["interface"]["public_key"] == "ABCDEF"
|
||||
assert result["interface"]["listen_port"] == 51820
|
||||
|
||||
@patch("lib.wireguard._run")
|
||||
def test_parses_peer_info(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"interface:\n"
|
||||
" public key: PUB\n"
|
||||
" listening port: 51820\n"
|
||||
"\n"
|
||||
"peer: PUBKEY1\n"
|
||||
" endpoint: 203.0.113.1:51820\n"
|
||||
" allowed ips: 10.137.0.2/32\n"
|
||||
" latest handshake: 2 minutes ago\n"
|
||||
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
|
||||
" persistent-keepalive: 25\n"
|
||||
),
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert len(result["peers"]) == 1
|
||||
peer = result["peers"][0]
|
||||
assert peer["public_key"] == "PUBKEY1"
|
||||
assert peer["endpoint"] == "203.0.113.1:51820"
|
||||
assert peer["persistent_keepalive"] == 25
|
||||
|
||||
|
||||
class TestGenerateWgShowParser:
|
||||
def test_parses_peer_output(self):
|
||||
output = (
|
||||
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||
)
|
||||
result = wireguard._parse_wg_show(output)
|
||||
assert "PUBKEY1" in result
|
||||
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
|
||||
|
||||
def test_empty_output(self):
|
||||
result = wireguard._parse_wg_show("")
|
||||
assert result == {}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
webui/api/certs.py - ACME certificate management API blueprint.
|
||||
|
||||
Exposed at /api/certs/* and delegates to lib.acme.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.acme import (
|
||||
get_cert_info,
|
||||
issue,
|
||||
list_certs,
|
||||
remove,
|
||||
renew,
|
||||
set_email,
|
||||
)
|
||||
|
||||
bp = Blueprint("certs", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Certificate listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/list", methods=["GET"])
|
||||
def list_certs_bp():
|
||||
try:
|
||||
return _ok(list_certs())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["GET"])
|
||||
def cert_details(domain):
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return _ok(info)
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/issue", methods=["POST"])
|
||||
def issue_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
webroot = body.get("webroot")
|
||||
standalone = body.get("standalone", False)
|
||||
try:
|
||||
result = issue(domain, webroot=webroot, standalone=standalone)
|
||||
if result.get("success"):
|
||||
return _ok(result)
|
||||
return jsonify(
|
||||
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
|
||||
), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renew
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/<domain>/renew", methods=["POST"])
|
||||
def renew_bp(domain):
|
||||
try:
|
||||
result = renew(domain)
|
||||
if result.get("success"):
|
||||
return _ok(result)
|
||||
return jsonify(
|
||||
{"ok": False, "error": result.get("error", "Unknown error"), "data": result}
|
||||
), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/<domain>", methods=["DELETE"])
|
||||
def remove_bp(domain):
|
||||
try:
|
||||
remove(domain)
|
||||
return _ok({"domain": domain})
|
||||
except (RuntimeError, FileNotFoundError) as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contact email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/email", methods=["POST"])
|
||||
def set_email_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
return _error("'email' is required", 400)
|
||||
try:
|
||||
set_email(email)
|
||||
return _ok({"email": email})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint.
|
||||
|
||||
Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.dnsmasq import (
|
||||
add_dns_record,
|
||||
add_static_lease,
|
||||
apply_config,
|
||||
get_config,
|
||||
get_lease_table,
|
||||
remove_dns_record,
|
||||
remove_static_lease,
|
||||
save_config,
|
||||
)
|
||||
|
||||
bp = Blueprint("dhcp", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
def _deep_merge(base, overrides):
|
||||
result = dict(base)
|
||||
for k, v in overrides.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = _deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
try:
|
||||
return _ok(get_config())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def post_config():
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
save_config(body)
|
||||
return _ok(body)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/config", methods=["PATCH"])
|
||||
def patch_config():
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
current = get_config()
|
||||
merged = _deep_merge(current, body)
|
||||
save_config(merged)
|
||||
return _ok(merged)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply_config()
|
||||
return _ok({"message": "dnsmasq configuration applied"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/leases", methods=["GET"])
|
||||
def leases_bp():
|
||||
try:
|
||||
return _ok(get_lease_table())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static leases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/static-lease", methods=["POST"])
|
||||
def add_static_lease_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
mac = body.get("mac", "").strip()
|
||||
ip = body.get("ip", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
if not mac or not ip:
|
||||
return _error("'mac' and 'ip' are required", 400)
|
||||
try:
|
||||
add_static_lease(mac, ip, hostname)
|
||||
return _ok({"mac": mac, "ip": ip, "hostname": hostname})
|
||||
except RuntimeError as 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)
|
||||
try:
|
||||
remove_static_lease(mac)
|
||||
return _ok({"mac": mac})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/dns-record", methods=["POST"])
|
||||
def add_dns_record_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
address = body.get("address", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
if not name or not address:
|
||||
return _error("'name' and 'address' are required", 400)
|
||||
try:
|
||||
add_dns_record(name, address, hostname)
|
||||
return _ok({"name": name, "address": address, "hostname": hostname})
|
||||
except RuntimeError as 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)
|
||||
try:
|
||||
remove_dns_record(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
webui/api/firewall.py - Firewall (firewalld) management API blueprint.
|
||||
|
||||
Exposed at /api/firewall/* and delegates all mutations to lib.firewall.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.firewall import (
|
||||
add_forward_port,
|
||||
add_rich_rule,
|
||||
create_zone,
|
||||
delete_zone,
|
||||
get_active_zones,
|
||||
get_available_zones,
|
||||
get_interfaces,
|
||||
get_rich_rules,
|
||||
get_services,
|
||||
get_zone_info,
|
||||
remove_forward_port,
|
||||
remove_rich_rule,
|
||||
set_masquerade,
|
||||
set_zone_interfaces,
|
||||
set_zone_services,
|
||||
)
|
||||
|
||||
bp = Blueprint("firewall", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["GET"])
|
||||
def list_zones():
|
||||
try:
|
||||
active = get_active_zones()
|
||||
available = get_available_zones()
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"data": {
|
||||
"active": active,
|
||||
"available": available,
|
||||
},
|
||||
}
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["GET"])
|
||||
def zone_details(name):
|
||||
try:
|
||||
info = get_zone_info(name)
|
||||
return jsonify({"ok": True, "data": info})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones", methods=["POST"])
|
||||
def create_zone_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone_name = body.get("name", "").strip()
|
||||
target = body.get("target", "default").strip() or "default"
|
||||
if not zone_name:
|
||||
return _error("Zone name is required", 400)
|
||||
try:
|
||||
if zone_name in get_available_zones():
|
||||
return _error(f"Zone '{zone_name}' already exists", 400)
|
||||
create_zone(zone_name, target)
|
||||
return _ok({"name": zone_name, "target": target})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/zones/<name>", methods=["DELETE"])
|
||||
def delete_zone_bp(name):
|
||||
try:
|
||||
available = get_available_zones()
|
||||
if name not in available:
|
||||
return _error(f"Zone '{name}' does not exist", 404)
|
||||
delete_zone(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones/<name>/interfaces", methods=["POST"])
|
||||
def set_zone_interfaces_bp(name):
|
||||
body = request.get_json(silent=True) or {}
|
||||
interfaces = body.get("interfaces", [])
|
||||
if not isinstance(interfaces, list):
|
||||
return _error("'interfaces' must be a list", 400)
|
||||
try:
|
||||
set_zone_interfaces(name, interfaces)
|
||||
return _ok({"zone": name, "interfaces": interfaces})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/zones/<name>/services", methods=["POST"])
|
||||
def set_zone_services_bp(name):
|
||||
body = request.get_json(silent=True) or {}
|
||||
services = body.get("services", [])
|
||||
if not isinstance(services, list):
|
||||
return _error("'services' must be a list", 400)
|
||||
try:
|
||||
set_zone_services(name, services)
|
||||
return _ok({"zone": name, "services": services})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Available services and interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/services", methods=["GET"])
|
||||
def list_services():
|
||||
try:
|
||||
return _ok(get_services())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/interfaces", methods=["GET"])
|
||||
def list_interfaces():
|
||||
try:
|
||||
return _ok(get_interfaces())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/rich-rules", methods=["POST"])
|
||||
def add_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:
|
||||
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})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/rich-rules/<zone>", methods=["GET"])
|
||||
def list_rich_rules(zone):
|
||||
try:
|
||||
rules = get_rich_rules(zone)
|
||||
return _ok(rules)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masquerade (NAT)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/masquerade", methods=["POST"])
|
||||
def set_masquerade_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
zone = body.get("zone", "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
return _error("'zone' and 'enable' (bool) are required", 400)
|
||||
try:
|
||||
set_masquerade(zone, bool(enable))
|
||||
return _ok({"zone": zone, "masquerade": bool(enable)})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/forward-port", methods=["POST"])
|
||||
def add_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)
|
||||
try:
|
||||
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})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
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)
|
||||
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)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
webui/api/proxy.py - Nginx proxy domain management API blueprint.
|
||||
|
||||
Exposed at /api/proxy/* and delegates to lib.nginx.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.nginx import (
|
||||
add_domain,
|
||||
apply,
|
||||
get_config,
|
||||
get_domains,
|
||||
remove_domain,
|
||||
set_management_proxy,
|
||||
test_config,
|
||||
update_domain,
|
||||
)
|
||||
|
||||
bp = Blueprint("proxy", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["GET"])
|
||||
def list_domains():
|
||||
try:
|
||||
return _ok(get_domains())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains", methods=["POST"])
|
||||
def add_domain_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
try:
|
||||
add_domain(
|
||||
domain, backend_host, int(backend_port), backend_proto, cert, extra_headers
|
||||
)
|
||||
return _ok({"domain": domain})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["GET"])
|
||||
def domain_details(domain):
|
||||
try:
|
||||
cfg = get_config()
|
||||
entry = cfg.get("domains", {}).get(domain)
|
||||
if entry is None:
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
return _ok({"domain": domain, **entry})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["PUT"])
|
||||
def update_domain_bp(domain):
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not body:
|
||||
return _error("Request body must be a JSON object with fields to update", 400)
|
||||
try:
|
||||
update_domain(domain, **body)
|
||||
return _ok({"domain": domain})
|
||||
except KeyError as exc:
|
||||
return _error(str(exc), 404)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/domains/<domain>", methods=["DELETE"])
|
||||
def remove_domain_bp(domain):
|
||||
try:
|
||||
cfg = get_config()
|
||||
if domain not in cfg.get("domains", {}):
|
||||
return _error(f"Domain '{domain}' not found", 404)
|
||||
remove_domain(domain)
|
||||
return _ok({"domain": domain})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply / test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
return _ok({"message": "nginx configuration applied and reloaded"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/test", methods=["POST"])
|
||||
def test_bp():
|
||||
try:
|
||||
ok, message = test_config()
|
||||
if ok:
|
||||
return _ok({"passed": True, "message": message})
|
||||
return jsonify({"ok": False, "error": message, "passed": False}), 400
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/management", methods=["POST"])
|
||||
def management_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
||||
flask_port = body.get("flask_port", 9090)
|
||||
auth_user = body.get("auth_user")
|
||||
auth_pass = body.get("auth_pass")
|
||||
try:
|
||||
set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass)
|
||||
return _ok({"domain": domain})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
code = 400 if isinstance(exc, ValueError) else 500
|
||||
return _error(str(exc), code)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
webui/api/wireguard.py - WireGuard tunnel management API blueprint.
|
||||
|
||||
Exposed at /api/wireguard/* and delegates to lib.wireguard.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.wireguard import (
|
||||
add_peer,
|
||||
apply,
|
||||
down,
|
||||
generate_client_conf,
|
||||
get_config,
|
||||
get_peer_status,
|
||||
get_peers,
|
||||
initialize,
|
||||
remove_peer,
|
||||
save_config,
|
||||
status,
|
||||
)
|
||||
|
||||
bp = Blueprint("wireguard", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(msg, code=400):
|
||||
return jsonify({"ok": False, "error": msg}), code
|
||||
|
||||
|
||||
def _ok(data=None):
|
||||
body = {"ok": True}
|
||||
if data is not None:
|
||||
body["data"] = data
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/config", methods=["GET"])
|
||||
def get_config_bp():
|
||||
try:
|
||||
cfg = get_config()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/config", methods=["POST"])
|
||||
def post_config():
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _error("Request body must be a JSON object", 400)
|
||||
try:
|
||||
save_config(body)
|
||||
safe = dict(body)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply / down
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/apply", methods=["POST"])
|
||||
def apply_bp():
|
||||
try:
|
||||
apply()
|
||||
return _ok({"message": "WireGuard configuration applied and tunnel brought up"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/down", methods=["POST"])
|
||||
def down_bp():
|
||||
try:
|
||||
down()
|
||||
return _ok({"message": "WireGuard tunnel brought down"})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
def status_bp():
|
||||
try:
|
||||
return _ok(status())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialize (first-time setup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/initialize", methods=["POST"])
|
||||
def initialize_bp():
|
||||
try:
|
||||
cfg = initialize()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peer management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/add-peer", methods=["POST"])
|
||||
def add_peer_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("'name' is required", 400)
|
||||
try:
|
||||
peer = add_peer(
|
||||
name=name,
|
||||
endpoint=body.get("endpoint"),
|
||||
allowed_ips=body.get("allowed_ips", []),
|
||||
persistent_keepalive=body.get("persistent_keepalive"),
|
||||
preshared_key=body.get("preshared_key"),
|
||||
)
|
||||
safe = dict(peer)
|
||||
safe.pop("private_key", None)
|
||||
return _ok(safe)
|
||||
except RuntimeError as 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)
|
||||
try:
|
||||
cfg = get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
return _error(f"Peer '{name}' not found", 404)
|
||||
remove_peer(name)
|
||||
return _ok({"name": name})
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/peers", methods=["GET"])
|
||||
def peers_bp():
|
||||
try:
|
||||
return _ok(get_peers())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/peer-status", methods=["GET"])
|
||||
def peer_status_bp():
|
||||
try:
|
||||
return _ok(get_peer_status())
|
||||
except RuntimeError as exc:
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client config generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@bp.route("/generate-client", methods=["POST"])
|
||||
def generate_client_bp():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return _error("Field 'name' is required", 400)
|
||||
try:
|
||||
cfg = get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
return _error(f"Peer '{name}' not found", 404)
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
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)
|
||||
return _ok({"config": conf_text, "name": name})
|
||||
except (KeyError, ValueError, RuntimeError) as exc:
|
||||
code = 404 if isinstance(exc, (KeyError, ValueError)) else 500
|
||||
return _error(str(exc), code)
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
server.py - Vacuum Wall management WebUI entry point.
|
||||
|
||||
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
|
||||
and enforces basic authentication before proxying to this port.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, render_template
|
||||
|
||||
from lib.acme import get_email, list_certs
|
||||
from lib.dnsmasq import get_config as dnsmasq_config
|
||||
from lib.dnsmasq import get_lease_table
|
||||
from lib.dnsmasq import get_status as dnsmasq_status
|
||||
from lib.firewall import get_active_zones, get_interfaces, get_zone_info
|
||||
from lib.nginx import get_config as nginx_config
|
||||
from lib.nginx import get_domains
|
||||
from lib.wireguard import get_config as wg_config
|
||||
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.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wireguard_bp
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 custom filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.template_filter("timestamp")
|
||||
def timestamp_filter(value):
|
||||
"""Convert an ISO timestamp string to a human-readable date."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.template_filter("bytes")
|
||||
def bytes_filter(value):
|
||||
"""Format a byte count to a human-readable string (KB / MB / GB)."""
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if num < 0:
|
||||
return "0 B"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(num) < 1024:
|
||||
return f"{num:.1f} {unit}"
|
||||
num /= 1024
|
||||
return f"{num:.1f} PB"
|
||||
|
||||
|
||||
@app.template_filter("duration")
|
||||
def duration_filter(value):
|
||||
"""Format a duration in seconds to a human-readable string."""
|
||||
try:
|
||||
total = int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if total < 0:
|
||||
return "0s"
|
||||
parts = []
|
||||
days, remainder = divmod(total, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if days:
|
||||
parts.append(f"{days}d")
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{seconds}s")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.template_filter("json_pretty")
|
||||
def json_pretty_filter(value):
|
||||
"""Pretty-print a JSON-serialisable value for debug displays."""
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safely(fn, default=None):
|
||||
"""Call *fn* and return *default* on any exception."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc:
|
||||
logger.warning("WebUI data load failed: %s", exc)
|
||||
return default
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
active_zones = _safely(get_active_zones, {})
|
||||
interfaces = _safely(get_interfaces, [])
|
||||
dnsmasq = _safely(dnsmasq_status, {})
|
||||
domains = _safely(get_domains, [])
|
||||
certs = _safely(list_certs, [])
|
||||
wg = _safely(wg_status, {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_zones=active_zones,
|
||||
interfaces=interfaces,
|
||||
dnsmasq=dnsmasq,
|
||||
domains=domains,
|
||||
certs=certs,
|
||||
wg_status=wg,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
active_zones=_safely(get_active_zones, {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
zones = {}
|
||||
for name in _safely(get_active_zones, {}):
|
||||
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
|
||||
return render_template(
|
||||
"zones.html",
|
||||
zones=zones,
|
||||
interfaces=_safely(get_interfaces, []),
|
||||
services=_safely(
|
||||
lambda: __import__(
|
||||
"lib.firewall", fromlist=["get_services"]
|
||||
).get_services(),
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
zones = list(_safely(get_active_zones, {}).keys())
|
||||
return render_template("rules.html", zones=zones)
|
||||
|
||||
|
||||
@app.route("/nat")
|
||||
def nat_page():
|
||||
zones = {}
|
||||
for name in _safely(get_active_zones, {}):
|
||||
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
|
||||
return render_template("nat.html", zones=zones)
|
||||
|
||||
|
||||
@app.route("/dhcp")
|
||||
def dhcp_page():
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=_safely(dnsmasq_config, {}),
|
||||
status=_safely(dnsmasq_status, {}),
|
||||
leases=_safely(get_lease_table, []),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/proxy")
|
||||
def proxy_page():
|
||||
return render_template(
|
||||
"proxy.html", domains=_safely(get_domains, []), config=_safely(nginx_config, {})
|
||||
)
|
||||
|
||||
|
||||
@app.route("/certs")
|
||||
def certs_page():
|
||||
return render_template(
|
||||
"certs.html", certs=_safely(list_certs, []), email=_safely(get_email, "")
|
||||
)
|
||||
|
||||
|
||||
@app.route("/wireguard")
|
||||
def wireguard_page():
|
||||
return render_template(
|
||||
"wireguard.html", config=_safely(wg_config, {}), status=_safely(wg_status, {})
|
||||
)
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
def logs_page():
|
||||
return render_template("logs.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=9090)
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateX(40px)";
|
||||
toast.style.transition = "all 0.3s ease";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast";
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.add("show");
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.remove("show");
|
||||
}
|
||||
|
||||
// Confirm dialog
|
||||
function confirmAction(message, onConfirm) {
|
||||
const existing = document.getElementById("confirm-modal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "confirm-modal";
|
||||
modal.className = "modal";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<p class="mb-2">${message}</p>
|
||||
<div style="display:flex; gap:0.75rem; justify-content:flex-end;">
|
||||
<button class="btn btn-outline" id="confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
openModal("confirm-modal");
|
||||
document.getElementById("confirm-cancel").onclick = () => closeModal("confirm-modal");
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal("confirm-modal");
|
||||
});
|
||||
}
|
||||
|
||||
function setupConfirmCallback(callback) {
|
||||
document.getElementById("confirm-ok")?.addEventListener("click", () => {
|
||||
closeModal("confirm-modal");
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh with HTMX
|
||||
function startAutoRefresh(endpoint, target, interval) {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("hx-get", endpoint);
|
||||
el.setAttribute("hx-target", `#${target}`);
|
||||
el.setAttribute("hx-swap", "innerHTML");
|
||||
el.setAttribute("hx-trigger", `every ${interval}s`);
|
||||
el.setAttribute("hx-swap-oob", "true");
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
// Time formatting
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
// Bytes formatting
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// Form helpers
|
||||
function resetForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (form) form.reset();
|
||||
}
|
||||
|
||||
function fillForm(formId, data) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX event handlers
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:responseError", (evt) => {
|
||||
const status = evt.detail.xhr?.status || 0;
|
||||
showToast(`Request failed (${status})`, "error");
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (evt) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Loading...";
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterRequest", (evt) => {
|
||||
const target = evt.target;
|
||||
const btn = target.closest(".btn");
|
||||
if (btn && btn.dataset.originalText !== undefined) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
delete btn.dataset.originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal.show").forEach((m) => m.classList.remove("show"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-input: #1a1a2e;
|
||||
--accent: #00b4d8;
|
||||
--accent-hover: #0096c7;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--danger: #e63946;
|
||||
--success: #2ecc71;
|
||||
--warning: #f39c12;
|
||||
--border: #2a2a4a;
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: #0f0f23;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar .logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
padding: 1.5rem;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
padding: 1rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover {
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar nav a.active {
|
||||
background: rgba(0, 180, 216, 0.12);
|
||||
color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table tr:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: rgba(0, 180, 216, 0.06);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #27ae60;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.15);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(243, 156, 18, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: rgba(230, 57, 70, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Toasts */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
animation: toastSlideIn 0.3s ease forwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.toast-message.toast-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-warning {
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-message.toast-info {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.modal.show .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch .slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
/* Text Colors */
|
||||
.text-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Flex Utilities */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spacing */
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-2,
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Vacuum Wall{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-card-hover: #0f3460d0;
|
||||
--accent: #00b4d8;
|
||||
--accent-hover: #0096c7;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--danger: #e63946;
|
||||
--danger-hover: #c62828;
|
||||
--success: #2ecc71;
|
||||
--warning: #f1c40f;
|
||||
--border: #1a1a3e;
|
||||
--input-bg: #0d1b2a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-header span {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: all 0.15s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover {
|
||||
color: var(--text);
|
||||
background: rgba(0, 180, 216, 0.05);
|
||||
}
|
||||
|
||||
.sidebar nav a.active {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 180, 216, 0.08);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
min-height: 100vh;
|
||||
width: calc(100vw - 220px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(0, 180, 216, 0.1);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(0, 180, 216, 0.03);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
|
||||
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
|
||||
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
|
||||
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
|
||||
|
||||
/* Status indicator */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-up { background: var(--success); }
|
||||
.status-down { background: var(--danger); }
|
||||
.status-pending { background: var(--warning); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
|
||||
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
|
||||
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text); }
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Scrollable log */
|
||||
.log-viewer {
|
||||
background: #0a0a14;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--border);
|
||||
border-radius: 22px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch .slider:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--text);
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Flex utils */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-right { text-align: right; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
/* Service status list */
|
||||
.service-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-list li {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.service-list .svc-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Inline form row */
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-form .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
margin-left: 0;
|
||||
width: 100vw;
|
||||
padding: 16px;
|
||||
}
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inline-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
VACUUM WALL
|
||||
<span>Firewall Management</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
|
||||
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
|
||||
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
|
||||
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
|
||||
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
|
||||
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
|
||||
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
|
||||
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
|
||||
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
|
||||
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
function showToast(message, type, duration) {
|
||||
duration = duration || 4000;
|
||||
var container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(function() { toast.classList.add('show'); });
|
||||
setTimeout(function() {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(function() { toast.remove(); }, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
var clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
if (clickedTab) clickedTab.classList.add('active');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.htmx-on-success').forEach(function(el) {
|
||||
var msg = el.getAttribute('data-success') || 'Operation successful';
|
||||
var type = el.getAttribute('data-type') || 'success';
|
||||
el.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail && evt.detail.successful) {
|
||||
showToast(msg, type);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-overlay.active').forEach(function(el) { el.classList.remove('active'); });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Certificates - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Certificates</h1>
|
||||
<div class="subtitle">SSL/TLS certificate management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Issuer</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Days Left</th>
|
||||
<th style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cert in (certs or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
|
||||
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
|
||||
<td>{{ cert.get('expiry', 'N/A') }}</td>
|
||||
<td>
|
||||
{% set days = cert.get('days_remaining') %}
|
||||
{% if cert.get('expired') or (days is not none and days <= 0) %}
|
||||
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
|
||||
{% elif days is not none and days <= 30 %}
|
||||
<span class="badge badge-warning">{{ days }} days</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">{{ days }} days</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/api/certs/renew/{{ cert.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Renewal started for {{ cert.domain }}" onsuccess="setTimeout(function(){ location.reload(); }, 2000);">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (certs or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Issue Certificate Modal -->
|
||||
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')">
|
||||
<div class="modal">
|
||||
<h2>Issue New Certificate</h2>
|
||||
<form hx-post="/api/certs/issue" hx-swap="none" class="htmx-on-success" data-success="Certificate issuance started" onsuccess="setTimeout(function(){closeModal('issue-cert-modal'); location.reload();}, 500);">
|
||||
<div class="form-group">
|
||||
<label for="cert-domain">Domain</label>
|
||||
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cert-email">Contact Email</label>
|
||||
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Issue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="subtitle">System overview and status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Zones</div>
|
||||
<div class="value">{{ zones|default([])|length }}</div>
|
||||
<div class="meta">Firewalld zones configured</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Proxy Domains</div>
|
||||
<div class="value">{{ domains|default([])|length }}</div>
|
||||
<div class="meta">SSL-terminated backends</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Certificates</div>
|
||||
<div class="value">{{ certs|default([])|length }}</div>
|
||||
{% set expired = certs|selectattr('expired')|list|default([])|length %}
|
||||
{% set expiring = certs|selectattr('days_remaining','le',30)|rejectattr('expired')|list|default([])|length %}
|
||||
<div class="meta">
|
||||
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
|
||||
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
|
||||
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">WireGuard</div>
|
||||
<div class="value" style="font-size:20px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}
|
||||
</div>
|
||||
<div class="meta">Tunnel state</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Active Leases</div>
|
||||
<div class="value">{{ leases|default([])|length }}</div>
|
||||
<div class="meta">DHCP clients connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Services</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for svc_name, svc in (services or {}).items() %}
|
||||
<div class="stat-card">
|
||||
<div class="label">{{ svc_name }}</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
|
||||
{{ 'Running' if svc.get('running') else 'Stopped' }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
|
||||
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (services or {}) %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Firewalld</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Dnsmasq</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Nginx</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot status-up"></span>Running
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">wg0</div>
|
||||
<div class="value" style="font-size:16px;">
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
{{ 'Up' if (wg_status is defined and wg_status.get('state') == 'up') else 'Down' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set warnings = [] %}
|
||||
{% if certs is defined %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('expired') %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %}
|
||||
{% elif cert.get('days_remaining') is not none and cert.days_remaining <= 30 %}
|
||||
{% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_remaining|string + " days") %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if warnings|length > 0 or (services is defined) %}
|
||||
<div class="section-title">Warnings & Activity</div>
|
||||
|
||||
<div class="card">
|
||||
{% if warnings|length > 0 %}
|
||||
<ul class="service-list">
|
||||
{% for w in warnings %}
|
||||
<li>
|
||||
<span class="status-dot status-pending"></span>
|
||||
<span class="svc-name">{{ w }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if not warnings and not (services or {}) %}
|
||||
<div class="text-muted text-sm">No warnings</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>DHCP & DNS</h1>
|
||||
<div class="subtitle">Dnsmasq configuration and lease management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DHCP Ranges -->
|
||||
<div class="section-title">DHCP Ranges</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/ranges" hx-swap="none" class="htmx-on-success" data-success="DHCP range added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="range-interface">Interface</label>
|
||||
<select id="range-interface" name="interface">
|
||||
<option value="">— Global —</option>
|
||||
{% for iface in (interfaces or []) %}
|
||||
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-start">Start IP</label>
|
||||
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-end">End IP</label>
|
||||
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="range-lease">Lease Time</label>
|
||||
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Range</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Lease Time</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rng.get('interface', '(global)') }}</td>
|
||||
<td>{{ rng.get('start', '') }}</td>
|
||||
<td>{{ rng.get('end', '') }}</td>
|
||||
<td>{{ rng.get('lease_time', '1h') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/ranges/{{ rng.get('start', '') }}/{{ rng.get('end', '') }}" hx-swap="none" class="htmx-on-success" data-success="Range removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this range?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Static Leases -->
|
||||
<div class="section-title">Static Leases</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/leases/static" hx-swap="none" class="htmx-on-success" data-success="Static lease added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="lease-mac">MAC Address</label>
|
||||
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-ip">IP Address</label>
|
||||
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lease-host">Hostname</label>
|
||||
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Lease</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('mac', '') }}</td>
|
||||
<td>{{ lease.get('ip', '') }}</td>
|
||||
<td>{{ lease.get('hostname', '-') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/leases/static/{{ lease.get('mac', '') }}" hx-swap="none" class="htmx-on-success" data-success="Lease removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this lease?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('static_leases', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom DNS Records -->
|
||||
<div class="section-title">Custom DNS Records</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<form hx-post="/api/dhcp/dns/records" hx-swap="none" class="htmx-on-success" data-success="DNS record added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="dns-ip">IP Address</label>
|
||||
<input type="text" id="dns-ip" name="ip" placeholder="192.168.1.10" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dns-hostname">Hostname / Domain</label>
|
||||
<input type="text" id="dns-hostname" name="hostname" placeholder="host.local" required style="width:200px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Record</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rec in ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td>{{ rec.get('ip', '') }}</td>
|
||||
<td>{{ rec.get('hostname', '') }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/dhcp/dns/records/{{ rec.get('ip', '') }}/{{ rec.get('hostname', '') }}" hx-swap="none" class="htmx-on-success" data-success="Record removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this record?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not ((config or {}).get('dns_records', []) or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current DHCP Leases -->
|
||||
<div class="section-title">Current DHCP Leases</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expires</th>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>Client ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in (leases or []) %}
|
||||
<tr>
|
||||
<td>{{ lease.get('expires', 'N/A') }}</td>
|
||||
<td>{{ lease.get('mac', 'N/A') }}</td>
|
||||
<td>{{ lease.get('ip', 'N/A') }}</td>
|
||||
<td>{{ lease.get('hostname', '*') or '*' }}</td>
|
||||
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (leases or []) %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-2 text-right">
|
||||
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/reload" hx-swap="none" class="htmx-on-success" data-success="Dnsmasq configuration reloaded">Apply & Restart Dnsmasq</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interfaces - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Interfaces</h1>
|
||||
<div class="subtitle">Network interface to zone bindings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Interface</th>
|
||||
<th>MAC Address</th>
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
<th>Zone</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for iface in (interfaces or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
||||
<td>
|
||||
{% for ip in iface.get('ips', []) %}
|
||||
{{ ip }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% if not iface.get('ips') %}N/A{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if iface.get('state') == 'up' else 'status-down' }}"></span>
|
||||
{{ 'Up' if iface.get('state') == 'up' else 'Down' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if zones %}
|
||||
<select
|
||||
class="htmx-on-success"
|
||||
data-success="Zone updated for {{ iface.name }}"
|
||||
hx-post="/api/firewall/zones/__ZONE__/interfaces/{{ iface.name }}"
|
||||
hx-swap="none"
|
||||
hx-select-oob="#toast-container *"
|
||||
onchange="assignInterfaceToZone(this, '{{ iface.name }}', '{{ iface.get('zone', '') }}')"
|
||||
>
|
||||
{% for zone in zones %}
|
||||
<option value="{{ zone.get('name', '') }}" {% if zone.get('name') == iface.get('zone') %}selected{% endif %}>{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<span class="text-muted">No zones configured</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="assignInterfaceToZone(this.previousElementSibling, '{{ iface.name }}', null)">Apply</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (interfaces or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function assignInterfaceToZone(selectEl, ifaceName, currentZone) {
|
||||
var zoneName = selectEl.value;
|
||||
var url = '/api/firewall/zones/' + encodeURIComponent(zoneName) + '/interfaces/' + encodeURIComponent(ifaceName);
|
||||
fetch(url, { method: 'POST' })
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
showToast('Assigned ' + ifaceName + ' to zone ' + zoneName, 'success');
|
||||
} else {
|
||||
return res.text().then(function(txt) { throw new Error(txt); });
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showToast('Failed to assign: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>System Logs</h1>
|
||||
<div class="subtitle">Service logs and journal output</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted">Auto-refresh</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="htmx-indicator text-sm" style="color:var(--accent);">
|
||||
<span id="refresh-indicator" style="display:none;">Refreshing...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
|
||||
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
|
||||
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
|
||||
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-journal" class="tab-content active">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-journal"
|
||||
hx-get="/api/logs/journal"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading journal entries...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-access" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-access"
|
||||
hx-get="/api/logs/nginx/access"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading Nginx access log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-nginx-error" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-nginx-error"
|
||||
hx-get="/api/logs/nginx/error"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading Nginx error log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-dnsmasq" class="tab-content">
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div class="log-viewer" id="log-dnsmasq"
|
||||
hx-get="/api/logs/dnsmasq"
|
||||
hx-trigger="every {{ (refresh_interval | default(15)) }}s"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#refresh-indicator">
|
||||
Loading dnsmasq log...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var autoRefreshTimer = null;
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var toggle = document.getElementById('auto-refresh-toggle');
|
||||
var indicators = document.querySelectorAll('.log-viewer');
|
||||
|
||||
if (toggle.checked) {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'every 15s');
|
||||
hx.trigger(el, 'htmx:refresh');
|
||||
});
|
||||
} else {
|
||||
indicators.forEach(function(el) {
|
||||
el.setAttribute('hx-trigger', 'never');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail && evt.detail.path && evt.detail.path.startsWith('/api/logs')) {
|
||||
document.getElementById('refresh-indicator').style.display = 'inline';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
document.getElementById('refresh-indicator').style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAT - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>NAT & Port Forwarding</h1>
|
||||
<div class="subtitle">Masquerading and destination NAT rules</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Masquerade (Source NAT)</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th style="width:120px;">Masquerade</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for zone in (zones or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
|
||||
<td>
|
||||
<label class="switch">
|
||||
<input type="checkbox"
|
||||
{% if zone.get('masquerade') %}checked{% endif %}
|
||||
onchange="toggleMasquerade('{{ zone.get('name', '') }}', this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="toggleMasquerade('{{ zone.get('name', '') }}', this.previousElementSibling.querySelector('input').checked)">Apply</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted text-sm">No zones configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Port Forwarding (DNAT)</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Forward Rule</h3>
|
||||
<form hx-post="/api/firewall/nat/forward" hx-swap="none" class="htmx-on-success" data-success="Forward rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="fw-zone">Zone</label>
|
||||
<select id="fw-zone" name="zone" required>
|
||||
<option value="">— Select —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-protocol">Protocol</label>
|
||||
<select id="fw-protocol" name="protocol">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-port">Port</label>
|
||||
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target">Target Address</label>
|
||||
<input type="text" id="fw-target" name="target" placeholder="192.168.1.100" required style="width:160px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fw-target-port">Target Port</label>
|
||||
<input type="number" id="fw-target-port" name="target_port" placeholder="80" min="1" max="65535" style="width:90px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Proto</th>
|
||||
<th>Port</th>
|
||||
<th>Target</th>
|
||||
<th>Tgt Port</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set all_forwards = [] %}
|
||||
{% for zone in (zones or []) %}
|
||||
{% for fwd in zone.get('forward_ports', []) %}
|
||||
{% set _ = all_forwards.append({'zone': zone.get('name'), 'proxy-protocol': fwd.get('proxy-protocol'), 'port': fwd.get('port'), 'to-addr': fwd.get('to-addr'), 'to-port': fwd.get('to-port')}) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for fwd in all_forwards %}
|
||||
<tr>
|
||||
<td><strong>{{ fwd.zone }}</strong></td>
|
||||
<td><span class="badge badge-info">{{ fwd['proxy-protocol'] }}</span></td>
|
||||
<td>{{ fwd.port }}</td>
|
||||
<td>{{ fwd['to-addr'] }}</td>
|
||||
<td>{{ fwd['to-port'] }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/nat/forward/{{ fwd.zone | urlencode }}/{{ fwd['proxy-protocol'] }}/{{ fwd['to-addr'] }}/{{ fwd['to-port'] }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this forward rule?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not all_forwards %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleMasquerade(zoneName, enabled) {
|
||||
var url = '/api/firewall/nat/masquerade/' + encodeURIComponent(zoneName);
|
||||
var body = JSON.stringify({ enable: enabled });
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
showToast('Masquerade ' + (enabled ? 'enabled' : 'disabled') + ' for ' + zoneName, 'success');
|
||||
} else {
|
||||
return res.text().then(function(t) { throw new Error(t); });
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Proxy - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>SSL Proxy Domains</h1>
|
||||
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
|
||||
<button class="btn btn-outline" hx-post="/api/proxy/reload" hx-swap="none" class="htmx-on-success" data-success="Nginx reloaded">Apply Changes (Reload Nginx)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Backend Host</th>
|
||||
<th>Backend Port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Certificate</th>
|
||||
<th style="width:140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain in (domains or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
|
||||
<td>{{ domain.get('backend_host', '-') }}</td>
|
||||
<td>{{ domain.get('backend_port', '-') }}</td>
|
||||
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
|
||||
<td>
|
||||
{% set matched_cert = None %}
|
||||
{% if certs %}
|
||||
{% for cert in certs %}
|
||||
{% if cert.get('domain') == domain.get('domain') %}
|
||||
{% set matched_cert = cert %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if matched_cert %}
|
||||
{% if matched_cert.get('expired') %}
|
||||
<span class="badge badge-danger">Expired</span>
|
||||
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
|
||||
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">Valid</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-danger">No cert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
|
||||
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" class="htmx-on-success" data-success="Domain removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove proxy for {{ domain.domain }}?')">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (domains or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Modal -->
|
||||
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add Proxy Domain</h2>
|
||||
<form hx-post="/api/proxy/domains" hx-swap="none" class="htmx-on-success" data-success="Domain added" onsuccess="setTimeout(function(){closeModal('add-domain-modal'); location.reload();}, 300);">
|
||||
<div class="form-group">
|
||||
<label for="new-domain">Domain</label>
|
||||
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-host">Backend Host</label>
|
||||
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-backend-port">Backend Port</label>
|
||||
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-protocol">Backend Protocol</label>
|
||||
<select id="new-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Domain</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Domain Modal -->
|
||||
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
|
||||
<div class="modal">
|
||||
<h2>Edit Proxy Domain</h2>
|
||||
<form id="edit-domain-form" hx-swap="none" class="htmx-on-success" data-success="Domain updated" onsuccess="setTimeout(function(){closeModal('edit-domain-modal'); location.reload();}, 300);">
|
||||
<input type="hidden" id="edit-original-domain" name="original_domain">
|
||||
<div class="form-group">
|
||||
<label for="edit-domain">Domain</label>
|
||||
<input type="text" id="edit-domain" name="domain" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-host">Backend Host</label>
|
||||
<input type="text" id="edit-backend-host" name="backend_host" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-backend-port">Backend Port</label>
|
||||
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-protocol">Backend Protocol</label>
|
||||
<select id="edit-protocol" name="protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEditDomainModal(domainName, d) {
|
||||
document.getElementById('edit-original-domain').value = d.domain;
|
||||
document.getElementById('edit-domain').value = d.domain;
|
||||
document.getElementById('edit-backend-host').value = d.backend_host || '';
|
||||
document.getElementById('edit-backend-port').value = d.backend_port || '';
|
||||
document.getElementById('edit-protocol').value = d.protocol || 'http';
|
||||
var form = document.getElementById('edit-domain-form');
|
||||
var target = '/api/proxy/domains/' + encodeURIComponent(d.domain);
|
||||
form.setAttribute('hx-put', target);
|
||||
openModal('edit-domain-modal');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Rules - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Rich Rules</h1>
|
||||
<div class="subtitle">Firewalld rich firewall rules per zone</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Rule</h3>
|
||||
<form hx-post="/api/firewall/rules" hx-swap="none" class="htmx-on-success" data-success="Rule added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="rule-zone">Zone</label>
|
||||
<select id="rule-zone" name="zone" required>
|
||||
<option value="">— Select zone —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule-text">Rule Expression</label>
|
||||
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-muted text-sm mt-2">
|
||||
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rules or False %}
|
||||
{% for zone_name, zone_rules in rules.items() %}
|
||||
<div class="card">
|
||||
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
|
||||
{% if zone_rules %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Rule</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in zone_rules %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ loop.index }}</td>
|
||||
<td style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule }}</td>
|
||||
<td>
|
||||
<form hx-delete="/api/firewall/rules/{{ zone_name | urlencode }}/{{ loop.index0 }}" hx-swap="none" class="htmx-on-success" data-success="Rule removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove this rule?')">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not (zones or []) %}
|
||||
<div class="card" style="border-color:var(--warning);">
|
||||
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}WireGuard - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>WireGuard</h1>
|
||||
<div class="subtitle">VPN tunnel management</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/down"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel stopped"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
Stop Tunnel
|
||||
</button>
|
||||
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
|
||||
hx-post="/api/wireguard/up"
|
||||
hx-swap="none"
|
||||
class="htmx-on-success"
|
||||
data-success="Tunnel started"
|
||||
onsuccess="setTimeout(function(){ location.reload(); }, 500);">
|
||||
Start Tunnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tunnel Status -->
|
||||
<div class="card mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3>
|
||||
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
|
||||
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-sm text-muted">
|
||||
{% if config %}
|
||||
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> |
|
||||
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> |
|
||||
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Peer Form -->
|
||||
<div class="section-title">Peers</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<h3>Add Peer</h3>
|
||||
<form hx-post="/api/wireguard/peers" hx-swap="none" class="htmx-on-success" data-success="Peer added" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<div class="inline-form">
|
||||
<div class="form-group">
|
||||
<label for="peer-name">Name</label>
|
||||
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-pubkey">Public Key</label>
|
||||
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="peer-allowed">Allowed IPs</label>
|
||||
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers|length + 2) %}{{ next }}/32" required style="width:160px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Peer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Peers Table -->
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Allowed IPs</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Latest Handshake</th>
|
||||
<th>Transfer</th>
|
||||
<th style="width:160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for peer in (peers or []) %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
|
||||
<strong>{{ peer.get('name', 'unnamed') }}</strong>
|
||||
</td>
|
||||
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
|
||||
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
|
||||
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
|
||||
<td class="text-sm">
|
||||
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
|
||||
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
|
||||
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') | urlencode }}" hx-swap="none" class="htmx-on-success" data-success="Peer removed" onsuccess="setTimeout(function(){ location.reload(); }, 300);">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove peer {{ peer.name }}?')">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (peers or []) %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadPeerConfig(peerName) {
|
||||
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zones - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Zones</h1>
|
||||
<div class="subtitle">Firewalld zone management</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for zone in (zones or []) %}
|
||||
<div class="card" style="position:relative;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
|
||||
<div class="text-muted text-sm" style="margin-bottom:10px;">
|
||||
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
|
||||
{% if zone.get('interfaces') %}
|
||||
{% for iface in zone.interfaces %}
|
||||
<span class="badge badge-info">{{ iface }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-muted" style="margin-bottom:4px;">Services</div>
|
||||
{% if zone.get('services') %}
|
||||
{% for svc in zone.services %}
|
||||
<span class="badge badge-success">{{ svc }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
|
||||
<form method="POST" action="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-post="/api/firewall/zones/{{ zone.get('name', '') }}/delete" hx-swap="none" onsubmit="return confirm('Delete zone {{ zone.name }}? This will affect traffic to its interfaces.');" class="htmx-on-success" data-success="Zone deleted">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not (zones or []) %}
|
||||
<div class="card">
|
||||
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Create Zone Modal -->
|
||||
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
|
||||
<div class="modal">
|
||||
<h2>Create Zone</h2>
|
||||
<form hx-post="/api/firewall/zones" hx-swap="none" class="htmx-on-success" data-success="Zone created" onsuccess="setTimeout(function(){closeModal('create-zone-modal');},500); location.reload();">
|
||||
<div class="form-group">
|
||||
<label for="zone-name">Zone Name</label>
|
||||
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-target">Target</label>
|
||||
<select id="zone-target" name="target">
|
||||
<option value="default">default</option>
|
||||
<option value="%%REJECT%%">%REJECT%</option>
|
||||
<option value="%%DROP%%">%DROP%</option>
|
||||
<option value="%%ACCEPT%%">%ACCEPT%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zone-services">Default Services (comma-separated)</label>
|
||||
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user