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:
2026-07-10 17:05:37 +00:00
parent 803258cf18
commit 05524f3756
27 changed files with 1805 additions and 521 deletions
+72 -14
View File
@@ -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.