Files
vacuum-wall/docs/security.md
T

14 KiB

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 WebUI user — not as root, and not as the daemon user. The automated renewal timer (vacuum-wall-acme.timer) runs acme.sh --cron as {{ USER_NAME }}. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports.

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.

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 file ops cp -- * /etc/nginx/, /etc/nginx/conf.d/, /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/snippets/vacuum-wall-ssl.conf Ensure correct ownership of SSL snippet
Dnsmasq systemctl reload dnsmasq Apply updated dnsmasq configuration
Dnsmasq systemctl is-active dnsmasq Check dnsmasq service status
Dnsmasq file ops cp -- * /etc/dnsmasq.d/ Copy rendered config files
Dnsmasq file ops tee /etc/dnsmasq.d/vacuum-wall.conf Write dnsmasq configuration
Dnsmasq file ops mkdir -p /etc/dnsmasq.d Ensure target directory exists
Dnsmasq leases cat /var/lib/dnsmasq/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 chown root:root /etc/wireguard/wg0.conf Ensure correct ownership of WG config
Certificates (none) acme.sh runs as the non-root service 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
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
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).
  • Wildcard entries exist only for commands where the full argument space is needed (firewall-cmd *, wg-quick *, wg *), but none 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 name.

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 and HTTP Basic Authentication. The .htpasswd file is stored at data/nginx/.htpasswd.

The management interface does not set security hardening headers (e.g., X-Content-Type-Options, X-Frame-Options, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary.

Proxy Domains

Every proxied domain configured in Vacuum Wall enforces:

  • HTTP-to-HTTPS redirect — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent.
  • HTTP Strict Transport Security (HSTS) — The Strict-Transport-Security header is set with a long max-age and includeSubDomains to prevent downgrade attacks.
  • Security headers on all proxied responses:
    • 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.

Additional proxy headers (extra_headers in the domain config) are delivered to the upstream backend via nginx proxy_set_header directives — they are not sent as response headers to clients.

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.

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, and (WebUI only) config/, data/ subdirs The project directory and runtime paths are writable
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 Restricts available address families
IPAddressDeny any Drops all network traffic by default
IPAddressAllow localhost Allows only loopback communication (required to reach the other process at 127.0.0.1)

The WebUI unit additionally restricts address families and denies all IP traffic except to localhost — it cannot reach any external network interface. 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 write access outside the project directory, and no ability to escalate privileges through kernel interfaces.

Network Security

Default Deny

The firewalld default zone policy is set to deny all incoming traffic. 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 with no hardcoded zone definitions. Zone structure is defined declaratively in config/firewall/config.json at runtime. 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 is rate-limited.
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 WireGuard (wg0) WireGuard tunnel traffic Semi-trusted. Firewall rules control which internal services VPN peers can reach. Traffic to the LAN is restricted to specific services and ports.
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 (net.ipv4.ip_forward = 1) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the internal zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.

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.