From 4d2c89dcd50d3c158dc76ac5ab843dd66105bc02 Mon Sep 17 00:00:00 2001
From: Lucas Christian <lucas@lucasec.com>
Date: Thu, 28 Dec 2023 22:26:56 -0800
Subject: T5873: vpn ipsec remote-access: support VTI interfaces

---
 src/conf_mode/vpn_ipsec.py | 74 +++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 66 insertions(+), 8 deletions(-)

(limited to 'src')

diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py
index cf82b767f..2c1ddc245 100755
--- a/src/conf_mode/vpn_ipsec.py
+++ b/src/conf_mode/vpn_ipsec.py
@@ -21,6 +21,9 @@ import jmespath
 
 from sys import exit
 from time import sleep
+from ipaddress import ip_address
+from netaddr import IPNetwork
+from netaddr import IPRange
 
 from vyos.base import Warning
 from vyos.config import Config
@@ -304,6 +307,14 @@ def verify(ipsec):
                     if dict_search('remote_access.radius.server', ipsec) == None:
                         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}'):
+                        raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!')
+
                 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 '\
@@ -330,26 +341,73 @@ def verify(ipsec):
                             raise ConfigError(f'Requested pool "{pool}" does not exist!')
 
         if 'pool' in ipsec['remote_access']:
+            pool_networks = []
             for pool, pool_config in ipsec['remote_access']['pool'].items():
-                if 'prefix' not in pool_config:
-                    raise ConfigError(f'Missing madatory prefix option for pool "{pool}"!')
+                if 'prefix' not in pool_config and 'range' not in pool_config:
+                    raise ConfigError(f'Mandatory prefix or range must be specified for pool "{pool}"!')
+
+                if 'prefix' in pool_config and 'range' in pool_config:
+                    raise ConfigError(f'Only one of prefix or range can be specified for pool "{pool}"!')
+
+                if 'prefix' in pool_config:
+                    range_is_ipv4 = is_ipv4(pool_config['prefix'])
+                    range_is_ipv6 = is_ipv6(pool_config['prefix'])
+
+                    net = IPNetwork(pool_config['prefix'])
+                    start = net.first
+                    stop = net.last
+                    for network in pool_networks:
+                        if start in network or stop in network:
+                            raise ConfigError(f'Prefix for pool "{pool}" is already part of another pool\'s range!')
+
+                    tmp = IPRange(start, stop)
+                    pool_networks.append(tmp)
+
+                if 'range' in pool_config:
+                    range_config = pool_config['range']
+                    if not {'start', 'stop'} <= set(range_config.keys()):
+                        raise ConfigError(f'Range start and stop address must be defined for pool "{pool}"!')
+
+                    range_both_ipv4 = is_ipv4(range_config['start']) and is_ipv4(range_config['stop'])
+                    range_both_ipv6 = is_ipv6(range_config['start']) and is_ipv6(range_config['stop'])
+
+                    if not (range_both_ipv4 or range_both_ipv6):
+                        raise ConfigError(f'Range start and stop must be of the same address family for pool "{pool}"!')
+
+                    if ip_address(range_config['stop']) < ip_address(range_config['start']):
+                        raise ConfigError(f'Range stop address must be greater or equal\n' \
+                                          'to the range\'s start address for pool "{pool}"!')
+
+                    range_is_ipv4 = is_ipv4(range_config['start'])
+                    range_is_ipv6 = is_ipv6(range_config['start'])
+
+                    start = range_config['start']
+                    stop = range_config['stop']
+                    for network in pool_networks:
+                        if start in network:
+                            raise ConfigError(f'Range "{range}" start address "{start}" already part of another pool\'s range!')
+                        if stop in network:
+                            raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another pool\'s range!')
+
+                    tmp = IPRange(start, stop)
+                    pool_networks.append(tmp)
 
                 if 'name_server' in pool_config:
                     if len(pool_config['name_server']) > 2:
                         raise ConfigError(f'Only two name-servers are supported for remote-access pool "{pool}"!')
 
                     for ns in pool_config['name_server']:
