From c536878cc1c207cd7bc136eef00f6c5e4bc757e6 Mon Sep 17 00:00:00 2001 From: Lucas Christian Date: Wed, 3 Jul 2024 23:14:45 -0700 Subject: [PATCH] T5873: Re-write of ipsec updown hook to support remote access VPN --- python/vyos/ifconfig/vti.py | 19 ++- python/vyos/utils/vti_updown_db.py | 149 ++++++++++++++++++++++++ smoketest/scripts/cli/test_vpn_ipsec.py | 19 ++- src/conf_mode/vpn_ipsec.py | 20 ++++ src/etc/ipsec.d/vti-up-down | 42 +++---- 5 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 python/vyos/utils/vti_updown_db.py diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 9511386f44c..251cbeb3692 100644 --- a/python/vyos/ifconfig/vti.py +++ b/python/vyos/ifconfig/vti.py @@ -15,6 +15,7 @@ from vyos.ifconfig.interface import Interface from vyos.utils.dict import dict_search +from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_readonly @Interface.register class VTIIf(Interface): @@ -27,6 +28,10 @@ class VTIIf(Interface): }, } + def __init__(self, ifname, **kwargs): + self.bypass_vti_updown_db = kwargs.pop("bypass_vti_updown_db", False) + super().__init__(ifname, **kwargs) + def _create(self): # This table represents a mapping from VyOS internal config dict to # arguments used by iproute2. For more information please refer to: @@ -57,8 +62,18 @@ def _create(self): self.set_interface('admin_state', 'down') def set_admin_state(self, state): - """ Handled outside by /etc/ipsec.d/vti-up-down """ - pass + """ + Set interface administrative state to be 'up' or 'down'. + + The interface will only be brought 'up' if ith is attached to an + active ipsec site-to-site connection or remote access connection. + """ + if state == 'down' or self.bypass_vti_updown_db: + super().set_admin_state(state) + elif vti_updown_db_exists(): + with open_vti_updown_db_readonly() as db: + if db.wantsInterfaceUp(self.ifname): + super().set_admin_state(state) def get_mac(self): """ Get a synthetic MAC address. """ diff --git a/python/vyos/utils/vti_updown_db.py b/python/vyos/utils/vti_updown_db.py new file mode 100644 index 00000000000..f19c09f9557 --- /dev/null +++ b/python/vyos/utils/vti_updown_db.py @@ -0,0 +1,149 @@ +# Copyright 2024 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 . + +import os + +from contextlib import contextmanager +from syslog import syslog + +VTI_WANT_UP_IFLIST = '/tmp/ipsec_vti_interfaces' + +def vti_updown_db_exists(): + return os.path.exists(VTI_WANT_UP_IFLIST) + +@contextmanager +def open_vti_updown_db_for_create_or_update(): + if vti_updown_db_exists(): + f = open(VTI_WANT_UP_IFLIST, 'r+') + else: + f = open(VTI_WANT_UP_IFLIST, 'x+') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +@contextmanager +def open_vti_updown_db_for_update(): + f = open(VTI_WANT_UP_IFLIST, 'r+') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +@contextmanager +def open_vti_updown_db_readonly(): + f = open(VTI_WANT_UP_IFLIST, 'r') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +def remove_vti_updown_db(): + # We need to process the DB first to bring down any interfaces still up + with open_vti_updown_db_for_update() as db: + db.syncInterfaces([]) + db.commit() + + os.unlink(VTI_WANT_UP_IFLIST) + +class VTIUpDownDB: + def __init__(self, f): + self._fileHandle = f + self._ifspecs = set([entry.strip() for entry in f.read().split(" ") if entry and not entry.isspace()]) + self._ifsUp = set() + self._ifsDown = set() + + def syncInterfaces(self, interface_list): + updated_ifspecs = set([ifspec for ifspec in self._ifspecs if ifspec.split(':')[0] in interface_list]) + removed_ifspecs = self._ifspecs - updated_ifspecs + self._ifspecs = updated_ifspecs + interfaces_to_bring_down = [ifspec.split(':')[0] for ifspec in removed_ifspecs] + self._ifsDown.update(interfaces_to_bring_down) + self._ifsUp.difference_update(interfaces_to_bring_down) + + def syncInterfacesAlwaysUp(self, interface_list): + new_unconditional_interfaces = set(interface_list) + current_unconditional_interfaces = set([ifspec for ifspec in self._ifspecs if ':' not in ifspec]) + added_unconditional_interfaces = new_unconditional_interfaces - current_unconditional_interfaces + removed_unconditional_interfaces = current_unconditional_interfaces - new_unconditional_interfaces + + for interface in added_unconditional_interfaces: + self.add(interface) + + for interface in removed_unconditional_interfaces: + self.remove(interface) + + def add(self, interface, connection = None, protocol = None): + ifspec = "{}:{}:{}".format(interface, connection, protocol) if (connection is not None and protocol is not None) else interface + if ifspec not in self._ifspecs: + self._ifspecs.add(ifspec) + self._ifsUp.add(interface) + self._ifsDown.discard(interface) + + def remove(self, interface, connection = None, protocol = None): + ifspec = "{}:{}:{}".format(interface, connection, protocol) if (connection is not None and protocol is not None) else interface + if ifspec in self._ifspecs: + self._ifspecs.remove(ifspec) + interface_remains = False + for ifspec in self._ifspecs: + if ifspec.split(':')[0] == interface: + interface_remains = True + + if not interface_remains: + self._ifsDown.add(interface) + self._ifsUp.discard(interface) + + def wantsInterfaceUp(self, interface): + for ifspec in self._ifspecs: + if ifspec.split(':')[0] == interface: + return True + + return False + + def commit(self): + from vyos.configquery import ConfigTreeQuery + from vyos.configdict import get_interface_dict + from vyos.ifconfig import VTIIf + from vyos.utils.process import call + from vyos.utils.network import get_interface_config + + self._fileHandle.seek(0) + self._fileHandle.write(' '.join(self._ifspecs)) + self._fileHandle.truncate() + + for interface in self._ifsDown: + vti_link = get_interface_config(interface) + vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) + if vti_link_up: + call(f'sudo ip link set {interface} down') + syslog(f'Interface {interface} is admin down ...') + + self._ifsDown.clear() + + for interface in self._ifsUp: + vti_link = get_interface_config(interface) + vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) + if not vti_link_up: + conf = ConfigTreeQuery() + _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) + if 'disable' not in vti: + tmp = VTIIf(interface, bypass_vti_updown_db = True) + tmp.update(vti) + call(f'sudo ip link set {interface} up') + + self._ifsUp.clear() diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index ddcff9615e0..427b416fd74 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -20,6 +20,7 @@ from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError +from vyos.ifconfig import Interface from vyos.utils.process import process_named_running from vyos.utils.file import read_file @@ -140,6 +141,7 @@ def tearDown(self): self.cli_delete(base_path) self.cli_delete(tunnel_path) + self.cli_delete(vti_path) self.cli_commit() # Check for no longer running process @@ -333,6 +335,12 @@ def test_site_to_site_vti(self): for line in swanctl_secrets_lines: self.assertRegex(swanctl_conf, fr'{line}') + # Site-to-site interfaces should start out as 'down' + self.assertEqual(Interface(vti).get_admin_state(), 'down') + + # Disable PKI + self.tearDownPKI() + def test_dmvpn(self): tunnel_if = 'tun100' @@ -469,9 +477,6 @@ def test_site_to_site_x509(self): self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{int_ca_name}.pem'))) self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem'))) - # There is only one VTI test so no need to delete this globally in tearDown() - self.cli_delete(vti_path) - # Disable PKI self.tearDownPKI() @@ -1224,6 +1229,14 @@ def test_remote_access_vti(self): self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{ca_name}.pem'))) self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem'))) + # Remote access interfaces should be set to 'up' during configure + self.assertEqual(Interface(vti).get_admin_state(), 'up') + + # Delete the connection to verify the VTI interfaces is taken down + self.cli_delete(base_path + ['remote-access', 'connection', conn_name]) + self.cli_commit() + self.assertEqual(Interface(vti).get_admin_state(), 'down') + self.tearDownPKI() if __name__ == '__main__': diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 1e96bc58ac5..c73b3b439d3 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -49,6 +49,7 @@ from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import call +from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_for_create_or_update, remove_vti_updown_db from vyos import ConfigError from vyos import airbag airbag.enable() @@ -93,6 +94,8 @@ def get_config(config=None): with_pki=True) ipsec['dhcp_interfaces'] = set() + ipsec['vti_interfaces'] = set() + ipsec['vti_interfaces_always_up'] = set() ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) @@ -301,6 +304,10 @@ def verify(ipsec): if not os.path.exists(f'/sys/class/net/{vti_interface}'): raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!') + ipsec['vti_interfaces'].add(vti_interface) + # remote access VPN interfaces are always up regardless of whether clients are connected + ipsec['vti_interfaces_always_up'].add(vti_interface) + if 'pool' in ra_conf: if {'dhcp', 'radius'} <= set(ra_conf['pool']): raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\ @@ -490,6 +497,7 @@ def verify(ipsec): vti_interface = peer_conf['vti']['bind'] if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') + ipsec['vti_interfaces'].add(vti_interface) if 'vti' not in peer_conf and 'tunnel' not in peer_conf: raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}") @@ -667,9 +675,21 @@ def apply(ipsec): systemd_service = 'strongswan.service' if not ipsec: call(f'systemctl stop {systemd_service}') + + if vti_updown_db_exists(): + remove_vti_updown_db() + else: call(f'systemctl reload-or-restart {systemd_service}') + if ipsec['vti_interfaces']: + with open_vti_updown_db_for_create_or_update() as db: + db.syncInterfaces(ipsec['vti_interfaces']) + db.syncInterfacesAlwaysUp(ipsec['vti_interfaces_always_up']) + db.commit() + elif vti_updown_db_exists(): + remove_vti_updown_db() + if ipsec.get('nhrp_exists', False): try: call_dependents() diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down index 01e9543c986..22c66576ce8 100755 --- a/src/etc/ipsec.d/vti-up-down +++ b/src/etc/ipsec.d/vti-up-down @@ -25,42 +25,30 @@ from syslog import openlog from syslog import LOG_PID from syslog import LOG_INFO -from vyos.configquery import ConfigTreeQuery -from vyos.configdict import get_interface_dict -from vyos.ifconfig import VTIIf from vyos.utils.process import call -from vyos.utils.network import get_interface_config +from vyos.utils.vti_updown_db import open_vti_updown_db_for_update if __name__ == '__main__': verb = os.getenv('PLUTO_VERB') connection = os.getenv('PLUTO_CONNECTION') interface = sys.argv[1] + if verb.endswith('-v6'): + protocol = 'v6' + else: + protocol = 'v4' + openlog(ident=f'vti-up-down', logoption=LOG_PID, facility=LOG_INFO) syslog(f'Interface {interface} {verb} {connection}') - if verb in ['up-client', 'up-host']: + if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']: call('sudo ip route delete default table 220') - vti_link = get_interface_config(interface) - - if not vti_link: - syslog(f'Interface {interface} not found') - sys.exit(0) - - vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) - - if verb in ['up-client', 'up-host']: - if not vti_link_up: - conf = ConfigTreeQuery() - _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) - if 'disable' not in vti: - tmp = VTIIf(interface) - tmp.update(vti) - call(f'sudo ip link set {interface} up') - else: - call(f'sudo ip link set {interface} down') - syslog(f'Interface {interface} is admin down ...') - elif verb in ['down-client', 'down-host']: - if vti_link_up: - call(f'sudo ip link set {interface} down') + if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']: + with open_vti_updown_db_for_update() as db: + db.add(interface, connection, protocol) + db.commit() + elif verb in ['down-client', 'down-client-v6', 'down-host', 'down-host-v6']: + with open_vti_updown_db_for_update() as db: + db.remove(interface, connection, protocol) + db.commit()