diff --git a/packages/ns-api/files/ns.ovpnrw b/packages/ns-api/files/ns.ovpnrw index ccaca50f6..834d66dfb 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): @@ -547,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: @@ -561,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"] = "" @@ -1041,6 +1079,22 @@ def connection_history(ovpninstance): return connections +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, capture_output=True) + except Exception as e: + 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, capture_output=True) + subprocess.run(["/etc/init.d/openvpn", "restart", ovpninstance], check=False, capture_output=True) + except Exception as e: + return utils.validation_error("instance", "certificates_regeneration_failed", ovpninstance) + return {"result": "success"} + cmd = sys.argv[1] if cmd == 'list': @@ -1088,7 +1142,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 +1201,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-api/files/ns.qos b/packages/ns-api/files/ns.qos index bbcb85aab..95f811d66 100755 --- a/packages/ns-api/files/ns.qos +++ b/packages/ns-api/files/ns.qos @@ -14,6 +14,16 @@ from nethsec import utils from nethsec.utils import ValidationError +def is_wan_interface(e_uci, interface_name): + """Check if an interface is a WAN interface""" + try: + wan_interfaces = e_uci.get('firewall', 'ns_wan', 'network', dtype=str, default=[], list=True) + return interface_name in wan_interfaces + except: + # assume WAN to avoid inverting bandwidth for unknown interfaces + return True + + def validate(data): if 'interface' not in data: raise ValidationError('name', 'required') @@ -69,12 +79,19 @@ elif cmd == 'call': device = [item['device'] for config_name, item in network_interfaces if config_name == key][0] except: continue + upload = int(interface['bandwidth_up'].removesuffix('mbit')) + download = int(interface['bandwidth_down'].removesuffix('mbit')) + # invert if not WAN interface + if not is_wan_interface(e_uci, key): + temp = upload + upload = download + download = temp result.append({ 'interface': key, 'device': device, 'disabled': interface['disabled'] == '1', - 'upload': int(interface['bandwidth_up'].removesuffix('mbit')), - 'download': int(interface['bandwidth_down'].removesuffix('mbit')), + 'upload': upload, + 'download': download, }) print(json.dumps({'rules': result})) @@ -86,6 +103,12 @@ elif cmd == 'call': if data['interface'] not in utils.get_all_by_type(e_uci, 'network', 'interface').keys(): raise ValidationError('name', 'invalid') + # invert bandwidth for non-WAN interfaces before saving + if not is_wan_interface(e_uci, data['interface']): + upload = data['upload'] + data['upload'] = data['download'] + data['download'] = upload + e_uci.set('qosify', data['interface'], 'interface') e_uci.set('qosify', data['interface'], 'name', data['interface']) e_uci.set('qosify', data['interface'], 'disabled', data['disabled']) @@ -129,6 +152,12 @@ elif cmd == 'call': if data['interface'] not in utils.get_all_by_type(e_uci, 'qosify', 'interface').keys(): raise ValidationError('name', 'invalid') + # invert bandwidth for non-WAN interfaces before saving + if not is_wan_interface(e_uci, data['interface']): + upload = data['upload'] + data['upload'] = data['download'] + data['download'] = upload + e_uci.set('qosify', data['interface'], 'disabled', data['disabled']) e_uci.set('qosify', data['interface'], 'bandwidth_up', f'{data["upload"]}mbit') e_uci.set('qosify', data['interface'], 'bandwidth_down', f'{data["download"]}mbit') diff --git a/packages/ns-dpi/files/dpi-nft b/packages/ns-dpi/files/dpi-nft index b8d343029..d06673761 100755 --- a/packages/ns-dpi/files/dpi-nft +++ b/packages/ns-dpi/files/dpi-nft @@ -21,7 +21,7 @@ chain dpi_actions { type filter hook prerouting priority filter + 10; policy accept; {% if log_enabled -%} - ct label netify-blocked counter log prefix "DPI block: " limit rate {{ log_limit }} + ct label netify-blocked counter log prefix "DPI block: " limit rate 1/second {% endif -%} ct label netify-blocked counter reject ct label bulk counter ip dscp set cs1 return @@ -37,8 +37,7 @@ def generate_dpi(): e_uci = EUci() template = Environment(loader=BaseLoader()).from_string(CHAIN) render = template.render( - log_enabled=e_uci.get('dpi', 'config', 'log_blocked', dtype=bool, default=False), - log_limit=e_uci.get('firewall', 'ns_defaults', 'rule_log_limit', dtype=str, default='1/second') + log_enabled=e_uci.get('dpi', 'config', 'log_blocked', dtype=bool, default=False) ) # save to nftables directory table-pre, only if the file is changed file_path = '/usr/share/nftables.d/table-pre/dpi_actions.nft' 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