summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLucas Christian <lucas@lucasec.com>2024-07-03 23:14:45 -0700
committerLucas Christian <lucas@lucasec.com>2024-07-26 18:26:30 -0700
commit376e2d898f26c13a31f80d877f4e2621fd6efb0f (patch)
tree8537e50f3c62b4dc880af60b57c4ccce612bdf44
parent4d2c89dcd50d3c158dc76ac5ab843dd66105bc02 (diff)
downloadvyos-1x-376e2d898f26c13a31f80d877f4e2621fd6efb0f.tar.gz
vyos-1x-376e2d898f26c13a31f80d877f4e2621fd6efb0f.zip
T5873: vpn ipsec: re-write of ipsec updown hook
-rw-r--r--python/vyos/ifconfig/vti.py19
-rw-r--r--python/vyos/utils/vti_updown_db.py194
-rwxr-xr-xsmoketest/scripts/cli/test_interfaces_vti.py3
-rwxr-xr-xsmoketest/scripts/cli/test_vpn_ipsec.py19
-rwxr-xr-xsrc/conf_mode/vpn_ipsec.py54
-rwxr-xr-xsrc/etc/ipsec.d/vti-up-down53
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)