fix: critical bugs + security hardening
Phase 1 (critical bugs): - Fix firewall import string-to-list bug (system_import.py) - Add rich rules removal in firewall config apply (handlers/firewall.py) Phase 2 (security hardening): - Restrict sudo wildcards to specific paths (sudoers.d/vacuum-walld) - Fix TOCTOU: use /run/vacuum-wall/ for temp files (nginx, dnsmasq, network handlers) - Remove unnecessary sudo from wg genkey/pubkey (handlers/wireguard.py) Phase 3 (validation): - Validate poll intervals > 0 (daemon/server.py) - Restrict sysctl to whitelisted parameters (handlers/network.py) Phase 4 (defensive programming): - Enforce shell=False in run() and run_proc() (lib/common.py) - Track issuance tasks for graceful shutdown (handlers/acme.py) - Add nginx template marker consistency tests (tests/test_system_import.py)
This commit is contained in:
+72
-14
@@ -105,6 +105,34 @@ Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firew
|
||||
|
||||
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
|
||||
|
||||
## System Config Import
|
||||
|
||||
On daemon startup, `lib/system_import.py` reconciles live system configurations
|
||||
with the declarative JSON configs. This ensures that configurations created
|
||||
by `scripts/install.sh` or edited manually in system files are imported into
|
||||
the JSON source of truth, preventing drift.
|
||||
|
||||
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
|
||||
import function:
|
||||
|
||||
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
|
||||
block between comment markers) → `config/dnsmasq/config.json`. Only writes
|
||||
if config doesn't exist or differs.
|
||||
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf` →
|
||||
`config/wireguard/config.json`. Skips if configs match.
|
||||
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
|
||||
(install-time files) → `config/network/config.json`. Only adds/updates
|
||||
interfaces; doesn't remove interfaces without a file (they may be pending apply).
|
||||
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf` →
|
||||
`config/nginx/config.json`. Only touches vacuum-wall-managed files
|
||||
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
|
||||
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones` →
|
||||
`config/firewall/config.json`. Only writes if no config file exists
|
||||
(firewalld state always takes precedence).
|
||||
|
||||
Import failures are silently logged as warnings — they never abort daemon startup.
|
||||
The returned list of updated subsystems is logged for debugging.
|
||||
|
||||
## Cross-Subsystem Sync Event Bus
|
||||
|
||||
When a subsystem's configuration changes, related subsystems are automatically
|
||||
@@ -116,28 +144,33 @@ subsystems — no handler calls into another handler's logic directly.
|
||||
1. A mutation handler saves its config (e.g., adding a DHCP range).
|
||||
2. The handler emits a `SyncEvent` on the event bus.
|
||||
3. Subscribers react by updating related subsystem configs:
|
||||
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services and `masquerade` to the
|
||||
firewall zone for each interface serving a DHCP range.
|
||||
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
|
||||
for each interface serving a DHCP range. Back-propagates gateway (interface
|
||||
IP) into DHCP ranges so clients receive their default route.
|
||||
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
|
||||
WireGuard interface and UDP 51820 rich rule.
|
||||
- **FirewallToDhcpSync**: Detects stale DHCP ranges for interfaces not in
|
||||
any zone (logs warnings, does not auto-remove).
|
||||
- **NetworkToAllSync**: Suggests DHCP ranges and syncs firewall zone
|
||||
interface assignments when network config changes.
|
||||
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
|
||||
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
|
||||
entries when no active peers exist.
|
||||
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
|
||||
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
|
||||
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
|
||||
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
|
||||
ranges. Syncs firewall zone interface assignments — adding new interfaces
|
||||
and removing stale ones no longer in network config.
|
||||
4. The handler refreshes state for the originating subsystem plus all
|
||||
transitively affected subsystems.
|
||||
transitively affected subsystems.
|
||||
|
||||
### Guard Rails
|
||||
|
||||
- **Idempotency**: Each subscriber reads current state, computes desired state,
|
||||
writes the diff. Running twice is safe.
|
||||
writes the diff. Running twice is safe.
|
||||
- **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle.
|
||||
Re-entrant emits for the same key are silently dropped.
|
||||
Re-entrant emits for the same key are silently dropped.
|
||||
- **Firewall-cmd separation**: Sync subscribers only write JSON config. They
|
||||
do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to
|
||||
push to firewalld.
|
||||
do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to
|
||||
push to firewalld.
|
||||
- **Error handling**: Subscriber exceptions are caught, logged as warnings,
|
||||
and do NOT abort the originating handler.
|
||||
and do NOT abort the originating handler.
|
||||
|
||||
### Frontend Impact
|
||||
|
||||
@@ -145,6 +178,28 @@ Minimal. The sync happens transparently in the backend. The "pending changes"
|
||||
indicator on the firewall page will show pending when DHCP or WireGuard saves
|
||||
(since sync writes JSON but does not call firewall-cmd).
|
||||
|
||||
## System Config Import
|
||||
|
||||
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
|
||||
to reconcile any drift between system configuration files and the declarative
|
||||
JSON configs. This is invoked from `daemon/server.py` during initialization.
|
||||
|
||||
Each subsystem import function parses the corresponding live system config and
|
||||
updates the JSON config if they differ:
|
||||
|
||||
| Subsystem | Source | Condition |
|
||||
|---|---|---|
|
||||
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
|
||||
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
|
||||
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
|
||||
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
|
||||
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
|
||||
|
||||
All imports are **idempotent** and **non-destructive**: they only write when
|
||||
configs differ, skip on failure (logged as warnings), and never abort daemon
|
||||
startup. This ensures that manual edits to system files (e.g., during install
|
||||
or troubleshooting) are reconciled into the declarative JSON source of truth.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Config — Declarative Settings
|
||||
@@ -185,7 +240,9 @@ data/
|
||||
├── networkd/ # Generated 50-<name>.network files
|
||||
```
|
||||
|
||||
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.
|
||||
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
||||
|
||||
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
|
||||
|
||||
## File System Layout
|
||||
|
||||
@@ -199,6 +256,7 @@ The following file system locations are used for integration with system service
|
||||
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
|
||||
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
|
||||
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+12
-6
@@ -486,12 +486,18 @@ Some subsystems depend on each other. When you modify one, related subsystems
|
||||
are updated automatically through the event bus.
|
||||
|
||||
| Trigger Subsystem | Affected Subsystem | What Happens |
|
||||
|-------------------|-------------------|--------------|
|
||||
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services and `masquerade`. Removing the last range removes them. |
|
||||
| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, and UDP 51820 rule. |
|
||||
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Zones with dhcp service but no range are logged as warnings. |
|
||||
| network (interface config) | firewall | Zone interface assignments in firewall config are updated to match. |
|
||||
|---|---|---|
|
||||
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
|
||||
| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, UDP 51820 rule, and inter-zone accept rules for each peer's allowed_ips subnets. Cleanup runs when no active peers exist. |
|
||||
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
|
||||
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
|
||||
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
|
||||
|
||||
Note: The firewall "Apply" button is still needed to push config changes to
|
||||
firewalld. Sync only updates the declarative JSON.
|
||||
firewalld. Sync only updates the declarative JSON.
|
||||
|
||||
Additionally, on daemon startup, `lib/system_import.py` reconciles live system
|
||||
configs (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative
|
||||
JSON. This prevents drift when configs were created by the install script or
|
||||
edited manually in system files. Reconciliation only writes when the existing
|
||||
JSON differs or is missing — no data is lost on re-run.
|
||||
@@ -117,6 +117,7 @@ The installer performs the following steps automatically:
|
||||
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
|
||||
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
|
||||
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
|
||||
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
|
||||
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
|
||||
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
|
||||
- `vacuum-wall.service` — the Flask WebUI backend.
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@ The app starts from `webui/static/app.js`:
|
||||
|
||||
```javascript
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
|
||||
|
||||
// 1. Register subsystem models
|
||||
modelRegister('firewall', {
|
||||
@@ -1151,7 +1151,7 @@ Render the toast notification container. Include in the main render root. See AP
|
||||
|
||||
## Versioned Imports
|
||||
|
||||
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
|
||||
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=8`) to invalidate browser cache when the framework changes. Page imports also include version pins (e.g., `?v=9`). The server handles caching headers; the version query string ensures browser cache invalidation.
|
||||
|
||||
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||||
|
||||
|
||||
+4
-2
@@ -93,15 +93,17 @@ After installation, access the management interface at `https://<hostname>.local
|
||||
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
|
||||
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
|
||||
├── lib/ # Subsystem abstraction layer
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip)
|
||||
│ ├── logging.py # Logging setup
|
||||
│ ├── firewall.py # firewalld bindings
|
||||
│ ├── network.py # systemd-networkd rendering & parsing
|
||||
│ ├── dnsmasq.py # DHCP/DNS configuration
|
||||
│ ├── nginx.py # Reverse proxy configuration
|
||||
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
|
||||
│ ├── sync.py # Cross-subsystem event bus
|
||||
│ ├── acme.py # Certificate management (ACME helpers)
|
||||
│ └── wireguard.py # VPN tunnel and peer management
|
||||
│ ├── wireguard.py # VPN tunnel and peer management
|
||||
│ └── system_import.py # Startup reconciler (imports live system configs into JSON)
|
||||
├── webui/ # Flask web application
|
||||
│ ├── server.py # Application entry point
|
||||
│ ├── api/ # REST API route modules (blueprints)
|
||||
|
||||
+15
-13
@@ -24,28 +24,29 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
|
||||
| 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 |
|
||||
| Nginx file ops | `cp -- * /etc/nginx/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp -- * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp -- * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
||||
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
|
||||
| Nginx status | `systemctl is-active nginx` | Check nginx service status |
|
||||
| Nginx file ops | `cp * /etc/nginx/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `cp * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
|
||||
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
|
||||
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
|
||||
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
|
||||
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| Networkd | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status |
|
||||
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
|
||||
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files |
|
||||
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
|
||||
| Dnsmasq file ops | `cp * /etc/dnsmasq.d/*` | Copy rendered config files |
|
||||
| Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table |
|
||||
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
|
||||
| WireGuard | `wg *` | WireGuard status and peer management |
|
||||
| WireGuard file ops | `cp -- * /etc/wireguard/*` | Copy rendered config files |
|
||||
| WireGuard file ops | `cp * /etc/wireguard/*` | Copy rendered config files |
|
||||
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
|
||||
| Certificates | (none) | acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used) |
|
||||
| Network queries | `ip -o link show` | List network interfaces |
|
||||
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
|
||||
| Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) |
|
||||
| Networkd | `networkctl status *` | Query interface status from networkd |
|
||||
| Networkd | `networkctl reload *` | Reload networkd for a specific interface |
|
||||
| Networkd | `networkctl reload` | Reload networkd for all interfaces |
|
||||
| Networkd file ops | `cp -- * /etc/systemd/network/*` | Copy rendered network unit files |
|
||||
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
|
||||
| Networkd file ops | `cp * /etc/systemd/network/*` | Copy rendered network unit files |
|
||||
| Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files |
|
||||
| Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
|
||||
| Sysctl | `sysctl -w *` | Set kernel parameters |
|
||||
@@ -101,7 +102,8 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
|
||||
| Directive | Value | Effect |
|
||||
|---|---|---|
|
||||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||||
| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
|
||||
| `ReadWritePaths` | project dir, `/tmp`, `/run/vacuum-wall`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
|
||||
| `RuntimeDirectory` | `vacuum-wall` (daemon only) | Creates `/run/vacuum-wall` owned by the daemon user; removed on stop |
|
||||
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
|
||||
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
|
||||
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
|
||||
|
||||
Reference in New Issue
Block a user