docs: update documentation and project structure

- Update AGENTS.md, README.md, and docs/* with revisions
- Refactor lib/acme.py and lib/state.py
- Add tests for acme module
- Remove install.sh and restart-services.sh (moved to scripts/)
- Normalize vendor files (acme.sh, htm.js)
This commit is contained in:
2026-07-02 14:41:02 +00:00
parent b4d13c4bd5
commit fb39af126a
15 changed files with 291 additions and 9019 deletions
+6
View File
@@ -8,6 +8,12 @@ __pycache__/
# Package build # Package build
*.egg-info/ *.egg-info/
# vendor dirs
vendor/*
!vendor/.empty
webui/static/vendor/*
!webui/static/vendor/.empty
# Tool caches # Tool caches
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
+48 -52
View File
@@ -24,74 +24,72 @@ through the daemon client over a Unix socket.
### Code Layout ### Code Layout
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA catch-all renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja). - `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja). All other paths return 404.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly. - `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints. - `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh. - `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`. - `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming an endpoint here auto-updates both server registry and client calls. All blueprints and handlers import from here. - `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here). - `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast. - `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers. - `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
- `lib/logging.py` — Logging setup used by both webui and daemon. - `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls. - `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`). - `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments). - `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`. - `config/<subsystem>/config.json` — Declarative JSON configs (source of truth).
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`. - `system/` — System file templates. `systemd/` (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/`, `lib/`, and `daemon/` are intentionally empty. Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`).
`__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
### Frontend (hoover) ### Frontend (hoover)
Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference. Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
Conventions: Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components). - All imports from `/static/hoover/index.js` (barrel export).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default. - Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })`.
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS. - Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
- `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff. - `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
- Events: `h()` uses `on:click` prefix. `html` templates use camelCase `onClick` (adapter translates to `on:click`).
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`. - State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root. - `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
- No build step — ES modules served raw. Assets versioned via `?v=N` query string. - No build step — ES modules served raw. Assets versioned via `?v=N` query string.
### Daemon Endpoints ### Daemon Endpoints
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var) - Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`)
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications - WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state notifications
- Can be started as `python -m daemon.server` or via the `vacuum-walld` console script - Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`)
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
## Environment Variables ## Environment Variables
- `VACUUM_WALL_DEV` — dev mode flag; when set, disables aggressive static asset caching - `VACUUM_WALL_DEV` — dev mode flag; disables aggressive static asset caching
- `VACUUM_WALLD_SOCKET` — override daemon socket path - `VACUUM_WALL_LOG_LEVEL` — log level (default `INFO`)
- `VACUUM_WALLD_SOCKET` — override daemon socket path (default `data/daemon.sock`)
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`) - `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
## Deployment - `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
`install.sh` installs only system components and configures them; the project serves from the repo root by default. Options via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override. Use `--dev` to auto-detect repo owner as service user; non-dev requires `--user`.
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs at `PROJECT_DIR/data/acme/`.
### Service Start Order
`firewalld``avahi-daemon``dnsmasq``vacuum-walld``vacuum-wall`
## Local Dev ## Local Dev
```bash ```bash
.venv/bin/python webui/server.py # binds 127.0.0.1:9090 # Setup
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
bash scripts/update-vendor.sh # fetches acme.sh + htm.js
# Start (Flask only, binds 127.0.0.1:9090)
.venv/bin/python webui/server.py
``` ```
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart). Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking. ## Blueprint / Handler / lib Mapping
## Blueprint ↔ Handler ↔ lib Mapping | Blueprint | URL Prefix | Handler | lib Module |
|-----------------------|---------------------|----------------------------|-----------------|
| Blueprint | URL Prefix | Handler Module | lib Module |
|-----------------------|---------------------|--------------------------|-----------------|
| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` | | `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` | | `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` | | `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` |
@@ -99,6 +97,7 @@ In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`,
| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` | | `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` |
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` | | `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — | | `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
## Privileged Operations ## Privileged Operations
@@ -111,20 +110,25 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
## API Response Contract ## API Response Contract
- Success: `{"ok": true, "data": <value>}` helper `_ok(data)` from `webui.api.common` (Flask) or `ok(data)` from `daemon.server` (aiohttp). - Success: `{"ok": true, "data": <value>}``_ok(data)` (Flask) or `ok(data)` (aiohttp)
- Error: `{"ok": false, "error": "msg"}` helper `_error(msg, code=400)` from `webui.api.common` or `error(msg, code)` from `daemon.server`. - Error: `{"ok": false, "error": "msg"}``_error(msg, code)` (Flask) or `error(msg, code)` (aiohttp)
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except. - `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
- HTTP codes: `400` bad request, `404` not found, `500` internal failure. - HTTP codes: `400` bad request, `404` not found, `409` conflict, `500` internal failure
## Deploy ## Deploy
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname. `scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
`MGMT_PASS` is strictly required; `MGMT_DOMAIN` auto-detected from hostname.
### Service Start Order
`firewalld``avahi-daemon``dnsmasq``vacuum-walld``vacuum-wall`
## Lint and Tests ## Lint and Tests
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`. **Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
**Tests:** pytest in `tests/`. Tests mock out subprocess calls — no system services required. **Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
```bash ```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint .venv/bin/ruff check lib/ webui/ tests/ # lint
@@ -132,24 +136,16 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
.venv/bin/python -m pytest tests/ -v # test .venv/bin/python -m pytest tests/ -v # test
``` ```
Install dev tooling with `pip install -e ".[dev]"`.
## Docs ## Docs
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below. `docs/` contains the authoritative reference for each subsystem. Read the relevant doc before reasoning about a subsystem.
| Doc | Contents | | Doc | Contents |
|-----|----------| |-----|----------|
| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management | | `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management |
| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels | | `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels |
| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting | | `docs/deployment.md` | Install script options, what scripts/install.sh does, post-install setup, troubleshooting |
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) | | `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns | | `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/hoover.md` | Custom frontend framework API reference | | `docs/hoover.md` | Custom frontend framework API reference |
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree | | `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
## Important Rules
1. Ask, don't assume. If something is unclear, ask before writing a single line.
2. Simplest solution first. Always implement the simplest thing that could work.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so.
+3 -3
View File
@@ -28,13 +28,13 @@ MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \ MGMT_PASS="strongpassword" \
MGMT_USER="admin" \ MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \ ACME_EMAIL="admin@example.com" \
bash install.sh bash scripts/install.sh
``` ```
### Install (Development) ### Install (Development)
```bash ```bash
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com" ./scripts/install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
``` ```
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly. `--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
@@ -52,7 +52,7 @@ bash install.sh
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) | | `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) | | `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage. CLI flags take precedence over environment variables. Run `./scripts/install.sh --help` for full usage.
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating. After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
+140 -42
View File
@@ -376,6 +376,22 @@ Toggle masquerade (source NAT) for a zone.
| `zone` | `string` | Zone name | | `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled | | `masquerade` | `boolean` | Whether masquerade is now enabled |
### State
#### Get Firewall State
```
GET /api/firewall/state
```
Return current firewall state from the daemon state store. Provides live firewall state data including active zones, services, and interfaces as polled by the daemon.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Firewall state data from the state collector |
### Info ### Info
#### Available Services #### Available Services
@@ -476,12 +492,7 @@ POST /api/dhcp/apply
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service. Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
**Response (`data`):** **Response:** `data` is `null` on success.
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
### Status ### Status
@@ -777,20 +788,6 @@ 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`):** Domain name plus backend configuration fields.
Returns HTTP `404` if the domain is not configured.
---
#### Update Domain #### Update Domain
``` ```
@@ -902,15 +899,35 @@ Return details for a single certificate.
Returns HTTP `404` if no certificate is found for the domain. Returns HTTP `404` if no certificate is found for the domain.
### Validation
#### Validate Certificate Issuance
```
POST /api/certs/validate
```
Run pre-flight checks before certificate issuance. Verifies domain format, ACME account registration, DNS resolution, and port 80 accessibility.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain to validate |
**Response (`data`):** Validation results object with per-check status.
Returns HTTP `400` if the domain is missing.
### Operations ### Operations
#### Issue Certificate #### Start Certificate Issuance
``` ```
POST /api/certs/issue POST /api/certs/issue/start
``` ```
Request a new certificate for a domain. Returns HTTP `500` if issuance fails. Create a new certificate issuance request. Issuance runs asynchronously in the background.
**Request Body:** **Request Body:**
@@ -920,12 +937,30 @@ Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
| `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email | | `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email |
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation | | `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
**Response:** `data` is `null` on success. **Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling issuance status |
Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline). Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
--- ---
#### Poll Certificate Issuance Status
```
GET /api/certs/issue/<request_id>
```
Poll the status of a certificate issuance request started by `POST /api/certs/issue/start`.
**Response (`data`):** Issuance status object containing progress, logs, and result.
Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to repeatedly fetch this endpoint until issuance completes or fails.
---
#### Renew Certificate #### Renew Certificate
``` ```
@@ -1125,12 +1160,7 @@ POST /api/wireguard/apply
Write the current configuration to `wg0.conf` and bring the tunnel up. Write the current configuration to `wg0.conf` and bring the tunnel up.
**Response (`data`):** **Response:** `data` is `null` on success.
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
--- ---
@@ -1429,12 +1459,54 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|-------|------|-------------| |-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) | | `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
Returns HTTP `500` if the value cannot be verified after write.
---
## Status API
Endpoints prefixed with `/api/status/...`. Aggregate status across all subsystems.
### Pending Changes
#### Check All Pending Changes
```
GET /api/status/pending
```
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `subsystems` | `object` | Map of subsystem name to pending status |
| `total_changes` | `number` | Total count of pending changes across all subsystems |
---
#### Apply All Pending Changes
```
POST /api/status/apply-all
```
Apply pending changes for all subsystems in dependency order.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `[object, ...]` | Any errors encountered during apply |
### Sysctl ### Sysctl
#### Set Kernel Parameter #### Set Kernel Parameter
``` ```
POST /api/sysctl/set POST /api/network/sysctl/set
``` ```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back. Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
@@ -1459,7 +1531,7 @@ Returns HTTP `500` if the value cannot be verified after write.
## Logs API ## Logs API
Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses. Endpoints prefixed with `/api/logs/...`. Return log lines as JSON strings, wrapped in the standard `{"ok": true, "data": ...}` response contract.
### System Journal ### System Journal
@@ -1469,9 +1541,13 @@ Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the s
GET /api/logs/journal GET /api/logs/journal
``` ```
Return recent system journal entries as rendered HTML log lines. Return recent system journal entries.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw journal log text |
### Nginx Logs ### Nginx Logs
@@ -1481,9 +1557,15 @@ Return recent system journal entries as rendered HTML log lines.
GET /api/logs/nginx/access GET /api/logs/nginx/access
``` ```
Return recent nginx access log entries as rendered HTML. Return recent nginx access log entries.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw access log text |
Returns HTTP `404` if the log file does not exist.
--- ---
@@ -1493,9 +1575,15 @@ Return recent nginx access log entries as rendered HTML.
GET /api/logs/nginx/error GET /api/logs/nginx/error
``` ```
Return recent nginx error log entries as rendered HTML. Return recent nginx error log entries.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw error log text |
Returns HTTP `404` if the log file does not exist.
### Dnsmasq Log ### Dnsmasq Log
@@ -1505,9 +1593,13 @@ Return recent nginx error log entries as rendered HTML.
GET /api/logs/dnsmasq GET /api/logs/dnsmasq
``` ```
Return recent dnsmasq journal entries as rendered HTML. Return recent dnsmasq journal entries.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw dnsmasq journal text |
### Application Log ### Application Log
@@ -1517,9 +1609,15 @@ Return recent dnsmasq journal entries as rendered HTML.
GET /api/logs/app GET /api/logs/app
``` ```
Return recent application log entries as rendered HTML. Return recent application log entries.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw application log text |
Returns HTTP `404` if the log file does not exist.
--- ---
+3 -3
View File
@@ -54,13 +54,13 @@ Vacuum Wall uses two distinct system users bridged by a shared group:
This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`. This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`.
**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group. **Dev mode variant**: When `scripts/install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`. The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `scripts/install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
## Install-Time Templating ## Install-Time Templating
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time: System configuration files in `system/` are Jinja2 templates rendered by `scripts/install.sh` at install time:
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root). - **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user. - **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user.
+9 -9
View File
@@ -25,13 +25,13 @@ Download the Vacuum Wall repository onto the target machine, then run the instal
# Production: all env vars # Production: all env vars
MGMT_DOMAIN=wall.example.com \ MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \ MGMT_PASS="strongpassword" \
./install.sh --user vacuum-wall ./scripts/install.sh --user vacuum-wall
# Dev mode: CLI flags, auto-detects repo owner # Dev mode: CLI flags, auto-detects repo owner
./install.sh --dev --mgmt-pass strongpassword ./scripts/install.sh --dev --mgmt-pass strongpassword
# mDNS (LAN-only, no DNS record needed) # mDNS (LAN-only, no DNS record needed)
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass ./scripts/install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
``` ```
### Options ### Options
@@ -51,7 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. | | `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
| `--force-venv` | — | No | Force recreation of the Python virtual environment. | | `--force-venv` | — | No | Force recreation of the Python virtual environment. |
Run `./install.sh --help` for full usage. Run `./scripts/install.sh --help` for full usage.
--- ---
@@ -71,7 +71,7 @@ In dev mode, the ownership model preserves the developer's ability to work with
### Running the Installer in Dev Mode ### Running the Installer in Dev Mode
```bash ```bash
./install.sh --dev --mgmt-pass strongpassword ./scripts/install.sh --dev --mgmt-pass strongpassword
``` ```
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above. The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
@@ -88,7 +88,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o
```bash ```bash
# Docker volume mount example # Docker volume mount example
./install.sh --path /app/vacuum-wall --user ww-app \ ./scripts/install.sh --path /app/vacuum-wall --user ww-app \
--mgmt-domain proxy.internal --mgmt-pass strongpassword --mgmt-domain proxy.internal --mgmt-pass strongpassword
``` ```
@@ -96,7 +96,7 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
--- ---
## What install.sh Does ## What scripts/install.sh Does
The installer performs the following steps automatically: The installer performs the following steps automatically:
@@ -130,7 +130,7 @@ The installer performs the following steps automatically:
### Idempotent Re-Runs ### Idempotent Re-Runs
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script: `scripts/install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
- Skips the Python venv (use `--force-venv` to rebuild) - Skips the Python venv (use `--force-venv` to rebuild)
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes - Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
@@ -138,7 +138,7 @@ The installer performs the following steps automatically:
- Preserves existing `config.json` files (skips initial write if file exists) - Preserves existing `config.json` files (skips initial write if file exists)
- Safely updates `htpasswd` (uses update mode instead of create mode) - Safely updates `htpasswd` (uses update mode instead of create mode)
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation. This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
--- ---
+8 -5
View File
@@ -43,22 +43,24 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
## Quick Start ## Quick Start
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables): To install Vacuum Wall on a Debian 13 system, run `scripts/install.sh` as root with required settings (CLI flags or environment variables):
```bash ```bash
# Production # Production
./install.sh --mgmt-pass yourpassword ./scripts/install.sh --mgmt-pass yourpassword
# Development (auto-detects your user) # Development (auto-detects your user)
./install.sh --dev --mgmt-pass yourpassword ./scripts/install.sh --dev --mgmt-pass yourpassword
``` ```
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options. After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `scripts/install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./scripts/install.sh --help` for all options.
## Project Structure ## Project Structure
``` ```
├── install.sh # Deployment script (renders Jinja2 templates) ├── scripts/ # Utility scripts
│ ├── install.sh # Deployment script (renders Jinja2 templates)
│ └── update-vendor.sh # Download vendored libraries (acme.sh, htm)
├── pyproject.toml # Project metadata + dependencies ├── pyproject.toml # Project metadata + dependencies
├── .venv/ # Python virtual environment ├── .venv/ # Python virtual environment
├── config/ # Declarative JSON configuration (source of truth) ├── config/ # Declarative JSON configuration (source of truth)
@@ -136,6 +138,7 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── config.md │ ├── config.md
│ └── hoover.md # Hoover SPA framework │ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts └── scripts/ # Utility scripts
├── install.sh # Deployment script (renders Jinja2 templates)
└── update-vendor.sh # Download vendored libraries (acme.sh, htm) └── update-vendor.sh # Download vendored libraries (acme.sh, htm)
``` ```
-506
View File
@@ -1,506 +0,0 @@
#!/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; }
# --- CLI argument parsing ---
_cli_user=""
_cli_is_dev=false
_cli_path=""
_cli_mgmt_pass=""
_cli_mgmt_user=""
_cli_mgmt_domain=""
_cli_force_venv=false
_cli_wan_iface=""
_cli_lan_ifaces=""
while [[ $# -gt 0 ]]; do
case "$1" in
--user|-u) _cli_user="$2"; shift 2 ;;
--path|-p) _cli_path="$2"; shift 2 ;;
--dev) _cli_is_dev=true; shift ;;
--mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;;
--mgmt-user) _cli_mgmt_user="$2"; shift 2 ;;
--mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;;
--force-venv) _cli_force_venv=true; shift ;;
--wan-iface) _cli_wan_iface="$2"; shift 2 ;;
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
-h|--help)
printf '%s\n' \
"Usage: install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
" --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
" -h, --help Show this help" \
"" \
"All options also have environment variable equivalents:" \
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
" MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
exit 0
;;
*)
err "Unknown argument: $1 (use --help for usage)"
;;
esac
done
# --- Resolve config: CLI flag > env var > default ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
# Optional settings with defaults
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
if [[ -n "$_cli_mgmt_domain" ]]; then
DOMAIN="$_cli_mgmt_domain"
elif [[ -n "${MGMT_DOMAIN:-}" ]]; then
DOMAIN="$MGMT_DOMAIN"
else
HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)
if [[ -z "$HOSTNAME_F" ]]; then
err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
fi
DOMAIN="${HOSTNAME_F}.local"
fi
# Install directory (CLI > env > repo root)
INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}"
if [[ -n "$INSTALL_DIR" ]]; then
PROJECT_DIR="$INSTALL_DIR"
else
PROJECT_DIR="$REPO_DIR"
fi
# Network interfaces (CLI > env — auto-detect happens later if still unset)
WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}"
LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
# --- 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."
# --- Validate required settings ---
missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
if (( ${#missing[@]} )); then
echo -e "${RED}[!!]${NC} Missing required settings:"
for v in "${missing[@]}"; do
case "$v" in
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
esac
done
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
exit 1
fi
ACME_HOME="$PROJECT_DIR/data/acme"
# Dev mode: auto-detect repo owner as service user
if [[ "$_cli_is_dev" == true ]]; then
_repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true
if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then
_cli_user="$_repo_owner"
log "Dev mode: using repo owner '$_repo_owner' as service user"
else
err "Dev mode: cannot determine repo owner (root or unavailable)."
fi
fi
# Resolve USER_NAME: dev mode auto-detects, non-dev requires --user
USER_NAME="${_cli_user:-${USER_NAME:-}}"
if [[ -z "$USER_NAME" ]]; then
err "WebUI user is required. Use --dev to auto-detect repo owner, or set --user / USER_NAME."
fi
# Daemon user name (derived from web UI user name)
USER_DAEMON_NAME="${USER_NAME}d"
# --- Safety check: running service as a regular user ---
if [[ "$_cli_is_dev" != true ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME")
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then
warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
warn "This runs the web service as your login account."
warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)."
fi
fi
# Create WebUI user if it does not exist
if ! id "$USER_NAME" &>/dev/null; then
log "Creating system user $USER_NAME..."
useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin "$USER_NAME"
fi
# Shared group: use the WebUI user's primary group
USER_GROUP=$(id -gn "$USER_NAME")
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
echo " WebUI user: $USER_NAME"
echo " Daemon user: $USER_DAEMON_NAME"
echo " Shared group: $USER_GROUP"
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 \
avahi-daemon
# --- 1b. Vendored libraries ---
log "Downloading vendored libraries..."
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
# --- 2. Setup users ---
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
# --- 2a. Create daemon user (has sudo for privileged operations) ---
if ! id "$USER_DAEMON_NAME" &>/dev/null; then
log "Creating system user $USER_DAEMON_NAME..."
useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \
--gid "$USER_GROUP" "$USER_DAEMON_NAME"
else
log "User $USER_DAEMON_NAME already exists."
usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true
fi
# --- 2b. Setup Python venv ---
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
log "Python venv already exists, skipping (use --force-venv to recreate)."
else
log "Setting up Python virtual environment..."
rm -rf "${PROJECT_DIR}/.venv"
python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv"
chmod -R g+x "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME"
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
else
log "acme.sh already installed."
fi
# Install the deploy hook into acme.sh's deploy directory
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
mkdir -p "$ACME_HOME/deploy"
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh"
# --- 3. Setup directories ---
log "Creating config and data directories..."
mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
if [[ "$_cli_is_dev" == true ]]; then
_dev_owner="$USER_NAME"
else
_dev_owner="$USER_DAEMON_NAME"
fi
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod -R g+rwX "$PROJECT_DIR"
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
# --- 4. Template rendering function ---
# Renders Jinja2 templates by injecting env vars as template context.
# Used for systemd units and sudoers files.
render_template() {
export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME
"${PROJECT_DIR}/.venv/bin/python3" -c "
import sys, os
from jinja2 import Template
text = open(sys.argv[1]).read()
env = {
'USER_NAME': os.environ['USER_NAME'],
'USER_DAEMON_NAME': os.environ.get('USER_DAEMON_NAME', ''),
'USER_GROUP': os.environ.get('USER_GROUP', ''),
'PROJECT_DIR': os.environ['PROJECT_DIR'],
'ACME_HOME': os.environ['ACME_HOME'],
}
print(Template(text).render(**env), end='')
" "$1"
}
# --- 5. Install sudoers ---
log "Installing sudoers whitelist..."
export USER_DAEMON_NAME
render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \
| install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-walld
visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!"
# --- 6. Install systemd units ---
log "Installing systemd units..."
export USER_GROUP
render_template "${PROJECT_DIR}/system/systemd/vacuum-walld.service" \
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-walld.service
render_template "${PROJECT_DIR}/system/systemd/vacuum-wall.service" \
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall.service
render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
| install -m 0644 /dev/stdin /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
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
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
# --- 8. Detect network interfaces ---
log "Detecting network interfaces..."
# Auto-detect WAN (interface with default gateway)
WAN_IFACE="${WAN_IFACE:-}"
if [[ -z "$WAN_IFACE" ]]; then
# Strip @if<port> suffix — physical port index can change on reboot
DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' | cut -d'@' -f1)
if [[ -n "$DETECTED_WAN" ]]; then
WAN_IFACE="$DETECTED_WAN"
log "Auto-detected WAN interface: $WAN_IFACE"
else
warn "Could not auto-detect WAN interface — set WAN_IFACE env var"
fi
fi
# Auto-detect LAN (all non-loopback, non-Docker, non-WAN, non-virtual interfaces)
LAN_IFACES="${LAN_IFACES:-}"
if [[ -z "$LAN_IFACES" ]]; then
DETECTED_LANS=$(ls /sys/class/net/ 2>/dev/null \
| grep -vE "^(lo|docker|br-|virbr|${WAN_IFACE})$")
if [[ -n "$DETECTED_LANS" ]]; then
LAN_IFACES=$(echo "$DETECTED_LANS" | paste -sd ',' -)
log "Auto-detected LAN interfaces: $LAN_IFACES"
else
warn "Could not auto-detect LAN interfaces — set LAN_IFACES env var"
fi
fi
# --- 9. Enable and start core services ---
log "Enabling services..."
systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld"
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
# Clean up old nginx bootstrap configs (replaced by daemon-generated config)
rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf
# Stop all services to ensure clean start order
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl stop vacuum-walld >/dev/null 2>&1 || true
# Start services in dependency order
systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
# Start daemon and wait for socket
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
_SOCKET="$PROJECT_DIR/data/daemon.sock"
for _i in $(seq 1 30); do
[[ -S "$_SOCKET" ]] && break
sleep 0.5
done
if [[ ! -S "$_SOCKET" ]]; then
warn "Daemon socket not found at $_SOCKET — skipping API configuration"
else
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
# --- 10. Configure subsystems via daemon API ---
log "Configuring subsystems via daemon API..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
MGMT_PASS="$MGMT_PASS" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import daemon.client as c
from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
GET_NETWORK_INFER_DHCP_RANGES,
)
import sys
domain = '${DOMAIN}'
mgmt_user = '${MGMT_USER}'
mgmt_pass = '${MGMT_PASS}'
wan_iface = '${WAN_IFACE}'
lan_ifaces = '${LAN_IFACES}'
# Self-signed cert for management domain
try:
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr)
# Management proxy domain + htpasswd
try:
c.post(POST_NGINX_DOMAINS_ADD, {
'domain': domain,
'paths': {
'/': {
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
'is_management': True,
},
'/ws': {
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
'is_websocket': True,
},
},
'auth_user': mgmt_user,
'auth_pass': mgmt_pass,
})
c.post(POST_NGINX_APPLY)
print(f' [proxy] Management proxy configured for {domain}')
except Exception as e:
print(f' [proxy] Warning: {e}', file=sys.stderr)
# Firewall config (interface detection done in bash above)
import json as _json
zones = {}
if wan_iface:
zones['public'] = {
'target': 'DEFAULT',
'interfaces': [i for i in wan_iface.split(',') if i],
'services': ['http', 'https', 'ssh'],
'masquerade': True,
}
if lan_ifaces:
zones['internal'] = {
'target': 'ACCEPT',
'interfaces': [i for i in lan_ifaces.split(',') if i],
'services': ['dhcp', 'dns', 'ntp'],
'masquerade': False,
}
# Always create vpn zone skeleton for later WireGuard setup
zones['vpn'] = {
'target': 'ACCEPT',
'interfaces': [],
'services': [],
'masquerade': False,
}
try:
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
c.post(POST_FIREWALL_CONFIG_APPLY)
print(' [firewall] Zones configured and applied')
except Exception as e:
print(f' [firewall] Warning: {e}', file=sys.stderr)
# IP forwarding
try:
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
print(' [network] IP forwarding enabled')
except Exception as e:
print(f' [network] Warning: {e}', file=sys.stderr)
# Infer DHCP ranges (logged for user reference)
try:
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
for iface, rng in ranges.get('ranges', {}).items():
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
except Exception:
pass
"
log "Subsystem configuration complete"
fi
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
warn "Could not restart nginx (check config)"
# --- Done ---
echo ""
echo "============================================"
echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
echo "============================================"
echo ""
echo " Management UI: https://$DOMAIN"
echo " User: $MGMT_USER"
echo " Daemon service: vacuum-walld.service"
echo " WebUI service: vacuum-wall.service"
echo " ACME renewal: vacuum-wall-acme.timer"
echo ""
echo " Firewall zones:"
if [[ -n "$WAN_IFACE" ]]; then
echo " public (WAN) → $WAN_IFACE"
else
echo " public (WAN) → not assigned"
fi
if [[ -n "$LAN_IFACES" ]]; then
echo " internal (LAN) → $LAN_IFACES"
else
echo " internal (LAN) → not assigned"
fi
echo ""
echo " Next steps:"
echo " 1. Register your ACME account at https://$DOMAIN/certs"
echo " 2. Verify zone assignments at https://$DOMAIN/interfaces"
echo " 3. Configure DHCP ranges for your LAN"
echo " 4. Add proxy domains with ACME certificates"
echo " 5. 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 ""
+55 -4
View File
@@ -248,7 +248,7 @@ def list_certs() -> list[dict]:
A list of dicts, one per certificate, with keys matching A list of dicts, one per certificate, with keys matching
the cert-info schema (domain, ca, cert_path, etc.). the cert-info schema (domain, ca, cert_path, etc.).
""" """
raw = _run_acme(["--list"]) raw = _run_acme(["--list", "--listraw"])
certs: list[dict] = [] certs: list[dict] = []
entries = _parse_list_output(raw) entries = _parse_list_output(raw)
@@ -472,7 +472,7 @@ def deploy(domain: str) -> None:
def _split_line(line: str, separator: str | None) -> list[str]: def _split_line(line: str, separator: str | None) -> list[str]:
"""Split a line by *separator*, falling back to whitespace for column output.""" """Split a line by *separator*, falling back to whitespace for column output."""
if separator in line: if separator is not None and separator in line:
return line.split(separator) return line.split(separator)
return line.split() return line.split()
@@ -503,8 +503,8 @@ def _parse_list_output(raw: str) -> list[dict]:
headers = _split_line(header_line, "\t") headers = _split_line(header_line, "\t")
separator = "\t" separator = "\t"
else: else:
headers = _split_line(header_line, None) # whitespace # Column-aligned: use position-based parsing via helper
separator = None return _parse_column_aligned(header_line, lines[1:])
if "Main_Domain" not in headers: if "Main_Domain" not in headers:
raise ValueError( raise ValueError(
@@ -526,6 +526,57 @@ def _parse_list_output(raw: str) -> list[dict]:
return entries return entries
def _find_header_positions(header_line: str):
"""Find start positions of each header word in a column-aligned header."""
names: list[str] = []
starts: list[int] = []
i = 0
while i < len(header_line):
while i < len(header_line) and header_line[i] == " ":
i += 1
j = i
while j < len(header_line) and header_line[j] != " ":
j += 1
if j > i:
names.append(header_line[i:j])
starts.append(i)
i = j
return names, starts
def _parse_column_aligned(header_line: str, data_lines: list[str]) -> list[dict]:
"""Parse column-aligned output using header positions to locate fields.
Unlike simple whitespace splitting, this preserves empty fields by using
character positions rather than token counts. Empty columns (e.g. missing
Profile or SAN_Domains) are correctly handled.
"""
names, starts = _find_header_positions(header_line)
if "Main_Domain" not in names:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
# Column ends: midpoint before next header starts (or end of line for last)
ends: list[int] = len(starts) * [len(header_line)]
for i in range(len(starts) - 1):
ends[i] = (starts[i] + starts[i + 1]) // 2
entries: list[dict] = []
for line in data_lines:
line = line.rstrip()
if not line.strip():
continue
entry: dict[str, str] = {}
for k, name in enumerate(names):
s, e = starts[k], ends[k]
cell = line[s:e] if len(line) > s else ""
entry[name.lower()] = cell.strip().strip('"')
entries.append(entry)
return entries
def _days_until(date_str: str) -> int | None: def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now.""" """Parse an ISO date string and return days until that date from now."""
if not date_str: if not date_str:
+2 -40
View File
@@ -818,48 +818,10 @@ def _collect_acme() -> dict[str, Any]:
""" """
email = _get_acme_email() email = _get_acme_email()
certs: list[dict[str, Any]] = []
try: try:
from lib.acme import ( from lib.acme import list_certs
_days_until,
_has_auto_renew,
_parse_list_output,
_run_acme,
)
raw = _run_acme(["--list"]) certs = list_certs()
entries = _parse_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
main = entry.get("main_domain", "")
if not main:
continue
san_domains = [
d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no"
]
cert_dir = acme_home / main
days = _days_until(entry.get("renew", ""))
certs.append(
{
"domain": main,
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": _has_auto_renew(main),
"san_domains": san_domains,
}
)
except Exception: except Exception:
logger.warning( logger.warning(
"ACME state collection failed, returning empty cert list", "ACME state collection failed, returning empty cert list",
-24
View File
@@ -1,24 +0,0 @@
#!/bin/bash
echo "systemctl restart nginx"
systemctl restart nginx
sleep 1
echo "systemctl restart vacuum-walld"
systemctl restart vacuum-walld
sleep 1
echo "systemctl restart vacuum-wall"
systemctl restart vacuum-wall
# Verify services are running
failed=0
for svc in nginx vacuum-walld vacuum-wall; do
if ! systemctl is-active --quiet "$svc"; then
echo "ERROR: $svc is not running" >&2
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
exit 1
fi
+14
View File
@@ -117,6 +117,20 @@ class TestParseListOutput:
result = acme._parse_list_output(raw) result = acme._parse_list_output(raw)
assert result == [] assert result == []
def test_pipe_separated_raw_format(self):
"""Pipe-separated output with empty fields (what --listraw produces)."""
raw = (
"Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n"
'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n'
)
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["profile"] == ""
assert result[0]["ca"] == "ZeroSSL.com"
assert result[0]["created"] == "2026-01-01"
assert result[0]["renew"] == "2026-07-01"
class TestDaysUntil: class TestDaysUntil:
def test_future_date(self): def test_future_date(self):
-8326
View File
File diff suppressed because it is too large Load Diff
Vendored Symlink
+1
View File
@@ -0,0 +1 @@
acme-3.1.3.sh
-4
View File
@@ -1,4 +0,0 @@
// htm mini (no caching) — https://github.com/developit/htm
// Vendored from: htm@3.1.1/mini/index.module.js
// License: Apache-2.0
export default function(n){for(var l,e,s=arguments,t=1,r="",u="",a=[0],c=function(n){1===t&&(n||(r=r.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(n?s[n]:r):3===t&&(n||r)?(a[1]=n?s[n]:r,t=2):2===t&&"..."===r&&n?a[2]=Object.assign(a[2]||{},s[n]):2===t&&r&&!n?(a[2]=a[2]||{})[r]=!0:t>=5&&(5===t?((a[2]=a[2]||{})[e]=n?r?r+s[n]:s[n]:r,t=6):(n||r)&&(a[2][e]+=n?r+s[n]:r)),r=""},h=0;h<n.length;h++){h&&(1===t&&c(),c(h));for(var i=0;i<n[h].length;i++)l=n[h][i],1===t?"<"===l?(c(),a=[a,"",null],t=3):r+=l:4===t?"--"===r&&">"===l?(t=1,r=""):r=l+r[0]:u?l===u?u="":r+=l:'"'===l||"'"===l?u=l:">"===l?(c(),t=1):t&&("="===l?(t=5,e=r,r=""):"/"===l&&(t<5||">"===n[h][i+1])?(c(),3===t&&(a=a[0]),t=a,(a=a[0]).push(this.apply(null,t.slice(1))),t=0):" "===l||"\t"===l||"\n"===l||"\r"===l?(c(),t=2):r+=l),3===t&&"!--"===r&&(t=4,a=a[0])}return c(),a.length>2?a.slice(1):a[1]}
Vendored Symlink
+1
View File
@@ -0,0 +1 @@
htm-3.1.1.js
+1 -1
View File
@@ -1 +1 @@
../../../vendor/htm.js ../../../vendor/htm-3.1.1.js