250 lines
29 KiB
Markdown
250 lines
29 KiB
Markdown
# Security Model
|
||
|
||
## Privilege Model
|
||
|
||
Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI user's primary group):
|
||
|
||
- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`.
|
||
- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.
|
||
|
||
ACME certificate operations via `acme.sh` run as the daemon user (`{{ USER_DAEMON_NAME }}`) — never as root, and never from the WebUI process (the WebUI never invokes acme.sh directly). The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_DAEMON_NAME }}`. Issuance and renewal triggered from the WebUI are executed by the daemon as its own subprocess, using webroot validation that does not require binding to privileged ports; the only sudo call around acme.sh is the `chmod g+rwX` that reopens group access on the ACME home (see Sudo Whitelist).
|
||
|
||
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
|
||
|
||
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process over the Unix socket, which carries **no authentication of its own**: access to it is protected purely by the socket's `0660` mode and shared-group ownership. The JWT handshake exists on the daemon's **WebSocket** endpoint: WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades.
|
||
|
||
## Communication Between WebUI and Daemon
|
||
|
||
The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:<group>` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands.
|
||
|
||
## Sudo Whitelist
|
||
|
||
The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) passwordless sudo access to a strict set of commands. The WebUI user has no sudo access. Each daemon entry is scoped to a single binary with allowed arguments:
|
||
|
||
| Category | Whitelisted Command | Purpose |
|
||
|---|---|---|
|
||
| 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 status | `systemctl is-active nginx` | Check nginx service status |
|
||
| Nginx file ops | `cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf` | Copy the rendered config include to its system path (pinned source and destination) |
|
||
| Nginx file ops | `cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf` | Copy the rendered SSL snippet to its system path (pinned source and destination) |
|
||
| 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 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 -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf` | Copy the rendered dnsmasq fragment to its system path (pinned source and destination) |
|
||
| 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 -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf` | Copy the rendered WG config to its system path (pinned source and destination) |
|
||
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
|
||
| Certificates | `chmod g+rwX {{ ACME_HOME }}/*` | Reopen group read/write on ACME home files after acme.sh hardens them to owner-only modes (`normalize_acme_home()`, run before every daemon acme.sh invocation). Files only: setgid directories already grant group rwx |
|
||
| 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 all interfaces |
|
||
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
|
||
| Networkd file ops | `cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/` | Copy rendered network unit files (pinned destination dir, `99-*` source pattern) |
|
||
| 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 |
|
||
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
|
||
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
|
||
|
||
Key safety properties:
|
||
|
||
- Each `Cmnd` entry specifies the full path to the binary (e.g., `/usr/bin/firewall-cmd`).
|
||
- Full-argument wildcard entries exist only for commands where the full argument space is needed (`firewall-cmd *`, `wg-quick *`, `wg *`, `sysctl -w *`, `journalctl --unit=* -n *`, `networkctl status *`, `networkctl reconfigure *`, `ip -o addr show *`); the remaining wildcard entries target fixed destination paths with a filename pattern (`cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/`, `rm /etc/systemd/network/*.network`, `chmod g+rwX {{ ACME_HOME }}/*`). All file-copy entries are pinned to a single source file under the daemon-owned `/run/vacuum-wall` runtime dir and a single destination path. None of the entries grant shell access or arbitrary command execution.
|
||
- `NOPASSWD` is used so the application never prompts for a password. `Defaults:<user>` restricts the secure path and disables TTY requirement.
|
||
- The sudoers file is rendered from a Jinja2 template at install time, substituting the configured `USER_DAEMON_NAME` and `ACME_HOME` variables (the install also renders `USER_NAME`, `USER_GROUP`, and `PROJECT_DIR` for the systemd unit templates).
|
||
|
||
## Daemon Client Path Resolution
|
||
|
||
The `daemon/client.py` module resolves `<param>` placeholders in URL paths before sending requests over the Unix socket. For example, a request to `/network/interfaces/<name>` with a body containing `{"name": "eth0"}` is rewritten to `/network/interfaces/eth0` before transmission. Parameter values are URL-encoded to handle special characters safely. This eliminates the need for the API layer to construct literal paths and ensures the daemon always receives concrete paths for routing.
|
||
|
||
## Web Security
|
||
|
||
### Management Interface
|
||
|
||
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. Static assets under `/static/` are served directly by nginx from `webui/static/` (unauthenticated, the same exposure as the Flask static route) with `Cache-Control: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive `Content-Security-Policy: default-src 'none'`.
|
||
|
||
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
|
||
|
||
Flask sets a full `Content-Security-Policy` (all sources locked to `'self'` with `img-src 'self' data:`) and `X-Content-Type-Options: nosniff` on **every** response via an `after_request` hook — the CSP includes `frame-ancestors 'none'`, `base-uri 'self'`, and `form-action 'self'`. `X-Frame-Options` and HSTS are absent on the management domain; clickjacking protection comes from the CSP `frame-ancestors 'none'` directive instead. The SPA relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.
|
||
|
||
The auth-exempt public path list covers the SPA root, static and vendor files, `POST /api/auth/login`, `POST /api/auth/refresh`, and the two WebAuthn authentication endpoints (`POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`). nginx writes the management domain's traffic to dedicated `wall_mgmt_access.log` / `wall_mgmt_error.log` files; non-management domains get per-domain `<domain>_access.log` / `<domain>_error.log` logs.
|
||
|
||
### Proxy Domains
|
||
|
||
Proxied domains **without** a management path enforce, at the nginx server level:
|
||
|
||
- **HTTP-to-HTTPS redirect** — rendered only when the domain has `force_ssl` enabled. All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent (the HTTP server block also serves the ACME HTTP-01 challenge location `/.well-known/acme-challenge/` before the redirect).
|
||
- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with `max-age=31536000; includeSubDomains` to prevent downgrade attacks.
|
||
- **Security headers** on all responses from the domain:
|
||
- `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing.
|
||
- `X-Frame-Options: DENY` — Prevents clickjacking via iframes.
|
||
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
|
||
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
|
||
|
||
Domains that carry a management path get none of the above — the management SPA receives its security headers from Flask instead (see Management Interface).
|
||
|
||
**Basic auth on proxy domains**: a domain-level `auth` block renders `auth_basic` + `auth_basic_user_file` on the whole server block, and per-path `auth` blocks apply it to individual proxied paths. The generated `.htpasswd` files hash passwords with **SHA-256 crypt** (mode 0640). The management domain never gets `auth_basic` — management auth is the Flask-layer JWT middleware.
|
||
|
||
Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
|
||
|
||
### JWT Authentication Lifecycle
|
||
|
||
JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:
|
||
|
||
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (5 min — the fresh-install bootstrap writes `access_token_ttl: 300`; TTLs are configurable in `config/auth/config.json`) and a refresh token (7 days) are issued, each bound to a fresh `session_id`.
|
||
2. **Validation**: Every API request to Flask includes `Authorization: Bearer <token>` and an `X-Session-Id` header. The `before_request` middleware returns 401 without the session header, validates the token signature, checks expiry, verifies the `X-Session-Id` matches the token's `session_id` claim (binding the token to the browser session that created it), queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
|
||
3. **Auto-refresh**: Before the access token expires, the frontend's `scheduleRefresh()` timer (fires at TTL − 60s, minimum 30s) calls `POST /api/auth/refresh` with the refresh token and `session_id` — the refresh endpoint requires a matching `session_id` so a stolen refresh token cannot be rotated without the originating session. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
|
||
4. **Revocation**: The primary revocation mechanism is **per-user JWT signing-secret rotation**: tokens are signed with a per-user secret (not a global key), and changing the password or resetting it, or changing permissions, rotates the user's secret (deleting the user removes the secret entirely), immediately invalidating every existing access and refresh token. The affected user's active refresh token `jti` is additionally inserted into `token_blacklist`, as is the access token's `jti` on logout (`POST /api/auth/logout`). On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (every 60s) and by a probabilistic check inside `blacklist_token()`.
|
||
|
||
Token theft protection:
|
||
- Short-lived access tokens (5 min) limit the window of exploitation
|
||
- Per-user signing-secret rotation on password/permission change plus the token blacklist prevent reuse after credential changes or logout
|
||
- `X-Session-Id` binding ties access and refresh tokens to the originating browser session
|
||
- XSS mitigations: CSP headers set by Flask on every response
|
||
|
||
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 5-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
|
||
|
||
### WebAuthn Security
|
||
|
||
WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:
|
||
|
||
- **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain.
|
||
- **Private key protection**: The private key never leaves the authenticator device. The server stores the `username`, `credential_id`, display `name`, `transports`, public key, and signature counter in the `webauthn_creds` table.
|
||
- **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
|
||
- **RP configuration**: `rp_id` and `origin` are **derived from the request** (`X-Forwarded-Proto`/`X-Forwarded-Host`) and validated against the live management domains, so credentials are bound to the domain the user actually reached. The `webauthn` section of `config/auth/config.json` holds only `enabled` and `rp_name` (the installer writes `rp_id`/`origin` on fresh install, but the runtime never reads them).
|
||
- **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.
|
||
|
||
### Header-Only Authentication and CSRF
|
||
|
||
The API exclusively reads the `Authorization` header — never cookies. This architecture eliminates CSRF risk:
|
||
|
||
- Cross-site requests cannot set custom HTTP headers due to browser CORS restrictions
|
||
- No cookie-based session to exploit
|
||
- No SameSite, double-submit, or origin checking needed
|
||
|
||
**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include:
|
||
- CSP headers set by Flask's `after_request` hook on every API/SPA response (nginx adds a separate `default-src 'none'` CSP only on `/static/`)
|
||
- Short-lived access tokens (5 min) with secret rotation and blacklist on logout
|
||
|
||
### TLS Configuration
|
||
|
||
The default nginx SSL configuration enforces modern TLS only:
|
||
|
||
- **Protocols**: TLSv1.2 and TLSv1.3. Older protocols (SSLv3, TLSv1.0, TLSv1.1) are disabled.
|
||
- **Cipher suites**: A curated set of AEAD ciphers using ECDHE key exchange (ECDHE-ECDSA and ECDHE-RSA with AES-GCM and CHACHA20-POLY1305). No non-ECDHE ciphers are included.
|
||
- **ssl_prefer_server_ciphers** defaults to `off` (client chooses).
|
||
- **Session settings**: `ssl_session_timeout 1d`, `ssl_session_cache shared:TLS:10m`, `ssl_session_tickets off`.
|
||
|
||
### Brute-Force Protection
|
||
|
||
Login and WebAuthn authentication attempts are rate-limited in-process with sliding windows that count failures only (a success resets the bucket):
|
||
|
||
- **Password login**: 10 failures per 300s, tracked per **username and per client IP** (`X-Real-IP`).
|
||
- **WebAuthn**: 5 failures per 600s, tracked per username and per client IP.
|
||
|
||
To prevent username enumeration, password verification for a nonexistent user runs a dummy Argon2id verification against a pre-computed hash, keeping timing uniform. The limiters are in-memory; counts reset on daemon restart (SIGHUP reload, process restart).
|
||
|
||
## Systemd Hardening
|
||
|
||
Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system:
|
||
|
||
| Directive | Value | Effect |
|
||
|---|---|---|
|
||
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
|
||
| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`, `/run/nginx.pid`), plus `/var/log/nginx` and `/var/log/vacuum-wall` (daemon); (WebUI only) `config/`, `data/` subdirs and `/var/log/vacuum-wall` | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it |
|
||
| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop |
|
||
| `RuntimeDirectoryMode` | `0750` (daemon only) | Group-readable runtime dirs (the shared group owns them) |
|
||
| `LogsDirectory` | `vacuum-wall` (both units) | Creates `/var/log/vacuum-wall` owned by the service user before namespace setup |
|
||
| `ExecReload` | `/bin/kill -HUP $MAINPID` (WebUI only) | SIGHUP triggers the WebUI's auto-reload (reloads `webui.*`/`lib.*` modules, then restarts via SIGTERM); the daemon unit has no `ExecReload` |
|
||
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` (`0750`) and `/run/nginx.pid` (`0644`) at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself; nginx rewrites the pid file on start) |
|
||
| `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` |
|
||
| `ProtectKernelTunables` | `yes` | Makes `/proc/sys`, `/sys`, and `/proc/sysrq-trigger` read-only |
|
||
| `ProtectKernelModules` | `yes` | Disables `init_module` and `finit_module` syscalls |
|
||
| `ProtectControlGroups` | `yes` | Mounts `/sys/fs/cgroup` as read-only |
|
||
| `ProtectHostname` | `yes` | Prevents the process from changing the system hostname |
|
||
| `RestrictNamespaces` | `yes` | Prevents creating new namespaces |
|
||
| `RestrictSUIDSGID` | `yes` | Removes setuid/setgid bits from newly created files |
|
||
| `LockPersonality` | `yes` | Prevents changing the execution domain |
|
||
| `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable |
|
||
| `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services |
|
||
| `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities |
|
||
| `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` (WebUI); `AF_UNIX AF_INET AF_INET6 AF_NETLINK` (daemon) | Restricts available address families; the daemon's extra `AF_NETLINK` is its only additional network primitive |
|
||
| `IPAddressDeny` | `any` (both units) | Drops all IP traffic by default |
|
||
| `IPAddressAllow` | `localhost` (both units) | Allows only loopback communication (required to reach the other process at 127.0.0.1) |
|
||
|
||
Both units deny all IP traffic except to localhost, so neither can reach any external network interface; the only difference in network access is the daemon's extra `AF_NETLINK` family (needed for its netlink queries). Both units use template variables (`{{ USER_NAME }}`, `{{ USER_GROUP }}`, `{{ USER_DAEMON_NAME }}`, `{{ PROJECT_DIR }}`) rendered at install time.
|
||
|
||
This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no ability to escalate privileges through kernel interfaces, and a strictly bounded write scope: outside the project directory the daemon's unit lists only `/etc/systemd/network`, `/etc/nginx`, `/etc/dnsmasq.d`, `/etc/wireguard`, `/var/log/nginx`, and `/var/log/vacuum-wall` (plus `/tmp` and the `/run` runtime entries), and the WebUI's unit lists only its `config/` and `data/` subdirs and `/var/log/vacuum-wall`.
|
||
|
||
## Network Security
|
||
|
||
### Default Deny
|
||
|
||
Incoming traffic is denied by default — this is firewalld's built-in behavior for the default zone (no Vacuum Wall code sets a zone target; `apply` only reconciles targets explicitly present in the config). Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default.
|
||
|
||
### Zone-Based Traffic Isolation
|
||
|
||
The `lib/firewall` module is a generic firewalld parser; zone structure is defined declaratively in `config/firewall/config.json` at runtime. The only hardcoded zone knowledge is `FIREWALLD_BUILTIN_ZONES` — the 9 zone names firewalld ships by default (`block`, `dmz`, `drop`, `external`, `home`, `host`, `internal`, `public`, `trusted`) — used so built-in zones are never flagged as unmanaged (not in config). The `public` zone is additionally special-cased: its masquerade state is not reconciled by `apply` and cannot be enabled through the masquerade endpoint (see IP Forwarding and NAT). A typical deployment uses:
|
||
|
||
| Zone | Interface | Purpose | Behavior |
|
||
|---|---|---|---|
|
||
| `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo rate-limiting is typical in this deployment but is not enforced by any Vacuum Wall code. |
|
||
| `internal` | LAN (e.g., `eth1`) | Trusted local network | DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed. |
|
||
| `vpn-<key>` | WireGuard (per-access-class interfaces) | WireGuard tunnel traffic, per access class | Semi-trusted. Created and maintained automatically by the WireGuard→firewall sync: one zone per access class with peers, with the class's WG interface assigned, masquerade enabled, a UDP listen-port accept rule, and inter-zone accept rules for internal subnets when the class has `lan_access`. A plain `vpn` zone is managed only as a legacy fallback for peers without an access class. |
|
||
| `trusted` / `loopback` | `lo` | Localhost communication | unrestricted; used for the Flask-to-nginx management proxy. |
|
||
| Custom zones | — | DMZ, guest networks, etc. | Additional zones can be created to isolate specific network segments with their own rule sets. |
|
||
|
||
### IP Forwarding and NAT
|
||
|
||
IP forwarding is **not** auto-enabled by Vacuum Wall — `net.ipv4.ip_forward` is one of the allowlisted sysctl keys an operator can set through the network API, and actual traffic flow is controlled by firewalld rules. Masquerade is auto-enabled by the WireGuard→firewall sync **only on VPN zones** (the per-access-class `vpn-<key>` zones and the legacy `vpn` zone), not on `internal`.
|
||
|
||
The `public` zone is special-cased around masquerade:
|
||
|
||
- **Refusal**: the masquerade endpoint refuses to enable masquerade on `public` — masquerade must be enabled on `internal` or `vpn` instead.
|
||
- **Auto-propagation**: at apply time, if any non-`public` zone has masquerade enabled, `apply` propagates masquerade to the `public` zone (and removes it when no non-public zone needs it), writing the propagated state back to the declarative config. Under the nftables backend, traffic exiting through a `public`-zoned WAN interface hits `public`'s POSTROUTING chain rather than the internal zone's, so NAT would silently fail without this propagation.
|
||
|
||
### Management Lockout Guard
|
||
|
||
The firewalld default zone is the catch-all for unassigned interfaces (normally the WAN), so removing both `https` (management access via nginx) and `ssh` (remote recovery) from it would leave no path back except a physical console. The config apply path and the per-zone services endpoint refuse such a change with HTTP `409` unless the request passes `{"force": true}`. The guard fails closed: if the default zone cannot be determined, the operation is treated as a lockout and refused.
|
||
|
||
### Interface-Coverage Invariant
|
||
|
||
Every interface managed by the network subsystem (`lo` and `wg*` excluded) must be covered by a zone in `config/firewall/config.json` or listed under the top-level `unmanaged` key. The config is the source of truth for zone interfaces — an omitted `interfaces` key counts as empty — so the check is computed from the config alone with no live-state fallback. Violations are rejected with HTTP `400` at save time (`POST`/`PATCH /firewall/config`) and HTTP `409` at apply time (`POST /firewall/config/apply`, overridable with `force: true`). Live drift is advisory only (the `uncovered_interfaces` state field).
|
||
|
||
## Input Validation
|
||
|
||
Interface names provided via the API are validated at two layers before any file system access or subprocess invocation:
|
||
|
||
- **API layer** (`webui/api/network.py`): The Flask route calls `validate_interface_name()` from `lib/common.py`, rejecting any name that doesn't match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`. Names containing `/`, `..`, spaces, or other disallowed characters return HTTP `400`.
|
||
- **Daemon handler layer** (`daemon/handlers/network.py`): Each handler re-validates the name from the request body using the same function. An invalid name raises `ValueError`, which the daemon converts to an error response before any `sudo` call.
|
||
|
||
This defense-in-depth approach ensures that even if a request bypasses the API layer, the daemon will still reject malicious interface names.
|
||
|
||
## Certificate Security
|
||
|
||
### acme.sh Integration
|
||
|
||
Certificate management is handled by acme.sh, which stores all certificates and private keys under `PROJECT_DIR/data/acme/` (set via the `ACME_HOME` environment variable). The directory is owned by and writable only by the service users.
|
||
|
||
### Private Key Protection
|
||
|
||
Private key material is never exposed through the WebUI API. The API returns certificate metadata such as domain names, validity dates, file paths, and renewal status. File paths are returned so downstream tooling (nginx, cert management) can reference them. When a domain's certificate is needed by nginx, the rendered nginx configuration references the acme.sh file paths directly via `ssl_certificate` and `ssl_certificate_key` directives — no symlinks are created.
|
||
|
||
### HSTS Enforcement
|
||
|
||
All HTTPS proxy domains (excluding the management interface) have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the `includeSubDomains` directive. This ensures browsers always use HTTPS for proxied domains and their subdomains, preventing SSL stripping attacks.
|
||
|
||
### Modern TLS Only
|
||
|
||
As noted in the Web Security section, the default SSL snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites using ECDHE key exchange. The cipher suite list excludes weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers.
|