29 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 thevacuum-walldbackground daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket atdata/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
--devmode): 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
Cmndentry 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-wallruntime dir and a single destination path. None of the entries grant shell access or arbitrary command execution. NOPASSWDis 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_NAMEandACME_HOMEvariables (the install also rendersUSER_NAME,USER_GROUP, andPROJECT_DIRfor 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_sslenabled. 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-Securityheader is set withmax-age=31536000; includeSubDomainsto 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:
- Login: User submits credentials via
POST /api/auth/login. The daemon verifies the password hash (Argon2id) againstdata/auth.db. On success, an access token (5 min — the fresh-install bootstrap writesaccess_token_ttl: 300; TTLs are configurable inconfig/auth/config.json) and a refresh token (7 days) are issued, each bound to a freshsession_id. - Validation: Every API request to Flask includes
Authorization: Bearer <token>and anX-Session-Idheader. Thebefore_requestmiddleware returns 401 without the session header, validates the token signature, checks expiry, verifies theX-Session-Idmatches the token'ssession_idclaim (binding the token to the browser session that created it), queries the SQLitetoken_blacklisttable, and verifies per-subsystem permissions. - Auto-refresh: Before the access token expires, the frontend's
scheduleRefresh()timer (fires at TTL − 60s, minimum 30s) callsPOST /api/auth/refreshwith the refresh token andsession_id— the refresh endpoint requires a matchingsession_idso 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. - 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
jtiis additionally inserted intotoken_blacklist, as is the access token'sjtion logout (POST /api/auth/logout). On refresh rotation the old refresh token'sjtiis blacklisted and the new token replaces the stored row inrefresh_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 insideblacklist_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-Idbinding 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) andorigin(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, displayname,transports, public key, and signature counter in thewebauthn_credstable. - 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_idandoriginare 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. Thewebauthnsection ofconfig/auth/config.jsonholds onlyenabledandrp_name(the installer writesrp_id/originon 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_requesthook on every API/SPA response (nginx adds a separatedefault-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 oninternalorvpninstead. - Auto-propagation: at apply time, if any non-
publiczone has masquerade enabled,applypropagates masquerade to thepubliczone (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 apublic-zoned WAN interface hitspublic'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 callsvalidate_interface_name()fromlib/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 HTTP400. - Daemon handler layer (
daemon/handlers/network.py): Each handler re-validates the name from the request body using the same function. An invalid name raisesValueError, which the daemon converts to an error response before anysudocall.
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.