From b1bcd96b0c1818cd7a7eac044cd86d6bc749eeca Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Wed, 25 Feb 2026 12:06:20 +0100 Subject: [PATCH 1/4] feat(openvpn-rw): add certificates renewal (CA and server) functionalities --- packages/ns-api/files/ns.ovpnrw | 65 ++++++++++++++++++- packages/ns-openvpn/files/ns-openvpn-renew-ca | 9 ++- .../ns-openvpn/files/ns-openvpn-renew-server | 32 +++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100755 packages/ns-openvpn/files/ns-openvpn-renew-server diff --git a/packages/ns-api/files/ns.ovpnrw b/packages/ns-api/files/ns.ovpnrw index ccaca50f6..76408e941 100755 --- a/packages/ns-api/files/ns.ovpnrw +++ b/packages/ns-api/files/ns.ovpnrw @@ -1,7 +1,7 @@ #!/usr/bin/python3 # -# Copyright (C) 2023 Nethesis S.r.l. +# Copyright (C) 2026 Nethesis S.r.l. # SPDX-License-Identifier: GPL-2.0-only # @@ -191,6 +191,30 @@ def slurp(path): with open(path) as fp: return fp.read() +def get_certificate_expiration(pem_path, label): + """Extract certificate expiration date from PEM file""" + try: + result = subprocess.run( + ["openssl", "x509", "-noout", "-subject", "-nameopt", "RFC2253", "-enddate"], + stdin=open(pem_path, 'r'), + capture_output=True, + text=True, + check=True + ) + + # Parse notAfter line: notAfter=Feb 21 13:04:51 2036 GMT + for line in result.stdout.split('\n'): + if line.startswith("notAfter="): + date_str = line.split("=")[1].strip() + try: + expiry_dt = datetime.strptime(date_str, "%b %d %H:%M:%S %Y %Z") + return {label: int(expiry_dt.timestamp())} + except: + pass + return {} + except: + return {} + def instance2number(input_string): numbers = re.findall(r'\d+', input_string) return int(numbers[0]) if numbers else None @@ -397,6 +421,19 @@ def get_configuration(ovpninstance): if 'ns_public_ip' not in ret: ret['ns_public_ip'] = ovpn.get_public_addresses(u) + # add certificate expiration dates + certificates = {} + ca_path = f'/etc/openvpn/{ovpninstance}/pki/ca.crt' + cert_path = f'/etc/openvpn/{ovpninstance}/pki/issued/server.crt' + + if os.path.exists(ca_path): + certificates.update(get_certificate_expiration(ca_path, 'CA')) + if os.path.exists(cert_path): + certificates.update(get_certificate_expiration(cert_path, 'server')) + + if certificates: + ret['certificates'] = certificates + return ret def set_configuration(args): @@ -1041,6 +1078,24 @@ def connection_history(ovpninstance): return connections +def renew_server_certificate(ovpninstance): + try: + subprocess.run(["/usr/sbin/ns-openvpn-renew-server", ovpninstance], check=True) + subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) + except Exception as e: + print(e, file=sys.stderr) + return utils.validation_error("instance", "server_certificate_renewal_failed", ovpninstance) + return {"result": "success"} + +def regenerate_all_certificates(ovpninstance): + try: + subprocess.run(["/usr/sbin/ns-openvpn-renew-ca", ovpninstance], check=True) + subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) + except Exception as e: + print(e, file=sys.stderr) + return utils.validation_error("instance", "certificates_regeneration_failed", ovpninstance) + return {"result": "success"} + cmd = sys.argv[1] if cmd == 'list': @@ -1088,7 +1143,9 @@ if cmd == 'list': "download-user-2fa": {"instance": "roadwarrior1", "username": "myuser"}, "download_all_user_configurations": {"instance": "roadwarrior1"}, "connection-history-csv": {"instance": "roadwarrior1", "timezone": "Europe/Rome"}, - "connection-history": {"instance": "roadwarrior1"} + "connection-history": {"instance": "roadwarrior1"}, + "renew-server-certificate": {"instance": "roadwarrior1"}, + "regenerate-all-certificates": {"instance": "roadwarrior1"} })) else: action = sys.argv[2] @@ -1145,5 +1202,9 @@ else: ret = connection_history_csv(args['instance'], args['timezone']) elif action == "connection-history": ret = connection_history(args['instance']) + elif action == "renew-server-certificate": + ret = renew_server_certificate(args["instance"]) + elif action == "regenerate-all-certificates": + ret = regenerate_all_certificates(args["instance"]) print(json.dumps(ret)) diff --git a/packages/ns-openvpn/files/ns-openvpn-renew-ca b/packages/ns-openvpn/files/ns-openvpn-renew-ca index dfd611eb1..2a6768d9d 100755 --- a/packages/ns-openvpn/files/ns-openvpn-renew-ca +++ b/packages/ns-openvpn/files/ns-openvpn-renew-ca @@ -5,12 +5,17 @@ # SPDX-License-Identifier: GPL-2.0-only # -# Setup EasyRSA pki +# Renew CA and all certificates for the specified OpenVPN instance instance=$1 if [ -z $instance ]; then exit 1 fi +cn=$(uci get system.@system[0].hostname | cut -d '.' -f 1) +if [ -z "$cn" ]; then + cn=NethSec +fi + # Set environment variables for EasyRSA export EASYRSA_BATCH=1 export EASYRSA_CERT_EXPIRE=3650 @@ -21,7 +26,7 @@ if [ -f /etc/openvpn/$instance/pki/ca.crt ]; then ( /usr/bin/easyrsa renew-ca /usr/bin/easyrsa revoke-issued server - /usr/bin/easyrsa build-server-full server nopass + EASYRSA_REQ_CN=$cn /usr/bin/easyrsa build-server-full server nopass /usr/bin/easyrsa gen-crl for f in $(find /etc/openvpn/$instance/pki/issued -name \*.crt ! -name server.crt); do name=$(basename $f | cut -d '.' -f 1) diff --git a/packages/ns-openvpn/files/ns-openvpn-renew-server b/packages/ns-openvpn/files/ns-openvpn-renew-server new file mode 100755 index 000000000..7f3cbe2be --- /dev/null +++ b/packages/ns-openvpn/files/ns-openvpn-renew-server @@ -0,0 +1,32 @@ +#!/bin/sh + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# Renew server certificate for the specified OpenVPN instance +instance=$1 +if [ -z $instance ]; then + exit 1 +fi + +cn=$(uci get system.@system[0].hostname | cut -d '.' -f 1) +if [ -z "$cn" ]; then + cn=NethSec +fi + +# Set environment variables for EasyRSA +export EASYRSA_BATCH=1 +export EASYRSA_CERT_EXPIRE=3650 +export EASYRSA_CRL_DAYS=3650 +export EASYRSA_REQ_CN=$cn + +if [ -f /etc/openvpn/$instance/pki/ca.crt ]; then + cd /etc/openvpn/$instance + ( + /usr/bin/easyrsa revoke server + /usr/bin/easyrsa gen-crl + /usr/bin/easyrsa build-server-full server nopass + ) +fi From 0facfd983c015578013f2a9d2c3f90ded1614e4a Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Thu, 26 Feb 2026 09:43:11 +0100 Subject: [PATCH 2/4] fix(openvpn-rw): normalize connected user names to lower case for consistency in accounts table --- packages/ns-api/files/ns.ovpnrw | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ns-api/files/ns.ovpnrw b/packages/ns-api/files/ns.ovpnrw index 76408e941..a5aba560f 100755 --- a/packages/ns-api/files/ns.ovpnrw +++ b/packages/ns-api/files/ns.ovpnrw @@ -584,6 +584,7 @@ def list_users(ovpninstance): u = EUci() ret = [] connected = ovpn.list_connected_clients(ovpninstance) + connected_lower_case = {k.lower(): v for k, v in connected.items()} expirations = list_user_expirations(ovpninstance) db = u.get("openvpn", ovpninstance, "ns_user_db", default=None) if not db: @@ -598,18 +599,18 @@ def list_users(ovpninstance): # migrated users can have the form @>domain> # make sure to duplicate connected info also removing the domain part normalized = {} - for user in connected: + for user in connected_lower_case: if '@' in user: nuser = user.split('@')[0] - normalized[nuser] = connected[user] - connected.update(normalized) + normalized[nuser] = connected_lower_case[user] + connected_lower_case.update(normalized) for user in db_users: # exclude users not enabled for OpenVPN if "openvpn_enabled" not in user: continue - if user['name'] in connected: + if user['name'].lower() in connected_lower_case: user["connected"] = True - user = user | connected[user['name']] + user = user | connected_lower_case[user['name'].lower()] else: user["connected"] = False user["expiration"] = "" From 21d0761159448a57475eb8a43ae203dd59ae6bad Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Thu, 26 Feb 2026 15:56:02 +0100 Subject: [PATCH 3/4] fix(ns.ovpnrw): capture output for server and CA renewal subprocesses --- packages/ns-api/files/ns.ovpnrw | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ns-api/files/ns.ovpnrw b/packages/ns-api/files/ns.ovpnrw index a5aba560f..8a8c07f25 100755 --- a/packages/ns-api/files/ns.ovpnrw +++ b/packages/ns-api/files/ns.ovpnrw @@ -1081,7 +1081,7 @@ def connection_history(ovpninstance): def renew_server_certificate(ovpninstance): try: - subprocess.run(["/usr/sbin/ns-openvpn-renew-server", ovpninstance], check=True) + subprocess.run(["/usr/sbin/ns-openvpn-renew-server", ovpninstance], check=True, capture_output=True) subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) except Exception as e: print(e, file=sys.stderr) @@ -1090,7 +1090,7 @@ def renew_server_certificate(ovpninstance): def regenerate_all_certificates(ovpninstance): try: - subprocess.run(["/usr/sbin/ns-openvpn-renew-ca", ovpninstance], check=True) + subprocess.run(["/usr/sbin/ns-openvpn-renew-ca", ovpninstance], check=True, capture_output=True) subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) except Exception as e: print(e, file=sys.stderr) From 2ff1806d24fe0410681785003763963bd90cd5a0 Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Thu, 26 Feb 2026 16:01:43 +0100 Subject: [PATCH 4/4] fix(openvpn-rw): capture output for OpenVPN restart during certificate renewal --- packages/ns-api/files/ns.ovpnrw | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ns-api/files/ns.ovpnrw b/packages/ns-api/files/ns.ovpnrw index 8a8c07f25..797fdf9c7 100755 --- a/packages/ns-api/files/ns.ovpnrw +++ b/packages/ns-api/files/ns.ovpnrw @@ -1082,7 +1082,7 @@ def connection_history(ovpninstance): def renew_server_certificate(ovpninstance): try: subprocess.run(["/usr/sbin/ns-openvpn-renew-server", ovpninstance], check=True, capture_output=True) - subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) + subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False, capture_output=True) except Exception as e: print(e, file=sys.stderr) return utils.validation_error("instance", "server_certificate_renewal_failed", ovpninstance) @@ -1091,7 +1091,7 @@ def renew_server_certificate(ovpninstance): def regenerate_all_certificates(ovpninstance): try: subprocess.run(["/usr/sbin/ns-openvpn-renew-ca", ovpninstance], check=True, capture_output=True) - subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False) + subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False, capture_output=True) except Exception as e: print(e, file=sys.stderr) return utils.validation_error("instance", "certificates_regeneration_failed", ovpninstance)