- Profiler: when not root, render server.conf to the staging dir and let the root helper validate (directive allowlist) and install it; control the openvpn service through the helper (doas, fixed commands). - Add ovpmon-helper and doas rules under DOCS/General/privilege-separation. - Document the design, rollout, results and limitations. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5.6 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 5 exact commands allowed)
v
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
installs /etc/openvpn/server.conf atomically
-> 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. - With
crl_verifyenabled the unprivileged OpenVPN user (nobody) must be able to readcrl.pem, buteasy-rsacreatespki/as700. Either keep CRL checking off or publish a copy ofcrl.pemto a root-owned, world-readable location (not automated yet). - 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).