- ovpmon-helper: new publish-crl command (validated copy of pki/crl.pem to /etc/openvpn/crl.pem, root:root 644); crl-verify allowlisted only for that path; CRL is refreshed on service start/restart. - Profiler: publish after gen-crl (init/revoke) and on server/configure; generator renders the published path when running unprivileged. - doas rule for publish-crl; docs and helper copy updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
7.5 KiB
Privilege separation: API services no longer run as root (2026-09-30)
Problem: ovpmon-api, ovpmon-gatherer and ovpmon-profiler ran as root. Any bug in an API (or a stolen admin token combined with an input-validation gap) meant root on the host.
Design
ovpmon-api / gatherer / profiler (user ovpmon, no login shell)
|
| doas -n /usr/local/sbin/ovpmon-helper <fixed args> (only 6 exact commands allowed)
v
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
installs /etc/openvpn/server.conf atomically
-> publish-crl : copies pki/crl.pem to /etc/openvpn/crl.pem (root:root 644)
-> service start|stop|restart|status : rc-service openvpn
| Item | Detail |
|---|---|
| Service user | ovpmon (system user, nologin), owns /var/lib/ovpmon (DBs, staging/), /var/log/ovpmon, APP_PROFILER/{easy-rsa,client-config,profiler.log}, runtime logs and __pycache__. Code and virtualenvs stay root-owned (read-only for the service) |
| OpenRC | command_user="ovpmon:ovpmon" in the three ovpmon-* init scripts; /etc/ovpmon/env stays root:root 600 (read by the init script before the privilege drop) |
| doas | /etc/doas.d/ovpmon.conf: permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args <install-config | service start|stop|restart|status>; nothing else is permitted |
| Helper | /usr/local/sbin/ovpmon-helper (root, 755). Source: DOCS/General/privilege-separation/ovpmon-helper |
| Profiler code | services/process.py: when not root, calls the helper (_run_helper, install_config); routers/server.py: renders to /var/lib/ovpmon/staging/server.conf, then asks the helper to install it (rejection → HTTP 400). Container and root paths are unchanged |
| Status log | the helper sets root:ovpmon 640 on openvpn-status.log before every start/restart so the gatherer can read it |
What the helper allows in server.conf
Only the directives the template produces, each with checked arguments: dev tun, proto, port, ca/cert/key/dh/tls-auth/crl-verify (files must resolve inside the PKI directory), tun-mtu, mssfix, topology subnet, server, fixed ifconfig-pool-persist, log, log-append, status, verb, push (only redirect-gateway def1 bypass-dhcp, route <net>, dhcp-option DNS <ip>), user nobody, group nogroup, ciphers/auth/keepalive, client-to-client, duplicate-cn, persist-*, script-security 2, client-connect/disconnect (script must be root-owned, not group/other-writable, directly in /etc/openvpn/scripts/), management (loopback only). Everything else (up, down, plugin, route-up, tls-verify, setenv, config, ...) is rejected. user nobody, group nogroup, server, ca, cert, key are mandatory. The file is read once (no TOCTOU between check and install), must be an ovpmon-owned regular file (no symlinks), ASCII only, at most 64 KiB.
Rollout (what was done)
- Backup:
/root/backup-p2-*.tar(/etc/openvpn,/var/lib/ovpmon,easy-rsa,client-config, init scripts, doas config, changed code) and/root/app-bak/p2/. - Create the user/group, install the helper and the doas rules; test the helper as
ovpmonbefore touching services. - Patch
process.py/server.py(root code path unchanged, so nothing changed while services still ran as root). chownruntime data, addcommand_user, restart the gatherer, then the API, then the profiler, checking each.
Results
| Check | Result |
|---|---|
| Processes | gunicorn, uvicorn and the gatherer run as ovpmon; only the supervise-daemon supervisors are root |
| Helper: current live config | accepted, live server.conf byte-identical |
Helper: 17 injected directives (up, plugin, script-security 3, client-connect /tmp/x, route-up, tls-verify, setenv, config, ca /etc/shadow, management 0.0.0.0, log /etc/passwd, user root, status /etc/cron.d/x, push "setenv-safe", dev tap, multi-argument push) |
all rejected, live config unchanged |
Helper: missing user nobody, cert outside PKI, CR injection, symlinked staged file |
rejected |
| doas: arbitrary command, helper with other args | denied |
| Service user cannot | read /etc/shadow, /root, /etc/hysteria/*.yaml, /etc/ovpmon/env; write /etc/openvpn, /etc/init.d, authorized_keys, application code; run iptables |
API: monitoring, config, process stats, server/configure via helper (config identical) |
200 |
API: create profile (easyrsa), download .ovpn, revoke |
200 |
| API: OpenVPN restart through the helper | 200; OpenVPN running, tun0 up, status log readable by the gatherer, egress rules (ip rule 102, MASQUERADE to hytun) intact, no gatherer errors |
Limitations / notes
- The supervisors stay root by design (they only respawn the service user's process).
- Helper checks resolve PKI paths at install time; a service-user-owned PKI directory could later swap a file for a symlink before OpenVPN (root) starts. Impact is limited to OpenVPN failing to parse or reading a key/cert-shaped file; keep the PKI directory owned by
ovpmononly. - CRL checking (
crl_verify) works with the unprivileged API, see the section below. - systemd deployments: same idea with
User=ovpmon, a polkit/sudoers rule for the helper's fixed commands, and the same helper (replacerc-servicewithsystemctl). - Rollback: restore
/etc/init.d/ovpmon-*from/root/app-bak/p2/,chown -R root:rootthe data directories, restart the services (the helper and doas rules can stay).
CRL publishing (crl_verify)
OpenVPN drops to nobody after start and re-reads the CRL on each new connection. easy-rsa creates pki/ as 0700 owned by the service user, so nobody could not read pki/crl.pem and every client would be refused once crl_verify was enabled.
| Item | Detail |
|---|---|
| Published copy | /etc/openvpn/crl.pem, root:root 644, written atomically by ovpmon-helper publish-crl |
| Helper checks | source must resolve inside the PKI directory, regular file (no symlink) owned by the service user, at most 1 MiB, PEM CRL markers, and openssl crl must parse it |
| When it runs | after gen-crl in Initialize PKI and in revoke (services/pki.py, if publishing fails the revoke call reports an error instead of silently leaving the old CRL), on server/configure (an error only when crl_verify is on) and on every helper service start|restart |
| Config | with an unprivileged API the generator renders crl-verify /etc/openvpn/crl.pem; as root/in a container it still uses pki/crl.pem. The helper allowlist accepts only the published path for crl-verify |
| doas | one more exact rule: args publish-crl |
Results (throw-away second OpenVPN instance on another port/subnet running as nobody with the same PKI and crl-verify /etc/openvpn/crl.pem; production OpenVPN untouched):
| Check | Result |
|---|---|
publish-crl as ovpmon |
ok; copy is root:root 644, readable by nobody; pki/crl.pem still unreadable by nobody |
| Garbage file, fake PEM markers, symlinked source | rejected |
crl_verify=true via API + server/configure |
200; config contains crl-verify /etc/openvpn/crl.pem, accepted by the helper; setting restored afterwards, live server.conf byte-identical to the original |
| Valid client with active CRL check | connects (Initialization Sequence Completed) |
| Revoke through the API, then reconnect | server sends certificate revoked, client is refused; no CRL read errors |