diff options
author | Lucas Christian <lucas@lucasec.com> | 2024-07-03 23:14:45 -0700 |
---|---|---|
committer | Lucas Christian <lucas@lucasec.com> | 2024-07-26 18:26:30 -0700 |
commit | 376e2d898f26c13a31f80d877f4e2621fd6efb0f (patch) | |
tree | 8537e50f3c62b4dc880af60b57c4ccce612bdf44 | |
parent | 4d2c89dcd50d3c158dc76ac5ab843dd66105bc02 (diff) | |
download | vyos-1x-376e2d898f26c13a31f80d877f4e2621fd6efb0f.tar.gz vyos-1x-376e2d898f26c13a31f80d877f4e2621fd6efb0f.zip |
T5873: vpn ipsec: re-write of ipsec updown hook
-rw-r--r-- | python/vyos/ifconfig/vti.py | 19 | ||||
-rw-r--r-- | python/vyos/utils/vti_updown_db.py | 194 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_interfaces_vti.py | 3 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_vpn_ipsec.py | 19 | ||||
-rwxr-xr-x | src/conf_mode/vpn_ipsec.py | 54 | ||||
-rwxr-xr-x | src/etc/ipsec.d/vti-up-down | 53 |
6 files changed, 302 insertions, 40 deletions
diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 9511386f4..251cbeb36 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 @@ class VTIIf(Interface): 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 000000000..b491fc6f2 --- /dev/null +++ b/python/vyos/utils/vti_updown_db.py @@ -0,0 +1,194 @@ +# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +import os + +from contextlib import contextmanager +from syslog import syslog + +VTI_WANT_UP_IFLIST = '/tmp/ipsec_vti_interfaces' + +def vti_updown_db_exists(): + """ Returns true if the database exists """ + return os.path.exists(VTI_WANT_UP_IFLIST) + +@contextmanager +def open_vti_updown_db_for_create_or_update(): + """ Opens the database for reading and writing, creating the database if it does not exist """ + 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(): + """ Opens the database for reading and writing, returning an error if it does not exist """ + f = open(VTI_WANT_UP_IFLIST, 'r+') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +@contextmanager +def open_vti_updown_db_readonly(): + """ Opens the database for reading, returning an error if it does not exist """ + f = open(VTI_WANT_UP_IFLIST, 'r') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +def remove_vti_updown_db(): + """ Brings down any interfaces referenced by the database and removes the database """ + # We need to process the DB first to bring down any interfaces still up + with open_vti_updown_db_for_update() as db: + db.removeAllOtherInterfaces([]) + # this usage of commit will only ever bring down interfaces, + # do not need to provide a functional interface dict supplier + db.commit(lambda _: None) + + os.unlink(VTI_WANT_UP_IFLIST) + +class VTIUpDownDB: + # The VTI Up-Down DB is a text-based database of space-separated "ifspecs". + # + # ifspecs can come in one of the two following formats: + # + # persistent format: <interface name> + # indicates the named interface should always be up. + # + # connection format: <interface name>:<connection name>:<protocol> + # indicates the named interface wants to be up due to an established + # connection <connection name> using the <protocol> protocol. + # + # The configuration tree and ipsec daemon connection up-down hook + # modify this file as needed and use it to determine when a + # particular event or configuration change should lead to changing + # the interface state. + + 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 add(self, interface, connection = None, protocol = None): + """ + Adds a new entry to the DB. + + If an interface name, connection name, and protocol are supplied, + creates a connection entry. + + If only an interface name is specified, creates a persistent entry + for the given interface. + """ + ifspec = f"{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): + """ + Removes a matching entry from the DB. + + If no matching entry can be fonud, the operation returns successfully. + """ + ifspec = f"{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): + """ Returns whether the DB contains at least one entry referencing the given interface """ + for ifspec in self._ifspecs: + if ifspec.split(':')[0] == interface: + return True + + return False + + def removeAllOtherInterfaces(self, interface_list): + """ Removes all interfaces not included in the given list from the DB """ + 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 setPersistentInterfaces(self, interface_list): + """ Updates the set of persistently up interfaces to match the given list """ + new_presistent_interfaces = set(interface_list) + current_presistent_interfaces = set([ifspec for ifspec in self._ifspecs if ':' not in ifspec]) + added_presistent_interfaces = new_presistent_interfaces - current_presistent_interfaces + removed_presistent_interfaces = current_presistent_interfaces - new_presistent_interfaces + + for interface in added_presistent_interfaces: + self.add(interface) + + for interface in removed_presistent_interfaces: + self.remove(interface) + + def commit(self, interface_dict_supplier): + """ + Writes the DB to disk and brings interfaces up and down as needed. + + Only interfaces referenced by entries modified in this DB session + are manipulated. If an interface is called to be brought up, the + provided interface_config_supplier function is invoked and expected + to return the config dictionary for the interface. + """ + 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: + vti = interface_dict_supplier(interface) + if 'disable' not in vti: + tmp = VTIIf(interface, bypass_vti_updown_db = True) + tmp.update(vti) + syslog(f'Interface {interface} is admin up ...') + + self._ifsUp.clear() diff --git a/smoketest/scripts/cli/test_interfaces_vti.py b/smoketest/scripts/cli/test_interfaces_vti.py index 871ac650b..8d90ca5ad 100755 --- a/smoketest/scripts/cli/test_interfaces_vti.py +++ b/smoketest/scripts/cli/test_interfaces_vti.py @@ -39,7 +39,8 @@ class VTIInterfaceTest(BasicInterfaceTest.TestCase): self.cli_commit() - # VTI interface are always down and only brought up by IPSec + # VTI interfaces are default down and only brought up when an + # IPSec connection is configured to use them for intf in self._interfaces: self.assertTrue(is_intf_addr_assigned(intf, addr)) self.assertEqual(Interface(intf).get_admin_state(), 'down') diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index 2674b37b6..3b8687b93 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -20,6 +20,7 @@ import unittest 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 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path) self.cli_delete(tunnel_path) + self.cli_delete(vti_path) self.cli_commit() # Check for no longer running process @@ -342,6 +344,12 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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' @@ -478,9 +486,6 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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() @@ -1340,6 +1345,14 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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 2c1ddc245..789d37a77 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -30,6 +30,7 @@ from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents from vyos.configdep import call_dependents +from vyos.configdict import get_interface_dict from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists from vyos.configverify import dynamic_interface_pattern @@ -50,6 +51,9 @@ from vyos.utils.network import interface_exists 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 +from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update +from vyos.utils.vti_updown_db import remove_vti_updown_db from vyos import ConfigError from vyos import airbag airbag.enable() @@ -107,6 +111,8 @@ def get_config(config=None): ipsec = config_dict_merge(default_values, ipsec) ipsec['dhcp_interfaces'] = set() + ipsec['enabled_vti_interfaces'] = set() + ipsec['persistent_vti_interfaces'] = 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']) @@ -124,6 +130,28 @@ def get_config(config=None): ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024' ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1' + # Collect the interface dicts for any refernced VTI interfaces in + # case we need to bring the interface up + ipsec['vti_interface_dicts'] = {} + + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + if 'vti' in peer_conf: + if 'bind' in peer_conf['vti']: + vti_interface = peer_conf['vti']['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + + if 'remote_access' in ipsec: + if 'connection' in ipsec['remote_access']: + for name, ra_conf in ipsec['remote_access']['connection'].items(): + if 'bind' in ra_conf: + vti_interface = ra_conf['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + return ipsec def get_dhcp_address(iface): @@ -308,13 +336,14 @@ def verify(ipsec): raise ConfigError('RADIUS authentication requires at least one server') if 'bind' in ra_conf: - if dict_search('options.disable_route_autoinstall', ipsec) == None: - Warning('It\'s recommended to use ipsec vti with the next command\n[set vpn ipsec option disable-route-autoinstall]') - vti_interface = ra_conf['bind'] - if not os.path.exists(f'/sys/class/net/{vti_interface}'): + if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!') + ipsec['enabled_vti_interfaces'].add(vti_interface) + # remote access VPN interfaces are always up regardless of whether clients are connected + ipsec['persistent_vti_interfaces'].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 '\ @@ -496,14 +525,11 @@ def verify(ipsec): if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") - if dict_search('options.disable_route_autoinstall', - ipsec) == None: - Warning('It\'s recommended to use ipsec vti with the next command\n[set vpn ipsec option disable-route-autoinstall]') - if 'bind' in peer_conf['vti']: 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['enabled_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}") @@ -681,9 +707,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['enabled_vti_interfaces']: + with open_vti_updown_db_for_create_or_update() as db: + db.removeAllOtherInterfaces(ipsec['enabled_vti_interfaces']) + db.setPersistentInterfaces(ipsec['persistent_vti_interfaces']) + db.commit(lambda interface: ipsec['vti_interface_dicts'][interface]) + 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 01e9543c9..e1765ae85 100755 --- a/src/etc/ipsec.d/vti-up-down +++ b/src/etc/ipsec.d/vti-up-down @@ -27,40 +27,41 @@ 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.commit import wait_for_commit_lock 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 + +def supply_interface_dict(interface): + # Lazy-load the running config on first invocation + try: + conf = supply_interface_dict.cached_config + except AttributeError: + conf = ConfigTreeQuery() + supply_interface_dict.cached_config = conf + + _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) + return vti 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']: - 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) + wait_for_commit_lock() - 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(supply_interface_dict) + 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(supply_interface_dict) |