diff options
27 files changed, 669 insertions, 59 deletions
diff --git a/.github/workflows/lint-with-darker-ruff.yml b/.github/workflows/lint-with-darker-ruff.yml new file mode 100644 index 000000000..01f7cd448 --- /dev/null +++ b/.github/workflows/lint-with-darker-ruff.yml @@ -0,0 +1,14 @@ +name: Lint py code with darker and ruff +on: + pull_request_target: + branches: + - current + +permissions: + pull-requests: write + contents: read + +jobs: + darker-ruff-lint: + uses: vyos/.github/.github/workflows/lint-with-darker-ruff.yml@current + secrets: inherit diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 4619361e5..1dde66ebf 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -48,7 +48,7 @@ server { ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; # proxy settings for HTTP API, if enabled; 503, if not - location ~ ^/(retrieve|configure|config-file|image|container-image|generate|show|reboot|reset|poweroff|docs|openapi.json|redoc|graphql) { + location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|docs|openapi.json|redoc|graphql) { {% if api is vyos_defined %} proxy_pass http://unix:/run/api.sock; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/data/templates/openvpn/server.conf.j2 b/data/templates/openvpn/server.conf.j2 index 408103558..be811f45e 100644 --- a/data/templates/openvpn/server.conf.j2 +++ b/data/templates/openvpn/server.conf.j2 @@ -11,11 +11,11 @@ dev-type {{ device_type }} dev {{ ifname }} persist-key {% if protocol is vyos_defined('tcp-active') %} -proto tcp-client +proto tcp{{ protocol_modifier }}-client {% elif protocol is vyos_defined('tcp-passive') %} -proto tcp-server +proto tcp{{ protocol_modifier }}-server {% else %} -proto udp +proto udp{{ protocol_modifier }} {% endif %} {% if local_host is vyos_defined %} local {{ local_host }} @@ -63,6 +63,9 @@ nobind # # OpenVPN Server mode # +{% if ip_version is vyos_defined('ipv6') %} +bind ipv6only +{% endif %} mode server tls-server {% if server is vyos_defined %} @@ -131,6 +134,9 @@ plugin "{{ plugin_dir }}/openvpn-otp.so" "otp_secrets=/config/auth/openvpn/{{ if # # OpenVPN site-2-site mode # +{% if ip_version is vyos_defined('ipv6') %} +bind ipv6only +{% endif %} ping {{ keep_alive.interval }} ping-restart {{ keep_alive.failure_count }} diff --git a/data/templates/router-advert/radvd.conf.j2 b/data/templates/router-advert/radvd.conf.j2 index 97180d164..a83bd03ac 100644 --- a/data/templates/router-advert/radvd.conf.j2 +++ b/data/templates/router-advert/radvd.conf.j2 @@ -19,7 +19,7 @@ interface {{ iface }} { {% if iface_config.reachable_time is vyos_defined %} AdvReachableTime {{ iface_config.reachable_time }}; {% endif %} - AdvIntervalOpt {{ 'off' if iface_config.no_send_advert is vyos_defined else 'on' }}; + AdvIntervalOpt {{ 'off' if iface_config.no_send_interval is vyos_defined else 'on' }}; AdvSendAdvert {{ 'off' if iface_config.no_send_advert is vyos_defined else 'on' }}; {% if iface_config.default_lifetime is vyos_defined %} AdvDefaultLifetime {{ iface_config.default_lifetime }}; diff --git a/interface-definitions/interfaces_openvpn.xml.in b/interface-definitions/interfaces_openvpn.xml.in index 3563caef2..3c844107e 100644 --- a/interface-definitions/interfaces_openvpn.xml.in +++ b/interface-definitions/interfaces_openvpn.xml.in @@ -318,6 +318,34 @@ </properties> <defaultValue>udp</defaultValue> </leafNode> + <leafNode name="ip-version"> + <properties> + <help>Force OpenVPN to use a specific IP protocol version</help> + <completionHelp> + <list>auto ipv4 ipv6 dual-stack</list> + </completionHelp> + <valueHelp> + <format>auto</format> + <description>Select one IP protocol to use based on local or remote host</description> + </valueHelp> + <valueHelp> + <format>_ipv4</format> + <description>Accept connections on or initate connections to IPv4 addresses only</description> + </valueHelp> + <valueHelp> + <format>_ipv6</format> + <description>Accept connections on or initate connections to IPv6 addresses only</description> + </valueHelp> + <valueHelp> + <format>dual-stack</format> + <description>Accept connections on both protocols simultaneously (only supported in server mode)</description> + </valueHelp> + <constraint> + <regex>(auto|ipv4|ipv6|dual-stack)</regex> + </constraint> + </properties> + <defaultValue>auto</defaultValue> + </leafNode> <leafNode name="remote-address"> <properties> <help>IP address of remote end of tunnel</help> diff --git a/interface-definitions/service_router-advert.xml.in b/interface-definitions/service_router-advert.xml.in index 166a4a0cf..3fd33540a 100644 --- a/interface-definitions/service_router-advert.xml.in +++ b/interface-definitions/service_router-advert.xml.in @@ -390,6 +390,12 @@ <valueless/> </properties> </leafNode> + <leafNode name="no-send-interval"> + <properties> + <help>Do not send Advertisement Interval option in RAs</help> + <valueless/> + </properties> + </leafNode> </children> </tagNode> </children> diff --git a/op-mode-definitions/ntp.xml.in b/op-mode-definitions/ntp.xml.in index 17250a45e..565a5edb5 100644 --- a/op-mode-definitions/ntp.xml.in +++ b/op-mode-definitions/ntp.xml.in @@ -6,25 +6,25 @@ <properties> <help>Show peer status of NTP daemon</help> </properties> - <command>${vyos_op_scripts_dir}/ntp.py show_sourcestats</command> + <command>sudo ${vyos_op_scripts_dir}/ntp.py show_sourcestats</command> <children> <node name="activity"> <properties> <help>Report the number of servers and peers that are online and offline</help> </properties> - <command>${vyos_op_scripts_dir}/ntp.py show_activity</command> + <command>sudo ${vyos_op_scripts_dir}/ntp.py show_activity</command> </node> <node name="sources"> <properties> <help>Show information about the current time sources being accessed</help> </properties> - <command>${vyos_op_scripts_dir}/ntp.py show_sources</command> + <command>sudo ${vyos_op_scripts_dir}/ntp.py show_sources</command> </node> <node name="system"> <properties> <help>Show parameters about the system clock performance</help> </properties> - <command>${vyos_op_scripts_dir}/ntp.py show_tracking</command> + <command>sudo ${vyos_op_scripts_dir}/ntp.py show_tracking</command> </node> </children> </node> diff --git a/op-mode-definitions/show-interfaces-macsec.xml.in b/op-mode-definitions/show-interfaces-macsec.xml.in index a264ff22e..28264d252 100644 --- a/op-mode-definitions/show-interfaces-macsec.xml.in +++ b/op-mode-definitions/show-interfaces-macsec.xml.in @@ -12,6 +12,14 @@ </completionHelp> </properties> <command>ip macsec show</command> + <children> + <leafNode name="detail"> + <properties> + <help>Show detailed MACsec interface information</help> + </properties> + <command>ip -s macsec show</command> + </leafNode> + </children> </node> <tagNode name="macsec"> <properties> diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py index e0fe1ddac..cf7c9d543 100644 --- a/python/vyos/configdep.py +++ b/python/vyos/configdep.py @@ -95,7 +95,8 @@ def get_dependency_dict(config: 'Config') -> dict: setattr(config, 'cached_dependency_dict', d) return d -def run_config_mode_script(script: str, config: 'Config'): +def run_config_mode_script(target: str, config: 'Config'): + script = target + '.py' path = os.path.join(directories['conf_mode'], script) name = canon_name(script) mod = load_as_module(name, path) @@ -109,15 +110,34 @@ def run_config_mode_script(script: str, config: 'Config'): except (VyOSError, ConfigError) as e: raise ConfigError(str(e)) from e +def run_conditionally(target: str, tagnode: str, config: 'Config'): + tag_ext = f'_{tagnode}' if tagnode else '' + script_name = f'{target}{tag_ext}' + + scripts_called = getattr(config, 'scripts_called', []) + commit_scripts = getattr(config, 'commit_scripts', []) + + debug_print(f'scripts_called: {scripts_called}') + debug_print(f'commit_scripts: {commit_scripts}') + + if script_name in commit_scripts and script_name not in scripts_called: + debug_print(f'dependency {script_name} deferred to priority') + return + + run_config_mode_script(target, config) + def def_closure(target: str, config: 'Config', tagnode: typing.Optional[str] = None) -> typing.Callable: - script = target + '.py' def func_impl(): + tag_value = '' if tagnode is not None: os.environ['VYOS_TAGNODE_VALUE'] = tagnode - run_config_mode_script(script, config) + tag_value = tagnode + run_conditionally(target, tag_value, config) + tag_ext = f'_{tagnode}' if tagnode is not None else '' func_impl.__name__ = f'{target}{tag_ext}' + return func_impl def set_dependents(case: str, config: 'Config', diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py index f975df45d..b6d4a5558 100644 --- a/python/vyos/configdiff.py +++ b/python/vyos/configdiff.py @@ -15,6 +15,7 @@ from enum import IntFlag from enum import auto +from itertools import chain from vyos.config import Config from vyos.configtree import DiffTree @@ -22,7 +23,10 @@ from vyos.configdict import dict_merge from vyos.utils.dict import get_sub_dict from vyos.utils.dict import mangle_dict_keys from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_to_key_paths from vyos.xml_ref import get_defaults +from vyos.xml_ref import owner +from vyos.xml_ref import priority class ConfigDiffError(Exception): """ @@ -94,6 +98,39 @@ def get_config_diff(config, key_mangling=None): return ConfigDiff(config, key_mangling, diff_tree=diff_t, diff_dict=diff_d) +def get_commit_scripts(config) -> list: + """Return the list of config scripts to be executed by commit + + Return a list of the scripts to be called by commit for the proposed + config. The list is ordered by priority for reference, however, the + actual order of execution by the commit algorithm is not reflected + (delete vs. add queue), nor needed for current use. + """ + if not config or not isinstance(config, Config): + raise TypeError("argument must me a Config instance") + + if hasattr(config, 'commit_scripts'): + return getattr(config, 'commit_scripts') + + D = get_config_diff(config) + d = D._diff_dict + s = set() + for p in chain(dict_to_key_paths(d['sub']), dict_to_key_paths(d['add'])): + p_owner = owner(p, with_tag=True) + if not p_owner: + continue + p_priority = priority(p) + if not p_priority: + # default priority in legacy commit-algorithm + p_priority = 0 + p_priority = int(p_priority) + s.add((p_priority, p_owner)) + + res = [x[1] for x in sorted(s, key=lambda x: x[0])] + setattr(config, 'commit_scripts', res) + + return res + class ConfigDiff(object): """ The class of config changes as represented by comparison between the diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index ccf2ce8f2..7d51b94e4 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -34,6 +34,9 @@ MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py'] SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py'] INSTALL_IMAGE = ['/usr/libexec/vyos/op_mode/image_installer.py', '--action', 'add', '--no-prompt', '--image-path'] +IMPORT_PKI = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'import'] +IMPORT_PKI_NO_PROMPT = ['/usr/libexec/vyos/op_mode/pki.py', + '--action', 'import', '--no-prompt'] REMOVE_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py', '--action', 'delete', '--no-prompt', '--image-name'] SET_DEFAULT_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py', @@ -239,6 +242,14 @@ class ConfigSession(object): out = self.__run_command(REMOVE_IMAGE + [name]) return out + def import_pki(self, path): + out = self.__run_command(IMPORT_PKI + path) + return out + + def import_pki_no_prompt(self, path): + out = self.__run_command(IMPORT_PKI_NO_PROMPT + path) + return out + def set_default_image(self, name): out = self.__run_command(SET_DEFAULT_IMAGE + [name]) return out diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 27fe793a8..5a0e2ddda 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -271,7 +271,7 @@ def load_private_key(raw_data, passphrase=None, wrap_tags=True): try: return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase) - except ValueError: + except (ValueError, TypeError): return False def load_openssh_public_key(raw_data, type): diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py index 1eb6abcd5..1a7a6b96f 100644 --- a/python/vyos/utils/dict.py +++ b/python/vyos/utils/dict.py @@ -267,6 +267,7 @@ def dict_to_paths_values(conf: dict) -> dict: dict_of_options[path] = dict_search(path,conf) return dict_of_options + def dict_to_key_paths(d: dict) -> list: """ Generator to return list of key paths from dict of list[str]|str """ diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index 91ce394f7..99d8432d2 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# 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 @@ -54,8 +54,8 @@ def is_valueless(path: list) -> bool: def is_leaf(path: list) -> bool: return load_reference().is_leaf(path) -def owner(path: list) -> str: - return load_reference().owner(path) +def owner(path: list, with_tag=False) -> str: + return load_reference().owner(path, with_tag=with_tag) def priority(path: list) -> str: return load_reference().priority(path) diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py index c85835ffd..5ff28daed 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# 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 @@ -139,28 +139,38 @@ class Xml: ref_path = path.copy() d = self.ref data = '' + tag = '' while ref_path and d: + tag_val = '' d = d.get(ref_path[0], {}) ref_path.pop(0) if self._is_tag_node(d) and ref_path: + tag_val = ref_path[0] ref_path.pop(0) if self._is_leaf_node(d) and ref_path: ref_path.pop(0) res = self._get_ref_node_data(d, name) if res is not None: data = res + tag = tag_val - return data + return data, tag - def owner(self, path: list) -> str: + def owner(self, path: list, with_tag=False) -> str: from pathlib import Path - data = self._least_upper_data(path, 'owner') + data, tag = self._least_upper_data(path, 'owner') + tag_ext = f'_{tag}' if tag else '' if data: - data = Path(data.split()[0]).name + if with_tag: + data = Path(data.split()[0]).stem + data = f'{data}{tag_ext}' + else: + data = Path(data.split()[0]).name return data def priority(self, path: list) -> str: - return self._least_upper_data(path, 'priority') + data, _ = self._least_upper_data(path, 'priority') + return data @staticmethod def _dict_get(d: dict, path: list) -> dict: diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index e93b07974..cd2ac890e 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -13,15 +13,13 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# -# import re import sys import json import glob + from argparse import ArgumentParser -from argparse import ArgumentTypeError from os.path import join from os.path import abspath from os.path import dirname diff --git a/smoketest/scripts/cli/test_config_dependency.py b/smoketest/scripts/cli/test_config_dependency.py index 14e88321a..99e807ac5 100755 --- a/smoketest/scripts/cli/test_config_dependency.py +++ b/smoketest/scripts/cli/test_config_dependency.py @@ -16,13 +16,39 @@ import unittest +from time import sleep -from base_vyostest_shim import VyOSUnitTestSHIM - +from vyos.utils.process import is_systemd_service_running +from vyos.utils.process import cmd from vyos.configsession import ConfigSessionError +from base_vyostest_shim import VyOSUnitTestSHIM + class TestConfigDep(VyOSUnitTestSHIM.TestCase): + @classmethod + def setUpClass(cls): + # smoketests are run without configd in 1.4; with configd in 1.5 + # the tests below check behavior under configd: + # test_configdep_error checks for regression under configd (T6559) + # test_configdep_prio_queue checks resolution under configd (T6671) + cls.running_state = is_systemd_service_running('vyos-configd.service') + + if not cls.running_state: + cmd('sudo systemctl start vyos-configd.service') + # allow time for init + sleep(1) + + super(TestConfigDep, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + super(TestConfigDep, cls).tearDownClass() + + # return to running_state + if not cls.running_state: + cmd('sudo systemctl stop vyos-configd.service') + def test_configdep_error(self): address_group = 'AG' address = '192.168.137.5' @@ -45,5 +71,60 @@ class TestConfigDep(VyOSUnitTestSHIM.TestCase): self.cli_delete(['nat']) self.cli_commit() + def test_configdep_prio_queue(self): + # confirm that that a dependency (in this case, conntrack -> + # conntrack-sync) is not immediately called if the target is + # scheduled in the priority queue, indicating that it may require an + # intermediate activitation (bond0) + bonding_base = ['interfaces', 'bonding'] + bond_interface = 'bond0' + bond_address = '192.0.2.1/24' + vrrp_group_base = ['high-availability', 'vrrp', 'group'] + vrrp_sync_group_base = ['high-availability', 'vrrp', 'sync-group'] + vrrp_group = 'ETH2' + vrrp_sync_group = 'GROUP' + conntrack_sync_base = ['service', 'conntrack-sync'] + conntrack_peer = '192.0.2.77' + + # simple set to trigger in-session conntrack -> conntrack-sync + # dependency; note that this is triggered on boot in 1.4 due to + # default 'system conntrack modules' + self.cli_set(['system', 'conntrack', 'table-size', '524288']) + + self.cli_set(['interfaces', 'ethernet', 'eth2', 'address', + '198.51.100.2/24']) + + self.cli_set(bonding_base + [bond_interface, 'address', + bond_address]) + self.cli_set(bonding_base + [bond_interface, 'member', 'interface', + 'eth3']) + + self.cli_set(vrrp_group_base + [vrrp_group, 'address', + '198.51.100.200/24']) + self.cli_set(vrrp_group_base + [vrrp_group, 'hello-source-address', + '198.51.100.2']) + self.cli_set(vrrp_group_base + [vrrp_group, 'interface', 'eth2']) + self.cli_set(vrrp_group_base + [vrrp_group, 'priority', '200']) + self.cli_set(vrrp_group_base + [vrrp_group, 'vrid', '22']) + self.cli_set(vrrp_sync_group_base + [vrrp_sync_group, 'member', + vrrp_group]) + + self.cli_set(conntrack_sync_base + ['failover-mechanism', 'vrrp', + 'sync-group', vrrp_sync_group]) + + self.cli_set(conntrack_sync_base + ['interface', bond_interface, + 'peer', conntrack_peer]) + + self.cli_commit() + + # clean up + self.cli_delete(bonding_base) + self.cli_delete(vrrp_group_base) + self.cli_delete(vrrp_sync_group_base) + self.cli_delete(conntrack_sync_base) + self.cli_delete(['interfaces', 'ethernet', 'eth2', 'address']) + self.cli_delete(['system', 'conntrack', 'table-size']) + self.cli_commit() + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_interfaces_openvpn.py b/smoketest/scripts/cli/test_interfaces_openvpn.py index d8a091aaa..e087b8735 100755 --- a/smoketest/scripts/cli/test_interfaces_openvpn.py +++ b/smoketest/scripts/cli/test_interfaces_openvpn.py @@ -250,6 +250,67 @@ class TestInterfacesOpenVPN(VyOSUnitTestSHIM.TestCase): interface = f'vtun{ii}' self.assertNotIn(interface, interfaces()) + def test_openvpn_client_ip_version(self): + # Test the client mode behavior combined with different IP protocol versions + + interface = 'vtun10' + remote_host = '192.0.2.10' + remote_host_v6 = 'fd00::2:10' + path = base_path + [interface] + auth_hash = 'sha1' + + # Default behavior: client uses uspecified protocol version (udp) + self.cli_set(path + ['device-type', 'tun']) + self.cli_set(path + ['encryption', 'data-ciphers', 'aes256']) + self.cli_set(path + ['hash', auth_hash]) + self.cli_set(path + ['mode', 'client']) + self.cli_set(path + ['persistent-tunnel']) + self.cli_set(path + ['protocol', 'udp']) + self.cli_set(path + ['remote-host', remote_host]) + self.cli_set(path + ['remote-port', remote_port]) + self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'certificate', 'ovpn_test']) + self.cli_set(path + ['vrf', vrf_name]) + self.cli_set(path + ['authentication', 'username', interface+'user']) + self.cli_set(path + ['authentication', 'password', interface+'secretpw']) + + self.cli_commit() + + config_file = f'/run/openvpn/{interface}.conf' + config = read_file(config_file) + + self.assertIn(f'dev vtun10', config) + self.assertIn(f'dev-type tun', config) + self.assertIn(f'persist-key', config) + self.assertIn(f'proto udp', config) + self.assertIn(f'rport {remote_port}', config) + self.assertIn(f'remote {remote_host}', config) + self.assertIn(f'persist-tun', config) + + # IPv4 only: client usees udp4 protocol + self.cli_set(path + ['ip-version', 'ipv4']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp4', config) + + # IPv6 only: client uses udp6 protocol + self.cli_set(path + ['ip-version', 'ipv6']) + self.cli_delete(path + ['remote-host', remote_host]) + self.cli_set(path + ['remote-host', remote_host_v6]) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp6', config) + + # IPv6 dual-stack: not allowed in client mode + self.cli_set(path + ['ip-version', 'dual-stack']) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path) + self.cli_commit() + def test_openvpn_server_verify(self): # Create one OpenVPN server interface and check required verify() stages interface = 'vtun5000' @@ -453,6 +514,74 @@ class TestInterfacesOpenVPN(VyOSUnitTestSHIM.TestCase): interface = f'vtun{ii}' self.assertNotIn(interface, interfaces()) + def test_openvpn_server_ip_version(self): + # Test the server mode behavior combined with each IP protocol version + + auth_hash = 'sha256' + port = '2000' + + interface = 'vtun20' + subnet = '192.0.20.0/24' + path = base_path + [interface] + + # Default behavior: client uses uspecified protocol version (udp) + self.cli_set(path + ['device-type', 'tun']) + self.cli_set(path + ['encryption', 'data-ciphers', 'aes192']) + self.cli_set(path + ['hash', auth_hash]) + self.cli_set(path + ['mode', 'server']) + self.cli_set(path + ['local-port', port]) + self.cli_set(path + ['server', 'subnet', subnet]) + self.cli_set(path + ['server', 'topology', 'subnet']) + + self.cli_set(path + ['replace-default-route']) + self.cli_set(path + ['tls', 'ca-certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'certificate', 'ovpn_test']) + self.cli_set(path + ['tls', 'dh-params', 'ovpn_test']) + + self.cli_commit() + + start_addr = inc_ip(subnet, '2') + stop_addr = last_host_address(subnet) + + config_file = f'/run/openvpn/{interface}.conf' + config = read_file(config_file) + + self.assertIn(f'dev {interface}', config) + self.assertIn(f'dev-type tun', config) + self.assertIn(f'persist-key', config) + self.assertIn(f'proto udp', config) # default protocol + self.assertIn(f'auth {auth_hash}', config) + self.assertIn(f'data-ciphers AES-192-CBC', config) + self.assertIn(f'topology subnet', config) + self.assertIn(f'lport {port}', config) + self.assertIn(f'push "redirect-gateway def1"', config) + + # IPv4 only: server usees udp4 protocol + self.cli_set(path + ['ip-version', 'ipv4']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp4', config) + + # IPv6 only: server uses udp6 protocol + bind ipv6only + self.cli_set(path + ['ip-version', 'ipv6']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp6', config) + self.assertIn(f'bind ipv6only', config) + + # IPv6 dual-stack: server uses udp6 protocol without bind ipv6only + self.cli_set(path + ['ip-version', 'dual-stack']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp6', config) + self.assertNotIn(f'bind ipv6only', config) + + self.cli_delete(base_path) + self.cli_commit() + def test_openvpn_site2site_verify(self): # Create one OpenVPN site2site interface and check required # verify() stages @@ -627,6 +756,63 @@ class TestInterfacesOpenVPN(VyOSUnitTestSHIM.TestCase): self.assertNotIn(interface, interfaces()) + def test_openvpn_site2site_ip_version(self): + # Test the site-to-site mode behavior combined with each IP protocol version + + encryption_cipher = 'aes256' + + interface = 'vtun30' + local_address = '192.0.30.1' + local_address_subnet = '255.255.255.252' + remote_address = '172.16.30.1' + path = base_path + [interface] + port = '3030' + + self.cli_set(path + ['local-address', local_address]) + self.cli_set(path + ['device-type', 'tun']) + self.cli_set(path + ['mode', 'site-to-site']) + self.cli_set(path + ['local-port', port]) + self.cli_set(path + ['remote-port', port]) + self.cli_set(path + ['shared-secret-key', 'ovpn_test']) + self.cli_set(path + ['remote-address', remote_address]) + self.cli_set(path + ['encryption', 'cipher', encryption_cipher]) + + self.cli_commit() + + config_file = f'/run/openvpn/{interface}.conf' + config = read_file(config_file) + + self.assertIn(f'dev-type tun', config) + self.assertIn(f'ifconfig {local_address} {remote_address}', config) + self.assertIn(f'proto udp', config) + self.assertIn(f'dev {interface}', config) + self.assertIn(f'secret /run/openvpn/{interface}_shared.key', config) + self.assertIn(f'lport {port}', config) + self.assertIn(f'rport {port}', config) + + # IPv4 only: server usees udp4 protocol + self.cli_set(path + ['ip-version', 'ipv4']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp4', config) + + # IPv6 only: server uses udp6 protocol + bind ipv6only + self.cli_set(path + ['ip-version', 'ipv6']) + self.cli_commit() + + config = read_file(config_file) + self.assertIn(f'proto udp6', config) + self.assertIn(f'bind ipv6only', config) + + # IPv6 dual-stack: not allowed in site-to-site mode + self.cli_set(path + ['ip-version', 'dual-stack']) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path) + self.cli_commit() + def test_openvpn_server_server_bridge(self): # Create OpenVPN server interface using bridge. # Validate configuration afterwards. diff --git a/smoketest/scripts/cli/test_service_router-advert.py b/smoketest/scripts/cli/test_service_router-advert.py index d1ff25a58..6dbb6add4 100755 --- a/smoketest/scripts/cli/test_service_router-advert.py +++ b/smoketest/scripts/cli/test_service_router-advert.py @@ -224,5 +224,34 @@ class TestServiceRADVD(VyOSUnitTestSHIM.TestCase): self.assertIn(tmp, config) self.assertIn('AdvValidLifetime 65528;', config) # default + def test_advsendadvert_advintervalopt(self): + ra_src = ['fe80::1', 'fe80::2'] + + self.cli_set(base_path + ['prefix', prefix]) + self.cli_set(base_path + ['no-send-advert']) + # commit changes + self.cli_commit() + + # Verify generated configuration + config = read_file(RADVD_CONF) + tmp = get_config_value('AdvSendAdvert') + self.assertEqual(tmp, 'off') + + tmp = get_config_value('AdvIntervalOpt') + self.assertEqual(tmp, 'on') + + self.cli_set(base_path + ['no-send-interval']) + # commit changes + self.cli_commit() + + # Verify generated configuration + config = read_file(RADVD_CONF) + tmp = get_config_value('AdvSendAdvert') + self.assertEqual(tmp, 'off') + + tmp = get_config_value('AdvIntervalOpt') + self.assertEqual(tmp, 'off') + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_system_option.py b/smoketest/scripts/cli/test_system_option.py index c6f48bfc6..ffb1d76ae 100755 --- a/smoketest/scripts/cli/test_system_option.py +++ b/smoketest/scripts/cli/test_system_option.py @@ -80,5 +80,20 @@ class TestSystemOption(VyOSUnitTestSHIM.TestCase): self.assertEqual(sysctl_read('net.ipv4.neigh.default.gc_thresh2'), gc_thresh2) self.assertEqual(sysctl_read('net.ipv4.neigh.default.gc_thresh3'), gc_thresh3) + def test_ssh_client_options(self): + loopback = 'lo' + ssh_client_opt_file = '/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf' + + self.cli_set(['system', 'option', 'ssh-client', 'source-interface', loopback]) + self.cli_commit() + + tmp = read_file(ssh_client_opt_file) + self.assertEqual(tmp, f'BindInterface {loopback}') + + self.cli_delete(['system', 'option']) + self.cli_commit() + self.assertFalse(os.path.exists(ssh_client_opt_file)) + + if __name__ == '__main__': unittest.main(verbosity=2, failfast=True) diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 9105ce1f8..8c1213e2b 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -123,6 +123,18 @@ def get_config(config=None): openvpn['module_load_dco'] = {} break + # Calculate the protocol modifier. This is concatenated to the protocol string to direct + # OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which + # type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces + # binding the socket in IPv6 mode, which can also receive IPv4 traffic (when using the + # default "ipv6" mode, we specify "bind ipv6only" to disable kernel dual-stack behaviors). + if openvpn['ip_version'] == 'ipv4': + openvpn['protocol_modifier'] = '4' + elif openvpn['ip_version'] in ['ipv6', 'dual-stack']: + openvpn['protocol_modifier'] = '6' + else: + openvpn['protocol_modifier'] = '' + return openvpn def is_ec_private_key(pki, cert_name): @@ -257,6 +269,9 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Protocol "tcp-passive" is not valid in client mode') + if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': + raise ConfigError('"ip-version dual-stack" is not supported in client mode') + if dict_search('tls.dh_params', openvpn): raise ConfigError('Cannot specify "tls dh-params" in client mode') @@ -264,6 +279,9 @@ def verify(openvpn): # OpenVPN site-to-site - VERIFY # elif openvpn['mode'] == 'site-to-site': + if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': + raise ConfigError('"ip-version dual-stack" is not supported in site-to-site mode') + if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn: raise ConfigError('Must specify "local-address" or add interface to bridge') @@ -487,6 +505,25 @@ def verify(openvpn): # not depending on any operation mode # + # verify that local_host/remote_host match with any ip_version override + # specified (if a dns name is specified for remote_host, no attempt is made + # to verify that record resolves to an address of the configured family) + if 'local_host' in openvpn: + if openvpn['ip_version'] == 'ipv4' and is_ipv6(openvpn['local_host']): + raise ConfigError('Cannot use an IPv6 "local-host" with "ip-version ipv4"') + elif openvpn['ip_version'] == 'ipv6' and is_ipv4(openvpn['local_host']): + raise ConfigError('Cannot use an IPv4 "local-host" with "ip-version ipv6"') + elif openvpn['ip_version'] == 'dual-stack': + raise ConfigError('Cannot use "local-host" with "ip-version dual-stack". "dual-stack" is only supported when OpenVPN binds to all available interfaces.') + + if 'remote_host' in openvpn: + remote_hosts = dict_search('remote_host', openvpn) + for remote_host in remote_hosts: + if openvpn['ip_version'] == 'ipv4' and is_ipv6(remote_host): + raise ConfigError('Cannot use an IPv6 "remote-host" with "ip-version ipv4"') + elif openvpn['ip_version'] == 'ipv6' and is_ipv4(remote_host): + raise ConfigError('Cannot use an IPv4 "remote-host" with "ip-version ipv6"') + # verify specified IP address is present on any interface on this system if 'local_host' in openvpn: if not is_addr_assigned(openvpn['local_host']): diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index d1647e3a1..52d0b7cda 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -85,6 +85,8 @@ def verify(options): raise ConfigError('No interface with address "{address}" configured!') if 'source_interface' in config: + # verify_source_interface reuires key 'ifname' + config['ifname'] = config['source_interface'] verify_source_interface(config) if 'source_address' in config: address = config['source_address'] diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper index 5d879471d..2a1c5a7b2 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper +++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper @@ -72,6 +72,22 @@ function delroute () { fi } +# try to communicate with vtysh +function vtysh_conf () { + # perform 10 attempts with 1 second delay for retries + for i in {1..10} ; do + if vtysh -c "conf t" -c "$1" ; then + logmsg info "Command was executed successfully via vtysh: \"$1\"" + return 0 + else + logmsg info "Failed to send command to vtysh, retrying in 1 second" + sleep 1 + fi + done + logmsg error "Failed to execute command via vtysh after 10 attempts: \"$1\"" + return 1 +} + # replace ip command with this wrapper function ip () { # pass comand to system `ip` if this is not related to routes change @@ -84,7 +100,7 @@ function ip () { delroute ${@:4} iptovtysh $@ logmsg info "Sending command to vtysh" - vtysh -c "conf t" -c "$VTYSH_CMD" + vtysh_conf "$VTYSH_CMD" else # add ip route to kernel logmsg info "Modifying routes in kernel: \"/usr/sbin/ip $@\"" diff --git a/src/op_mode/ntp.py b/src/op_mode/ntp.py index e14cc46d0..6ec0fedcb 100644 --- a/src/op_mode/ntp.py +++ b/src/op_mode/ntp.py @@ -110,49 +110,62 @@ def _is_configured(): if not config.exists("service ntp"): raise vyos.opmode.UnconfiguredSubsystem("NTP service is not enabled.") +def _extend_command_vrf(): + config = ConfigTreeQuery() + if config.exists('service ntp vrf'): + vrf = config.value('service ntp vrf') + return f'ip vrf exec {vrf} ' + return '' + + def show_activity(raw: bool): _is_configured() command = f'chronyc' if raw: - command += f" -c activity" - return _get_raw_data(command) + command += f" -c activity" + return _get_raw_data(command) else: - command += f" activity" - return cmd(command) + command = _extend_command_vrf() + command + command += f" activity" + return cmd(command) def show_sources(raw: bool): _is_configured() command = f'chronyc' if raw: - command += f" -c sources" - return _get_raw_data(command) + command += f" -c sources" + return _get_raw_data(command) else: - command += f" sources -v" - return cmd(command) + command = _extend_command_vrf() + command + command += f" sources -v" + return cmd(command) def show_tracking(raw: bool): _is_configured() command = f'chronyc' if raw: - command += f" -c tracking" - return _get_raw_data(command) + command += f" -c tracking" + return _get_raw_data(command) else: - command += f" tracking" - return cmd(command) + command = _extend_command_vrf() + command + command += f" tracking" + return cmd(command) def show_sourcestats(raw: bool): _is_configured() command = f'chronyc' if raw: - command += f" -c sourcestats" - return _get_raw_data(command) + command += f" -c sourcestats" + return _get_raw_data(command) else: - command += f" sourcestats -v" - return cmd(command) + command = _extend_command_vrf() + command + command += f" sourcestats -v" + return cmd(command) + if __name__ == '__main__': try: diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 84b080023..ab613e5c4 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -316,7 +316,13 @@ def generate_certificate_request(private_key=None, key_type=None, return_request default_values = get_default_values() subject = {} - subject['country'] = ask_input('Enter country code:', default=default_values['country']) + while True: + country = ask_input('Enter country code:', default=default_values['country']) + if len(country) != 2: + print("Country name must be a 2 character country code") + continue + subject['country'] = country + break subject['state'] = ask_input('Enter state:', default=default_values['state']) subject['locality'] = ask_input('Enter locality:', default=default_values['locality']) subject['organization'] = ask_input('Enter organization name:', default=default_values['organization']) @@ -693,7 +699,7 @@ def generate_wireguard_psk(interface=None, peer=None, install=False): print(f'Pre-shared key: {psk}') # Import functions -def import_ca_certificate(name, path=None, key_path=None): +def import_ca_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None): if path: if not os.path.exists(path): print(f'File not found: {path}') @@ -717,19 +723,20 @@ def import_ca_certificate(name, path=None, key_path=None): return key = None - passphrase = ask_input('Enter private key passphrase: ') or None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None with open(key_path) as f: key_data = f.read() key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) if not key: - print(f'Invalid private key or passphrase: {path}') + print(f'Invalid private key or passphrase: {key_path}') return install_certificate(name, private_key=key, is_ca=True) -def import_certificate(name, path=None, key_path=None): +def import_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None): if path: if not os.path.exists(path): print(f'File not found: {path}') @@ -753,14 +760,15 @@ def import_certificate(name, path=None, key_path=None): return key = None - passphrase = ask_input('Enter private key passphrase: ') or None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None with open(key_path) as f: key_data = f.read() key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) if not key: - print(f'Invalid private key or passphrase: {path}') + print(f'Invalid private key or passphrase: {key_path}') return install_certificate(name, private_key=key, is_ca=False) @@ -799,7 +807,7 @@ def import_dh_parameters(name, path): install_dh_parameters(name, dh) -def import_keypair(name, path=None, key_path=None): +def import_keypair(name, path=None, key_path=None, no_prompt=False, passphrase=None): if path: if not os.path.exists(path): print(f'File not found: {path}') @@ -823,14 +831,15 @@ def import_keypair(name, path=None, key_path=None): return key = None - passphrase = ask_input('Enter private key passphrase: ') or None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None with open(key_path) as f: key_data = f.read() key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) if not key: - print(f'Invalid private key or passphrase: {path}') + print(f'Invalid private key or passphrase: {key_path}') return install_keypair(name, None, private_key=key, prompt=False) @@ -1011,6 +1020,9 @@ if __name__ == '__main__': parser.add_argument('--filename', help='Write certificate into specified filename', action='store') parser.add_argument('--key-filename', help='Write key into specified filename', action='store') + parser.add_argument('--no-prompt', action='store_true', help='Perform action non-interactively') + parser.add_argument('--passphrase', help='A passphrase to decrypt the private key') + args = parser.parse_args() try: @@ -1054,15 +1066,18 @@ if __name__ == '__main__': generate_wireguard_psk(args.interface, peer=args.peer, install=args.install) elif args.action == 'import': if args.ca: - import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename) + import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) elif args.certificate: - import_certificate(args.certificate, path=args.filename, key_path=args.key_filename) + import_certificate(args.certificate, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) elif args.crl: import_crl(args.crl, args.filename) elif args.dh: import_dh_parameters(args.dh, args.filename) elif args.keypair: - import_keypair(args.keypair, path=args.filename, key_path=args.key_filename) + import_keypair(args.keypair, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) elif args.openvpn: import_openvpn_secret(args.openvpn, args.filename) elif args.action == 'show': diff --git a/src/services/vyos-configd b/src/services/vyos-configd index d797e90cf..3674d9627 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -30,6 +30,7 @@ from vyos.defaults import directories from vyos.utils.boot import boot_configuration_complete from vyos.configsource import ConfigSourceString from vyos.configsource import ConfigSourceError +from vyos.configdiff import get_commit_scripts from vyos.config import Config from vyos import ConfigError @@ -220,6 +221,12 @@ def initialization(socket): dependent_func: dict[str, list[typing.Callable]] = {} setattr(config, 'dependent_func', dependent_func) + commit_scripts = get_commit_scripts(config) + logger.debug(f'commit_scripts: {commit_scripts}') + + scripts_called = [] + setattr(config, 'scripts_called', scripts_called) + return config def process_node_data(config, data, last: bool = False) -> int: @@ -228,6 +235,7 @@ def process_node_data(config, data, last: bool = False) -> int: return R_ERROR_DAEMON script_name = None + os.environ['VYOS_TAGNODE_VALUE'] = '' args = [] config.dependency_list.clear() @@ -244,6 +252,12 @@ def process_node_data(config, data, last: bool = False) -> int: args = res.group(3).split() args.insert(0, f'{script_name}.py') + tag_value = os.getenv('VYOS_TAGNODE_VALUE', '') + tag_ext = f'_{tag_value}' if tag_value else '' + script_record = f'{script_name}{tag_ext}' + scripts_called = getattr(config, 'scripts_called', []) + scripts_called.append(script_record) + if script_name not in include_set: return R_PASS @@ -302,11 +316,12 @@ if __name__ == '__main__': socket.send(resp.encode()) config = initialization(socket) elif message["type"] == "node": - if message["last"]: - logger.debug(f'final element of priority queue') res = process_node_data(config, message["data"], message["last"]) response = res.to_bytes(1, byteorder=sys.byteorder) logger.debug(f"Sending response {res}") socket.send(response) + if message["last"] and config: + scripts_called = getattr(config, 'scripts_called', []) + logger.debug(f'scripts_called: {scripts_called}') else: logger.critical(f"Unexpected message: {message}") diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 7f5233c6b..97633577d 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -212,6 +212,22 @@ class ImageModel(ApiModel): } } +class ImportPkiModel(ApiModel): + op: StrictStr + path: List[StrictStr] + passphrase: StrictStr = None + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "import_pki", + "path": ["op", "mode", "path"], + "passphrase": "passphrase", + } + } + + class ContainerImageModel(ApiModel): op: StrictStr name: StrictStr = None @@ -585,6 +601,14 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel, return success(msg) +def create_path_import_pki_no_prompt(path): + correct_paths = ['ca', 'certificate', 'key-pair'] + if path[1] not in correct_paths: + return False + path[1] = '--' + path[1].replace('-', '') + path[3] = '--key-filename' + return path[1:] + @app.post('/configure') def configure_op(data: Union[ConfigureModel, ConfigureListModel], @@ -814,6 +838,44 @@ def reset_op(data: ResetModel): return success(res) +@app.post('/import-pki') +def import_pki(data: ImportPkiModel): + session = app.state.vyos_session + + op = data.op + path = data.path + + lock.acquire() + + try: + if op == 'import-pki': + # need to get rid or interactive mode for private key + if len(path) == 5 and path[3] in ['key-file', 'private-key']: + path_no_prompt = create_path_import_pki_no_prompt(path) + if not path_no_prompt: + return error(400, f"Invalid command: {' '.join(path)}") + if data.passphrase: + path_no_prompt += ['--passphrase', data.passphrase] + res = session.import_pki_no_prompt(path_no_prompt) + else: + res = session.import_pki(path) + if not res[0].isdigit(): + return error(400, res) + # commit changes + session.commit() + res = res.split('. ')[0] + else: + return error(400, f"'{op}' is not a valid operation") + except ConfigSessionError as e: + return error(400, str(e)) + except Exception as e: + logger.critical(traceback.format_exc()) + return error(500, "An internal error occured. Check the logs for details.") + finally: + lock.release() + + return success(res) + @app.post('/poweroff') def poweroff_op(data: PoweroffModel): session = app.state.vyos_session |