200e078bc5
- 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
153 lines
12 KiB
Markdown
153 lines
12 KiB
Markdown
# 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 `config/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., `<hostname>.local`)
|
|
|
|
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 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.
|
|
|
|
## 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 ──→ 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
|
|
```
|
|
|
|
### 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/`.
|
|
|
|
## Install-Time Templating
|
|
|
|
System configuration files in `system/` are Jinja2 templates rendered by `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).
|
|
- **`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.
|
|
|
|
## State Management
|
|
|
|
Vacuum Wall uses a declarative configuration model. Persistent user-facing configuration lives in `config/<subsystem>/config.json`. Runtime artifacts and generated files live in `data/<subsystem>/`. The application renders these declarations into the format expected by the underlying system service.
|
|
|
|
| Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence |
|
|
|---|---|---|---|---|
|
|
| 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. |
|
|
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | 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. |
|
|
|
|
## Directory Structure
|
|
|
|
### Config — Declarative Settings
|
|
|
|
Config files are persistent, user-editable JSON that defines the desired state for each subsystem:
|
|
|
|
```
|
|
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/
|
|
└── config.json # WireGuard interface and peer configuration
|
|
```
|
|
|
|
### Data — Runtime Artifacts
|
|
|
|
The `data/` directory holds generated files, credentials, and subsystem artifacts:
|
|
|
|
```
|
|
data/
|
|
├── nginx/
|
|
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
|
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
|
├── dnsmasq/
|
|
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
|
├── 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
|
|
```
|
|
|
|
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
|
|
|
## 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`. | 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/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
|
|
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
|
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the configured system user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
|
|
|
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. |