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>
This commit is contained in:
1 parent
6f9e800779
commit
9ffdbfa259
8 files changed
+102
-8
No files matched your search
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from utils.auth import verify_token
|
from utils.auth import verify_token
|
||||||
from services import generator, process
|
from services import generator, process, config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ def configure_server(db: Session = Depends(get_db)):
|
|||||||
ok, msg = process.install_config()
|
ok, msg = process.install_config()
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=400, detail=f"Configuration rejected: {msg}")
|
raise HTTPException(status_code=400, detail=f"Configuration rejected: {msg}")
|
||||||
|
ok, msg = process.publish_crl()
|
||||||
|
if not ok:
|
||||||
|
if config.get_system_settings(db).crl_verify:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Configuration installed, but CRL publishing failed: {msg}")
|
||||||
|
logger.warning(f"[SERVER] CRL not published (crl_verify is off): {msg}")
|
||||||
return {"message": "Server configuration generated", "path": output_path}
|
return {"message": "Server configuration generated", "path": output_path}
|
||||||
|
|
||||||
# Ensure we can write to /etc/openvpn
|
# Ensure we can write to /etc/openvpn
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
|||||||
file_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
file_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
||||||
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
||||||
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
||||||
|
from .process import is_container
|
||||||
|
if os.geteuid() != 0 and not is_container():
|
||||||
|
# unprivileged API: OpenVPN (nobody) cannot enter the 0700 pki dir, use the copy published by the helper
|
||||||
|
file_crl_path = "/etc/openvpn/crl.pem"
|
||||||
|
|
||||||
# Render template
|
# Render template
|
||||||
ctx = dict(
|
ctx = dict(
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ def init_pki(db: Session):
|
|||||||
|
|
||||||
# Gen CRL
|
# Gen CRL
|
||||||
_run_easyrsa(["gen-crl"], env)
|
_run_easyrsa(["gen-crl"], env)
|
||||||
|
_publish_crl()
|
||||||
|
|
||||||
return "PKI Initialized"
|
return "PKI Initialized"
|
||||||
|
|
||||||
@@ -183,9 +184,20 @@ def build_client(username: str, db: Session):
|
|||||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _publish_crl():
|
||||||
|
"""Make the fresh CRL readable by OpenVPN when the API runs unprivileged (see ovpmon-helper)."""
|
||||||
|
from .process import publish_crl
|
||||||
|
ok, msg = publish_crl()
|
||||||
|
if not ok:
|
||||||
|
logger.error(f"CRL was generated but could not be published: {msg}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
def revoke_client(username: str, db: Session):
|
def revoke_client(username: str, db: Session):
|
||||||
validate_username(username)
|
validate_username(username)
|
||||||
env = _get_easyrsa_env(db)
|
env = _get_easyrsa_env(db)
|
||||||
_run_easyrsa(["revoke", username], env)
|
_run_easyrsa(["revoke", username], env)
|
||||||
_run_easyrsa(["gen-crl"], env)
|
_run_easyrsa(["gen-crl"], env)
|
||||||
|
if not _publish_crl():
|
||||||
|
raise RuntimeError("Certificate revoked, but the CRL could not be published to OpenVPN")
|
||||||
return True
|
return True
|
||||||
@@ -48,6 +48,19 @@ def install_config():
|
|||||||
return False, msg
|
return False, msg
|
||||||
|
|
||||||
|
|
||||||
|
def publish_crl():
|
||||||
|
"""Publish pki/crl.pem to a root-owned location readable by the OpenVPN user (nobody).
|
||||||
|
No-op when running as root/in a container (OpenVPN reads the PKI directly). Returns (ok, message)."""
|
||||||
|
if is_container() or os.geteuid() == 0:
|
||||||
|
return True, "not required"
|
||||||
|
rc, data, raw = _run_helper(["publish-crl"])
|
||||||
|
if rc == 0 and data and data.get("status") == "ok":
|
||||||
|
return True, "CRL published"
|
||||||
|
msg = (data or {}).get("error") or raw or "helper failed"
|
||||||
|
logger.error(f"[PROCESS] publish-crl failed: {msg}")
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
|
||||||
def control_service(action: str):
|
def control_service(action: str):
|
||||||
"""
|
"""
|
||||||
Action: start, stop, restart
|
Action: start, stop, restart
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ Problem: `ovpmon-api`, `ovpmon-gatherer` and `ovpmon-profiler` ran as root. Any
|
|||||||
```
|
```
|
||||||
ovpmon-api / gatherer / profiler (user ovpmon, no login shell)
|
ovpmon-api / gatherer / profiler (user ovpmon, no login shell)
|
||||||
|
|
|
|
||||||
| doas -n /usr/local/sbin/ovpmon-helper <fixed args> (only 5 exact commands allowed)
|
| doas -n /usr/local/sbin/ovpmon-helper <fixed args> (only 6 exact commands allowed)
|
||||||
v
|
v
|
||||||
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
|
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
|
||||||
installs /etc/openvpn/server.conf atomically
|
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
|
-> service start|stop|restart|status : rc-service openvpn
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,6 +50,28 @@ Only the directives the template produces, each with checked arguments: `dev tun
|
|||||||
## Limitations / notes
|
## Limitations / notes
|
||||||
- The supervisors stay root by design (they only respawn the service user's process).
|
- 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.
|
- 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).
|
- 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`).
|
- 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).
|
- 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 |
|
||||||
@@ -46,7 +46,7 @@ The Profiler restarts OpenVPN through `rc-service openvpn` (Alpine) or `systemct
|
|||||||
|
|
||||||
## 4a. Run as an unprivileged user (recommended)
|
## 4a. Run as an unprivileged user (recommended)
|
||||||
|
|
||||||
Create the `ovpmon` user, give it the data directories, add `command_user="ovpmon:ovpmon"` to the init scripts (or `User=ovpmon` in systemd units), install the root helper and the doas rules. The API then renders `server.conf` to `/var/lib/ovpmon/staging/`; the helper validates and installs it and controls the `openvpn` service. Full procedure, helper source and results: [Privilege separation](../Changes/2026-09-30_Privilege_Separation.md); files in [`privilege-separation/`](privilege-separation/).
|
Create the `ovpmon` user, give it the data directories, add `command_user="ovpmon:ovpmon"` to the init scripts (or `User=ovpmon` in systemd units), install the root helper and the doas rules. The API then renders `server.conf` to `/var/lib/ovpmon/staging/`; the helper validates and installs it, publishes the CRL for OpenVPN and controls the `openvpn` service. Full procedure, helper source and results: [Privilege separation](../Changes/2026-09-30_Privilege_Separation.md); files in [`privilege-separation/`](privilege-separation/).
|
||||||
|
|
||||||
## 5. UI and Nginx (HTTPS on 8088)
|
## 5. UI and Nginx (HTTPS on 8088)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# /etc/doas.d/ovpmon.conf (root:root 640)
|
|
||||||
# The unprivileged ovpmon service user may run exactly these helper commands as root.
|
|
||||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args install-config
|
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args install-config
|
||||||
|
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args publish-crl
|
||||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service start
|
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service start
|
||||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service stop
|
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service stop
|
||||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service restart
|
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service restart
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ TARGET = "/etc/openvpn/server.conf"
|
|||||||
PKI_DIR = "/opt/OpenVPN-Monitoring-Simple/APP_PROFILER/easy-rsa/pki"
|
PKI_DIR = "/opt/OpenVPN-Monitoring-Simple/APP_PROFILER/easy-rsa/pki"
|
||||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||||
STATUS_LOG = "/var/log/openvpn/openvpn-status.log"
|
STATUS_LOG = "/var/log/openvpn/openvpn-status.log"
|
||||||
|
CRL_SRC = PKI_DIR + "/crl.pem"
|
||||||
|
CRL_PUBLISHED = "/etc/openvpn/crl.pem"
|
||||||
|
CRL_MAX = 1024 * 1024
|
||||||
SERVICE_USER = "ovpmon"
|
SERVICE_USER = "ovpmon"
|
||||||
MAX_SIZE = 64 * 1024
|
MAX_SIZE = 64 * 1024
|
||||||
CIPHERS_RE = re.compile(r"^[A-Za-z0-9:_-]{1,200}$")
|
CIPHERS_RE = re.compile(r"^[A-Za-z0-9:_-]{1,200}$")
|
||||||
@@ -81,8 +84,10 @@ def check_line(tokens):
|
|||||||
need(len(a) == 1 and is_int(a[0], 1, 10), "bad explicit-exit-notify")
|
need(len(a) == 1 and is_int(a[0], 1, 10), "bad explicit-exit-notify")
|
||||||
elif d in ("port", "management-port"):
|
elif d in ("port", "management-port"):
|
||||||
need(len(a) == 1 and is_int(a[0], 1, 65535), "bad port")
|
need(len(a) == 1 and is_int(a[0], 1, 65535), "bad port")
|
||||||
elif d in ("ca", "cert", "key", "dh", "crl-verify"):
|
elif d in ("ca", "cert", "key", "dh"):
|
||||||
need(len(a) == 1 and under(a[0], PKI_DIR), d + " must be a file inside the PKI directory")
|
need(len(a) == 1 and under(a[0], PKI_DIR), d + " must be a file inside the PKI directory")
|
||||||
|
elif d == "crl-verify":
|
||||||
|
need(a == [CRL_PUBLISHED], "crl-verify must be " + CRL_PUBLISHED)
|
||||||
elif d == "tls-auth":
|
elif d == "tls-auth":
|
||||||
need(len(a) == 2 and under(a[0], PKI_DIR) and a[1] in ("0", "1"), "bad tls-auth")
|
need(len(a) == 2 and under(a[0], PKI_DIR) and a[1] in ("0", "1"), "bad tls-auth")
|
||||||
elif d == "tun-mtu":
|
elif d == "tun-mtu":
|
||||||
@@ -192,11 +197,40 @@ def prepare_status_log():
|
|||||||
os.chmod(STATUS_LOG, 0o640)
|
os.chmod(STATUS_LOG, 0o640)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_crl():
|
||||||
|
"""Copy the CRL generated by easy-rsa to a root-owned, world-readable path.
|
||||||
|
OpenVPN reads the CRL as the unprivileged user 'nobody', who cannot enter the 0700 pki/ directory."""
|
||||||
|
uid = pwd.getpwnam(SERVICE_USER).pw_uid
|
||||||
|
need(under(CRL_SRC, PKI_DIR), "CRL source outside PKI directory")
|
||||||
|
fd = os.open(CRL_SRC, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
try:
|
||||||
|
st = os.fstat(fd)
|
||||||
|
need(stat.S_ISREG(st.st_mode) and st.st_uid in (0, uid), "CRL must be a regular file owned by " + SERVICE_USER)
|
||||||
|
need(0 < st.st_size <= CRL_MAX, "CRL size out of range")
|
||||||
|
data = os.read(fd, CRL_MAX + 1)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
need(data.startswith(b"-----BEGIN X509 CRL-----") and data.rstrip().endswith(b"-----END X509 CRL-----"), "not a PEM CRL")
|
||||||
|
r = subprocess.run(["/usr/bin/openssl", "crl", "-noout", "-inform", "PEM"], input=data, capture_output=True, timeout=20)
|
||||||
|
need(r.returncode == 0, "openssl rejects the CRL")
|
||||||
|
tmp = CRL_PUBLISHED + ".tmp"
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, CRL_PUBLISHED)
|
||||||
|
|
||||||
|
|
||||||
def service(action):
|
def service(action):
|
||||||
need(action in ("start", "stop", "restart", "status"), "invalid action")
|
need(action in ("start", "stop", "restart", "status"), "invalid action")
|
||||||
if action in ("start", "restart"):
|
if action in ("start", "restart"):
|
||||||
need(os.path.isfile(TARGET), "server.conf is not installed")
|
need(os.path.isfile(TARGET), "server.conf is not installed")
|
||||||
prepare_status_log()
|
prepare_status_log()
|
||||||
|
if os.path.isfile(CRL_SRC):
|
||||||
|
try:
|
||||||
|
publish_crl()
|
||||||
|
except Exception:
|
||||||
|
pass # a broken CRL must not stop the VPN; crl-verify will then keep the previously published file
|
||||||
r = subprocess.run(["/sbin/rc-service", "openvpn", action], capture_output=True, text=True, timeout=60)
|
r = subprocess.run(["/sbin/rc-service", "openvpn", action], capture_output=True, text=True, timeout=60)
|
||||||
return r.returncode, (r.stdout + r.stderr).strip()[-500:]
|
return r.returncode, (r.stdout + r.stderr).strip()[-500:]
|
||||||
|
|
||||||
@@ -207,11 +241,15 @@ def main(argv):
|
|||||||
install_config()
|
install_config()
|
||||||
print(json.dumps({"status": "ok"}))
|
print(json.dumps({"status": "ok"}))
|
||||||
return 0
|
return 0
|
||||||
|
if argv == ["publish-crl"]:
|
||||||
|
publish_crl()
|
||||||
|
print(json.dumps({"status": "ok"}))
|
||||||
|
return 0
|
||||||
if len(argv) == 2 and argv[0] == "service":
|
if len(argv) == 2 and argv[0] == "service":
|
||||||
rc, out = service(argv[1])
|
rc, out = service(argv[1])
|
||||||
print(json.dumps({"status": "ok" if rc == 0 else "error", "output": out}))
|
print(json.dumps({"status": "ok" if rc == 0 else "error", "output": out}))
|
||||||
return 0 if rc == 0 else 2
|
return 0 if rc == 0 else 2
|
||||||
raise Reject("usage: install-config | service start|stop|restart|status")
|
raise Reject("usage: install-config | publish-crl | service start|stop|restart|status")
|
||||||
except Reject as e:
|
except Reject as e:
|
||||||
print(json.dumps({"status": "rejected", "error": str(e)}))
|
print(json.dumps({"status": "rejected", "error": str(e)}))
|
||||||
return 3
|
return 3
|
||||||
|
|||||||
Reference in new issue
Block a user