Files
OpenVPN-Monitoring-Simple/DOCS/Changes/2026-09-30_Privilege_Separation.md
T
iclaoudezinandClaude Sonnet 5.5 6f9e800779 Run API services unprivileged; add root helper for OpenVPN config and service control
- 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>
2026-09-30 12:33:15 +00:00

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)

  1. 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/.
  2. Create the user/group, install the helper and the doas rules; test the helper as ovpmon before touching services.
  3. Patch process.py / server.py (root code path unchanged, so nothing changed while services still ran as root).
  4. chown runtime data, add command_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 ovpmon only.
  • With crl_verify enabled the unprivileged OpenVPN user (nobody) must be able to read crl.pem, but easy-rsa creates pki/ as 700. Either keep CRL checking off or publish a copy of crl.pem to 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 (replace rc-service with systemctl).
  • Rollback: restore /etc/init.d/ovpmon-* from /root/app-bak/p2/, chown -R root:root the data directories, restart the services (the helper and doas rules can stay).