# 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 (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 `; 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 `, `dhcp-option DNS `), `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 |