diff options
23 files changed, 283 insertions, 158 deletions
diff --git a/data/templates/dhcp-client/override.conf.j2 b/data/templates/dhcp-client/override.conf.j2 index d09320270..c2e059c7b 100644 --- a/data/templates/dhcp-client/override.conf.j2 +++ b/data/templates/dhcp-client/override.conf.j2 @@ -3,9 +3,6 @@ {% set if_metric = '-e IF_METRIC=' ~ dhcp_options.default_route_distance if dhcp_options.default_route_distance is vyos_defined else '' %} {% set dhclient_options = '-d -nw -cf ' ~ isc_dhclient_dir ~ '/dhclient_' ~ ifname ~ '.conf -pf ' ~ isc_dhclient_dir ~ '/dhclient_' ~ ifname ~ '.pid -lf ' ~ isc_dhclient_dir ~ '/dhclient_' ~ ifname ~ '.leases ' ~ if_metric %} -[Unit] -ConditionPathExists={{ isc_dhclient_dir }}/dhclient_%i.conf - [Service] ExecStart= ExecStart={{ vrf_command }}/sbin/dhclient -4 {{ dhclient_options }} {{ ifname }} diff --git a/data/templates/high-availability/keepalived.conf.j2 b/data/templates/high-availability/keepalived.conf.j2 index 240161748..c0d66ae54 100644 --- a/data/templates/high-availability/keepalived.conf.j2 +++ b/data/templates/high-availability/keepalived.conf.j2 @@ -138,8 +138,8 @@ vrrp_instance {{ name }} { {% endif %} {% if group_config.excluded_address is vyos_defined %} virtual_ipaddress_excluded { -{% for addr in group_config.excluded_address %} - {{ addr }} +{% for addr, addr_config in group_config.excluded_address.items() %} + {{ addr }}{{ ' dev ' + addr_config.interface if addr_config.interface is vyos_defined }} {% endfor %} } {% endif %} diff --git a/data/templates/ipsec/swanctl/remote_access.j2 b/data/templates/ipsec/swanctl/remote_access.j2 index bce8684fe..af7f2994e 100644 --- a/data/templates/ipsec/swanctl/remote_access.j2 +++ b/data/templates/ipsec/swanctl/remote_access.j2 @@ -4,7 +4,7 @@ {% set esp = esp_group[rw_conf.esp_group] %} ra-{{ name }} { remote_addrs = %any - local_addrs = {{ rw_conf.local_address if rw_conf.local_address is vyos_defined else '%any' }} + local_addrs = {{ rw_conf.local_address if rw_conf.local_address is not vyos_defined('any') else '%any' }} # dhcp:{{ rw_conf.dhcp_interface if rw_conf.dhcp_interface is vyos_defined else 'no' }} proposals = {{ ike_group[rw_conf.ike_group] | get_esp_ike_cipher | join(',') }} version = {{ ike.key_exchange[4:] if ike.key_exchange is vyos_defined else "0" }} send_certreq = no diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index 74fd229b4..0e6e3c863 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -71,6 +71,7 @@ if ! grep -q '^tacacs' /etc/passwd; then adduser --quiet tacacs${level} sudo adduser --quiet tacacs${level} disk adduser --quiet tacacs${level} frr + adduser --quiet tacacs${level} _kea fi level=$(( level+1 )) done 2>&1 | grep -v "User tacacs${level} already exists" @@ -102,6 +103,7 @@ if ! grep -q '^radius_priv_user' /etc/passwd; then adduser --quiet radius_priv_user disk adduser --quiet radius_priv_user users adduser --quiet radius_priv_user frr + adduser --quiet radius_priv_user _kea fi # add hostsd group for vyos-hostsd diff --git a/interface-definitions/high-availability.xml.in b/interface-definitions/high-availability.xml.in index 558404882..7108aa06c 100644 --- a/interface-definitions/high-availability.xml.in +++ b/interface-definitions/high-availability.xml.in @@ -294,25 +294,34 @@ #include <include/generic-interface-broadcast.xml.i> </children> </tagNode> - <leafNode name="excluded-address"> + <tagNode name="excluded-address"> <properties> <help>Virtual address (If you need additional IPv4 and IPv6 in same group)</help> <valueHelp> + <format>ipv4net</format> + <description>IPv4 address and prefix length</description> + </valueHelp> + <valueHelp> + <format>ipv6net</format> + <description>IPv6 address and prefix length</description> + </valueHelp> + <valueHelp> <format>ipv4</format> - <description>IP address</description> + <description>IPv4 address</description> </valueHelp> <valueHelp> <format>ipv6</format> <description>IPv6 address</description> </valueHelp> - <multi/> <constraint> - <validator name="ipv4-host"/> - <validator name="ipv6-host"/> + <validator name="ip-host"/> + <validator name="ip-address"/> </constraint> - <constraintErrorMessage>Virtual address must be a valid IPv4 or IPv6 address with prefix length (e.g. 192.0.2.3/24 or 2001:db8:ff::10/64)</constraintErrorMessage> </properties> - </leafNode> + <children> + #include <include/generic-interface-broadcast.xml.i> + </children> + </tagNode> <leafNode name="vrid"> <properties> <help>Virtual router identifier</help> diff --git a/interface-definitions/include/ipsec/authentication-x509.xml.i b/interface-definitions/include/ipsec/authentication-x509.xml.i index db675c0bf..1d04c94ba 100644 --- a/interface-definitions/include/ipsec/authentication-x509.xml.i +++ b/interface-definitions/include/ipsec/authentication-x509.xml.i @@ -5,7 +5,7 @@ </properties> <children> #include <include/pki/certificate-key.xml.i> - #include <include/pki/ca-certificate.xml.i> + #include <include/pki/ca-certificate-multi.xml.i> </children> </node> <!-- include end --> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index 44ca1c7a0..833019d68 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -825,6 +825,7 @@ #include <include/ipsec/esp-group.xml.i> #include <include/ipsec/ike-group.xml.i> #include <include/ipsec/local-address.xml.i> + #include <include/dhcp-interface.xml.i> #include <include/ipsec/local-traffic-selector.xml.i> #include <include/ipsec/replay-window.xml.i> <leafNode name="timeout"> diff --git a/op-mode-definitions/dhcp.xml.in b/op-mode-definitions/dhcp.xml.in index 3c42c8e8f..0b4a05ffe 100644 --- a/op-mode-definitions/dhcp.xml.in +++ b/op-mode-definitions/dhcp.xml.in @@ -293,7 +293,7 @@ <script>${vyos_completion_dir}/list_interfaces</script> </completionHelp> </properties> - <command>sudo systemctl restart "dhclient@$4.service"</command> + <command>sudo ${vyos_op_scripts_dir}/dhcp.py renew_client_lease --family inet --interface "$4"</command> </tagNode> </children> </node> @@ -309,7 +309,7 @@ <script>${vyos_completion_dir}/list_interfaces</script> </completionHelp> </properties> - <command>sudo systemctl restart "dhcp6c@$4.service"</command> + <command>sudo ${vyos_op_scripts_dir}/dhcp.py renew_client_lease --family inet6 --interface "$4"</command> </tagNode> </children> </node> diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 4111d7271..cb9f0cbb8 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -633,7 +633,7 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): Return a dictionary with the necessary interface config keys. """ - from vyos.utils.system import get_half_cpus + from vyos.cpu import get_core_count from vyos.template import is_ipv4 dict = config.get_config_dict(base, key_mangling=('-', '_'), @@ -643,7 +643,7 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): with_pki=with_pki) # set CPUs cores to process requests - dict.update({'thread_count' : get_half_cpus()}) + dict.update({'thread_count' : get_core_count()}) # we need to store the path to the secrets file dict.update({'chap_secrets_file' : chap_secrets}) diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index e1af1a682..8dab9a4ca 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -81,7 +81,7 @@ class InternalError(Error): def _is_op_mode_function_name(name): - if re.match(r"^(show|clear|reset|restart|add|update|delete|generate|set)", name): + if re.match(r"^(show|clear|reset|restart|add|update|delete|generate|set|renew)", name): return True else: return False diff --git a/python/vyos/system/grub.py b/python/vyos/system/grub.py index 864ed65aa..5ab9d76f0 100644 --- a/python/vyos/system/grub.py +++ b/python/vyos/system/grub.py @@ -56,7 +56,7 @@ REGEX_KERNEL_CMDLINE: str = r'^BOOT_IMAGE=/(?P<boot_type>boot|live)/((?P<image_v REGEX_GRUB_BOOT_OPTS: str = r'^\s*set boot_opts="(?P<boot_opts>[^$]+)"$' -def install(drive_path: str, boot_dir: str, efi_dir: str, id: str = 'VyOS') -> None: +def install(drive_path: str, boot_dir: str, efi_dir: str, id: str = 'VyOS', chroot : str = "") -> None: """Install GRUB for both BIOS and EFI modes (hybrid boot) Args: @@ -65,17 +65,22 @@ def install(drive_path: str, boot_dir: str, efi_dir: str, id: str = 'VyOS') -> N efi_dir (str): a path to '/boot/efi' directory """ + if chroot: + chroot_cmd = f"chroot {chroot}" + else: + chroot_cmd = "" + efi_installation_arch = "x86_64" if platform.machine() == "aarch64": efi_installation_arch = "arm64" elif platform.machine() == "x86_64": cmd( - f'grub-install --no-floppy --target=i386-pc \ + f'{chroot_cmd} grub-install --no-floppy --target=i386-pc \ --boot-directory={boot_dir} {drive_path} --force' ) cmd( - f'grub-install --no-floppy --recheck --target={efi_installation_arch}-efi \ + f'{chroot_cmd} grub-install --no-floppy --recheck --target={efi_installation_arch}-efi \ --force-extra-removable --boot-directory={boot_dir} \ --efi-directory={efi_dir} --bootloader-id="{id}" \ --no-uefi-secure-boot' @@ -374,7 +379,7 @@ def create_structure(root_dir: str = '') -> None: if not root_dir: root_dir = disk.find_persistence() - Path(f'{root_dir}/GRUB_DIR_VYOS_VERS').mkdir(parents=True, exist_ok=True) + Path(f'{root_dir}/{GRUB_DIR_VYOS_VERS}').mkdir(parents=True, exist_ok=True) def set_console_type(console_type: str, root_dir: str = '') -> None: @@ -458,3 +463,4 @@ def sort_inodes(dir_path: str) -> None: for item in temp_list_new: new_name = Path(f'{item.as_posix()[0:-4]}') item.rename(new_name) + diff --git a/python/vyos/template.py b/python/vyos/template.py index 19a469c8f..1aa9ace8b 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-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 @@ -32,8 +32,21 @@ _TESTS = {} # reuse Environments with identical settings to improve performance @functools.lru_cache(maxsize=2) def _get_environment(location=None): + from os import getenv + if location is None: - loc_loader=FileSystemLoader(directories["templates"]) + # Sometimes functions that rely on templates need to be executed outside of VyOS installations: + # for example, installer functions are executed for image builds, + # and anything may be invoked for testing from a developer's machine. + # This environment variable allows running any unmodified code + # with a custom template location. + location_env_var = getenv("VYOS_TEMPLATE_DIR") + if location_env_var: + print(f"Using environment variable {location_env_var}") + template_dir = location_env_var + else: + template_dir = directories["templates"] + loc_loader=FileSystemLoader(template_dir) else: loc_loader=FileSystemLoader(location) env = Environment( diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py index 5d41c0c05..55813a5f7 100644 --- a/python/vyos/utils/system.py +++ b/python/vyos/utils/system.py @@ -79,13 +79,6 @@ def sysctl_apply(sysctl_dict: dict[str, str], revert: bool = True) -> bool: # everything applied return True -def get_half_cpus(): - """ return 1/2 of the numbers of available CPUs """ - cpu = os.cpu_count() - if cpu > 1: - cpu /= 2 - return int(cpu) - def find_device_file(device): """ Recurively search /dev for the given device file and return its full path. If no device file was found 'None' is returned """ diff --git a/smoketest/scripts/cli/base_accel_ppp_test.py b/smoketest/scripts/cli/base_accel_ppp_test.py index ac4bbcfe5..cc27cfbe9 100644 --- a/smoketest/scripts/cli/base_accel_ppp_test.py +++ b/smoketest/scripts/cli/base_accel_ppp_test.py @@ -21,7 +21,7 @@ from configparser import ConfigParser from vyos.configsession import ConfigSession from vyos.configsession import ConfigSessionError from vyos.template import is_ipv4 -from vyos.utils.system import get_half_cpus +from vyos.cpu import get_core_count from vyos.utils.process import process_named_running from vyos.utils.process import cmd @@ -132,7 +132,7 @@ class BasicAccelPPPTest: return out def verify(self, conf): - self.assertEqual(conf["core"]["thread-count"], str(get_half_cpus())) + self.assertEqual(conf["core"]["thread-count"], str(get_core_count())) def test_accel_name_servers(self): # Verify proper Name-Server configuration for IPv4 and IPv6 diff --git a/smoketest/scripts/cli/test_protocols_bgp.py b/smoketest/scripts/cli/test_protocols_bgp.py index 5f238b25a..e8556cf44 100755 --- a/smoketest/scripts/cli/test_protocols_bgp.py +++ b/smoketest/scripts/cli/test_protocols_bgp.py @@ -1241,6 +1241,13 @@ class TestProtocolsBGP(VyOSUnitTestSHIM.TestCase): with self.assertRaises(ConfigSessionError) as e: self.cli_commit() + self.cli_set(base_path + ['peer-group', 'peer1', 'remote-as', 'internal']) + self.cli_commit() + + conf = self.getFRRconfig(' address-family l2vpn evpn') + + self.assertIn('neighbor peer1 route-reflector-client', conf) + def test_bgp_99_bmp(self): target_name = 'instance-bmp' target_address = '127.0.0.1' diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index 09e10a2c4..ab832e91e 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -31,7 +31,7 @@ nhrp_path = ['protocols', 'nhrp'] base_path = ['vpn', 'ipsec'] charon_file = '/etc/strongswan.d/charon.conf' -dhcp_waiting_file = '/tmp/ipsec_dhcp_waiting' +dhcp_interfaces_file = '/tmp/ipsec_dhcp_interfaces' swanctl_file = '/etc/swanctl/swanctl.conf' peer_ip = '203.0.113.45' @@ -178,10 +178,10 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.cli_commit() - self.assertTrue(os.path.exists(dhcp_waiting_file)) + self.assertTrue(os.path.exists(dhcp_interfaces_file)) - dhcp_waiting = read_file(dhcp_waiting_file) - self.assertIn(f'{interface}.{vif}', dhcp_waiting) # Ensure dhcp-failed interface was added for dhclient hook + dhcp_interfaces = read_file(dhcp_interfaces_file) + self.assertIn(f'{interface}.{vif}', dhcp_interfaces) # Ensure dhcp interface was added for dhclient hook self.cli_delete(ethernet_path + [interface, 'vif', vif, 'address']) @@ -413,6 +413,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.cli_set(peer_base_path + ['authentication', 'local-id', peer_name]) self.cli_set(peer_base_path + ['authentication', 'mode', 'x509']) self.cli_set(peer_base_path + ['authentication', 'remote-id', 'peer2']) + self.cli_set(peer_base_path + ['authentication', 'x509', 'ca-certificate', ca_name]) self.cli_set(peer_base_path + ['authentication', 'x509', 'ca-certificate', int_ca_name]) self.cli_set(peer_base_path + ['authentication', 'x509', 'certificate', peer_name]) self.cli_set(peer_base_path + ['connection-type', 'initiate']) @@ -465,8 +466,8 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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'{int_ca_name}_1.pem'))) - self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{int_ca_name}_2.pem'))) + self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{ca_name}.pem'))) + 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() @@ -666,7 +667,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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}_1.pem'))) + 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() @@ -778,7 +779,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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}_1.pem'))) + 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() @@ -893,10 +894,71 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): 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}_1.pem'))) + 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_dhcp_fail_handling(self): + # Skip process check - connection is not created for this test + self.skip_process_check = True + + # Interface for dhcp-interface + self.cli_set(ethernet_path + [interface, 'vif', vif, 'address', 'dhcp']) # Use VLAN to avoid getting IP from qemu dhcp server + + # This is a known to be good configuration for Microsoft Windows 10 and Apple iOS 17 + self.setupPKI() + + ike_group = 'IKE-RW' + esp_group = 'ESP-RW' + + conn_name = 'vyos-rw' + ip_pool_name = 'ra-rw-ipv4' + username = 'vyos' + password = 'secret' + ike_lifetime = '7200' + eap_lifetime = '3600' + local_id = 'ipsec.vyos.net' + + name_server = '172.16.254.100' + prefix = '172.16.250.0/28' + + # 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']) + + # ESP + self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime]) + 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 + ['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]) + 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, 'dhcp-interface', f'{interface}.{vif}']) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', name_server]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'prefix', prefix]) + + self.cli_commit() + + self.assertTrue(os.path.exists(dhcp_interfaces_file)) + + dhcp_interfaces = read_file(dhcp_interfaces_file) + self.assertIn(f'{interface}.{vif}', dhcp_interfaces) # Ensure dhcp interface was added for dhclient hook + + self.cli_delete(ethernet_path + [interface, 'vif', vif, 'address']) + + self.tearDownPKI() + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index f1c59cbde..512fa26e9 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -450,15 +450,15 @@ def verify(bgp): verify_route_map(afi_config['route_map'][tmp], bgp) if 'route_reflector_client' in afi_config: - if 'remote_as' in peer_config and peer_config['remote_as'] != 'internal' and peer_config['remote_as'] != bgp['system_as']: + peer_group_as = peer_config.get('remote_as') + + if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): raise ConfigError('route-reflector-client only supported for iBGP peers') else: if 'peer_group' in peer_config: peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp) - if peer_group_as != None and peer_group_as != 'internal' and peer_group_as != bgp['system_as']: + if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): raise ConfigError('route-reflector-client only supported for iBGP peers') - else: - raise ConfigError('route-reflector-client only supported for iBGP peers') # Throw an error if a peer group is not configured for allow range for prefix in dict_search('listen.range', bgp) or []: diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 388f2a709..0c2f232df 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -32,10 +32,7 @@ from vyos.configverify import verify_interface_exists from vyos.configverify import dynamic_interface_pattern from vyos.defaults import directories from vyos.ifconfig import Interface -from vyos.pki import encode_certificate from vyos.pki import encode_public_key -from vyos.pki import find_chain -from vyos.pki import load_certificate from vyos.pki import load_private_key from vyos.pki import wrap_certificate from vyos.pki import wrap_crl @@ -75,7 +72,7 @@ KEY_PATH = f'{swanctl_dir}/private/' CA_PATH = f'{swanctl_dir}/x509ca/' CRL_PATH = f'{swanctl_dir}/x509crl/' -DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting' +DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_interfaces' def get_config(config=None): if config: @@ -94,6 +91,7 @@ def get_config(config=None): with_recursive_defaults=True, with_pki=True) + ipsec['dhcp_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']) @@ -126,11 +124,11 @@ def verify_pki_x509(pki, x509_conf): if not pki or 'ca' not in pki or 'certificate' not in pki: raise ConfigError(f'PKI is not configured') - ca_cert_name = x509_conf['ca_certificate'] cert_name = x509_conf['certificate'] - if not dict_search_args(pki, 'ca', ca_cert_name, 'certificate'): - raise ConfigError(f'Missing CA certificate on specified PKI CA certificate "{ca_cert_name}"') + for ca_cert_name in x509_conf['ca_certificate']: + if not dict_search_args(pki, 'ca', ca_cert_name, 'certificate'): + raise ConfigError(f'Missing CA certificate on specified PKI CA certificate "{ca_cert_name}"') if not dict_search_args(pki, 'certificate', cert_name, 'certificate'): raise ConfigError(f'Missing certificate on specified PKI certificate "{cert_name}"') @@ -229,6 +227,32 @@ def verify(ipsec): if 'remote_access' in ipsec: if 'connection' in ipsec['remote_access']: for name, ra_conf in ipsec['remote_access']['connection'].items(): + if 'local_address' not in ra_conf and 'dhcp_interface' not in ra_conf: + raise ConfigError(f"Missing local-address or dhcp-interface on remote-access connection {name}") + + if 'dhcp_interface' in ra_conf: + dhcp_interface = ra_conf['dhcp_interface'] + + verify_interface_exists(dhcp_interface) + dhcp_base = directories['isc_dhclient_dir'] + + 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) + + address = get_dhcp_address(dhcp_interface) + count = 0 + while not address and count < dhcp_wait_attempts: + address = get_dhcp_address(dhcp_interface) + count += 1 + sleep(dhcp_wait_sleep) + + if not address: + ipsec['dhcp_no_address'][f'ra_{name}'] = dhcp_interface + print(f"Failed to get address from dhcp-interface on remote-access connection {name} -- skipped") + continue + if 'esp_group' in ra_conf: if 'esp_group' not in ipsec or ra_conf['esp_group'] not in ipsec['esp_group']: raise ConfigError(f"Invalid esp-group on {name} remote-access config") @@ -386,6 +410,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) + address = get_dhcp_address(dhcp_interface) count = 0 while not address and count < dhcp_wait_attempts: @@ -394,7 +420,7 @@ def verify(ipsec): sleep(dhcp_wait_sleep) if not address: - ipsec['dhcp_no_address'][peer] = dhcp_interface + ipsec['dhcp_no_address'][f'peer_{peer}'] = dhcp_interface print(f"Failed to get address from dhcp-interface on site-to-site peer {peer} -- skipped") continue @@ -443,32 +469,24 @@ def cleanup_pki_files(): os.unlink(file_path) def generate_pki_files_x509(pki, x509_conf): - ca_cert_name = x509_conf['ca_certificate'] - ca_cert_data = dict_search_args(pki, 'ca', ca_cert_name, 'certificate') - ca_cert_crls = dict_search_args(pki, 'ca', ca_cert_name, 'crl') or [] - ca_index = 1 - crl_index = 1 + for ca_cert_name in x509_conf['ca_certificate']: + ca_cert_data = dict_search_args(pki, 'ca', ca_cert_name, 'certificate') + ca_cert_crls = dict_search_args(pki, 'ca', ca_cert_name, 'crl') or [] + crl_index = 1 - ca_cert = load_certificate(ca_cert_data) - pki_ca_certs = [load_certificate(ca['certificate']) for ca in pki['ca'].values()] + with open(os.path.join(CA_PATH, f'{ca_cert_name}.pem'), 'w') as f: + f.write(wrap_certificate(ca_cert_data)) - ca_cert_chain = find_chain(ca_cert, pki_ca_certs) + for crl in ca_cert_crls: + with open(os.path.join(CRL_PATH, f'{ca_cert_name}_{crl_index}.pem'), 'w') as f: + f.write(wrap_crl(crl)) + crl_index += 1 cert_name = x509_conf['certificate'] cert_data = dict_search_args(pki, 'certificate', cert_name, 'certificate') key_data = dict_search_args(pki, 'certificate', cert_name, 'private', 'key') protected = 'passphrase' in x509_conf - for ca_cert_obj in ca_cert_chain: - with open(os.path.join(CA_PATH, f'{ca_cert_name}_{ca_index}.pem'), 'w') as f: - f.write(encode_certificate(ca_cert_obj)) - ca_index += 1 - - for crl in ca_cert_crls: - with open(os.path.join(CRL_PATH, f'{ca_cert_name}_{crl_index}.pem'), 'w') as f: - f.write(wrap_crl(crl)) - crl_index += 1 - with open(os.path.join(CERT_PATH, f'{cert_name}.pem'), 'w') as f: f.write(wrap_certificate(cert_data)) @@ -503,9 +521,9 @@ def generate(ipsec): render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes}) return - if ipsec['dhcp_no_address']: + if ipsec['dhcp_interfaces']: with open(DHCP_HOOK_IFLIST, 'w') as f: - f.write(" ".join(ipsec['dhcp_no_address'].values())) + f.write(" ".join(ipsec['dhcp_interfaces'])) elif os.path.exists(DHCP_HOOK_IFLIST): os.unlink(DHCP_HOOK_IFLIST) @@ -522,13 +540,23 @@ def generate(ipsec): if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']: for rw, rw_conf in ipsec['remote_access']['connection'].items(): + if f'ra_{rw}' in ipsec['dhcp_no_address']: + continue + + local_ip = '' + if 'local_address' in rw_conf: + local_ip = rw_conf['local_address'] + elif 'dhcp_interface' in rw_conf: + local_ip = get_dhcp_address(rw_conf['dhcp_interface']) + + ipsec['remote_access']['connection'][rw]['local_address'] = local_ip if 'authentication' in rw_conf and 'x509' in rw_conf['authentication']: generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509']) 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 peer in ipsec['dhcp_no_address']: + if f'peer_{peer}' in ipsec['dhcp_no_address']: continue if peer_conf['authentication']['mode'] == 'x509': diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook index e6edc1ac3..ebb100e8b 100755 --- a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook +++ b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook @@ -14,69 +14,32 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -if [ "$reason" == "REBOOT" ] || [ "$reason" == "EXPIRE" ]; then - return 0 -fi - -DHCP_HOOK_IFLIST="/tmp/ipsec_dhcp_waiting" +DHCP_HOOK_IFLIST="/tmp/ipsec_dhcp_interfaces" -if [ -f $DHCP_HOOK_IFLIST ] && [ "$reason" == "BOUND" ]; then - if grep -qw $interface $DHCP_HOOK_IFLIST; then - sudo rm $DHCP_HOOK_IFLIST - sudo /usr/libexec/vyos/conf_mode/vpn_ipsec.py - return 0 - fi +if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then + exit 0 fi -if [ "$old_ip_address" == "$new_ip_address" ] && [ "$reason" == "BOUND" ]; then - return 0 +# Re-generate the config on the following events: +# - BOUND: always re-generate +# - RENEW: re-generate if the IP address changed +# - REBIND: re-generate if the IP address changed +if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then + if [ "$old_ip_address" == "$new_ip_address" ]; then + exit 0 + fi +elif [ "$reason" != "BOUND" ]; then + exit 0 fi -python3 - <<PYEND -import os -import re - -from vyos.utils.process import call -from vyos.utils.process import cmd -from vyos.utils.file import read_file -from vyos.utils.file import write_file - -SWANCTL_CONF="/etc/swanctl/swanctl.conf" - -def ipsec_down(ip_address): - # This prevents the need to restart ipsec and kill all active connections, only the stale connection is closed - status = cmd('sudo ipsec statusall') - connection_name = None - for line in status.split("\n"): - if line.find(ip_address) > 0: - regex_match = re.search(r'(peer_[^:\[]+)', line) - if regex_match: - connection_name = regex_match[1] - break - if connection_name: - call(f'sudo ipsec down {connection_name}') +# Best effort wait for any active commit to finish +sudo python3 - <<PYEND +from vyos.utils.commit import wait_for_commit_lock if __name__ == '__main__': - interface = os.getenv('interface') - new_ip = os.getenv('new_ip_address') - old_ip = os.getenv('old_ip_address') - - if os.path.exists(SWANCTL_CONF): - conf_lines = read_file(SWANCTL_CONF) - found = False - to_match = f'# dhcp:{interface}' - - for i, line in enumerate(conf_lines): - if line.find(to_match) > 0: - conf_lines[i] = line.replace(old_ip, new_ip) - found = True - - if found: - write_file(SWANCTL_CONF, conf_lines) - ipsec_down(old_ip) - call('sudo ipsec rereadall') - call('sudo ipsec reload') - call('sudo swanctl -q') - + wait_for_commit_lock() exit(0) PYEND + +# Now re-generate the config +sudo /usr/libexec/vyos/conf_mode/vpn_ipsec.py diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index d27e1baf7..f6029c748 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright (C) 2022-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -33,6 +33,7 @@ from vyos.kea import kea_get_leases from vyos.kea import kea_get_pool_from_subnet_id from vyos.kea import kea_delete_lease from vyos.utils.process import is_systemd_service_running +from vyos.utils.process import call time_string = "%a %b %d %H:%M:%S %Z %Y" @@ -79,14 +80,20 @@ def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], orig :return list """ inet_suffix = '6' if family == 'inet6' else '4' - leases = kea_get_leases(inet_suffix) + try: + leases = kea_get_leases(inet_suffix) + except: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server lease information') if pool is None: pool = _get_dhcp_pools(family=family) else: pool = [pool] - active_config = kea_get_active_config(inet_suffix) + try: + active_config = kea_get_active_config(inet_suffix) + except: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') data = [] for lease in leases: @@ -309,6 +316,25 @@ def _verify(func): return func(*args, **kwargs) return _wrapper +def _verify_client(func): + """Decorator checks if interface is configured as DHCP client""" + from functools import wraps + from vyos.ifconfig import Section + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + family = kwargs.get('family') + v = 'v6' if family == 'inet6' else '' + interface = kwargs.get('interface') + interface_path = Section.get_config_path(interface) + unconf_message = f'DHCP{v} client not configured on interface {interface}!' + + # Check if config does not exist + if not config.exists(f'interfaces {interface_path} address dhcp{v}'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + return _wrapper @_verify def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str]): @@ -474,6 +500,16 @@ def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[ else: return _get_formatted_client_leases(lease_data, family=family) +@_verify_client +def renew_client_lease(raw: bool, family: ArgFamily, interface: str): + if not raw: + v = 'v6' if family == 'inet6' else '' + print(f'Restarting DHCP{v} client on interface {interface}...') + if family == 'inet6': + call(f'systemctl restart dhcp6c@{interface}.service') + else: + call(f'systemctl restart dhclient@{interface}.service') + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index 1510a667c..1cfb5f5a1 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -33,27 +33,31 @@ DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:' MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first' MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' -def annotated_list(images_list: list[str]) -> list[str]: +def annotate_list(images_list: list[str]) -> list[str]: """Annotate list of images with additional info Args: images_list (list[str]): a list of image names Returns: - list[str]: a list of image names with additional info + dict[str, str]: a dict of annotations indexed by image name """ - index_running: int = None - index_default: int = None - try: - index_running = images_list.index(image.get_running_image()) - index_default = images_list.index(image.get_default_image()) - except ValueError: - pass - if index_running is not None: - images_list[index_running] += ' (running)' - if index_default is not None: - images_list[index_default] += ' (default boot)' - return images_list + running = image.get_running_image() + default = image.get_default_image() + annotated = {} + for image_name in images_list: + annotated[image_name] = f'{image_name}' + if running in images_list: + annotated[running] = annotated[running] + ' (running)' + if default in images_list: + annotated[default] = annotated[default] + ' (default boot)' + return annotated + +def define_format(images): + annotated = annotate_list(images) + def format_selection(image_name): + return annotated[image_name] + return format_selection @compat.grub_cfg_update def delete_image(image_name: Optional[str] = None, @@ -63,14 +67,16 @@ def delete_image(image_name: Optional[str] = None, Args: image_name (str): a name of image to delete """ - available_images: list[str] = annotated_list(grub.version_list()) + available_images: list[str] = grub.version_list() + format_selection = define_format(available_images) if image_name is None: if no_prompt: exit('An image name is required for delete action') else: image_name = select_entry(available_images, DELETE_IMAGE_LIST_MSG, - DELETE_IMAGE_PROMPT_MSG) + DELETE_IMAGE_PROMPT_MSG, + format_selection) if image_name == image.get_running_image(): exit(MSG_DELETE_IMAGE_RUNNING) if image_name == image.get_default_image(): @@ -113,14 +119,16 @@ def set_image(image_name: Optional[str] = None, Args: image_name (str): an image name """ - available_images: list[str] = annotated_list(grub.version_list()) + available_images: list[str] = grub.version_list() + format_selection = define_format(available_images) if image_name is None: if not prompt: exit('An image name is required for set action') else: image_name = select_entry(available_images, SET_IMAGE_LIST_MSG, - SET_IMAGE_PROMPT_MSG) + SET_IMAGE_PROMPT_MSG, + format_selection) if image_name == image.get_default_image(): exit(f'The image "{image_name}" already configured as default') if image_name not in available_images: diff --git a/src/op_mode/openvpn.py b/src/op_mode/openvpn.py index fd9d2db92..d54a67199 100755 --- a/src/op_mode/openvpn.py +++ b/src/op_mode/openvpn.py @@ -205,11 +205,11 @@ def _format_openvpn(data: list) -> str: intf = d['intf'] l_host = d['local_host'] l_port = d['local_port'] + out += f'\nOpenVPN status on {intf}\n\n' for client in d['clients']: r_host = client['remote_host'] r_port = client['remote_port'] - out += f'\nOpenVPN status on {intf}\n\n' name = client['name'] remote = r_host + ':' + r_port if r_host and r_port else 'N/A' tunnel = client['tunnel'] @@ -220,9 +220,8 @@ def _format_openvpn(data: list) -> str: data_out.append([name, remote, tunnel, local, tx_bytes, rx_bytes, online_since]) - if data_out: - out += tabulate(data_out, headers) - out += "\n" + out += tabulate(data_out, headers) + out += "\n" return out diff --git a/src/systemd/dhclient@.service b/src/systemd/dhclient@.service index 099f7ed52..d430d8868 100644 --- a/src/systemd/dhclient@.service +++ b/src/systemd/dhclient@.service @@ -3,6 +3,7 @@ Description=DHCP client on %i Documentation=man:dhclient(8) StartLimitIntervalSec=0 After=vyos-router.service +ConditionPathExists=/run/dhclient/dhclient_%i.conf [Service] Type=exec |