refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry - Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard) - Add system/acme-deploy.py, vacuum-walld sudoers and systemd service - Update API routes to use daemon client instead of lib/ directly - Update lib/, tests/, and webui/ for new architecture - Update docs and deployment scripts
This commit is contained in:
+27
-11
@@ -22,8 +22,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
|
||||
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.
|
||||
4. The Flask application processes the request and communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
|
||||
5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
|
||||
6. Flask renders an HTML or JSON response, which nginx returns 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.
|
||||
|
||||
@@ -33,14 +34,24 @@ The following diagram summarizes how the Flask WebUI communicates with each mana
|
||||
|
||||
```
|
||||
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
|
||||
Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
|
||||
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
||||
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME
|
||||
Flask WebUI ──→ lib/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
|
||||
Flask WebUI ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp server)
|
||||
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
|
||||
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
|
||||
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
|
||||
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
|
||||
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
|
||||
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
|
||||
```
|
||||
|
||||
Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `config/` and runtime artifacts from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged service user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
|
||||
### Two-User Model with Shared Group
|
||||
|
||||
Vacuum Wall uses two distinct system users bridged by a shared group:
|
||||
|
||||
- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Owns the project directory and data files. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process).
|
||||
- **`vacuum-wall`** (web UI user): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`.
|
||||
- **`vacuum-wall`** (shared group): Both users belong to this group. The daemon socket is owned by `vacuum-walld:vacuum-wall` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by `vacuum-walld:vacuum-wall` with group-read+execute, giving the web UI user read access to configs and shared files.
|
||||
|
||||
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`.
|
||||
|
||||
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/`.
|
||||
|
||||
@@ -48,8 +59,9 @@ The `lib/` modules auto-discover the project root at runtime via `Path(__file__)
|
||||
|
||||
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
|
||||
|
||||
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ 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-wall`** — `{{ USER_NAME }}` is substituted to produce the sudoers whitelist.
|
||||
- **`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-wall`** — Reserved for the WebUI user; currently contains no sudo rules (privilege escalation is handled entirely by the daemon).
|
||||
- The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is.
|
||||
|
||||
Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data.
|
||||
@@ -60,7 +72,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
|
||||
|
||||
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|
||||
|---|---|---|---|---|
|
||||
| firewalld | N/A (firewalld manages own state) | `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 an automated backup snapshot. |
|
||||
| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` serves as an automated backup snapshot. |
|
||||
| dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. |
|
||||
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `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 | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
|
||||
@@ -76,6 +88,8 @@ Config files are persistent, user-editable JSON that defines the desired state f
|
||||
config/
|
||||
├── dnsmasq/
|
||||
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
├── firewall/
|
||||
│ └── config.json # Firewall zones, rich rules, forward ports
|
||||
├── nginx/
|
||||
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
||||
└── wireguard/
|
||||
@@ -96,6 +110,8 @@ data/
|
||||
├── firewall/
|
||||
│ └── rules.json # Auto-generated firewall rule state backup
|
||||
├── acme/ # ACME certificate files (acme.sh home)
|
||||
├── logs/
|
||||
│ └── vacuum-wall.log # Application log file
|
||||
└── wireguard/ # WireGuard runtime artifacts
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user