-                        v4_addr_and_ns = is_ipv4(ns) and not is_ipv4(pool_config['prefix'])
-                        v6_addr_and_ns = is_ipv6(ns) and not is_ipv6(pool_config['prefix'])
+                        v4_addr_and_ns = is_ipv4(ns) and not range_is_ipv4
+                        v6_addr_and_ns = is_ipv6(ns) and not range_is_ipv6
                         if v4_addr_and_ns or v6_addr_and_ns:
-                           raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and name-server adresses!')
+                           raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and name-server addresses!')
 
                 if 'exclude' in pool_config:
                     for exclude in pool_config['exclude']:
-                        v4_addr_and_exclude = is_ipv4(exclude) and not is_ipv4(pool_config['prefix'])
-                        v6_addr_and_exclude = is_ipv6(exclude) and not is_ipv6(pool_config['prefix'])
+                        v4_addr_and_exclude = is_ipv4(exclude) and not range_is_ipv4
+                        v6_addr_and_exclude = is_ipv6(exclude) and not range_is_ipv6
                         if v4_addr_and_exclude or v6_addr_and_exclude:
-                           raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and exclude prefixes!')
+                           raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and exclude prefixes!')
 
         if 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']:
             for server, server_config in ipsec['remote_access']['radius']['server'].items():
-- 
cgit v1.2.3


From 376e2d898f26c13a31f80d877f4e2621fd6efb0f Mon Sep 17 00:00:00 2001
From: Lucas Christian <lucas@lucasec.com>
Date: Wed, 3 Jul 2024 23:14:45 -0700
Subject: T5873: vpn ipsec: re-write of ipsec updown hook

---
 python/vyos/ifconfig/vti.py                  |  19 ++-
 python/vyos/utils/vti_updown_db.py           | 194 +++++++++++++++++++++++++++
 smoketest/scripts/cli/test_interfaces_vti.py |   3 +-
 smoketest/scripts/cli/test_vpn_ipsec.py      |  19 ++-
 src/conf_mode/vpn_ipsec.py                   |  54 ++++++--
 src/etc/ipsec.d/vti-up-down                  |  53 ++++----
 6 files changed, 302 insertions(+), 40 deletions(-)
 create mode 100644 python/vyos/utils/vti_updown_db.py

(limited to 'src')

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)
-- 
cgit v1.2.3


From 404b641121d3f5f7686b6ad75236ff64b0733cf9 Mon Sep 17 00:00:00 2001
From: Lucas Christian <lucas@lucasec.com>
Date: Sun, 7 Jul 2024 03:11:00 -0700
Subject: T5873: vpn ipsec: ignore dhcp/vti settings when connection disabled

---
 src/conf_mode/vpn_ipsec.py | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

(limited to 'src')

diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py
index 789d37a77..e8a0bc414 100755
--- a/src/conf_mode/vpn_ipsec.py
+++ b/src/conf_mode/vpn_ipsec.py
@@ -280,7 +280,8 @@ def verify(ipsec):
                     if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
                         raise ConfigError(f"Invalid dhcp-interface on remote-access connection {name}")
 
-                    ipsec['dhcp_interfaces'].add(dhcp_interface)
+                    if 'disable' not in ra_conf:
+                        ipsec['dhcp_interfaces'].add(dhcp_interface)
 
                     address = get_dhcp_address(dhcp_interface)
                     count = 0
@@ -340,9 +341,10 @@ def verify(ipsec):
                     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 'disable' not in ra_conf:
+                        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']):
@@ -507,7 +509,8 @@ def verify(ipsec):
                 if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
                     raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}")
 
-                ipsec['dhcp_interfaces'].add(dhcp_interface)
+                if 'disable' not in peer_conf:
+                    ipsec['dhcp_interfaces'].add(dhcp_interface)
 
                 address = get_dhcp_address(dhcp_interface)
                 count = 0
@@ -529,7 +532,8 @@ 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['enabled_vti_interfaces'].add(vti_interface)
+                    if 'disable' not in peer_conf:
+                        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}")
-- 
cgit v1.2.3