From 2c92731f1cfef0cd812754daf7dd4add6d165942 Mon Sep 17 00:00:00 2001 From: Antonio Quartulli Date: Sun, 30 Aug 2026 19:58:00 +0200 Subject: openvpn: T8264: create the interface type the data path needs apply() restarts the daemon and then creates the interface itself, as a tun device. OpenVPN declines the offload when it finds a device of the wrong type, so DCO worked only until the first reconfiguration. The Kernel module takes its operating mode at creation time and iproute2 can neither set nor report it, so go through netlink - multipoint for a server, point-to-point otherwise, matching what OpenVPN asks for itself. A server adopting a point-to-point device rejects every client, and the attribute is dropped silently if it is ever unrecognised, so read the mode back. A raw option does not change this. verify() turns away the ones known to drop the offload, and beyond those "openvpn-option" is documented as the user's own responsibility - so an offloaded interface is created either way. A raw option that does decline the offload now leaves the daemon unable to open the device it is given, which is at least visible, rather than quietly carrying traffic in userspace. --- src/conf_mode/interfaces_openvpn.py | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'src') diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index ff5dcf07b..b4876f3b1 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -65,6 +65,7 @@ from vyos.utils.permission import chown from vyos.utils.process import cmdl from vyos.utils.network import is_addr_assigned from vyos.utils.network import interface_exists +from vyos.utils.network import get_interface_config from vyos import ConfigError from vyos import airbag @@ -978,6 +979,13 @@ def apply(openvpn): if not is_addr_assigned(openvpn['local_host']): cmdl(['sysctl', '-w', 'net.ipv4.ip_nonlocal_bind=1']) + # The interface type follows the data path, and OpenVPN will not adopt a + # device of the wrong kind - drop a leftover from the previous setting. + if interface_exists(interface): + offloaded = dict_search('linkinfo.info_kind', get_interface_config(interface)) + if (dict_search('offload.dco', openvpn) is not None) != (offloaded == 'ovpn'): + VTunIf(interface).remove() + # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process -- cgit v1.2.3 From 06dee60d87c6aa498cb43a7b3f43dd00e9b74ac6 Mon Sep 17 00:00:00 2001 From: Antonio Quartulli Date: Mon, 31 Aug 2026 10:04:46 +0200 Subject: openvpn: T8264: only disturb the interface when the daemon restarts A CRL or client-config only commit leaves the daemon running, so dropping the interface there takes it away from underneath a live tunnel. The operating mode has to be compared too: a "mode" change alone leaves an "ovpn" device of the right kind but the wrong mode, which OpenVPN adopts just the same and which then rejects every peer. So does the device type, which the Kernel pins at creation - a "device-type" change otherwise left the daemon a tun device it cannot open as tap. --- src/conf_mode/interfaces_openvpn.py | 62 +++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index b4876f3b1..21d9e4adc 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -41,6 +41,9 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import VTunIf +from vyos.netlink.ovpn import get_ovpn_mode +from vyos.netlink.ovpn import OVPN_MODE_MP +from vyos.netlink.ovpn import OVPN_MODE_P2P from vyos.pki import load_dh_parameters from vyos.pki import load_private_key from vyos.pki import sort_ca_chain @@ -145,6 +148,18 @@ def get_config(config=None): ifname, openvpn = get_interface_dict(conf, base, with_pki=True) openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) + # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all + # OpenVPN interfaces. Check if DCO is used by any other interface instance. + tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + for interface, interface_config in tmp.items(): + # If one interface has DCO configured, enable it. No need to further check + # all other OpenVPN interfaces. We must use a dedicated key to indicate + # the Kernel module must be loaded or not. The per interface "offload.dco" + # key is required per OpenVPN interface instance. + if dict_search('offload.dco', interface_config) != None: + openvpn['module_load_dco'] = {} + break + if 'deleted' in openvpn: return openvpn @@ -167,8 +182,11 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'openvpn-option']): openvpn.update({'restart_required': {}}) - if is_node_changed(conf, base + [ifname, 'offload', 'dco']): - openvpn.update({'restart_required': {}}) + # the offload, the operating mode and the device type all decide what kind + # of interface the data path needs, which can only change on a restart + for node in [['offload', 'dco'], ['mode'], ['device-type']]: + if is_node_changed(conf, base + [ifname] + node): + openvpn.update({'restart_required': {}}) # Detect changes that are limited to per-client CCD entries (T6478). # OpenVPN reads client-config-dir files at connect time, so adding or @@ -188,18 +206,6 @@ def get_config(config=None): if dict_search('server.mfa.totp', tmp) == None: del openvpn['server']['mfa'] - # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all - # OpenVPN interfaces. Check if DCO is used by any other interface instance. - tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - for interface, interface_config in tmp.items(): - # If one interface has DCO configured, enable it. No need to further check - # all other OpenVPN interfaces. We must use a dedicated key to indicate - # the Kernel module must be loaded or not. The per interface "offload.dco" - # key is required per OpenVPN interface instance. - if dict_search('offload.dco', interface_config) != None: - openvpn['module_load_dco'] = {} - break - # Calculate the protocol modifier. This is concatenated to the protocol string to direct # OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which # type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces @@ -979,11 +985,29 @@ def apply(openvpn): if not is_addr_assigned(openvpn['local_host']): cmdl(['sysctl', '-w', 'net.ipv4.ip_nonlocal_bind=1']) - # The interface type follows the data path, and OpenVPN will not adopt a - # device of the wrong kind - drop a leftover from the previous setting. - if interface_exists(interface): - offloaded = dict_search('linkinfo.info_kind', get_interface_config(interface)) - if (dict_search('offload.dco', openvpn) is not None) != (offloaded == 'ovpn'): + # The interface type follows the data path, and OpenVPN adopts whatever it + # finds - including an "ovpn" device in the wrong operating mode, which + # then rejects every peer. Drop a leftover that no longer matches. Only do + # so when the daemon is restarted below, or a commit that leaves it running + # would take the interface away from underneath it. + if 'restart_required' in openvpn and interface_exists(interface): + if dict_search('offload.dco', openvpn) is None: + drop = get_ovpn_mode(interface) is not None + elif openvpn['mode'] == 'server': + drop = get_ovpn_mode(interface) != OVPN_MODE_MP + else: + drop = get_ovpn_mode(interface) != OVPN_MODE_P2P + + # The Kernel pins tun against tap when the device is made and refuses + # to hand a "tap" device to a daemon asking for a "tun" one. An "ovpn" + # device carries no such type, hence the None. + if not drop: + tmp = dict_search( + 'linkinfo.info_data.type', get_interface_config(interface) + ) + drop = tmp is not None and tmp != openvpn['device_type'] + + if drop: VTunIf(interface).remove() # No matching OpenVPN process running - maybe it got killed or none -- cgit v1.2.3 From 963e4cc5b36b9efd8fec9fe52a6fbacee6f4a2e5 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 16 Sep 2026 20:47:19 +0200 Subject: T8264: openvpn: exit early in restart_required loop --- src/conf_mode/interfaces_openvpn.py | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 21d9e4adc..2b235c057 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -187,6 +187,7 @@ def get_config(config=None): for node in [['offload', 'dco'], ['mode'], ['device-type']]: if is_node_changed(conf, base + [ifname] + node): openvpn.update({'restart_required': {}}) + break # Detect changes that are limited to per-client CCD entries (T6478). # OpenVPN reads client-config-dir files at connect time, so adding or -- cgit v1.2.3