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
+103
-9
No files matched your search
@@ -46,7 +46,7 @@ The Profiler restarts OpenVPN through `rc-service openvpn` (Alpine) or `systemct
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
@@ -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 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 stop
|
||||
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"
|
||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||
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"
|
||||
MAX_SIZE = 64 * 1024
|
||||
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")
|
||||
elif d in ("port", "management-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")
|
||||
elif d == "crl-verify":
|
||||
need(a == [CRL_PUBLISHED], "crl-verify must be " + CRL_PUBLISHED)
|
||||
elif d == "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":
|
||||
@@ -192,11 +197,40 @@ def prepare_status_log():
|
||||
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):
|
||||
need(action in ("start", "stop", "restart", "status"), "invalid action")
|
||||
if action in ("start", "restart"):
|
||||
need(os.path.isfile(TARGET), "server.conf is not installed")
|
||||
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)
|
||||
return r.returncode, (r.stdout + r.stderr).strip()[-500:]
|
||||
|
||||
@@ -207,11 +241,15 @@ def main(argv):
|
||||
install_config()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if argv == ["publish-crl"]:
|
||||
publish_crl()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if len(argv) == 2 and argv[0] == "service":
|
||||
rc, out = service(argv[1])
|
||||
print(json.dumps({"status": "ok" if rc == 0 else "error", "output": out}))
|
||||
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:
|
||||
print(json.dumps({"status": "rejected", "error": str(e)}))
|
||||
return 3
|
||||
|
||||
Reference in new issue
Block a user