diff options
-rw-r--r-- | data/templates/ipsec/swanctl.conf.j2 | 2 | ||||
-rw-r--r-- | data/templates/ipsec/swanctl/remote_access.j2 | 9 | ||||
-rw-r--r-- | interface-definitions/include/ipsec/bind.xml.i | 10 | ||||
-rw-r--r-- | interface-definitions/vpn_ipsec.xml.in | 49 | ||||
-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 | 275 | ||||
-rwxr-xr-x | src/conf_mode/vpn_ipsec.py | 128 | ||||
-rwxr-xr-x | src/etc/ipsec.d/vti-up-down | 53 |
10 files changed, 687 insertions, 55 deletions
diff --git a/data/templates/ipsec/swanctl.conf.j2 b/data/templates/ipsec/swanctl.conf.j2 index d44d0f5e4..698a9135e 100644 --- a/data/templates/ipsec/swanctl.conf.j2 +++ b/data/templates/ipsec/swanctl.conf.j2 @@ -31,6 +31,8 @@ pools { {{ pool }} { {% if pool_config.prefix is vyos_defined %} addrs = {{ pool_config.prefix }} +{% elif pool_config.range is vyos_defined %} + addrs = {{ pool_config.range.start }}-{{ pool_config.range.stop }} {% endif %} {% if pool_config.name_server is vyos_defined %} dns = {{ pool_config.name_server | join(',') }} diff --git a/data/templates/ipsec/swanctl/remote_access.j2 b/data/templates/ipsec/swanctl/remote_access.j2 index e384ae972..c79f292b4 100644 --- a/data/templates/ipsec/swanctl/remote_access.j2 +++ b/data/templates/ipsec/swanctl/remote_access.j2 @@ -46,7 +46,7 @@ {% endif %} } children { - ikev2-vpn { + {{ name }}-client { esp_proposals = {{ esp | get_esp_ike_cipher(ike) | join(',') }} {% if esp.life_bytes is vyos_defined %} life_bytes = {{ esp.life_bytes }} @@ -69,6 +69,13 @@ {% set local_port = rw_conf.local.port if rw_conf.local.port is vyos_defined else '' %} {% set local_suffix = '[%any/{1}]'.format(local_port) if local_port else '' %} local_ts = {{ local_prefix | join(local_suffix + ",") }}{{ local_suffix }} +{% if rw_conf.bind is vyos_defined %} +{# The key defaults to 0 and will match any policies which similarly do not have a lookup key configuration. #} +{# Thus we simply shift the key by one to also support a vti0 interface #} +{% set if_id = rw_conf.bind | replace('vti', '') | int + 1 %} + if_id_in = {{ if_id }} + if_id_out = {{ if_id }} +{% endif %} } } } diff --git a/interface-definitions/include/ipsec/bind.xml.i b/interface-definitions/include/ipsec/bind.xml.i new file mode 100644 index 000000000..edc46d403 --- /dev/null +++ b/interface-definitions/include/ipsec/bind.xml.i @@ -0,0 +1,10 @@ +<!-- include start from ipsec/bind.xml.i --> +<leafNode name="bind"> + <properties> + <help>VTI tunnel interface associated with this configuration</help> + <completionHelp> + <path>interfaces vti</path> + </completionHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index 4a7fde75b..d9d6fd93b 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -854,6 +854,7 @@ #include <include/dhcp-interface.xml.i> #include <include/ipsec/local-traffic-selector.xml.i> #include <include/ipsec/replay-window.xml.i> + #include <include/ipsec/bind.xml.i> <leafNode name="timeout"> <properties> <help>Timeout to close connection if no data is transmitted</help> @@ -978,6 +979,45 @@ </constraint> </properties> </leafNode> + <node name="range"> + <properties> + <help>Local IPv4 or IPv6 pool range</help> + </properties> + <children> + <leafNode name="start"> + <properties> + <help>First IP address for local pool range</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 start address of pool</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 start address of pool</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> + </properties> + </leafNode> + <leafNode name="stop"> + <properties> + <help>Last IP address for local pool range</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 end address of pool</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 end address of pool</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> + </properties> + </leafNode> + </children> + </node> #include <include/name-server-ipv4-ipv6.xml.i> </children> </tagNode> @@ -1201,14 +1241,7 @@ <help>Virtual tunnel interface</help> </properties> <children> - <leafNode name="bind"> - <properties> - <help>VTI tunnel interface associated with this configuration</help> - <completionHelp> - <path>interfaces vti</path> - </completionHelp> - </properties> - </leafNode> + #include <include/ipsec/bind.xml.i> #include <include/ipsec/esp-group.xml.i> </children> </node> 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 2dc66485b..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() @@ -1086,5 +1091,269 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.tearDownPKI() + def test_remote_access_pool_range(self): + # Same as test_remote_access but using an IP pool range instead of prefix + self.setupPKI() + + ike_group = 'IKE-RW' + esp_group = 'ESP-RW' + + conn_name = 'vyos-rw' + local_address = '192.0.2.1' + ip_pool_name = 'ra-rw-ipv4' + username = 'vyos' + password = 'secret' + ike_lifetime = '7200' + eap_lifetime = '3600' + local_id = 'ipsec.vyos.net' + + name_servers = ['172.16.254.100', '172.16.254.101'] + range_start = '172.16.250.2' + range_stop = '172.16.250.254' + + # IKE + self.cli_set(base_path + ['ike-group', ike_group, 'key-exchange', 'ikev2']) + self.cli_set(base_path + ['ike-group', ike_group, 'lifetime', ike_lifetime]) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'dh-group', '2']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'hash', 'sha256']) + + # ESP + self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime]) + self.cli_set(base_path + ['esp-group', esp_group, 'pfs', 'disable']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'hash', 'sha384']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'hash', 'sha1']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'hash', 'sha256']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-id', local_id]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-users', 'username', username, 'password', password]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'server-mode', 'x509']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'certificate', peer_name]) + # verify() - CA cert required for x509 auth + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'ca-certificate', ca_name]) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'esp-group', esp_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'ike-group', ike_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'local-address', local_address]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name]) + + for ns in name_servers: + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', ns]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'start', range_start]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'stop', range_stop]) + + self.cli_commit() + + # verify applied configuration + swanctl_conf = read_file(swanctl_file) + swanctl_lines = [ + f'{conn_name}', + f'remote_addrs = %any', + f'local_addrs = {local_address}', + f'proposals = aes256-sha512-modp2048,aes256-sha256-modp2048,aes256-sha256-modp1024,aes128gcm128-sha256-modp2048', + f'version = 2', + f'send_certreq = no', + f'rekey_time = {ike_lifetime}s', + f'keyingtries = 0', + f'pools = {ip_pool_name}', + f'id = "{local_id}"', + f'auth = pubkey', + f'certs = peer1.pem', + f'auth = eap-mschapv2', + f'eap_id = %any', + f'esp_proposals = aes256-sha512,aes256-sha384,aes256-sha256,aes256-sha1,aes128gcm128-sha256', + f'life_time = {eap_lifetime}s', + f'dpd_action = clear', + f'replay_window = 32', + f'inactivity = 28800', + f'local_ts = 0.0.0.0/0,::/0', + ] + for line in swanctl_lines: + self.assertIn(line, swanctl_conf) + + swanctl_secrets_lines = [ + f'eap-{conn_name}-{username}', + f'secret = "{password}"', + f'id-{conn_name}-{username} = "{username}"', + ] + for line in swanctl_secrets_lines: + self.assertIn(line, swanctl_conf) + + swanctl_pool_lines = [ + f'{ip_pool_name}', + f'addrs = {range_start}-{range_stop}', + f'dns = {",".join(name_servers)}', + ] + for line in swanctl_pool_lines: + self.assertIn(line, swanctl_conf) + + # Check Root CA, Intermediate CA and Peer cert/key pair is present + 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'))) + + self.tearDownPKI() + + def test_remote_access_vti(self): + # Set up and use a VTI interface for the remote access VPN + self.setupPKI() + + ike_group = 'IKE-RW' + esp_group = 'ESP-RW' + + conn_name = 'vyos-rw' + local_address = '192.0.2.1' + vti = 'vti10' + ip_pool_name = 'ra-rw-ipv4' + username = 'vyos' + password = 'secret' + ike_lifetime = '7200' + eap_lifetime = '3600' + local_id = 'ipsec.vyos.net' + + name_servers = ['10.1.1.1'] + range_start = '10.1.1.10' + range_stop = '10.1.1.254' + + # VTI interface + self.cli_set(vti_path + [vti, 'address', '10.1.1.1/24']) + + # IKE + self.cli_set(base_path + ['ike-group', ike_group, 'key-exchange', 'ikev2']) + self.cli_set(base_path + ['ike-group', ike_group, 'lifetime', ike_lifetime]) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'dh-group', '2']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'hash', 'sha256']) + + # ESP + self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime]) + self.cli_set(base_path + ['esp-group', esp_group, 'pfs', 'disable']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'hash', 'sha384']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'hash', 'sha1']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'hash', 'sha256']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-id', local_id]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-users', 'username', username, 'password', password]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'server-mode', 'x509']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'certificate', peer_name]) + # verify() - CA cert required for x509 auth + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'ca-certificate', ca_name]) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'esp-group', esp_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'ike-group', ike_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'bind', vti]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'local-address', local_address]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name]) + + for ns in name_servers: + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', ns]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'start', range_start]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'stop', range_stop]) + + self.cli_commit() + + # verify applied configuration + swanctl_conf = read_file(swanctl_file) + + if_id = vti.lstrip('vti') + # The key defaults to 0 and will match any policies which similarly do + # not have a lookup key configuration - thus we shift the key by one + # to also support a vti0 interface + if_id = str(int(if_id) +1) + + swanctl_lines = [ + f'{conn_name}', + f'remote_addrs = %any', + f'local_addrs = {local_address}', + f'proposals = aes256-sha512-modp2048,aes256-sha256-modp2048,aes256-sha256-modp1024,aes128gcm128-sha256-modp2048', + f'version = 2', + f'send_certreq = no', + f'rekey_time = {ike_lifetime}s', + f'keyingtries = 0', + f'pools = {ip_pool_name}', + f'id = "{local_id}"', + f'auth = pubkey', + f'certs = peer1.pem', + f'auth = eap-mschapv2', + f'eap_id = %any', + f'esp_proposals = aes256-sha512,aes256-sha384,aes256-sha256,aes256-sha1,aes128gcm128-sha256', + f'life_time = {eap_lifetime}s', + f'dpd_action = clear', + f'replay_window = 32', + f'if_id_in = {if_id}', # will be 11 for vti10 - shifted by one + f'if_id_out = {if_id}', + f'inactivity = 28800', + f'local_ts = 0.0.0.0/0,::/0', + ] + for line in swanctl_lines: + self.assertIn(line, swanctl_conf) + + swanctl_secrets_lines = [ + f'eap-{conn_name}-{username}', + f'secret = "{password}"', + f'id-{conn_name}-{username} = "{username}"', + ] + for line in swanctl_secrets_lines: + self.assertIn(line, swanctl_conf) + + swanctl_pool_lines = [ + f'{ip_pool_name}', + f'addrs = {range_start}-{range_stop}', + f'dns = {",".join(name_servers)}', + ] + for line in swanctl_pool_lines: + self.assertIn(line, swanctl_conf) + + # Check Root CA, Intermediate CA and Peer cert/key pair is present + 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__': unittest.main(verbosity=2) diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index cf82b767f..e8a0bc414 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -21,12 +21,16 @@ 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 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 @@ -47,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() @@ -104,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']) @@ -121,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): @@ -249,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 @@ -304,6 +336,16 @@ 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: + vti_interface = ra_conf['bind'] + if not interface_exists(vti_interface): + raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!') + + 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']): raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\ @@ -330,26 +372,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(): @@ -420,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 @@ -438,14 +528,12 @@ 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!') + 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}") @@ -623,9 +711,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) |