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. --- python/vyos/ifconfig/vtun.py | 22 +++++++++++++ python/vyos/netlink/ovpn.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 python/vyos/netlink/ovpn.py (limited to 'python') diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py index fbe7127d5..6490fdf83 100644 --- a/python/vyos/ifconfig/vtun.py +++ b/python/vyos/ifconfig/vtun.py @@ -14,6 +14,7 @@ # License along with this library. If not, see . from vyos.ifconfig.interface import Interface +from vyos.utils.dict import dict_search @Interface.register class VTunIf(Interface): @@ -32,6 +33,27 @@ class VTunIf(Interface): established (client mode). The latter will only be brought up once the server can be reached, thus we might need to create this interface in advance for the service to be operational. """ + # An offloaded data path lives in the "ovpn" Kernel module, and OpenVPN + # declines the offload when it finds a device of any other type. The + # operating mode is fixed at creation time, so it has to be set here. + if dict_search('offload.dco', self.config) is not None: + # Imported here rather than at module scope: vyos.netlink.ovpn + # imports pyroute2, which every consumer of vyos.ifconfig would + # otherwise pay for on import. + from errno import EEXIST + from pyroute2.netlink.exceptions import NetlinkError + from vyos.netlink.ovpn import add_ovpn_interface + + try: + return add_ovpn_interface( + self.ifname, self.config.get('mode') == 'server' + ) + except NetlinkError as e: + if e.code != EEXIST: + raise + # interface created by OpenVPN daemon in the meantime ... + return None + try: cmd = ['openvpn', '--mktun', '--dev-type', self.config['device_type'], '--dev', self.ifname] diff --git a/python/vyos/netlink/ovpn.py b/python/vyos/netlink/ovpn.py new file mode 100644 index 000000000..fe325bd60 --- /dev/null +++ b/python/vyos/netlink/ovpn.py @@ -0,0 +1,74 @@ +# Copyright VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +""" +Creation of "ovpn" interfaces for OpenVPN data channel offload. + +The Kernel module takes the operating mode as a link attribute at creation +time and it can not be changed afterwards. iproute2 has no support for the +link type, so it can only ever create a device in the default point-to-point +mode - which a server then adopts and rejects every client on. Go through +netlink directly instead. +""" + +from pyroute2 import IPRoute +from pyroute2.netlink import nla +from pyroute2.netlink.rtnl.ifinfmsg import ifinfmsg + +# uapi: enum ovpn_mode +OVPN_MODE_P2P = 0 +OVPN_MODE_MP = 1 + + +class ovpn_data(nla): + prefix = 'IFLA_' + nla_map = ( + ('IFLA_OVPN_UNSPEC', 'none'), + ('IFLA_OVPN_MODE', 'uint8'), + ) + + +# pyroute2 does not know the link type either, so teach it +ifinfmsg.ifinfo.data_map.setdefault('ovpn', ovpn_data) + + +def get_ovpn_mode(ifname: str): + """Operating mode of an "ovpn" interface, or None when the interface is + missing or of any other type. iproute2 has no decoder for the attribute, + so this is the only way to tell a multipoint device from a point-to-point + one - and adopting the wrong one makes OpenVPN reject every peer.""" + with IPRoute() as ipr: + links = ipr.get_links(ifname=ifname) + + if not links: + return None + info = links[0].get_attr('IFLA_LINKINFO') + if info is None or info.get_attr('IFLA_INFO_KIND') != 'ovpn': + return None + data = info.get_attr('IFLA_INFO_DATA') + return None if data is None else data.get_attr('IFLA_OVPN_MODE') + + +def add_ovpn_interface(ifname: str, multipoint: bool) -> None: + """Create an "ovpn" interface, multipoint for servers and point-to-point + for everything else - matching what OpenVPN would ask for itself.""" + mode = OVPN_MODE_MP if multipoint else OVPN_MODE_P2P + with IPRoute() as ipr: + ipr.link('add', ifname=ifname, kind='ovpn', ovpn_mode=mode) + + # the attribute is silently dropped if pyroute2 ever stops recognising the + # link type, and the Kernel then quietly falls back to point-to-point + if get_ovpn_mode(ifname) != mode: + raise ValueError(f'Could not set operating mode on interface {ifname}') -- cgit v1.2.3 From b8f6de86d9350fecfb4a58e48ac6414d8784690d Mon Sep 17 00:00:00 2001 From: Antonio Quartulli Date: Thu, 10 Sep 2026 16:56:21 +0200 Subject: smoketest: T8264: cover adopting a pre-created DCO interface assertDcoDataPath() checks the link kind, the operating mode and that the daemon is up, and none of those change when the offload carries nothing. A client whose server is not up yet never opens its tun, so VyOS creates the "ovpn" device for it and OpenVPN has to adopt that device once the server appears. Get the adoption wrong and the daemon takes the device but not its ifindex: dco_new_peer() fails, the daemon exits and deletes the interface on its way out, systemd starts it again and the second attempt creates a device of its own. The tunnel comes up either way, so the state it ends in proves nothing - the restart it cost does. Ask the Kernel for its peers as well, which is the one thing no amount of userspace bookkeeping can fake. --- python/vyos/netlink/ovpn.py | 85 ++++++++++++++++- smoketest/scripts/cli/test_interfaces_openvpn.py | 111 +++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/netlink/ovpn.py b/python/vyos/netlink/ovpn.py index fe325bd60..232c57810 100644 --- a/python/vyos/netlink/ovpn.py +++ b/python/vyos/netlink/ovpn.py @@ -14,7 +14,7 @@ # along with this library. If not, see . """ -Creation of "ovpn" interfaces for OpenVPN data channel offload. +Creation and inspection of "ovpn" interfaces for OpenVPN data channel offload. The Kernel module takes the operating mode as a link attribute at creation time and it can not be changed afterwards. iproute2 has no support for the @@ -23,14 +23,24 @@ mode - which a server then adopts and rejects every client on. Go through netlink directly instead. """ +from socket import if_nametoindex + from pyroute2 import IPRoute +from pyroute2.netlink import NLM_F_DUMP +from pyroute2.netlink import NLM_F_REQUEST +from pyroute2.netlink import genlmsg from pyroute2.netlink import nla +from pyroute2.netlink.generic import GenericNetlinkSocket from pyroute2.netlink.rtnl.ifinfmsg import ifinfmsg # uapi: enum ovpn_mode OVPN_MODE_P2P = 0 OVPN_MODE_MP = 1 +# uapi: OVPN_FAMILY_NAME and enum ovpn_nl_commands +OVPN_FAMILY_NAME = 'ovpn' +OVPN_CMD_PEER_GET = 3 + class ovpn_data(nla): prefix = 'IFLA_' @@ -44,6 +54,79 @@ class ovpn_data(nla): ifinfmsg.ifinfo.data_map.setdefault('ovpn', ovpn_data) +class ovpnmsg(genlmsg): + """OVPN_A_* of the "ovpn" generic netlink family. Only the attributes read + below are decoded; the rest stay opaque so a Kernel that grows new ones + does not break the parser.""" + + prefix = 'OVPN_A_' + nla_map = ( + ('OVPN_A_UNSPEC', 'none'), + ('OVPN_A_IFINDEX', 'uint32'), + ('OVPN_A_PEER', 'peer'), + ('OVPN_A_KEYCONF', 'hex'), + ) + + class peer(nla): + prefix = 'OVPN_A_PEER_' + nla_map = ( + ('OVPN_A_PEER_UNSPEC', 'none'), + ('OVPN_A_PEER_ID', 'uint32'), + ('OVPN_A_PEER_REMOTE_IPV4', 'hex'), + ('OVPN_A_PEER_REMOTE_IPV6', 'hex'), + ('OVPN_A_PEER_REMOTE_IPV6_SCOPE_ID', 'hex'), + ('OVPN_A_PEER_REMOTE_PORT', 'hex'), + ('OVPN_A_PEER_SOCKET', 'hex'), + ('OVPN_A_PEER_SOCKET_NETNSID', 'hex'), + ('OVPN_A_PEER_VPN_IPV4', 'hex'), + ('OVPN_A_PEER_VPN_IPV6', 'hex'), + ('OVPN_A_PEER_LOCAL_IPV4', 'hex'), + ('OVPN_A_PEER_LOCAL_IPV6', 'hex'), + ('OVPN_A_PEER_LOCAL_PORT', 'hex'), + ('OVPN_A_PEER_KEEPALIVE_INTERVAL', 'uint32'), + ('OVPN_A_PEER_KEEPALIVE_TIMEOUT', 'uint32'), + ('OVPN_A_PEER_DEL_REASON', 'hex'), + ('OVPN_A_PEER_VPN_RX_BYTES', 'uint64'), + ('OVPN_A_PEER_VPN_TX_BYTES', 'uint64'), + ('OVPN_A_PEER_VPN_RX_PACKETS', 'uint64'), + ('OVPN_A_PEER_VPN_TX_PACKETS', 'uint64'), + ('OVPN_A_PEER_LINK_RX_BYTES', 'uint64'), + ('OVPN_A_PEER_LINK_TX_BYTES', 'uint64'), + ('OVPN_A_PEER_LINK_RX_PACKETS', 'uint64'), + ('OVPN_A_PEER_LINK_TX_PACKETS', 'uint64'), + ) + + +def get_ovpn_peers(ifname: str) -> list: + """The peers the Kernel holds for an "ovpn" interface, as dictionaries of + the decoded OVPN_A_PEER_* attributes. + + A peer only exists here once OpenVPN's OVPN_CMD_PEER_NEW succeeded, so + this is the one way to tell an offload that carries traffic from a device + that merely looks right. The byte counters are the Kernel's own, unlike + the ones OpenVPN reports, which add its userspace socket accounting. + + OVPN_CMD_PEER_GET carries GENL_ADMIN_PERM, so this needs CAP_NET_ADMIN. + """ + peers = [] + with GenericNetlinkSocket() as sock: + sock.bind(OVPN_FAMILY_NAME, ovpnmsg) + + msg = ovpnmsg() + msg['cmd'] = OVPN_CMD_PEER_GET + msg['version'] = 1 + msg['attrs'] = [('OVPN_A_IFINDEX', if_nametoindex(ifname))] + + for reply in sock.nlm_request( + msg, msg_type=sock.prid, msg_flags=NLM_F_REQUEST | NLM_F_DUMP + ): + peer = reply.get_attr('OVPN_A_PEER') + if peer is not None: + peers.append(dict(peer['attrs'])) + + return peers + + def get_ovpn_mode(ifname: str): """Operating mode of an "ovpn" interface, or None when the interface is missing or of any other type. iproute2 has no decoder for the attribute, diff --git a/smoketest/scripts/cli/test_interfaces_openvpn.py b/smoketest/scripts/cli/test_interfaces_openvpn.py index 987586a67..b3b832df6 100755 --- a/smoketest/scripts/cli/test_interfaces_openvpn.py +++ b/smoketest/scripts/cli/test_interfaces_openvpn.py @@ -27,6 +27,7 @@ from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.utils.process import cmdl +from vyos.utils.process import popen from vyos.utils.process import process_named_running from vyos.utils.process import is_systemd_service_running from vyos.utils.file import read_file @@ -239,6 +240,116 @@ class TestInterfacesOpenVPN(VyOSUnitTestSHIM.TestCase): self.assertDcoDataPath(interface, multipoint=False) + def test_openvpn_dco_peer_reaches_the_kernel(self): + # VyOS creates the "ovpn" device itself whenever the daemon cannot - + # a client whose server is not up yet never opens its tun, which is + # the very reason for pre-creating it. OpenVPN then has to adopt a + # device it did not make once the server appears, and only a peer in + # the Kernel proves it ended up with a usable one: the link kind, the + # operating mode and a running daemon all look exactly the same when + # the offload carries nothing at all. + server = 'vtun5100' + client = 'vtun5101' + port = '1195' + + # the client first, with nothing listening - VyOS makes the device + path = base_path + [client] + self.cli_set(path + ['device-type', 'tun']) + self.cli_set(path + ['mode', 'client']) + self.cli_set(path + ['remote-host', '127.0.0.1']) + self.cli_set(path + ['remote-port', port]) + self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'certificate', 'ovpn_test']) + self.cli_set(path + ['encryption', 'data-ciphers', 'aes256gcm']) + self.cli_set(path + ['offload', 'dco']) + self.cli_commit() + + self.assertDcoDataPath(client, multipoint=False) + + # now give it something to connect to + path = base_path + [server] + self.cli_set(path + ['device-type', 'tun']) + self.cli_set(path + ['mode', 'server']) + self.cli_set(path + ['local-port', port]) + self.cli_set(path + ['server', 'subnet', '10.99.0.0/24']) + self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'dh-params', 'ovpn_test']) + self.cli_set(path + ['encryption', 'data-ciphers', 'aes256gcm']) + self.cli_set(path + ['offload', 'dco']) + self.cli_set(path + ['keep-alive', 'interval', '1']) + self.cli_set(path + ['keep-alive', 'failure-count', '2']) + self.cli_commit() + + self.assertDcoDataPath(server) + + self.assertGreater( + self._peers(server), + 0, + f'the Kernel holds no peer for {server}\n' f'{self._openvpn_log(server)}', + ) + # the side that had to adopt a device VyOS made for it + self.assertGreater( + self._peers(client), + 0, + f'{client} has no peer after adopting the device VyOS made\n' + f'probe: {getattr(self, "_peer_detail", "")}\n' + f'{self._ip_link(client)}\n{self._openvpn_log(client)}', + ) + + # A correct adoption costs the daemon nothing. Take the ifindex of the + # adopted device away and dco_new_peer() fails, the daemon exits, the + # device goes with it and systemd starts the whole thing again - the + # tunnel comes back, so only the restart counter tells the two apart. + restarts = cmdl( + [ + 'systemctl', + 'show', + '-p', + 'NRestarts', + '--value', + f'openvpn@{client}.service', + ] + ).strip() + self.assertEqual( + restarts, + '0', + f'{client} restarted {restarts} time(s) adopting its device\n' + f'{self._openvpn_log(client)}', + ) + + # OVPN_CMD_PEER_GET carries GENL_ADMIN_PERM, hence the detour through + # sudo. The probe swallows its own errors so a missing interface or a + # refused dump reads as "no peer" with the reason attached, instead of + # erroring out of the test with nothing to go on. + _peer_probe = ( + 'import sys\n' + 'from vyos.netlink.ovpn import get_ovpn_peers\n' + 'try:\n' + ' print(len(get_ovpn_peers(sys.argv[1])))\n' + 'except Exception as e:\n' + ' print(f"0 {type(e).__name__}: {e}")\n' + ) + + def _peers(self, interface): + out = '' + for _ in range(30): + out = cmdl(['sudo', 'python3', '-c', self._peer_probe, interface]).strip() + if out.split()[0] != '0': + return int(out.split()[0]) + sleep(1) + self._peer_detail = out + return 0 + + def _ip_link(self, interface): + out, _ = popen(f'sudo ip -d link show dev {interface}') + return f'--- ip -d link {interface}\n{out}' + + def _openvpn_log(self, interface): + unit = f'openvpn@{interface}.service' + out = cmdl(['sudo', 'journalctl', '-u', unit, '-n', '25', '--no-pager']) + return f'--- {unit}\n{out}' + def test_openvpn_client_dco_raw_option(self): # A raw option no longer keeps VyOS from creating the device: the # offload is the user's responsibility once they pass one, and a -- cgit v1.2.3