Files
OpenVPN-Monitoring-Simple/DOCS/Changes/2026-09-30_Privilege_Separation.md
T
iclaoudezinandClaude Sonnet 5.5 9ffdbfa259 Publish CRL for the unprivileged OpenVPN user so crl_verify works
- 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>
2026-09-30 12:38:30 +00:00

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)

  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.
  • 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 (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).

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