diff options
Diffstat (limited to 'python')
168 files changed, 8139 insertions, 620 deletions
diff --git a/python/setup.py b/python/setup.py index 2d614e724..571b956ee 100644 --- a/python/setup.py +++ b/python/setup.py @@ -1,5 +1,14 @@ import os +import sys +import subprocess from setuptools import setup +from setuptools.command.build_py import build_py + +sys.path.append('./vyos') +from defaults import directories + +def desc_out(f): + return os.path.splitext(f)[0] + '.desc' def packages(directory): return [ @@ -8,6 +17,43 @@ def packages(directory): if os.path.isfile(os.path.join(_[0], '__init__.py')) ] + +class GenerateProto(build_py): + ver = os.environ.get('OCAML_VERSION') + if ver: + proto_path = f'/opt/opam/{ver}/share/vyconf' + else: + proto_path = directories['proto_path'] + + def run(self): + # find all .proto files in vyconf proto_path + proto_files = [] + for _, _, files in os.walk(self.proto_path): + for file in files: + if file.endswith('.proto'): + proto_files.append(file) + + # compile each .proto file to Python + for proto_file in proto_files: + subprocess.check_call( + [ + 'protoc', + '--python_out=vyos/proto', + f'--proto_path={self.proto_path}/', + f'--descriptor_set_out=vyos/proto/{desc_out(proto_file)}', + proto_file, + ] + ) + subprocess.check_call( + [ + 'vyos/proto/generate_dataclass.py', + 'vyos/proto/vyconf.desc', + '--out-dir=vyos/proto', + ] + ) + + build_py.run(self) + setup( name = "vyos", version = "1.3.0", @@ -29,4 +75,7 @@ setup( "config-mgmt = vyos.config_mgmt:run", ], }, + cmdclass={ + 'build_py': GenerateProto, + }, ) diff --git a/python/vyos/accel_ppp.py b/python/vyos/accel_ppp.py index bae695fc3..b1160dc76 100644 --- a/python/vyos/accel_ppp.py +++ b/python/vyos/accel_ppp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/python/vyos/accel_ppp_util.py b/python/vyos/accel_ppp_util.py index ae75e6654..85e8a964c 100644 --- a/python/vyos/accel_ppp_util.py +++ b/python/vyos/accel_ppp_util.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -221,10 +221,12 @@ def verify_accel_ppp_ip_pool(vpn_config): for interface, interface_config in vpn_config['interface'].items(): if dict_search('client_subnet', interface_config): break + if dict_search('external_dhcp.dhcp_relay', interface_config): + break else: raise ConfigError( 'Local auth and noauth mode requires local client-ip-pool \ - or client-ipv6-pool or client-subnet to be configured!') + or client-ipv6-pool or client-subnet or dhcp-relay to be configured!') else: raise ConfigError( "Local auth mode requires local client-ip-pool \ diff --git a/python/vyos/airbag.py b/python/vyos/airbag.py index 3c7a144b7..1dcccdd47 100644 --- a/python/vyos/airbag.py +++ b/python/vyos/airbag.py @@ -1,4 +1,4 @@ -# Copyright 2019-2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,10 +15,12 @@ import sys from datetime import datetime +from collections import deque from vyos import debug from vyos.logger import syslog from vyos.version import get_full_version_data +from vyos.defaults import airbag_noteworthy_size def enable(log=True): @@ -27,7 +29,7 @@ def enable(log=True): _intercepting_exceptions() -_noteworthy = [] +_noteworthy = deque(maxlen=airbag_noteworthy_size) def noteworthy(msg): @@ -72,7 +74,7 @@ def bug_report(dtype, value, trace): note = '' if _noteworthy: note = 'noteworthy:\n' - note += '\n'.join(_noteworthy) + note += '\n'.join(list(_noteworthy)) information.update({ 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), diff --git a/python/vyos/base.py b/python/vyos/base.py index ca96d96ce..67f92564e 100644 --- a/python/vyos/base.py +++ b/python/vyos/base.py @@ -1,4 +1,4 @@ -# Copyright 2018-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,8 +15,7 @@ from textwrap import fill - -class BaseWarning: +class UserMessage: def __init__(self, header, message, **kwargs): self.message = message self.kwargs = kwargs @@ -33,7 +32,6 @@ class BaseWarning: messages = self.message.split('\n') isfirstmessage = True initial_indent = self.textinitindent - print('') for mes in messages: mes = fill(mes, initial_indent=initial_indent, subsequent_indent=self.standardindent, **self.kwargs) @@ -44,17 +42,24 @@ class BaseWarning: print('', flush=True) +class Message(): + def __init__(self, message, **kwargs): + self.Message = UserMessage('', message, **kwargs) + self.Message.print() + class Warning(): def __init__(self, message, **kwargs): - self.BaseWarn = BaseWarning('WARNING: ', message, **kwargs) - self.BaseWarn.print() + print('') + self.UserMessage = UserMessage('WARNING: ', message, **kwargs) + self.UserMessage.print() class DeprecationWarning(): def __init__(self, message, **kwargs): # Reformat the message and trim it to 72 characters in length - self.BaseWarn = BaseWarning('DEPRECATION WARNING: ', message, **kwargs) - self.BaseWarn.print() + print('') + self.UserMessage = UserMessage('DEPRECATION WARNING: ', message, **kwargs) + self.UserMessage.print() class ConfigError(Exception): diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 94215531d..136bd36e8 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -49,7 +49,9 @@ DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') REGEX_WARN_VYOS = r'(// Warning: Do not remove the following line.)' REGEX_WARN_VYATTA = r'(/\* Warning: Do not remove the following line. \*/)' REGEX_COMPONENT_VERSION_VYOS = r'// vyos-config-version:\s+"([\w@:-]+)"\s*' -REGEX_COMPONENT_VERSION_VYATTA = r'/\* === vyatta-config-version:\s+"([\w@:-]+)"\s+=== \*/' +REGEX_COMPONENT_VERSION_VYATTA = ( + r'/\* === vyatta-config-version:\s+"([\w@:-]+)"\s+=== \*/' +) REGEX_RELEASE_VERSION_VYOS = r'// Release version:\s+(\S*)\s*' REGEX_RELEASE_VERSION_VYATTA = r'/\* Release version:\s+(\S*)\s*\*/' @@ -62,16 +64,31 @@ CONFIG_FILE_VERSION = """\ warn_filter_vyos = re.compile(REGEX_WARN_VYOS) warn_filter_vyatta = re.compile(REGEX_WARN_VYATTA) -regex_filter = { 'vyos': dict(zip(['component', 'release'], - [re.compile(REGEX_COMPONENT_VERSION_VYOS), - re.compile(REGEX_RELEASE_VERSION_VYOS)])), - 'vyatta': dict(zip(['component', 'release'], - [re.compile(REGEX_COMPONENT_VERSION_VYATTA), - re.compile(REGEX_RELEASE_VERSION_VYATTA)])) } +regex_filter = { + 'vyos': dict( + zip( + ['component', 'release'], + [ + re.compile(REGEX_COMPONENT_VERSION_VYOS), + re.compile(REGEX_RELEASE_VERSION_VYOS), + ], + ) + ), + 'vyatta': dict( + zip( + ['component', 'release'], + [ + re.compile(REGEX_COMPONENT_VERSION_VYATTA), + re.compile(REGEX_RELEASE_VERSION_VYATTA), + ], + ) + ), +} + @dataclass class VersionInfo: - component: Optional[dict[str,int]] = None + component: Optional[dict[str, int]] = None release: str = get_version() vintage: str = 'vyos' config_body: Optional[str] = None @@ -84,8 +101,9 @@ class VersionInfo: return bool(self.config_body is None) def update_footer(self): - f = CONFIG_FILE_VERSION.format(component_to_string(self.component), - self.release) + f = CONFIG_FILE_VERSION.format( + component_to_string(self.component), self.release + ) self.footer_lines = f.splitlines() def update_syntax(self): @@ -121,13 +139,16 @@ class VersionInfo: except Exception as e: raise ValueError(e) from e + def component_to_string(component: dict) -> str: - l = [f'{k}@{v}' for k, v in sorted(component.items(), key=lambda x: x[0])] + l = [f'{k}@{v}' for k, v in sorted(component.items(), key=lambda x: x[0])] # noqa: E741 return ':'.join(l) + def component_from_string(string: str) -> dict: return {k: int(v) for k, v in re.findall(r'([\w,-]+)@(\d+)', string)} + def version_info_from_file(config_file) -> VersionInfo: """Return config file component and release version info.""" version_info = VersionInfo() @@ -166,27 +187,27 @@ def version_info_from_file(config_file) -> VersionInfo: return version_info + def version_info_from_system() -> VersionInfo: """Return system component and release version info.""" d = component_version() sort_d = dict(sorted(d.items(), key=lambda x: x[0])) - version_info = VersionInfo( - component = sort_d, - release = get_version(), - vintage = 'vyos' - ) + version_info = VersionInfo(component=sort_d, release=get_version(), vintage='vyos') return version_info + def version_info_copy(v: VersionInfo) -> VersionInfo: """Make a copy of dataclass.""" return replace(v) + def version_info_prune_component(x: VersionInfo, y: VersionInfo) -> VersionInfo: """In place pruning of component keys of x not in y.""" if x.component is None or y.component is None: return - x.component = { k: v for k,v in x.component.items() if k in y.component } + x.component = {k: v for k, v in x.component.items() if k in y.component} + def add_system_version(config_str: str = None, out_file: str = None): """Wrap config string with system version and write to out_file. @@ -202,3 +223,11 @@ def add_system_version(config_str: str = None, out_file: str = None): version_info.write(out_file) else: sys.stdout.write(version_info.write_string()) + + +def append_system_version(file: str): + """Append system version data to existing file""" + version_info = version_info_from_system() + version_info.update_footer() + with open(file, 'a') as f: + f.write(version_info.write_string()) diff --git a/python/vyos/compose_config.py b/python/vyos/compose_config.py index 79a8718c5..1e7837858 100644 --- a/python/vyos/compose_config.py +++ b/python/vyos/compose_config.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/config.py b/python/vyos/config.py index 1fab46761..6f7c76ca7 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -73,8 +73,11 @@ from vyos.xml_ref import ext_dict_merge from vyos.xml_ref import relative_defaults from vyos.utils.dict import get_sub_dict from vyos.utils.dict import mangle_dict_keys +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.backend import vyconf_backend from vyos.configsource import ConfigSource from vyos.configsource import ConfigSourceSession +from vyos.configsource import ConfigSourceVyconfSession class ConfigDict(dict): _from_defaults = {} @@ -131,8 +134,13 @@ class Config(object): subtrees. """ def __init__(self, session_env=None, config_source=None): + self.vyconf_session = None if config_source is None: - self._config_source = ConfigSourceSession(session_env) + if vyconf_backend() and boot_configuration_complete(): + self._config_source = ConfigSourceVyconfSession(session_env) + self.vyconf_session = self._config_source._vyconf_session + else: + self._config_source = ConfigSourceSession(session_env) else: if not isinstance(config_source, ConfigSource): raise TypeError("config_source not of type ConfigSource") @@ -149,6 +157,18 @@ class Config(object): return self._running_config return self._session_config + def get_bool_attr(self, attr) -> bool: + if not hasattr(self, attr): + return False + else: + tmp = getattr(self, attr) + if not isinstance(tmp, bool): + return False + return tmp + + def set_bool_attr(self, attr, val): + setattr(self, attr, val) + def _make_path(self, path): # Backwards-compatibility stuff: original implementation used string paths # libvyosconfig paths are lists, but since node names cannot contain whitespace, diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 1c2b70fdf..b549b3005 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -25,10 +25,12 @@ from filecmp import cmp from datetime import datetime from textwrap import dedent from pathlib import Path -from tabulate import tabulate from shutil import copy, chown +from subprocess import Popen +from subprocess import DEVNULL from urllib.parse import urlsplit from urllib.parse import urlunsplit +from tabulate import tabulate from vyos.config import Config from vyos.configtree import ConfigTree @@ -44,6 +46,7 @@ from vyos.utils.io import ask_yes_no from vyos.utils.boot import boot_configuration_complete from vyos.utils.process import is_systemd_service_active from vyos.utils.process import rc_cmd +from vyos.defaults import DEFAULT_COMMIT_CONFIRM_MINUTES SAVE_CONFIG = '/usr/libexec/vyos/vyos-save-config.py' config_json = '/run/vyatta/config/config.json' @@ -56,7 +59,6 @@ commit_hooks = { 'commit_archive': '02vyos-commit-archive', } -DEFAULT_TIME_MINUTES = 10 timer_name = 'commit-confirm' config_file = os.path.join(directories['config'], 'config.boot') @@ -144,14 +146,16 @@ class ConfigMgmt: ['system', 'config-management'], key_mangling=('-', '_'), get_first_key=True, - with_defaults=True, + with_recursive_defaults=True, ) self.max_revisions = int(d.get('commit_revisions', 0)) self.num_revisions = 0 self.locations = d.get('commit_archive', {}).get('location', []) self.source_address = d.get('commit_archive', {}).get('source_address', '') - self.reboot_unconfirmed = bool(d.get('commit_confirm') == 'reboot') + self.reboot_unconfirmed = bool( + d.get('commit_confirm', {}).get('action') == 'reboot' + ) self.config_dict = d if config.exists(['system', 'host-name']): @@ -181,7 +185,7 @@ class ConfigMgmt: # Console script functions # def commit_confirm( - self, minutes: int = DEFAULT_TIME_MINUTES, no_prompt: bool = False + self, minutes: int = DEFAULT_COMMIT_CONFIRM_MINUTES, no_prompt: bool = False ) -> Tuple[str, int]: """Commit with reload/reboot to saved config in 'minutes' minutes if 'confirm' call is not issued. @@ -229,7 +233,14 @@ Proceed ?""" else: cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' - os.system(cmd) + Popen( + cmd.split(), + stdout=DEVNULL, + stderr=DEVNULL, + stdin=DEVNULL, + close_fds=True, + preexec_fn=os.setsid, + ) if self.reboot_unconfirmed: msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' @@ -287,7 +298,7 @@ Proceed ?""" # commits under commit-confirm are not added to revision list unless # confirmed, hence a soft revert is to revision 0 - revert_ct = self._get_config_tree_revision(0) + revert_ct = self.get_config_tree_revision(0) message = '[commit-confirm] Reverting to previous config now' os.system('wall -n ' + message) @@ -296,7 +307,11 @@ Proceed ?""" session = ConfigSession(os.getpid(), app='config-mgmt') try: - session.load_explicit(revert_ct) + if session.vyconf_backend(): + session.load_config_obj(revert_ct) + else: + session.load_explicit(revert_ct) + session.commit() except ConfigSessionError as e: raise ConfigMgmtError(e) from e @@ -351,7 +366,7 @@ Proceed ?""" ) return msg, 1 - rollback_ct = self._get_config_tree_revision(rev) + rollback_ct = self.get_config_tree_revision(rev) try: load(rollback_ct, switch='explicit') print('Rollback diff has been applied.') @@ -382,7 +397,7 @@ Proceed ?""" if rev1 is not None: if not self._check_revision_number(rev1): return f'Invalid revision number {rev1}', 1 - ct1 = self._get_config_tree_revision(rev1) + ct1 = self.get_config_tree_revision(rev1) ct2 = self.working_config msg = f'No changes between working and revision {rev1} configurations.\n' if rev2 is not None: @@ -390,7 +405,7 @@ Proceed ?""" return f'Invalid revision number {rev2}', 1 # compare older to newer ct2 = ct1 - ct1 = self._get_config_tree_revision(rev2) + ct1 = self.get_config_tree_revision(rev2) msg = f'No changes between revisions {rev2} and {rev1} configurations.\n' out = '' @@ -575,7 +590,7 @@ Proceed ?""" r = f.read().decode() return r - def _get_config_tree_revision(self, rev: int): + def get_config_tree_revision(self, rev: int): c = self._get_file_revision(rev) return ConfigTree(c) @@ -805,7 +820,7 @@ def run(): '-t', dest='minutes', type=int, - default=DEFAULT_TIME_MINUTES, + default=DEFAULT_COMMIT_CONFIRM_MINUTES, help="Minutes until reboot, unless 'confirm'", ) commit_confirm.add_argument( diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py index cf7c9d543..04de66493 100644 --- a/python/vyos/configdep.py +++ b/python/vyos/configdep.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -102,11 +102,16 @@ def run_config_mode_script(target: str, config: 'Config'): mod = load_as_module(name, path) config.set_level([]) + dry_run = config.get_bool_attr('dry_run') try: c = mod.get_config(config) mod.verify(c) - mod.generate(c) - mod.apply(c) + if not dry_run: + mod.generate(c) + mod.apply(c) + else: + if hasattr(mod, 'call_dependents'): + mod.call_dependents() except (VyOSError, ConfigError) as e: raise ConfigError(str(e)) from e diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 5a353b110..d91d88d88 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -491,10 +491,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'dhcp-options']) if dhcp: dict.update({'dhcp_options_changed' : {}}) - - # Changine interface VRF assignemnts require a DHCP restart, too - dhcp = is_node_changed(config, base + [ifname, 'vrf']) - if dhcp: dict.update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'dhcpv6-options']) + if dhcpv6: dict.update({'dhcpv6_options_changed' : {}}) # Some interfaces come with a source_interface which must also not be part # of any other bond or bridge interface as it is exclusivly assigned as the @@ -519,6 +517,14 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk else: dict['ipv6']['address'].update({'eui64_old': eui64}) + interface_identifier = leaf_node_changed(config, base + [ifname, 'ipv6', 'address', 'interface-identifier']) + if interface_identifier: + tmp = dict_search('ipv6.address', dict) + if not tmp: + dict.update({'ipv6': {'address': {'interface_identifier_old': interface_identifier}}}) + else: + dict['ipv6']['address'].update({'interface_identifier_old': interface_identifier}) + for vif, vif_config in dict.get('vif', {}).items(): # Add subinterface name to dictionary dict['vif'][vif].update({'ifname' : f'{ifname}.{vif}'}) @@ -543,6 +549,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif', vif, 'dhcp-options']) if dhcp: dict['vif'][vif].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif', vif, 'dhcpv6-options']) + if dhcpv6: dict['vif'][vif].update({'dhcpv6_options_changed' : {}}) for vif_s, vif_s_config in dict.get('vif_s', {}).items(): # Add subinterface name to dictionary @@ -569,6 +577,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'dhcp-options']) if dhcp: dict['vif_s'][vif_s].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'dhcpv6-options']) + if dhcpv6: dict['vif_s'][vif_s].update({'dhcpv6_options_changed' : {}}) for vif_c, vif_c_config in vif_s_config.get('vif_c', {}).items(): # Add subinterface name to dictionary @@ -597,6 +607,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'vif-c', vif_c, 'dhcp-options']) if dhcp: dict['vif_s'][vif_s]['vif_c'][vif_c].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'vif-c', vif_c, 'dhcpv6-options']) + if dhcpv6: dict['vif_s'][vif_s]['vif_c'][vif_c].update({'dhcpv6_options_changed' : {}}) # Check vif, vif-s/vif-c VLAN interfaces for removal dict = get_removed_vlans(config, base + [ifname], dict) @@ -622,6 +634,23 @@ def get_vlan_ids(interface): return vlan_ids +def get_vlans_ids_and_range(interface): + vlan_ids = set() + + vlan_filter_status = json.loads(cmd(f'bridge -j -d vlan show dev {interface}')) + + if vlan_filter_status is not None: + for interface_status in vlan_filter_status: + for vlan_entry in interface_status.get("vlans", []): + start = vlan_entry["vlan"] + end = vlan_entry.get("vlanEnd") + if end: + vlan_ids.add(f"{start}-{end}") + else: + vlan_ids.add(str(start)) + + return vlan_ids + def get_accel_dict(config, base, chap_secrets, with_pki=False): """ Common utility function to retrieve and mangle the Accel-PPP configuration @@ -632,6 +661,7 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): Return a dictionary with the necessary interface config keys. """ from vyos.utils.cpu import get_core_count + from vyos.utils.cpu import get_half_cpus from vyos.template import is_ipv4 dict = config.get_config_dict(base, key_mangling=('-', '_'), @@ -641,7 +671,16 @@ 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_core_count()}) + match dict.get('thread_count'): + case 'all': + dict['thread_count'] = get_core_count() + case 'half': + dict['thread_count'] = get_half_cpus() + case str(x) if x.isdigit(): + dict['thread_count'] = int(x) + case _: + dict['thread_count'] = get_core_count() + # we need to store the path to the secrets file dict.update({'chap_secrets_file' : chap_secrets}) @@ -664,3 +703,18 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): dict['authentication']['radius']['server'][server]['acct_port'] = '0' return dict + +def get_flowtable_interfaces(config): + """ + Return all interfaces used in flowtables + """ + ft_base = ['firewall', 'flowtable'] + + if not config.exists(ft_base): + return [] + + ifaces = [] + for ft_name in config.list_nodes(ft_base): + ifaces += config.return_values(ft_base + [ft_name, 'interface']) + + return ifaces diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py index b6d4a5558..5e21a16e5 100644 --- a/python/vyos/configdiff.py +++ b/python/vyos/configdiff.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 5d6ca9be9..b661b1a9e 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,10 +13,10 @@ # 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/>. -''' +""" A small library that allows querying existence or value(s) of config settings from op mode, and execution of arbitrary op mode commands. -''' +""" import os import json @@ -37,12 +37,16 @@ from vyos.utils.error import cli_shell_api_err from vyos.xml_ref import multi_to_list from vyos.xml_ref import is_tag from vyos.base import Warning +from vyos.utils.backend import vyconf_backend +from vyos.configsource import ConfigSourceVyconfSession config_file = os.path.join(directories['config'], 'config.boot') + class ConfigQueryError(Exception): pass + class GenericConfigQuery: def __init__(self): pass @@ -56,6 +60,7 @@ class GenericConfigQuery: def values(self, path: list): raise NotImplementedError + class GenericOpRun: def __init__(self): pass @@ -63,6 +68,7 @@ class GenericOpRun: def run(self, path: list, **kwargs): raise NotImplementedError + class CliShellApiConfigQuery(GenericConfigQuery): def __init__(self): super().__init__() @@ -88,22 +94,27 @@ class CliShellApiConfigQuery(GenericConfigQuery): raise ConfigQueryError('No values for given path') return out + class ConfigTreeQuery(GenericConfigQuery): def __init__(self): super().__init__() if boot_configuration_complete(): - config_source = ConfigSourceSession() + if vyconf_backend(): + config_source = ConfigSourceVyconfSession() + else: + config_source = ConfigSourceSession() self.config = Config(config_source=config_source) else: try: with open(config_file) as f: config_string = f.read() - except OSError as err: + except OSError: config_string = '' - config_source = ConfigSourceString(running_config_text=config_string, - session_config_text=config_string) + config_source = ConfigSourceString( + running_config_text=config_string, session_config_text=config_string + ) self.config = Config(config_source=config_source) def exists(self, path: list): @@ -118,13 +129,28 @@ class ConfigTreeQuery(GenericConfigQuery): def list_nodes(self, path: list): return self.config.list_nodes(path) - def get_config_dict(self, path=[], effective=False, key_mangling=None, - get_first_key=False, no_multi_convert=False, - no_tag_node_value_mangle=False): - return self.config.get_config_dict(path, effective=effective, - key_mangling=key_mangling, get_first_key=get_first_key, - no_multi_convert=no_multi_convert, - no_tag_node_value_mangle=no_tag_node_value_mangle) + def get_config_dict( + self, + path=[], + effective=False, + key_mangling=None, + get_first_key=False, + no_multi_convert=False, + no_tag_node_value_mangle=False, + with_defaults=False, + with_recursive_defaults=False, + ): + return self.config.get_config_dict( + path, + effective=effective, + key_mangling=key_mangling, + get_first_key=get_first_key, + no_multi_convert=no_multi_convert, + no_tag_node_value_mangle=no_tag_node_value_mangle, + with_defaults=with_defaults, + with_recursive_defaults=with_recursive_defaults, + ) + class VbashOpRun(GenericOpRun): def __init__(self): @@ -132,35 +158,45 @@ class VbashOpRun(GenericOpRun): def run(self, path: list, **kwargs): cmd = ' '.join(path) - (out, err) = popen(f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}', stderr=STDOUT, **kwargs) + (out, err) = popen( + f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}', stderr=STDOUT, **kwargs + ) if err: raise ConfigQueryError(out) return out -def query_context(config_query_class=CliShellApiConfigQuery, - op_run_class=VbashOpRun): + +def query_context(config_query_class=CliShellApiConfigQuery, op_run_class=VbashOpRun): query = config_query_class() run = op_run_class() return query, run + def verify_mangling(key_mangling): - if not (isinstance(key_mangling, tuple) and - len(key_mangling) == 2 and - isinstance(key_mangling[0], str) and - isinstance(key_mangling[1], str)): - raise ValueError("key_mangling must be a tuple of two strings") + if not ( + isinstance(key_mangling, tuple) + and len(key_mangling) == 2 + and isinstance(key_mangling[0], str) + and isinstance(key_mangling[1], str) + ): + raise ValueError('key_mangling must be a tuple of two strings') + def op_mode_run(cmd): - """ low-level to avoid overhead """ + """low-level to avoid overhead""" p = subprocess.Popen(cmd, stdout=subprocess.PIPE) out = p.stdout.read() p.wait() return p.returncode, out.decode() -def op_mode_config_dict(path=None, key_mangling=None, - no_tag_node_value_mangle=False, - no_multi_convert=False, get_first_key=False): +def op_mode_config_dict( + path=None, + key_mangling=None, + no_tag_node_value_mangle=False, + no_multi_convert=False, + get_first_key=False, +): if path is None: path = [] command = ['/bin/cli-shell-api', '--show-active-only', 'showConfig'] @@ -185,8 +221,11 @@ def op_mode_config_dict(path=None, key_mangling=None, if key_mangling is not None: verify_mangling(key_mangling) - config_dict = mangle_dict_keys(config_dict, - key_mangling[0], key_mangling[1], - no_tag_node_value_mangle=no_tag_node_value_mangle) + config_dict = mangle_dict_keys( + config_dict, + key_mangling[0], + key_mangling[1], + no_tag_node_value_mangle=no_tag_node_value_mangle, + ) return get_sub_dict(config_dict, path, get_first_key=get_first_key) diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index dd3ad1e3d..cd0088b60 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright 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; @@ -17,20 +17,36 @@ import os import re import sys import subprocess +from tempfile import NamedTemporaryFile +from typing import TypeAlias +from typing import Union from vyos.defaults import directories from vyos.utils.process import is_systemd_service_running from vyos.utils.dict import dict_to_paths +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.backend import vyconf_backend +from vyos.vyconf_session import VyconfSession +from vyos.base import Warning as Warn +from vyos.defaults import DEFAULT_COMMIT_CONFIRM_MINUTES +from vyos.configtree import ConfigTree + +# type of config file path or configtree +ConfigObj: TypeAlias = Union[str, ConfigTree] + CLI_SHELL_API = '/bin/cli-shell-api' -SET = '/usr/libexec/vyos/vyconf/vy_set' -DELETE = '/usr/libexec/vyos/vyconf/vy_delete' +SET = '/opt/vyatta/sbin/my_set' +DELETE = '/opt/vyatta/sbin/my_delete' COMMENT = '/opt/vyatta/sbin/my_comment' COMMIT = '/opt/vyatta/sbin/my_commit' +COMMIT_CONFIRM = ['/usr/bin/config-mgmt', 'commit_confirm', '-y'] +CONFIRM = ['/usr/bin/config-mgmt', 'confirm'] DISCARD = '/opt/vyatta/sbin/my_discard' SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig'] LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile'] MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py'] +MERGE_CONFIG = ['/usr/libexec/vyos/vyos-merge-config.py'] SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py'] INSTALL_IMAGE = [ '/usr/libexec/vyos/op_mode/image_installer.py', @@ -63,6 +79,7 @@ GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate'] SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show'] RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset'] REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot'] +RENEW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'renew'] POWEROFF = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'poweroff'] OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add'] OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete'] @@ -116,6 +133,10 @@ def inject_vyos_env(env): env['vyos_sbin_dir'] = '/usr/sbin' env['vyos_validators_dir'] = '/usr/libexec/vyos/validators' + # with the retirement of the Cstore backend, this will remain as the + # sole indication of legacy CLI config mode, as checked by VyconfSession + env['_OFR_CONFIGURE'] = 'ok' + # if running the vyos-configd daemon, inject the vyshim env var if is_systemd_service_running('vyos-configd.service'): env['vyshim'] = '/usr/sbin/vyshim' @@ -132,7 +153,7 @@ class ConfigSession(object): The write API of VyOS. """ - def __init__(self, session_id, app=APP): + def __init__(self, session_id, app=APP, shared=False): """ Creates a new config session. @@ -160,32 +181,52 @@ class ConfigSession(object): for k, v in env_list: session_env[k] = v + session_env['CONFIGSESSION_PID'] = str(session_id) + self.__session_env = session_env self.__session_env['COMMIT_VIA'] = app self.__run_command([CLI_SHELL_API, 'setupSession']) + if vyconf_backend() and boot_configuration_complete(): + self._vyconf_session = VyconfSession( + pid=session_id, on_error=ConfigSessionError + ) + else: + self._vyconf_session = None + + self.shared = shared + def __del__(self): - try: - output = ( - subprocess.check_output( - [CLI_SHELL_API, 'teardownSession'], env=self.__session_env + if self.shared: + return + if self._vyconf_session is None: + try: + output = ( + subprocess.check_output( + [CLI_SHELL_API, 'teardownSession'], env=self.__session_env + ) + .decode() + .strip() ) - .decode() - .strip() - ) - if output: + if output: + print( + 'cli-shell-api teardownSession output for sesion {0}: {1}'.format( + self.__session_id, output + ), + file=sys.stderr, + ) + except Exception as e: print( - 'cli-shell-api teardownSession output for sesion {0}: {1}'.format( - self.__session_id, output - ), + 'Could not tear down session {0}: {1}'.format(self.__session_id, e), file=sys.stderr, ) - except Exception as e: - print( - 'Could not tear down session {0}: {1}'.format(self.__session_id, e), - file=sys.stderr, - ) + else: + if self._vyconf_session.session_changed(): + Warn('Exiting with uncommitted changes') + self._vyconf_session.discard() + self._vyconf_session.exit_config_mode() + self._vyconf_session.teardown() def __run_command(self, cmd_list): p = subprocess.Popen( @@ -204,12 +245,18 @@ class ConfigSession(object): def get_session_env(self): return self.__session_env + def vyconf_backend(self) -> bool: + return bool(self._vyconf_session) + def set(self, path, value=None): if not value: value = [] else: value = [value] - self.__run_command([SET] + path + value) + if self._vyconf_session is None: + self.__run_command([SET] + path + value) + else: + self._vyconf_session.set(path + value) def set_section(self, path: list, d: dict): try: @@ -223,7 +270,10 @@ class ConfigSession(object): value = [] else: value = [value] - self.__run_command([DELETE] + path + value) + if self._vyconf_session is None: + self.__run_command([DELETE] + path + value) + else: + self._vyconf_session.delete(path + value) def load_section(self, path: list, d: dict): try: @@ -261,20 +311,46 @@ class ConfigSession(object): self.__run_command([COMMENT] + path + value) def commit(self): - out = self.__run_command([COMMIT]) + if self._vyconf_session is None: + out = self.__run_command([COMMIT]) + else: + out, _ = self._vyconf_session.commit() + + return out + + def commit_confirm(self, minutes: int = DEFAULT_COMMIT_CONFIRM_MINUTES): + out = self.__run_command(COMMIT_CONFIRM + [f'-t {minutes}']) + + return out + + def confirm(self): + out = self.__run_command(CONFIRM) + return out def discard(self): - self.__run_command([DISCARD]) + if self._vyconf_session is None: + self.__run_command([DISCARD]) + else: + out, _ = self._vyconf_session.discard() def show_config(self, path, format='raw'): - config_data = self.__run_command(SHOW_CONFIG + path) + if self._vyconf_session is None: + config_data = self.__run_command(SHOW_CONFIG + path) + else: + config_data, _ = self._vyconf_session.show_config(path) if format == 'raw': return config_data - def load_config(self, file_path): - out = self.__run_command(LOAD_CONFIG + [file_path]) + def load_config(self, file_path, cached: bool = False): + if self._vyconf_session is None: + out = self.__run_command(LOAD_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.load_config( + file_name=file_path, cached=cached + ) + return out def load_explicit(self, file_path): @@ -286,12 +362,41 @@ class ConfigSession(object): except LoadConfigError as e: raise ConfigSessionError(e) from e + def load_config_obj(self, config_obj: ConfigObj): + if isinstance(config_obj, ConfigTree): + with NamedTemporaryFile() as f: + config_obj.write_cache(f.name) + self.load_config(f.name, cached=True) + else: + self.load_config(config_obj) + def migrate_and_load_config(self, file_path): - out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.load_config(file_name=file_path, migrate=True) + + return out + + def merge_config(self, file_path, destructive=False): + if self._vyconf_session is None: + destr = ['--destructive'] if destructive else [] + out = self.__run_command(MERGE_CONFIG + [file_path] + destr) + else: + out, _ = self._vyconf_session.merge_config( + file_name=file_path, destructive=destructive + ) + return out def save_config(self, file_path): - out = self.__run_command(SAVE_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(SAVE_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.save_config( + file=file_path, append_version=True + ) + return out def install_image(self, url): @@ -330,6 +435,10 @@ class ConfigSession(object): out = self.__run_command(RESET + path) return out + def renew(self, path): + out = self.__run_command(RENEW + path) + return out + def poweroff(self, path): out = self.__run_command(POWEROFF + path) return out diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index 59e5ac8a1..24a17c711 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -1,5 +1,5 @@ -# Copyright 2020-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -17,9 +17,16 @@ import os import re import subprocess +from typing import Union from vyos.configtree import ConfigTree from vyos.utils.boot import boot_configuration_complete +from vyos.vyconf_session import VyconfSession +from vyos.vyconf_session import VyconfSessionError +from vyos.defaults import directories +from vyos.xml_ref import is_tag +from vyos.xml_ref import is_leaf +from vyos.xml_ref import is_multi class VyOSError(Exception): """ @@ -310,6 +317,116 @@ class ConfigSourceSession(ConfigSource): except VyOSError: return False +class ConfigSourceVyconfSession(ConfigSource): + def __init__(self, session_env=None): + super().__init__() + + if session_env: + self.__session_env = session_env + else: + self.__session_env = None + + if session_env and 'CONFIGSESSION_PID' in session_env: + self.pid = int(session_env['CONFIGSESSION_PID']) + else: + self.pid = os.getppid() + + self._vyconf_session = VyconfSession(pid=self.pid) + try: + out = self._vyconf_session.get_config() + except VyconfSessionError as e: + raise ConfigSourceError(f'Init error in {type(self)}: {e}') + + session_dir = directories['vyconf_session_dir'] + + self.running_cache_path = os.path.join(session_dir, f'running_cache_{out}') + self.session_cache_path = os.path.join(session_dir, f'session_cache_{out}') + + self._running_config = ConfigTree(internal=self.running_cache_path) + self._session_config = ConfigTree(internal=self.session_cache_path) + + if os.path.isfile(self.running_cache_path): + os.remove(self.running_cache_path) + if os.path.isfile(self.session_cache_path): + os.remove(self.session_cache_path) + + # N.B. level not yet implemented pending integration with legacy CLI + # cf. T7374 + self._level = [] + + def __del__(self): + try: + if not self._vyconf_session.in_session(): + self._vyconf_session.teardown() + except AttributeError: + pass + + def get_level(self): + return self._level + + def set_level(self): + pass + + def session_changed(self): + """ + Returns: + True if the config session has uncommited changes, False otherwise. + """ + try: + return self._vyconf_session.session_changed() + except VyconfSessionError: + # no actionable session info on error + return False + + def in_session(self): + """ + Returns: + True if called from a configuration session, False otherwise. + """ + return self._vyconf_session.in_session() + + def show_config(self, path: Union[str,list] = None, default: str = None, + effective: bool = False): + """ + Args: + path (str|list): Configuration tree path, or empty + default (str): Default value to return + + Returns: + str: working configuration + """ + + if path is None: + path = [] + if isinstance(path, str): + path = path.split() + + ct = self._running_config if effective else self._session_config + with_node = True if self.is_tag(path) else False + ct_at_path = ct.get_subtree(path, with_node=with_node) if path else ct + + res = ct_at_path.to_string().strip() + + return res if res else default + + def is_tag(self, path): + try: + return is_tag(path) + except ValueError: + return False + + def is_leaf(self, path): + try: + return is_leaf(path) + except ValueError: + return False + + def is_multi(self, path): + try: + return is_multi(path) + except ValueError: + return False + class ConfigSourceString(ConfigSource): def __init__(self, running_config_text=None, session_config_text=None): super().__init__() @@ -319,3 +436,13 @@ class ConfigSourceString(ConfigSource): self._session_config = ConfigTree(session_config_text) if session_config_text else None except ValueError: raise ConfigSourceError(f"Init error in {type(self)}") + +class ConfigSourceCache(ConfigSource): + def __init__(self, running_config_cache=None, session_config_cache=None): + super().__init__() + + try: + self._running_config = ConfigTree(internal=running_config_cache) if running_config_cache else None + self._session_config = ConfigTree(internal=session_config_cache) if session_config_cache else None + except ValueError: + raise ConfigSourceError(f"Init error in {type(self)}") diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 8d27a7e46..aeeb329c5 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -1,5 +1,5 @@ # configtree -- a standalone VyOS config file manipulation library (Python bindings) -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright 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; @@ -19,7 +19,9 @@ import logging from ctypes import cdll, c_char_p, c_void_p, c_int, c_bool -LIBPATH = '/usr/lib/libvyosconfig.so.0' +BUILD_PATH = '/tmp/libvyosconfig/_build/libvyosconfig.so' +INSTALL_PATH = '/usr/lib/libvyosconfig.so.0' +LIBPATH = BUILD_PATH if os.path.isfile(BUILD_PATH) else INSTALL_PATH def replace_backslash(s, search, replace): @@ -48,7 +50,7 @@ def unescape_backslash(string: str) -> str: def extract_version(s): """Extract the version string from the config string""" t = re.split('(^//)', s, maxsplit=1, flags=re.MULTILINE) - return (s, ''.join(t[1:])) + return (t[0], ''.join(t[1:])) def check_path(path): @@ -64,9 +66,14 @@ class ConfigTreeError(Exception): class ConfigTree(object): - def __init__(self, config_string=None, address=None, libpath=LIBPATH): - if config_string is None and address is None: - raise TypeError("ConfigTree() requires one of 'config_string' or 'address'") + def __init__( + self, config_string=None, address=None, internal=None, libpath=LIBPATH + ): + if config_string is None and address is None and internal is None: + raise TypeError( + "ConfigTree() requires one of 'config_string', 'address', or 'internal'" + ) + self.__config = None self.__lib = cdll.LoadLibrary(libpath) @@ -87,6 +94,13 @@ class ConfigTree(object): self.__to_commands.argtypes = [c_void_p, c_char_p] self.__to_commands.restype = c_char_p + self.__read_internal = self.__lib.read_internal + self.__read_internal.argtypes = [c_char_p] + self.__read_internal.restype = c_void_p + + self.__write_internal = self.__lib.write_internal + self.__write_internal.argtypes = [c_void_p, c_char_p] + self.__to_json = self.__lib.to_json self.__to_json.argtypes = [c_void_p] self.__to_json.restype = c_char_p @@ -166,7 +180,22 @@ class ConfigTree(object): self.__destroy = self.__lib.destroy self.__destroy.argtypes = [c_void_p] - if address is None: + self.__equal = self.__lib.equal + self.__equal.argtypes = [c_void_p, c_void_p] + self.__equal.restype = c_bool + + if address is not None: + self.__config = address + self.__version = '' + elif internal is not None: + config = self.__read_internal(internal.encode()) + if config is None: + msg = self.__get_error().decode() + raise ValueError('Failed to read internal rep: {0}'.format(msg)) + else: + self.__config = config + self.__version = '' + elif config_string is not None: config_section, version_section = extract_version(config_string) config_section = escape_backslash(config_section) config = self.__from_string(config_section.encode()) @@ -177,8 +206,9 @@ class ConfigTree(object): self.__config = config self.__version = version_section else: - self.__config = address - self.__version = '' + raise TypeError( + "ConfigTree() requires one of 'config_string', 'address', or 'internal'" + ) self.__migration = os.environ.get('VYOS_MIGRATION') if self.__migration: @@ -188,6 +218,11 @@ class ConfigTree(object): if self.__config is not None: self.__destroy(self.__config) + def __eq__(self, other): + if isinstance(other, ConfigTree): + return self.__equal(self._get_config(), other._get_config()) + return False + def __str__(self): return self.to_string() @@ -197,6 +232,9 @@ class ConfigTree(object): def get_version_string(self): return self.__version + def write_cache(self, file_name): + self.__write_internal(self._get_config(), file_name.encode()) + def to_string(self, ordered_values=False, no_version=False): config_string = self.__to_string(self.__config, ordered_values).decode() config_string = unescape_backslash(config_string) @@ -461,6 +499,28 @@ def union(left, right, libpath=LIBPATH): return tree +def merge(left, right, destructive=False, libpath=LIBPATH): + if left is None: + left = ConfigTree(config_string='\n') + if right is None: + right = ConfigTree(config_string='\n') + if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): + raise TypeError('Arguments must be instances of ConfigTree') + + __lib = cdll.LoadLibrary(libpath) + __tree_merge = __lib.tree_merge + __tree_merge.argtypes = [c_bool, c_void_p, c_void_p] + __tree_merge.restype = c_void_p + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + + res = __tree_merge(destructive, left._get_config(), right._get_config()) + tree = ConfigTree(address=res) + + return tree + + def mask_inclusive(left, right, libpath=LIBPATH): if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): raise TypeError('Arguments must be instances of ConfigTree') @@ -554,6 +614,44 @@ def reference_tree_cache_to_json(cache_path, render_file, libpath=LIBPATH): raise ConfigTreeError(msg) +# validate_tree_filter c_ptr rt_cache validator_dir +def validate_tree_filter( + config_tree, + cache_path='/usr/share/vyos/reftree.cache', + validator_dir='/usr/libexec/vyos/validators', + libpath=LIBPATH, +): + try: + __lib = cdll.LoadLibrary(libpath) + __validate_tree_filter = __lib.validate_tree_filter + __validate_tree_filter.argtypes = [c_void_p, c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __validate_tree_filter( + config_tree._get_config(), cache_path.encode(), validator_dir.encode() + ) + except Exception as e: + raise ConfigTreeError(e) + + msg = __get_error().decode() + tree = ConfigTree(address=res) + + return tree, msg + + +def validate_tree( + config_tree, + cache_path='/usr/share/vyos/reftree.cache', + validator_dir='/usr/libexec/vyos/validators', +): + _, out = validate_tree_filter( + config_tree, cache_path=cache_path, validator_dir=validator_dir + ) + + return out + + class DiffTree: def __init__(self, left, right, path=[], libpath=LIBPATH): if left is None: diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 4084425b1..cc4419913 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -92,6 +92,9 @@ def verify_mtu_ipv6(config): tmp = dict_search('ipv6.address.eui64', config) if tmp != None: raise ConfigError(error_msg) + tmp = dict_search('ipv6.address.interface_identifier', config) + if tmp != None: raise ConfigError(error_msg) + def verify_vrf(config): """ Common helper function used by interface implementations to perform @@ -356,6 +359,7 @@ def verify_vlan_config(config): verify_vrf(vlan) verify_mirror_redirect(vlan) verify_mtu_parent(vlan, config) + verify_mtu_ipv6(vlan) # 802.1ad (Q-in-Q) VLANs for s_vlan_id in config.get('vif_s', {}): @@ -367,6 +371,7 @@ def verify_vlan_config(config): verify_vrf(s_vlan) verify_mirror_redirect(s_vlan) verify_mtu_parent(s_vlan, config) + verify_mtu_ipv6(s_vlan) for c_vlan_id in s_vlan.get('vif_c', {}): c_vlan = s_vlan['vif_c'][c_vlan_id] @@ -378,6 +383,7 @@ def verify_vlan_config(config): verify_mirror_redirect(c_vlan) verify_mtu_parent(c_vlan, config) verify_mtu_parent(c_vlan, s_vlan) + verify_mtu_ipv6(c_vlan) def verify_diffie_hellman_length(file, min_keysize): @@ -521,6 +527,25 @@ def verify_pki_dh_parameters(config: dict, dh_name: str, min_key_size: int=0): if dh_bits < min_key_size: raise ConfigError(f'Minimum DH key-size is {min_key_size} bits!') +def verify_pki_openssh_key(config: dict, key_name: str): + """ + Common helper function user by PKI consumers to perform recurring + validation functions on OpenSSH keys + """ + if 'pki' not in config: + raise ConfigError('PKI is not configured!') + + if 'openssh' not in config['pki']: + raise ConfigError('PKI does not contain any OpenSSH keys!') + + if key_name not in config['pki']['openssh']: + raise ConfigError(f'OpenSSH key "{key_name}" not found in configuration!') + + if 'public' in config['pki']['openssh'][key_name]: + if not {'key', 'type'} <= set(config['pki']['openssh'][key_name]['public']): + raise ConfigError('Both public key and type must be defined for '\ + f'OpenSSH public key "{key_name}"!') + def verify_eapol(config: dict): """ Common helper function used by interface implementations to perform diff --git a/python/vyos/debug.py b/python/vyos/debug.py index 6ce42b173..5b6e8172e 100644 --- a/python/vyos/debug.py +++ b/python/vyos/debug.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 9757a34df..5e3ba4e63 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,10 +15,10 @@ import os -base_dir = '/usr/libexec/vyos/' +base_dir = '/usr/libexec/vyos' directories = { - 'base' : base_dir, + 'base' : f'{base_dir}', 'data' : '/usr/share/vyos/', 'conf_mode' : f'{base_dir}/conf_mode', 'op_mode' : f'{base_dir}/op_mode', @@ -37,7 +37,24 @@ directories = { 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', 'completion_dir' : f'{base_dir}/completion', - 'ca_certificates' : '/usr/local/share/ca-certificates/vyos' + 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', + 'ppp_nexthop_dir' : '/run/ppp_nexthop', + 'proto_path' : '/usr/share/vyos/vyconf', + 'vyconf_session_dir' : f'{base_dir}/vyconf/session' +} + +systemd_services = { + 'haproxy' : 'haproxy.service', + 'syslog' : 'syslog.service', + 'snmpd' : 'snmpd.service', +} + +internal_ports = { + 'certbot_haproxy' : 65080, # Certbot running behing haproxy +} + +config_files = { + 'sshd_user_ca' : '/run/sshd/trusted_user_ca', } config_status = '/tmp/vyos-config-status' @@ -56,10 +73,20 @@ config_default = os.path.join(directories['data'], 'config.boot.default') rt_symbolic_names = { # Standard routing tables for Linux & reserved IDs for VyOS - 'default': 253, # Confusingly, a final fallthru, not the default. - 'main': 254, # The actual global table used by iproute2 unless told otherwise. + 'default': 253, # Confusingly, a final fallthru, not the default. + 'main': 254, # The actual global table used by iproute2 unless told otherwise. 'local': 255, # Special kernel loopback table. } rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] + +vyconfd_conf = '/etc/vyos/vyconfd.conf' + +DEFAULT_COMMIT_CONFIRM_MINUTES = 10 + +commit_hooks = {'pre': '/etc/commit/pre-hooks.d', + 'post': '/etc/commit/post-hooks.d' + } + +airbag_noteworthy_size = 20 diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py index 4710a5d40..6c362163c 100644 --- a/python/vyos/ethtool.py +++ b/python/vyos/ethtool.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 314e8dfe3..b136b6fca 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -1,4 +1,4 @@ -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -233,6 +233,9 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): hook_name = 'prerouting' if hook == 'NAM': hook_name = f'name' + # for policy + if hook == 'route' or hook == 'route6': + hook_name = hook output.append(f'{ip_name} {prefix}addr {operator} @GEOIP_CC{def_suffix}_{hook_name}_{fw_name}_{rule_id}') if 'mac_address' in side_conf: @@ -310,6 +313,16 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @D_{group_name}') + elif 'remote_group' in group: + group_name = group['remote_group'] + operator = '' + if group_name[0] == '!': + operator = '!=' + group_name = group_name[1:] + if ip_name == 'ip': + output.append(f'{ip_name} {prefix}addr {operator} @R_{group_name}') + elif ip_name == 'ip6': + output.append(f'{ip_name} {prefix}addr {operator} @R6_{group_name}') if 'mac_group' in group: group_name = group['mac_group'] operator = '' @@ -348,7 +361,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if iiface[0] == '!': operator = '!=' iiface = iiface[1:] - output.append(f'iifname {operator} {{{iiface}}}') + output.append(f'iifname {operator} {{"{iiface}"}}') elif 'group' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['group'] if iiface[0] == '!': @@ -363,7 +376,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if oiface[0] == '!': operator = '!=' oiface = oiface[1:] - output.append(f'oifname {operator} {{{oiface}}}') + output.append(f'oifname {operator} {{"{oiface}"}}') elif 'group' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['group'] if oiface[0] == '!': @@ -461,14 +474,14 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): output.append('gre version 1') if gre_key: - # The offset of the key within the packet shifts depending on the C-flag. - # nftables cannot handle complex enough expressions to match multiple + # The offset of the key within the packet shifts depending on the C-flag. + # nftables cannot handle complex enough expressions to match multiple # offsets based on bitfields elsewhere. - # We enforce a specific match for the checksum flag in validation, so the - # gre_flags dict will always have a 'checksum' key when gre_key is populated. - if not gre_flags['checksum']: + # We enforce a specific match for the checksum flag in validation, so the + # gre_flags dict will always have a 'checksum' key when gre_key is populated. + if not gre_flags['checksum']: # No "unset" child node means C is set, we offset key lookup +32 bits - output.append(f'@th,64,32 == {gre_key}') + output.append(f'@th,64,32 == {gre_key}') else: output.append(f'@th,32,32 == {gre_key}') @@ -627,7 +640,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): return " ".join(output) def parse_gre_flags(flags, force_keyed=False): - flag_map = { # nft does not have symbolic names for these. + flag_map = { # nft does not have symbolic names for these. 'checksum': 1<<0, 'routing': 1<<1, 'key': 1<<2, @@ -638,7 +651,7 @@ def parse_gre_flags(flags, force_keyed=False): include = 0 exclude = 0 for fl_name, fl_state in flags.items(): - if not fl_state: + if not fl_state: include |= flag_map[fl_name] else: # 'unset' child tag exclude |= flag_map[fl_name] @@ -731,14 +744,14 @@ class GeoIPLock(object): def __exit__(self, exc_type, exc_value, tb): os.unlink(self.file) -def geoip_update(firewall, force=False): +def geoip_update(firewall=None, policy=None, force=False): with GeoIPLock(geoip_lock_file) as lock: if not lock: print("Script is already running") return False - if not firewall: - print("Firewall is not configured") + if not firewall and not policy: + print("Firewall and policy are not configured") return True if not os.path.exists(geoip_database): @@ -753,23 +766,41 @@ def geoip_update(firewall, force=False): ipv4_sets = {} ipv6_sets = {} + ipv4_codes_policy = {} + ipv6_codes_policy = {} + + ipv4_sets_policy = {} + ipv6_sets_policy = {} + # Map country codes to set names - for codes, path in dict_search_recursive(firewall, 'country_code'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' - if ( path[0] == 'ipv4'): - for code in codes: - ipv4_codes.setdefault(code, []).append(set_name) - elif ( path[0] == 'ipv6' ): - set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' - for code in codes: - ipv6_codes.setdefault(code, []).append(set_name) - - if not ipv4_codes and not ipv6_codes: + if firewall: + for codes, path in dict_search_recursive(firewall, 'country_code'): + set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + if ( path[0] == 'ipv4'): + for code in codes: + ipv4_codes.setdefault(code, []).append(set_name) + elif ( path[0] == 'ipv6' ): + set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' + for code in codes: + ipv6_codes.setdefault(code, []).append(set_name) + + if policy: + for codes, path in dict_search_recursive(policy, 'country_code'): + set_name = f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}' + if ( path[0] == 'route'): + for code in codes: + ipv4_codes_policy.setdefault(code, []).append(set_name) + elif ( path[0] == 'route6' ): + set_name = f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}' + for code in codes: + ipv6_codes_policy.setdefault(code, []).append(set_name) + + if not ipv4_codes and not ipv6_codes and not ipv4_codes_policy and not ipv6_codes_policy: if force: - print("GeoIP not in use by firewall") + print("GeoIP not in use by firewall and policy") return True - geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes]) + geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes, *ipv4_codes_policy, *ipv6_codes_policy]) # Iterate IP blocks to assign to sets for start, end, code in geoip_data: @@ -778,19 +809,29 @@ def geoip_update(firewall, force=False): ip_range = f'{start}-{end}' if start != end else start for setname in ipv4_codes[code]: ipv4_sets.setdefault(setname, []).append(ip_range) + if code in ipv4_codes_policy and ipv4: + ip_range = f'{start}-{end}' if start != end else start + for setname in ipv4_codes_policy[code]: + ipv4_sets_policy.setdefault(setname, []).append(ip_range) if code in ipv6_codes and not ipv4: ip_range = f'{start}-{end}' if start != end else start for setname in ipv6_codes[code]: ipv6_sets.setdefault(setname, []).append(ip_range) + if code in ipv6_codes_policy and not ipv4: + ip_range = f'{start}-{end}' if start != end else start + for setname in ipv6_codes_policy[code]: + ipv6_sets_policy.setdefault(setname, []).append(ip_range) render(nftables_geoip_conf, 'firewall/nftables-geoip-update.j2', { 'ipv4_sets': ipv4_sets, - 'ipv6_sets': ipv6_sets + 'ipv6_sets': ipv6_sets, + 'ipv4_sets_policy': ipv4_sets_policy, + 'ipv6_sets_policy': ipv6_sets_policy, }) result = run(f'nft --file {nftables_geoip_conf}') if result != 0: - print('Error: GeoIP failed to update firewall') + print('Error: GeoIP failed to update firewall/policy') return False return True diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index ba44978d1..f4ed69205 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -1,4 +1,4 @@ -# Copyright 2024-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -60,6 +60,10 @@ def get_frrender_dict(conf, argv=None) -> dict: from vyos.configdict import get_dhcp_interfaces from vyos.configdict import get_pppoe_interfaces + # We need to re-set the CLI path to the root level, as this function uses + # conf.exists() with an absolute path form the CLI root + conf.set_level([]) + # Create an empty dictionary which will be filled down the code path and # returned to the caller dict = {} @@ -88,7 +92,7 @@ def get_frrender_dict(conf, argv=None) -> dict: if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None: del default_values['area'][area_num]['area_type']['nssa'] - for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']: + for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'nhrp', 'rip', 'static']: if dict_search(f'redistribute.{protocol}', ospf) is None: del default_values['redistribute'][protocol] if not bool(default_values['redistribute']): @@ -539,6 +543,21 @@ def get_frrender_dict(conf, argv=None) -> dict: elif conf.exists_effective(ospfv3_vrf_path): vrf['name'][vrf_name]['protocols'].update({'ospfv3' : {'deleted' : ''}}) + # We need to check the CLI if the RPKI node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + rpki_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'rpki'] + if 'rpki' in vrf_config.get('protocols', []): + rpki = conf.get_config_dict(rpki_vrf_path, key_mangling=('-', '_'), get_first_key=True, + with_pki=True, with_recursive_defaults=True) + rpki_ssh_key_base = '/run/frr/id_rpki' + for cache, cache_config in rpki.get('cache',{}).items(): + if 'ssh' in cache_config: + cache_config['ssh']['public_key_file'] = f'{rpki_ssh_key_base}_{cache}.pub' + cache_config['ssh']['private_key_file'] = f'{rpki_ssh_key_base}_{cache}' + vrf['name'][vrf_name]['protocols'].update({'rpki' : rpki}) + elif conf.exists_effective(rpki_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'rpki' : {'deleted' : ''}}) + # We need to check the CLI if the static node is present and thus load in all the default # values present on the CLI - that's why we have if conf.exists() static_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'static'] @@ -599,8 +618,10 @@ def get_frrender_dict(conf, argv=None) -> dict: dict.update({'vrf' : vrf}) if os.path.exists(frr_debug_enable): + print(f'---- get_frrender_dict({conf}) ----') import pprint pprint.pprint(dict) + print('-----------------------------------') return dict @@ -669,7 +690,7 @@ class FRRender: output += render_to_string('frr/ripngd.frr.j2', config_dict['ripng']) output += '\n' if 'rpki' in config_dict and 'deleted' not in config_dict['rpki']: - output += render_to_string('frr/rpki.frr.j2', config_dict['rpki']) + output += render_to_string('frr/rpki.frr.j2', {'rpki': config_dict['rpki']}) output += '\n' if 'segment_routing' in config_dict and 'deleted' not in config_dict['segment_routing']: output += render_to_string('frr/zebra.segment_routing.frr.j2', config_dict['segment_routing']) @@ -691,6 +712,9 @@ class FRRender: debug('FRR: START CONFIGURATION RENDERING') # we can not reload an empty file, thus we always embed the marker output = '!\n' + # Enable FRR logging + output += 'log syslog\n' + output += 'log facility local7\n' # Enable SNMP agentx support # SNMP AgentX support cannot be disabled once enabled if 'snmp' in config_dict: diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py index 206b2bba1..7838fa9a2 100644 --- a/python/vyos/ifconfig/__init__.py +++ b/python/vyos/ifconfig/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/afi.py b/python/vyos/ifconfig/afi.py index fd263d220..a391cb8a0 100644 --- a/python/vyos/ifconfig/afi.py +++ b/python/vyos/ifconfig/afi.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py index a659b9bd2..ba31ae0c9 100644 --- a/python/vyos/ifconfig/bond.py +++ b/python/vyos/ifconfig/bond.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -485,11 +485,25 @@ class BondIf(Interface): # Add (enslave) interfaces to bond value = dict_search('member.interface', config) + is_first = True for interface in (value or []): - # if we've come here we already verified the interface - # does not have an addresses configured so just flush - # any remaining ones - Interface(interface).flush_addrs() + # Physical bond member interface instance + tmp_if = Interface(interface) + # At this point, we've confirmed that the interface has no + # configured addresses, so we can safely flush any remaining ones. + tmp_if.flush_addrs() + + # T7571: This behavior changed from Linux Kernel 5.4 (used in VyOS 1.3) + # to Kernel 6.6 (starting with VyOS 1.4). Previously, the MAC address of + # the first member interface in a bond was adopted as the bond's default MAC. + # In newer versions, a synthetic MAC address is assigned instead. + # + # Re-assign first underlay MAC address to the bond + if is_first: + self.set_mac(tmp_if.get_mac()) + is_first = False + + # Assign underlaying interface to logical bond self.add_port(interface) # Add system mac address for 802.3ad - default address is all zero diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index d534dade7..ba06e3757 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -19,7 +19,7 @@ from vyos.utils.assertion import assert_list from vyos.utils.assertion import assert_positive from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists -from vyos.configdict import get_vlan_ids +from vyos.configdict import get_vlans_ids_and_range from vyos.configdict import list_diff @Interface.register @@ -376,11 +376,21 @@ class BridgeIf(Interface): if 'priority' in interface_config: lower.set_path_priority(interface_config['priority']) + # set BPDU guard + tmp = dict_search('bpdu_guard', interface_config) + value = '1' if (tmp != None) else '0' + lower.set_bpdu_guard(value) + + # set root guard + tmp = dict_search('root_guard', interface_config) + value = '1' if (tmp != None) else '0' + lower.set_root_guard(value) + if 'enable_vlan' in config: add_vlan = [] native_vlan_id = None allowed_vlan_ids= [] - cur_vlan_ids = get_vlan_ids(interface) + cur_vlan_ids = get_vlans_ids_and_range(interface) if 'native_vlan' in interface_config: vlan_id = interface_config['native_vlan'] @@ -389,14 +399,8 @@ class BridgeIf(Interface): if 'allowed_vlan' in interface_config: for vlan in interface_config['allowed_vlan']: - vlan_range = vlan.split('-') - if len(vlan_range) == 2: - for vlan_add in range(int(vlan_range[0]),int(vlan_range[1]) + 1): - add_vlan.append(str(vlan_add)) - allowed_vlan_ids.append(str(vlan_add)) - else: - add_vlan.append(vlan) - allowed_vlan_ids.append(vlan) + add_vlan.append(vlan) + allowed_vlan_ids.append(vlan) # Remove redundant VLANs from the system for vlan in list_diff(cur_vlan_ids, add_vlan): diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py index 7402da55a..3cf3297b4 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -29,6 +29,8 @@ class Control(Section): _command_get = {} _command_set = {} _signature = {} + config = {} + ifname = None def __init__(self, **kargs): # some commands (such as operation comands - show interfaces, etc.) @@ -48,7 +50,7 @@ class Control(Section): def _popen(self, command): return popen(command, self.debug) - def _cmd(self, command): + def _cmd(self, command, env=None): import re if 'netns' in self.config: # This command must be executed from default netns 'ip link set dev X netns X' @@ -61,7 +63,7 @@ class Control(Section): command = command else: command = f'ip netns exec {self.config["netns"]} {command}' - return cmd(command, self.debug) + return cmd(command, self.debug, env=env) def _get_command(self, config, name): """ diff --git a/python/vyos/ifconfig/dummy.py b/python/vyos/ifconfig/dummy.py index 29a1965a3..93066c965 100644 --- a/python/vyos/ifconfig/dummy.py +++ b/python/vyos/ifconfig/dummy.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index 93727bdf6..864a9d0bc 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/geneve.py b/python/vyos/ifconfig/geneve.py index f7fddb812..7c5b7c0fb 100644 --- a/python/vyos/ifconfig/geneve.py +++ b/python/vyos/ifconfig/geneve.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -48,7 +48,7 @@ class GeneveIf(Interface): 'parameters.ipv6.flowlabel' : 'flowlabel', } - cmd = 'ip link add name {ifname} type geneve id {vni} remote {remote}' + cmd = 'ip link add name {ifname} type geneve id {vni} remote {remote} dstport {port}' for vyos_key, iproute2_key in mapping.items(): # dict_search will return an empty dict "{}" for valueless nodes like # "parameters.nolearning" - thus we need to test the nodes existence diff --git a/python/vyos/ifconfig/input.py b/python/vyos/ifconfig/input.py index 201d3cacb..6cb1eb64c 100644 --- a/python/vyos/ifconfig/input.py +++ b/python/vyos/ifconfig/input.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index cb73e2597..3b3536301 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1,4 +1,4 @@ -# Copyright 2019-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -22,10 +22,10 @@ from copy import deepcopy from glob import glob from ipaddress import IPv4Network -from netifaces import ifaddresses -# this is not the same as socket.AF_INET/INET6 -from netifaces import AF_INET -from netifaces import AF_INET6 +from ipaddress import IPv6Interface +from netifaces import ifaddresses # pylint: disable = no-name-in-module +from socket import AF_INET +from socket import AF_INET6 from netaddr import EUI from netaddr import mac_unix_expanded @@ -216,6 +216,16 @@ class Interface(Control): 'location': '/sys/class/net/{ifname}/brport/priority', 'errormsg': '{ifname} is not a bridge port member' }, + 'bpdu_guard': { + 'validate': assert_boolean, + 'location': '/sys/class/net/{ifname}/brport/bpdu_guard', + 'errormsg': '{ifname} is not a bridge port member' + }, + 'root_guard': { + 'validate': assert_boolean, + 'location': '/sys/class/net/{ifname}/brport/root_block', + 'errormsg': '{ifname} is not a bridge port member' + }, 'proxy_arp': { 'validate': assert_boolean, 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp', @@ -412,11 +422,11 @@ class Interface(Control): self._cmd(f'nft {nft_command}') def _del_interface_from_ct_iface_map(self): - nft_command = f'delete element inet vrf_zones ct_iface_map {{ "{self.ifname}" }}' + nft_command = f'delete element inet vrf_zones ct_iface_map {{ \'"{self.ifname}"\' }}' self._nft_check_and_run(nft_command) def _add_interface_to_ct_iface_map(self, vrf_table_id: int): - nft_command = f'add element inet vrf_zones ct_iface_map {{ "{self.ifname}" : {vrf_table_id} }}' + nft_command = f'add element inet vrf_zones ct_iface_map {{ \'"{self.ifname}"\' : {vrf_table_id} }}' self._nft_check_and_run(nft_command) def get_ifindex(self): @@ -595,12 +605,16 @@ class Interface(Control): """ Add/Remove interface from given VRF instance. + Keyword arguments: + vrf: VRF instance name or empty string (default VRF) + + Return True if VRF was changed, False otherwise + Example: >>> from vyos.ifconfig import Interface >>> Interface('eth0').set_vrf('foo') >>> Interface('eth0').set_vrf() """ - # Don't allow for netns yet if 'netns' in self.config: return False @@ -611,21 +625,33 @@ class Interface(Control): # Get current VRF table ID old_vrf_tableid = get_vrf_tableid(self.ifname) - self.set_interface('vrf', vrf) + # Always stop the DHCP client process to clean up routes within the VRF + # where the process was originally started. There is no need to add a + # condition to only call the method if "address dhcp" was defined, as + # this is handled inside set_dhcp(v6) by only stopping if the daemon is + # running. DHCP client process restart will be handled later on once the + # interface is moved to the new VRF. + self.set_dhcp(False) + self.set_dhcpv6(False) + + # Move interface in/out of VRF + self.set_interface('vrf', vrf) if vrf: # Get routing table ID number for VRF vrf_table_id = get_vrf_tableid(vrf) # Add map element with interface and zone ID - if vrf_table_id: + if vrf_table_id and old_vrf_tableid != vrf_table_id: # delete old table ID from nftables if it has changed, e.g. interface moved to a different VRF - if old_vrf_tableid and old_vrf_tableid != int(vrf_table_id): - self._del_interface_from_ct_iface_map() + self._del_interface_from_ct_iface_map() self._add_interface_to_ct_iface_map(vrf_table_id) + return True else: - self._del_interface_from_ct_iface_map() + if old_vrf_tableid != get_vrf_tableid(self.ifname): + self._del_interface_from_ct_iface_map() + return True - return True + return False def set_arp_cache_tmo(self, tmo): """ @@ -893,7 +919,11 @@ class Interface(Control): tmp = self.get_interface('ipv6_autoconf') if tmp == autoconf: return None - return self.set_interface('ipv6_autoconf', autoconf) + rc = self.set_interface('ipv6_autoconf', autoconf) + if autoconf == '0': + flushed = self.flush_ipv6_slaac_addrs() + self.flush_ipv6_slaac_routes(ra_addrs=flushed) + return rc def add_ipv6_eui64_address(self, prefix): """ @@ -921,6 +951,20 @@ class Interface(Control): prefixlen = prefix.split('/')[1] self.del_addr(f'{eui64}/{prefixlen}') + def set_ipv6_interface_identifier(self, identifier): + """ + Set the interface identifier for IPv6 autoconf. + """ + cmd = f'ip token set {identifier} dev {self.ifname}' + self._cmd(cmd) + + def del_ipv6_interface_identifier(self): + """ + Delete the interface identifier for IPv6 autoconf. + """ + cmd = f'ip token delete dev {self.ifname}' + self._cmd(cmd) + def set_ipv6_forwarding(self, forwarding): """ Configure IPv6 interface-specific Host/Router behaviour. @@ -1071,6 +1115,28 @@ class Interface(Control): """ self.set_interface('path_priority', priority) + def set_bpdu_guard(self, state): + """ + Set BPDU guard state for a bridge port. When enabled, the port will be + disabled if it receives a BPDU packet. + + Example: + >>> from vyos.ifconfig import Interface + >>> Interface('eth0').set_bpdu_guard(1) + """ + self.set_interface('bpdu_guard', state) + + def set_root_guard(self, state): + """ + Set root guard state for a bridge port. When enabled, the port will be + disabled if it receives a superior BPDU that would make it a root port. + + Example: + >>> from vyos.ifconfig import Interface + >>> Interface('eth0').set_root_guard(1) + """ + self.set_interface('root_guard', state) + def set_port_isolation(self, on_or_off): """ Controls whether a given port will be isolated, which means it will be @@ -1181,7 +1247,7 @@ class Interface(Control): """ return self.get_addr_v4() + self.get_addr_v6() - def add_addr(self, addr): + def add_addr(self, addr: str, vrf_changed: bool=False) -> bool: """ Add IP(v6) address to interface. Address is only added if it is not already assigned to that interface. Address format must be validated @@ -1214,15 +1280,14 @@ class Interface(Control): # add to interface if addr == 'dhcp': - self.set_dhcp(True) + self.set_dhcp(True, vrf_changed=vrf_changed) elif addr == 'dhcpv6': - self.set_dhcpv6(True) + self.set_dhcpv6(True, vrf_changed=vrf_changed) elif not is_intf_addr_assigned(self.ifname, addr, netns=netns): netns_cmd = f'ip netns exec {netns}' if netns else '' tmp = f'{netns_cmd} ip addr add {addr} dev {self.ifname}' # Add broadcast address for IPv4 if is_ipv4(addr): tmp += ' brd +' - self._cmd(tmp) else: return False @@ -1232,7 +1297,7 @@ class Interface(Control): return True - def del_addr(self, addr): + def del_addr(self, addr: str) -> bool: """ Delete IP(v6) address from interface. Address is only deleted if it is assigned to that interface. Address format must be exactly the same as @@ -1295,6 +1360,71 @@ class Interface(Control): # flush all addresses self._cmd(cmd) + def flush_ipv6_slaac_addrs(self) -> list: + """ + Flush all IPv6 addresses installed in response to router advertisement + messages from this interface. + + Will raise an exception on error. + Will return a list of flushed IPv6 addresses. + """ + netns = get_interface_namespace(self.ifname) + netns_cmd = f'ip netns exec {netns}' if netns else '' + tmp = get_interface_address(self.ifname) + if not tmp or 'addr_info' not in tmp: + return + + # Parse interface IP addresses. Example data: + # {'family': 'inet6', 'local': '2001:db8:1111:0:250:56ff:feb3:38c5', + # 'prefixlen': 64, 'scope': 'global', 'dynamic': True, + # 'mngtmpaddr': True, 'protocol': 'kernel_ra', + # 'valid_life_time': 2591987, 'preferred_life_time': 14387} + flushed = [] + for addr_info in tmp['addr_info']: + if 'protocol' not in addr_info: + continue + if (addr_info['protocol'] == 'kernel_ra' and + addr_info['scope'] == 'global'): + # Flush IPv6 addresses installed by router advertisement + ra_addr = f"{addr_info['local']}/{addr_info['prefixlen']}" + flushed.append(ra_addr) + cmd = f'{netns_cmd} ip -6 addr del dev {self.ifname} {ra_addr}' + self._cmd(cmd) + return flushed + + def flush_ipv6_slaac_routes(self, ra_addrs: list=[]) -> None: + """ + Flush IPv6 default routes installed in response to router advertisement + messages from this interface. + + Will raise an exception on error. + """ + # Find IPv6 connected prefixes for flushed SLAAC addresses + connected = [] + for addr in ra_addrs if isinstance(ra_addrs, list) else []: + connected.append(str(IPv6Interface(addr).network)) + + netns = get_interface_namespace(self.ifname) + netns_cmd = f'ip netns exec {netns}' if netns else '' + + tmp = self._cmd(f'{netns_cmd} ip -j -6 route show dev {self.ifname}') + tmp = json.loads(tmp) + # Parse interface routes. Example data: + # {'dst': 'default', 'gateway': 'fe80::250:56ff:feb3:cdba', + # 'protocol': 'ra', 'metric': 1024, 'flags': [], 'expires': 1398, + # 'metrics': [{'hoplimit': 64}], 'pref': 'medium'} + for route in tmp: + # If it's a default route received from RA, delete it + if (dict_search('dst', route) == 'default' and + dict_search('protocol', route) == 'ra'): + self._cmd(f'{netns_cmd} ip -6 route del default via {route["gateway"]} dev {self.ifname}') + # Remove connected prefixes received from RA + if dict_search('dst', route) in connected: + # If it's a connected prefix, delete it + self._cmd(f'{netns_cmd} ip -6 route del {route["dst"]} dev {self.ifname}') + + return None + def add_to_bridge(self, bridge_dict): """ Adds the interface to the bridge with the passed port config. @@ -1305,8 +1435,6 @@ class Interface(Control): # drop all interface addresses first self.flush_addrs() - ifname = self.ifname - for bridge, bridge_config in bridge_dict.items(): # add interface to bridge - use Section.klass to get BridgeIf class Section.klass(bridge)(bridge, create=True).add_port(self.ifname) @@ -1322,7 +1450,7 @@ class Interface(Control): bridge_vlan_filter = Section.klass(bridge)(bridge, create=True).get_vlan_filter() if int(bridge_vlan_filter): - cur_vlan_ids = get_vlan_ids(ifname) + cur_vlan_ids = get_vlan_ids(self.ifname) add_vlan = [] native_vlan_id = None allowed_vlan_ids= [] @@ -1345,18 +1473,18 @@ class Interface(Control): # Remove redundant VLANs from the system for vlan in list_diff(cur_vlan_ids, add_vlan): - cmd = f'bridge vlan del dev {ifname} vid {vlan} master' + cmd = f'bridge vlan del dev {self.ifname} vid {vlan} master' self._cmd(cmd) for vlan in allowed_vlan_ids: - cmd = f'bridge vlan add dev {ifname} vid {vlan} master' + cmd = f'bridge vlan add dev {self.ifname} vid {vlan} master' self._cmd(cmd) # Setting native VLAN to system if native_vlan_id: - cmd = f'bridge vlan add dev {ifname} vid {native_vlan_id} pvid untagged master' + cmd = f'bridge vlan add dev {self.ifname} vid {native_vlan_id} pvid untagged master' self._cmd(cmd) - def set_dhcp(self, enable): + def set_dhcp(self, enable: bool, vrf_changed: bool=False): """ Enable/Disable DHCP client on a given interface. """ @@ -1396,7 +1524,9 @@ class Interface(Control): # the old lease is released a new one is acquired (T4203). We will # only restart DHCP client if it's option changed, or if it's not # running, but it should be running (e.g. on system startup) - if 'dhcp_options_changed' in self.config or not is_systemd_service_active(systemd_service): + if (vrf_changed or + ('dhcp_options_changed' in self.config) or + (not is_systemd_service_active(systemd_service))): return self._cmd(f'systemctl restart {systemd_service}') else: if is_systemd_service_active(systemd_service): @@ -1423,19 +1553,18 @@ class Interface(Control): return None - def set_dhcpv6(self, enable): + def set_dhcpv6(self, enable: bool, vrf_changed: bool=False): """ Enable/Disable DHCPv6 client on a given interface. """ if enable not in [True, False]: raise ValueError() - ifname = self.ifname config_base = directories['dhcp6_client_dir'] - config_file = f'{config_base}/dhcp6c.{ifname}.conf' - script_file = f'/etc/wide-dhcpv6/dhcp6c.{ifname}.script' # can not live under /run b/c of noexec mount option - systemd_override_file = f'/run/systemd/system/dhcp6c@{ifname}.service.d/10-override.conf' - systemd_service = f'dhcp6c@{ifname}.service' + config_file = f'{config_base}/dhcp6c.{self.ifname}.conf' + script_file = f'/etc/wide-dhcpv6/dhcp6c.{self.ifname}.script' # can not live under /run b/c of noexec mount option + systemd_override_file = f'/run/systemd/system/dhcp6c@{self.ifname}.service.d/10-override.conf' + systemd_service = f'dhcp6c@{self.ifname}.service' # Rendered client configuration files require additional settings config = deepcopy(self.config) @@ -1452,7 +1581,10 @@ class Interface(Control): # We must ignore any return codes. This is required to enable # DHCPv6-PD for interfaces which are yet not up and running. - return self._popen(f'systemctl restart {systemd_service}') + if (vrf_changed or + ('dhcpv6_options_changed' in self.config) or + (not is_systemd_service_active(systemd_service))): + return self._popen(f'systemctl restart {systemd_service}') else: if is_systemd_service_active(systemd_service): self._cmd(f'systemctl stop {systemd_service}') @@ -1669,30 +1801,31 @@ class Interface(Control): else: self.del_addr(addr) - # start DHCPv6 client when only PD was configured - if dhcpv6pd: - self.set_dhcpv6(True) - # XXX: Bind interface to given VRF or unbind it if vrf is not set. Unbinding # will call 'ip link set dev eth0 nomaster' which will also drop the # interface out of any bridge or bond - thus this is checked before. + vrf_changed = False if 'is_bond_member' in config: bond_if = next(iter(config['is_bond_member'])) tmp = get_interface_config(config['ifname']) if 'master' in tmp and tmp['master'] != bond_if: - self.set_vrf('') + vrf_changed = self.set_vrf('') elif 'is_bridge_member' in config: bridge_if = next(iter(config['is_bridge_member'])) tmp = get_interface_config(config['ifname']) if 'master' in tmp and tmp['master'] != bridge_if: - self.set_vrf('') + vrf_changed = self.set_vrf('') else: - self.set_vrf(config.get('vrf', '')) + vrf_changed = self.set_vrf(config.get('vrf', '')) + + # start DHCPv6 client when only PD was configured + if dhcpv6pd: + self.set_dhcpv6(True, vrf_changed=vrf_changed) # Add this section after vrf T4331 for addr in new_addr: - self.add_addr(addr) + self.add_addr(addr, vrf_changed=vrf_changed) # Configure MSS value for IPv4 TCP connections tmp = dict_search('ip.adjust_mss', config) @@ -1771,11 +1904,26 @@ class Interface(Control): value = '0' if (tmp != None) else '1' self.set_ipv6_forwarding(value) + # Delete old interface identifier + # This should be before setting the accept_ra value + old = dict_search('ipv6.address.interface_identifier_old', config) + now = dict_search('ipv6.address.interface_identifier', config) + if old and not now: + # accept_ra of ra is required to delete the interface identifier + self.set_ipv6_accept_ra('2') + self.del_ipv6_interface_identifier() + + # Set IPv6 Interface identifier + # This should be before setting the accept_ra value + tmp = dict_search('ipv6.address.interface_identifier', config) + if tmp: + # accept_ra is required to set the interface identifier + self.set_ipv6_accept_ra('2') + self.set_ipv6_interface_identifier(tmp) + # IPv6 router advertisements tmp = dict_search('ipv6.address.autoconf', config) - value = '2' if (tmp != None) else '1' - if 'dhcpv6' in new_addr: - value = '2' + value = '2' if (tmp != None) else '0' self.set_ipv6_accept_ra(value) # IPv6 address autoconfiguration diff --git a/python/vyos/ifconfig/l2tpv3.py b/python/vyos/ifconfig/l2tpv3.py index dfaa006aa..ea9294e99 100644 --- a/python/vyos/ifconfig/l2tpv3.py +++ b/python/vyos/ifconfig/l2tpv3.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py index 13e8a2c50..f4fc2c906 100644 --- a/python/vyos/ifconfig/loopback.py +++ b/python/vyos/ifconfig/loopback.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py index 3b4dc223f..4d76a1d46 100644 --- a/python/vyos/ifconfig/macsec.py +++ b/python/vyos/ifconfig/macsec.py @@ -1,4 +1,4 @@ -# Copyright 2020-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index fe948b920..7a26f9ef5 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/operational.py b/python/vyos/ifconfig/operational.py index dc2742123..e60518948 100644 --- a/python/vyos/ifconfig/operational.py +++ b/python/vyos/ifconfig/operational.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/pppoe.py b/python/vyos/ifconfig/pppoe.py index 85ca3877e..4ca66cf4d 100644 --- a/python/vyos/ifconfig/pppoe.py +++ b/python/vyos/ifconfig/pppoe.py @@ -1,4 +1,4 @@ -# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/section.py b/python/vyos/ifconfig/section.py index 50273cf67..4ea606495 100644 --- a/python/vyos/ifconfig/section.py +++ b/python/vyos/ifconfig/section.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py index d92ef23dc..e43a2f177 100644 --- a/python/vyos/ifconfig/sstpc.py +++ b/python/vyos/ifconfig/sstpc.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py index df904f7d5..f96364161 100644 --- a/python/vyos/ifconfig/tunnel.py +++ b/python/vyos/ifconfig/tunnel.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py index 2c8709d20..f4075fa02 100644 --- a/python/vyos/ifconfig/veth.py +++ b/python/vyos/ifconfig/veth.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/vrrp.py b/python/vyos/ifconfig/vrrp.py index 3ee22706c..4949fe571 100644 --- a/python/vyos/ifconfig/vrrp.py +++ b/python/vyos/ifconfig/vrrp.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 78f5895f8..030aa1ed7 100644 --- a/python/vyos/ifconfig/vti.py +++ b/python/vyos/ifconfig/vti.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py index ee790f275..e6963ce5d 100644 --- a/python/vyos/ifconfig/vtun.py +++ b/python/vyos/ifconfig/vtun.py @@ -1,4 +1,4 @@ -# Copyright 2020-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py index 58844885b..0f55acf10 100644 --- a/python/vyos/ifconfig/vxlan.py +++ b/python/vyos/ifconfig/vxlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 519012625..c4e70056c 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -22,10 +22,13 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +from vyos.base import Warning +from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 - +from vyos.template import is_ipv4 +from vyos.utils.network import get_wireguard_peers class WireGuardOperational(Operational): def _dump(self): """Dump wireguard data in a python friendly way.""" @@ -49,7 +52,7 @@ class WireGuardOperational(Operational): 'private_key': None if private_key == '(none)' else private_key, 'public_key': None if public_key == '(none)' else public_key, 'listen_port': int(listen_port), - 'fw_mark': None if fw_mark == 'off' else int(fw_mark), + 'fw_mark': None if fw_mark == 'off' else int(fw_mark, 16), 'peers': {}, } else: @@ -90,12 +93,15 @@ class WireGuardOperational(Operational): c.set_level(['interfaces', 'wireguard', self.config['ifname']]) description = c.return_effective_value(['description']) ips = c.return_effective_values(['address']) + hostnames = c.return_effective_values(['host-name']) answer = 'interface: {}\n'.format(self.config['ifname']) if description: answer += ' description: {}\n'.format(description) if ips: answer += ' address: {}\n'.format(', '.join(ips)) + if hostnames: + answer += ' hostname: {}\n'.format(', '.join(hostnames)) answer += ' public key: {}\n'.format(wgdump['public_key']) answer += ' private key: (hidden)\n' @@ -155,6 +161,77 @@ class WireGuardOperational(Operational): answer += '\n' return answer + def get_latest_handshakes(self): + """Get latest handshake time for each peer""" + output = {} + + # Dump wireguard last handshake + tmp = self._cmd(f'wg show {self.ifname} latest-handshakes') + # Output: + # PUBLIC-KEY= 1732812147 + for line in tmp.split('\n'): + if not line: + # Skip empty lines and last line + continue + items = line.split('\t') + + if len(items) != 2: + continue + + output[items[0]] = int(items[1]) + + return output + + def reset_peer(self, peer_name=None, public_key=None): + c = ConfigTreeQuery() + tmp = c.get_config_dict(['interfaces', 'wireguard', self.ifname], + effective=True, get_first_key=True, + key_mangling=('-', '_'), with_defaults=True) + + current_peers = self._dump().get(self.ifname, {}).get('peers', {}) + + for peer, peer_config in tmp['peer'].items(): + peer_public_key = peer_config['public_key'] + if peer_name is None or peer == peer_name or public_key == peer_public_key: + if ('address' not in peer_config and 'host_name' not in peer_config) or 'port' not in peer_config: + if peer_name is not None: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" address/host-name unset!') + continue + + # As we work with an effective config, a port CLI node is always + # available when an address/host-name is defined on the CLI + port = peer_config['port'] + + # address has higher priority than host-name + if 'address' in peer_config: + address = peer_config['address'] + new_endpoint = f'{address}:{port}' + else: + host_name = peer_config['host_name'] + new_endpoint = f'{host_name}:{port}' + + if 'disable' in peer_config: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" disabled!') + continue + + cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' + try: + if (peer_public_key in current_peers + and 'endpoint' in current_peers[peer_public_key] + and current_peers[peer_public_key]['endpoint'] is not None + ): + current_endpoint = current_peers[peer_public_key]['endpoint'] + message = f'Resetting {self.ifname} peer {peer_public_key} from {current_endpoint} endpoint to {new_endpoint} ... ' + else: + message = f'Resetting {self.ifname} peer {peer_public_key} endpoint to {new_endpoint} ... ' + print(message, end='') + + self._cmd(cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': + tmp['max_dns_retry']}) + print('done') + except: + print(f'Error\nPlease try to run command manually:\n{cmd}\n') + @Interface.register class WireGuardIf(Interface): @@ -175,29 +252,66 @@ class WireGuardIf(Interface): """Get a synthetic MAC address.""" return self.get_mac_synthetic() + def get_peer_public_keys(self, config, disabled=False): + """Get list of configured peer public keys""" + if 'peer' not in config: + return [] + + public_keys = [] + + for _, peer_config in config['peer'].items(): + if disabled == ('disable' in peer_config): + public_keys.append(peer_config['public_key']) + + return public_keys + def update(self, config): """General helper function which works on a dictionary retrived by get_config_dict(). It's main intention is to consolidate the scattered interface setup code and provide a single point of entry when workin on any interface.""" - tmp_file = NamedTemporaryFile('w') - tmp_file.write(config['private_key']) - tmp_file.flush() - # Wireguard base command is identical for every peer - base_cmd = 'wg set {ifname}' + base_cmd = f'wg set {self.ifname}' + + interface_cmd = base_cmd if 'port' in config: - base_cmd += ' listen-port {port}' + interface_cmd += ' listen-port {port}' if 'fwmark' in config: - base_cmd += ' fwmark {fwmark}' + interface_cmd += ' fwmark {fwmark}' + + with NamedTemporaryFile('w') as tmp_file: + tmp_file.write(config['private_key']) + tmp_file.flush() + + interface_cmd += f' private-key {tmp_file.name}' + interface_cmd = interface_cmd.format(**config) + # T6490: execute command to ensure interface configured + self._cmd(interface_cmd) + + current_peer_public_keys = get_wireguard_peers(self.ifname) - base_cmd += f' private-key {tmp_file.name}' - base_cmd = base_cmd.format(**config) - # T6490: execute command to ensure interface configured - self._cmd(base_cmd) + if 'rebuild_required' in config: + # Remove all existing peers that no longer exist in config + current_public_keys = self.get_peer_public_keys(config) + cmd_remove_peers = [f' peer {public_key} remove' + for public_key in current_peer_public_keys + if public_key not in current_public_keys] + if cmd_remove_peers: + self._cmd(base_cmd + ''.join(cmd_remove_peers)) if 'peer' in config: + # Group removal of disabled peers in one command + current_disabled_peers = self.get_peer_public_keys(config, disabled=True) + cmd_disabled_peers = [f' peer {public_key} remove' + for public_key in current_disabled_peers] + if cmd_disabled_peers: + self._cmd(base_cmd + ''.join(cmd_disabled_peers)) + + peer_cmds = [] + peer_domain_cmds = [] + peer_psk_files = [] + for peer, peer_config in config['peer'].items(): # T4702: No need to configure this peer when it was explicitly # marked as disabled - also active sessions are terminated as @@ -206,17 +320,20 @@ class WireGuardIf(Interface): continue # start of with a fresh 'wg' command - cmd = base_cmd + ' peer {public_key}' + peer_cmd = ' peer {public_key}' + + cmd = peer_cmd - # If no PSK is given remove it by using /dev/null - passing keys via - # the shell (usually bash) is considered insecure, thus we use a file - no_psk_file = '/dev/null' - psk_file = no_psk_file if 'preshared_key' in peer_config: - psk_file = '/tmp/tmp.wireguard.psk' - with open(psk_file, 'w') as f: - f.write(peer_config['preshared_key']) - cmd += f' preshared-key {psk_file}' + with NamedTemporaryFile(mode='w', delete=False) as tmp_file: + tmp_file.write(peer_config['preshared_key']) + tmp_file.flush() + cmd += f' preshared-key {tmp_file.name}' + peer_psk_files.append(tmp_file.name) + else: + # If no PSK is given remove it by using /dev/null - passing keys via + # the shell (usually bash) is considered insecure, thus we use a file + cmd += f' preshared-key /dev/null' # Persistent keepalive is optional if 'persistent_keepalive' in peer_config: @@ -228,18 +345,38 @@ class WireGuardIf(Interface): peer_config['allowed_ips'] = [peer_config['allowed_ips']] cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - # Endpoint configuration is optional + peer_cmds.append(cmd.format(**peer_config)) + + cmd = peer_cmd + + # Ensure peer is created even if dns not working if {'address', 'port'} <= set(peer_config): if is_ipv6(peer_config['address']): cmd += ' endpoint [{address}]:{port}' - else: + elif is_ipv4(peer_config['address']): cmd += ' endpoint {address}:{port}' + else: + # don't set endpoint if address uses domain name + continue + elif {'host_name', 'port'} <= set(peer_config): + cmd += ' endpoint {host_name}:{port}' + else: + continue + + peer_domain_cmds.append(cmd.format(**peer_config)) - self._cmd(cmd.format(**peer_config)) + try: + if peer_cmds: + self._cmd(base_cmd + ''.join(peer_cmds)) - # PSK key file is not required to be stored persistently as its backed by CLI - if psk_file != no_psk_file and os.path.exists(psk_file): - os.remove(psk_file) + if peer_domain_cmds: + self._cmd(base_cmd + ''.join(peer_domain_cmds), env={ + 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) + except Exception as e: + Warning(f'Failed to apply Wireguard peers on {self.ifname}: {e}') + finally: + for tmp in peer_psk_files: + os.unlink(tmp) # call base class super().update(config) diff --git a/python/vyos/ifconfig/wireless.py b/python/vyos/ifconfig/wireless.py index 121f56bd5..69fd87347 100644 --- a/python/vyos/ifconfig/wireless.py +++ b/python/vyos/ifconfig/wireless.py @@ -1,4 +1,4 @@ -# Copyright 2020-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py index 004a64b39..2b5714b85 100644 --- a/python/vyos/ifconfig/wwan.py +++ b/python/vyos/ifconfig/wwan.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/iflag.py b/python/vyos/iflag.py index 3ce73c1bf..179f33497 100644 --- a/python/vyos/iflag.py +++ b/python/vyos/iflag.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/include/__init__.py b/python/vyos/include/__init__.py new file mode 100644 index 000000000..ba196ffed --- /dev/null +++ b/python/vyos/include/__init__.py @@ -0,0 +1,15 @@ +# Copyright 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/>. + diff --git a/python/vyos/include/uapi/__init__.py b/python/vyos/include/uapi/__init__.py new file mode 100644 index 000000000..ba196ffed --- /dev/null +++ b/python/vyos/include/uapi/__init__.py @@ -0,0 +1,15 @@ +# Copyright 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/>. + diff --git a/python/vyos/include/uapi/linux/__init__.py b/python/vyos/include/uapi/linux/__init__.py new file mode 100644 index 000000000..ba196ffed --- /dev/null +++ b/python/vyos/include/uapi/linux/__init__.py @@ -0,0 +1,15 @@ +# Copyright 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/>. + diff --git a/python/vyos/include/uapi/linux/fib_rules.py b/python/vyos/include/uapi/linux/fib_rules.py new file mode 100644 index 000000000..83544f69b --- /dev/null +++ b/python/vyos/include/uapi/linux/fib_rules.py @@ -0,0 +1,20 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +FIB_RULE_PERMANENT = 0x00000001 +FIB_RULE_INVERT = 0x00000002 +FIB_RULE_UNRESOLVED = 0x00000004 +FIB_RULE_IIF_DETACHED = 0x00000008 +FIB_RULE_DEV_DETACHED = FIB_RULE_IIF_DETACHED +FIB_RULE_OIF_DETACHED = 0x00000010 diff --git a/python/vyos/include/uapi/linux/icmpv6.py b/python/vyos/include/uapi/linux/icmpv6.py new file mode 100644 index 000000000..cc30b76fd --- /dev/null +++ b/python/vyos/include/uapi/linux/icmpv6.py @@ -0,0 +1,18 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +ICMPV6_ROUTER_PREF_LOW = 3 +ICMPV6_ROUTER_PREF_MEDIUM = 0 +ICMPV6_ROUTER_PREF_HIGH = 1 +ICMPV6_ROUTER_PREF_INVALID = 2 diff --git a/python/vyos/include/uapi/linux/if_arp.py b/python/vyos/include/uapi/linux/if_arp.py new file mode 100644 index 000000000..80c16a83d --- /dev/null +++ b/python/vyos/include/uapi/linux/if_arp.py @@ -0,0 +1,176 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# ARP protocol HARDWARE identifiers +ARPHRD_NETROM = 0 # from KA9Q: NET/ROM pseudo +ARPHRD_ETHER = 1 # Ethernet 10Mbps +ARPHRD_EETHER = 2 # Experimental Ethernet +ARPHRD_AX25 = 3 # AX.25 Level 2 +ARPHRD_PRONET = 4 # PROnet token ring +ARPHRD_CHAOS = 5 # Chaosnet +ARPHRD_IEEE802 = 6 # IEEE 802.2 Ethernet/TR/TB +ARPHRD_ARCNET = 7 # ARCnet +ARPHRD_APPLETLK = 8 # APPLEtalk +ARPHRD_DLCI = 15 # Frame Relay DLCI +ARPHRD_ATM = 19 # ATM +ARPHRD_METRICOM = 23 # Metricom STRIP (new IANA id) +ARPHRD_IEEE1394 = 24 # IEEE 1394 IPv4 - RFC 2734 +ARPHRD_EUI64 = 27 # EUI-64 +ARPHRD_INFINIBAND = 32 # InfiniBand + +# Dummy types for non-ARP hardware +ARPHRD_SLIP = 256 +ARPHRD_CSLIP = 257 +ARPHRD_SLIP6 = 258 +ARPHRD_CSLIP6 = 259 +ARPHRD_RSRVD = 260 # Notional KISS type +ARPHRD_ADAPT = 264 +ARPHRD_ROSE = 270 +ARPHRD_X25 = 271 # CCITT X.25 +ARPHRD_HWX25 = 272 # Boards with X.25 in firmware +ARPHRD_CAN = 280 # Controller Area Network +ARPHRD_MCTP = 290 +ARPHRD_PPP = 512 +ARPHRD_CISCO = 513 # Cisco HDLC +ARPHRD_HDLC = ARPHRD_CISCO # Alias for CISCO +ARPHRD_LAPB = 516 # LAPB +ARPHRD_DDCMP = 517 # Digital's DDCMP protocol +ARPHRD_RAWHDLC = 518 # Raw HDLC +ARPHRD_RAWIP = 519 # Raw IP + +ARPHRD_TUNNEL = 768 # IPIP tunnel +ARPHRD_TUNNEL6 = 769 # IP6IP6 tunnel +ARPHRD_FRAD = 770 # Frame Relay Access Device +ARPHRD_SKIP = 771 # SKIP vif +ARPHRD_LOOPBACK = 772 # Loopback device +ARPHRD_LOCALTLK = 773 # Localtalk device +ARPHRD_FDDI = 774 # Fiber Distributed Data Interface +ARPHRD_BIF = 775 # AP1000 BIF +ARPHRD_SIT = 776 # sit0 device - IPv6-in-IPv4 +ARPHRD_IPDDP = 777 # IP over DDP tunneller +ARPHRD_IPGRE = 778 # GRE over IP +ARPHRD_PIMREG = 779 # PIMSM register interface +ARPHRD_HIPPI = 780 # High Performance Parallel Interface +ARPHRD_ASH = 781 # Nexus 64Mbps Ash +ARPHRD_ECONET = 782 # Acorn Econet +ARPHRD_IRDA = 783 # Linux-IrDA +ARPHRD_FCPP = 784 # Point to point fibrechannel +ARPHRD_FCAL = 785 # Fibrechannel arbitrated loop +ARPHRD_FCPL = 786 # Fibrechannel public loop +ARPHRD_FCFABRIC = 787 # Fibrechannel fabric + +ARPHRD_IEEE802_TR = 800 # Magic type ident for TR +ARPHRD_IEEE80211 = 801 # IEEE 802.11 +ARPHRD_IEEE80211_PRISM = 802 # IEEE 802.11 + Prism2 header +ARPHRD_IEEE80211_RADIOTAP = 803 # IEEE 802.11 + radiotap header +ARPHRD_IEEE802154 = 804 +ARPHRD_IEEE802154_MONITOR = 805 # IEEE 802.15.4 network monitor + +ARPHRD_PHONET = 820 # PhoNet media type +ARPHRD_PHONET_PIPE = 821 # PhoNet pipe header +ARPHRD_CAIF = 822 # CAIF media type +ARPHRD_IP6GRE = 823 # GRE over IPv6 +ARPHRD_NETLINK = 824 # Netlink header +ARPHRD_6LOWPAN = 825 # IPv6 over LoWPAN +ARPHRD_VSOCKMON = 826 # Vsock monitor header + +ARPHRD_VOID = 0xFFFF # Void type, nothing is known +ARPHRD_NONE = 0xFFFE # Zero header length + +# ARP protocol opcodes +ARPOP_REQUEST = 1 # ARP request +ARPOP_REPLY = 2 # ARP reply +ARPOP_RREQUEST = 3 # RARP request +ARPOP_RREPLY = 4 # RARP reply +ARPOP_InREQUEST = 8 # InARP request +ARPOP_InREPLY = 9 # InARP reply +ARPOP_NAK = 10 # (ATM)ARP NAK + +ARPHRD_TO_NAME = { + ARPHRD_NETROM: "netrom", + ARPHRD_ETHER: "ether", + ARPHRD_EETHER: "eether", + ARPHRD_AX25: "ax25", + ARPHRD_PRONET: "pronet", + ARPHRD_CHAOS: "chaos", + ARPHRD_IEEE802: "ieee802", + ARPHRD_ARCNET: "arcnet", + ARPHRD_APPLETLK: "atalk", + ARPHRD_DLCI: "dlci", + ARPHRD_ATM: "atm", + ARPHRD_METRICOM: "metricom", + ARPHRD_IEEE1394: "ieee1394", + ARPHRD_INFINIBAND: "infiniband", + ARPHRD_SLIP: "slip", + ARPHRD_CSLIP: "cslip", + ARPHRD_SLIP6: "slip6", + ARPHRD_CSLIP6: "cslip6", + ARPHRD_RSRVD: "rsrvd", + ARPHRD_ADAPT: "adapt", + ARPHRD_ROSE: "rose", + ARPHRD_X25: "x25", + ARPHRD_HWX25: "hwx25", + ARPHRD_CAN: "can", + ARPHRD_PPP: "ppp", + ARPHRD_HDLC: "hdlc", + ARPHRD_LAPB: "lapb", + ARPHRD_DDCMP: "ddcmp", + ARPHRD_RAWHDLC: "rawhdlc", + ARPHRD_TUNNEL: "ipip", + ARPHRD_TUNNEL6: "tunnel6", + ARPHRD_FRAD: "frad", + ARPHRD_SKIP: "skip", + ARPHRD_LOOPBACK: "loopback", + ARPHRD_LOCALTLK: "ltalk", + ARPHRD_FDDI: "fddi", + ARPHRD_BIF: "bif", + ARPHRD_SIT: "sit", + ARPHRD_IPDDP: "ip/ddp", + ARPHRD_IPGRE: "gre", + ARPHRD_PIMREG: "pimreg", + ARPHRD_HIPPI: "hippi", + ARPHRD_ASH: "ash", + ARPHRD_ECONET: "econet", + ARPHRD_IRDA: "irda", + ARPHRD_FCPP: "fcpp", + ARPHRD_FCAL: "fcal", + ARPHRD_FCPL: "fcpl", + ARPHRD_FCFABRIC: "fcfb0", + ARPHRD_FCFABRIC+1: "fcfb1", + ARPHRD_FCFABRIC+2: "fcfb2", + ARPHRD_FCFABRIC+3: "fcfb3", + ARPHRD_FCFABRIC+4: "fcfb4", + ARPHRD_FCFABRIC+5: "fcfb5", + ARPHRD_FCFABRIC+6: "fcfb6", + ARPHRD_FCFABRIC+7: "fcfb7", + ARPHRD_FCFABRIC+8: "fcfb8", + ARPHRD_FCFABRIC+9: "fcfb9", + ARPHRD_FCFABRIC+10: "fcfb10", + ARPHRD_FCFABRIC+11: "fcfb11", + ARPHRD_FCFABRIC+12: "fcfb12", + ARPHRD_IEEE802_TR: "tr", + ARPHRD_IEEE80211: "ieee802.11", + ARPHRD_IEEE80211_PRISM: "ieee802.11/prism", + ARPHRD_IEEE80211_RADIOTAP: "ieee802.11/radiotap", + ARPHRD_IEEE802154: "ieee802.15.4", + ARPHRD_IEEE802154_MONITOR: "ieee802.15.4/monitor", + ARPHRD_PHONET: "phonet", + ARPHRD_PHONET_PIPE: "phonet_pipe", + ARPHRD_CAIF: "caif", + ARPHRD_IP6GRE: "gre6", + ARPHRD_NETLINK: "netlink", + ARPHRD_6LOWPAN: "6lowpan", + ARPHRD_NONE: "none", + ARPHRD_VOID: "void", +}
\ No newline at end of file diff --git a/python/vyos/include/uapi/linux/lwtunnel.py b/python/vyos/include/uapi/linux/lwtunnel.py new file mode 100644 index 000000000..c598513a5 --- /dev/null +++ b/python/vyos/include/uapi/linux/lwtunnel.py @@ -0,0 +1,38 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +LWTUNNEL_ENCAP_NONE = 0 +LWTUNNEL_ENCAP_MPLS = 1 +LWTUNNEL_ENCAP_IP = 2 +LWTUNNEL_ENCAP_ILA = 3 +LWTUNNEL_ENCAP_IP6 = 4 +LWTUNNEL_ENCAP_SEG6 = 5 +LWTUNNEL_ENCAP_BPF = 6 +LWTUNNEL_ENCAP_SEG6_LOCAL = 7 +LWTUNNEL_ENCAP_RPL = 8 +LWTUNNEL_ENCAP_IOAM6 = 9 +LWTUNNEL_ENCAP_XFRM = 10 + +ENCAP_TO_NAME = { + LWTUNNEL_ENCAP_MPLS: 'mpls', + LWTUNNEL_ENCAP_IP: 'ip', + LWTUNNEL_ENCAP_IP6: 'ip6', + LWTUNNEL_ENCAP_ILA: 'ila', + LWTUNNEL_ENCAP_BPF: 'bpf', + LWTUNNEL_ENCAP_SEG6: 'seg6', + LWTUNNEL_ENCAP_SEG6_LOCAL: 'seg6local', + LWTUNNEL_ENCAP_RPL: 'rpl', + LWTUNNEL_ENCAP_IOAM6: 'ioam6', + LWTUNNEL_ENCAP_XFRM: 'xfrm', +} diff --git a/python/vyos/include/uapi/linux/neighbour.py b/python/vyos/include/uapi/linux/neighbour.py new file mode 100644 index 000000000..8878353e3 --- /dev/null +++ b/python/vyos/include/uapi/linux/neighbour.py @@ -0,0 +1,34 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +NTF_USE = (1 << 0) +NTF_SELF = (1 << 1) +NTF_MASTER = (1 << 2) +NTF_PROXY = (1 << 3) +NTF_EXT_LEARNED = (1 << 4) +NTF_OFFLOADED = (1 << 5) +NTF_STICKY = (1 << 6) +NTF_ROUTER = (1 << 7) +NTF_EXT_MANAGED = (1 << 0) +NTF_EXT_LOCKED = (1 << 1) + +NTF_FlAGS = { + 'self': NTF_SELF, + 'router': NTF_ROUTER, + 'extern_learn': NTF_EXT_LEARNED, + 'offload': NTF_OFFLOADED, + 'master': NTF_MASTER, + 'sticky': NTF_STICKY, + 'locked': NTF_EXT_LOCKED, +} diff --git a/python/vyos/include/uapi/linux/rtnetlink.py b/python/vyos/include/uapi/linux/rtnetlink.py new file mode 100644 index 000000000..f3778fa65 --- /dev/null +++ b/python/vyos/include/uapi/linux/rtnetlink.py @@ -0,0 +1,63 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +RTM_F_NOTIFY = 0x100 +RTM_F_CLONED = 0x200 +RTM_F_EQUALIZE = 0x400 +RTM_F_PREFIX = 0x800 +RTM_F_LOOKUP_TABLE = 0x1000 +RTM_F_FIB_MATCH = 0x2000 +RTM_F_OFFLOAD = 0x4000 +RTM_F_TRAP = 0x8000 +RTM_F_OFFLOAD_FAILED = 0x20000000 + +RTNH_F_DEAD = 1 +RTNH_F_PERVASIVE = 2 +RTNH_F_ONLINK = 4 +RTNH_F_OFFLOAD = 8 +RTNH_F_LINKDOWN = 16 +RTNH_F_UNRESOLVED = 32 +RTNH_F_TRAP = 64 + +RT_TABLE_COMPAT = 252 +RT_TABLE_DEFAULT = 253 +RT_TABLE_MAIN = 254 +RT_TABLE_LOCAL = 255 + +RTAX_FEATURE_ECN = (1 << 0) +RTAX_FEATURE_SACK = (1 << 1) +RTAX_FEATURE_TIMESTAMP = (1 << 2) +RTAX_FEATURE_ALLFRAG = (1 << 3) +RTAX_FEATURE_TCP_USEC_TS = (1 << 4) + +RT_FlAGS = { + 'dead': RTNH_F_DEAD, + 'onlink': RTNH_F_ONLINK, + 'pervasive': RTNH_F_PERVASIVE, + 'offload': RTNH_F_OFFLOAD, + 'trap': RTNH_F_TRAP, + 'notify': RTM_F_NOTIFY, + 'linkdown': RTNH_F_LINKDOWN, + 'unresolved': RTNH_F_UNRESOLVED, + 'rt_offload': RTM_F_OFFLOAD, + 'rt_trap': RTM_F_TRAP, + 'rt_offload_failed': RTM_F_OFFLOAD_FAILED, +} + +RT_TABLE_TO_NAME = { + RT_TABLE_COMPAT: 'compat', + RT_TABLE_DEFAULT: 'default', + RT_TABLE_MAIN: 'main', + RT_TABLE_LOCAL: 'local', +} diff --git a/python/vyos/initialsetup.py b/python/vyos/initialsetup.py index cb6b9e459..bff3adf20 100644 --- a/python/vyos/initialsetup.py +++ b/python/vyos/initialsetup.py @@ -1,7 +1,7 @@ # initialsetup -- functions for setting common values in config file, # for use in installation and first boot scripts # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright 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; diff --git a/python/vyos/ioctl.py b/python/vyos/ioctl.py index 51574c1db..7f9ad226a 100644 --- a/python/vyos/ioctl.py +++ b/python/vyos/ioctl.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/ipsec.py b/python/vyos/ipsec.py index 28f77565a..81f3d0812 100644 --- a/python/vyos/ipsec.py +++ b/python/vyos/ipsec.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 951c83693..a47371e88 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,4 +1,4 @@ -# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -20,8 +20,8 @@ import socket from datetime import datetime from datetime import timezone +from vyos import ConfigError from vyos.template import is_ipv6 -from vyos.template import isc_static_route from vyos.template import netmask_from_cidr from vyos.utils.dict import dict_search_args from vyos.utils.file import file_permissions @@ -44,6 +44,7 @@ kea4_options = { 'wpad_url': 'wpad-url', 'ipv6_only_preferred': 'v6-only-preferred', 'captive_portal': 'v4-captive-portal', + 'capwap_controller': 'capwap-ac-v4', } kea6_options = { @@ -56,9 +57,10 @@ kea6_options = { 'nisplus_server': 'nisp-servers', 'sntp_server': 'sntp-servers', 'captive_portal': 'v6-captive-portal', + 'capwap_controller': 'capwap-ac-v6', } -kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket' +kea_ctrl_socket = '/run/kea/dhcp{inet}{vrf_append}-ctrl-socket' def _format_hex_string(in_str): @@ -111,22 +113,21 @@ def kea_parse_options(config): default_route = '' if 'default_router' in config: - default_route = isc_static_route('0.0.0.0/0', config['default_router']) + default_route = f'0.0.0.0/0 - {config["default_router"]}' routes = [ - isc_static_route(route, route_options['next_hop']) + f'{route} - {route_options["next_hop"]}' for route, route_options in config['static_route'].items() ] options.append( { - 'name': 'rfc3442-static-route', + 'name': 'classless-static-route', 'data': ', '.join( routes if not default_route else routes + [default_route] ), } ) - options.append({'name': 'windows-static-route', 'data': ', '.join(routes)}) if 'time_zone' in config: with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f: @@ -147,7 +148,7 @@ def kea_parse_options(config): def kea_parse_subnet(subnet, config): - out = {'subnet': subnet, 'id': int(config['subnet_id'])} + out = {'subnet': subnet, 'id': int(config['subnet_id']), 'user-context': {}} if 'option' in config: out['option-data'] = kea_parse_options(config['option']) @@ -165,6 +166,9 @@ def kea_parse_subnet(subnet, config): out['valid-lifetime'] = int(config['lease']) out['max-valid-lifetime'] = int(config['lease']) + if 'ping_check' in config: + out['user-context']['enable-ping-check'] = True + if 'range' in config: pools = [] for num, range_config in config['range'].items(): @@ -218,6 +222,9 @@ def kea_parse_subnet(subnet, config): reservations.append(reservation) out['reservations'] = reservations + if 'dynamic_dns_update' in config: + out.update(kea_parse_ddns_settings(config['dynamic_dns_update'])) + return out @@ -347,9 +354,62 @@ def kea6_parse_subnet(subnet, config): return out +def kea_parse_tsig_algo(algo_spec): + translate = { + 'md5': 'HMAC-MD5', + 'sha1': 'HMAC-SHA1', + 'sha224': 'HMAC-SHA224', + 'sha256': 'HMAC-SHA256', + 'sha384': 'HMAC-SHA384', + 'sha512': 'HMAC-SHA512' + } + if algo_spec not in translate: + raise ConfigError(f'Unsupported TSIG algorithm: {algo_spec}') + return translate[algo_spec] + +def kea_parse_enable_disable(value): + return True if value == 'enable' else False + +def kea_parse_ddns_settings(config): + data = {} + + if send_updates := config.get('send_updates'): + data['ddns-send-updates'] = kea_parse_enable_disable(send_updates) + + if override_client_update := config.get('override_client_update'): + data['ddns-override-client-update'] = kea_parse_enable_disable(override_client_update) + + if override_no_update := config.get('override_no_update'): + data['ddns-override-no-update'] = kea_parse_enable_disable(override_no_update) + + if update_on_renew := config.get('update_on_renew'): + data['ddns-update-on-renew'] = kea_parse_enable_disable(update_on_renew) + + if conflict_resolution := config.get('conflict_resolution'): + data['ddns-use-conflict-resolution'] = kea_parse_enable_disable(conflict_resolution) + + if 'replace_client_name' in config: + data['ddns-replace-client-name'] = config['replace_client_name'] + if 'generated_prefix' in config: + data['ddns-generated-prefix'] = config['generated_prefix'] + if 'qualifying_suffix' in config: + data['ddns-qualifying-suffix'] = config['qualifying_suffix'] + if 'ttl_percent' in config: + data['ddns-ttl-percent'] = int(config['ttl_percent']) / 100 + if 'hostname_char_set' in config: + data['hostname-char-set'] = config['hostname_char_set'] + if 'hostname_char_replacement' in config: + data['hostname-char-replacement'] = config['hostname_char_replacement'] -def _ctrl_socket_command(inet, command, args=None): - path = kea_ctrl_socket.format(inet=inet) + return data + +def _ctrl_socket_command(inet, vrf_name, command, args=None): + if vrf_name: + vrf_append = f'-{vrf_name}' + else: + vrf_append = '' + + path = kea_ctrl_socket.format(inet=inet, vrf_append=vrf_append) if not os.path.exists(path): return None @@ -375,8 +435,8 @@ def _ctrl_socket_command(inet, command, args=None): return json.loads(result.decode('utf-8')) -def kea_get_leases(inet): - leases = _ctrl_socket_command(inet, f'lease{inet}-get-all') +def kea_get_leases(inet, vrf_name): + leases = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-get-all') if not leases or 'result' not in leases or leases['result'] != 0: return [] @@ -384,10 +444,35 @@ def kea_get_leases(inet): return leases['arguments']['leases'] -def kea_delete_lease(inet, ip_address): +def kea_add_lease( + inet, + vrf_name, + ip_address, + host_name=None, + mac_address=None, + iaid=None, + duid=None, + subnet_id=None, +): args = {'ip-address': ip_address} - result = _ctrl_socket_command(inet, f'lease{inet}-del', args) + if host_name: + args['hostname'] = host_name + + if subnet_id: + args['subnet-id'] = subnet_id + + # IPv4 requires MAC address, IPv6 requires either MAC address or DUID + if mac_address: + args['hw-address'] = mac_address + if duid: + args['duid'] = duid + + # IPv6 requires IAID + if inet == '6' and iaid: + args['iaid'] = iaid + + result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-add', args) if result and 'result' in result: return result['result'] == 0 @@ -395,8 +480,19 @@ def kea_delete_lease(inet, ip_address): return False -def kea_get_active_config(inet): - config = _ctrl_socket_command(inet, 'config-get') +def kea_delete_lease(inet, ip_address, vrf_name=''): + args = {'ip-address': ip_address} + + result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-del', args) + + if result and 'result' in result: + return result['result'] == 0 + + return False + + +def kea_get_active_config(inet, vrf_name): + config = _ctrl_socket_command(inet, vrf_name, 'config-get') if not config or 'result' not in config or config['result'] != 0: return None @@ -430,6 +526,32 @@ def kea_get_pool_from_subnet_id(config, inet, subnet_id): return None +def kea_get_domain_from_subnet_id(config, inet, subnet_id): + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + + if not shared_networks: + return None + + for network in shared_networks: + if f'subnet{inet}' not in network: + continue + + for subnet in network[f'subnet{inet}']: + if 'id' in subnet and int(subnet['id']) == int(subnet_id): + for option in subnet['option-data']: + if option['name'] == 'domain-name': + return option['data'] + + # domain-name is not found in subnet, fallback to shared-network pool option + for option in network['option-data']: + if option['name'] == 'domain-name': + return option['data'] + + return None + + def kea_get_static_mappings(config, inet, pools=[]) -> list: """ Get DHCP static mapping from active Kea DHCPv4 or DHCPv6 configuration @@ -464,22 +586,21 @@ def kea_get_static_mappings(config, inet, pools=[]) -> list: return mappings -def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list: +def kea_get_server_leases(config, inet, vrf_name, pools=[], state=[], origin=None) -> list: """ Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration :return list """ - leases = kea_get_leases(inet) + leases = kea_get_leases(inet, vrf_name) data = [] for lease in leases: lifetime = lease['valid-lft'] - expiry = lease['cltt'] + lifetime + start = lease['cltt'] + expiry = start + lifetime - lease['start_timestamp'] = datetime.fromtimestamp( - expiry - lifetime, timezone.utc - ) - lease['expire_timestamp'] = ( + lease['start_time'] = datetime.fromtimestamp(start, timezone.utc) + lease['expire_time'] = ( datetime.fromtimestamp(expiry, timezone.utc) if expiry else None ) @@ -492,19 +613,24 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list if config else '-' ) + data_lease['domain'] = ( + kea_get_domain_from_subnet_id(config, inet, lease['subnet-id']) + if config + else '' + ) data_lease['end'] = ( - lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None + lease['expire_time'].timestamp() if lease['expire_time'] else None ) data_lease['origin'] = 'local' # TODO: Determine remote in HA # remove trailing dot in 'hostname' to ensure consistency for `vyos-hostsd-client` - data_lease['hostname'] = lease.get('hostname', '-').rstrip('.') + data_lease['hostname'] = lease.get('hostname', '').rstrip('.') or '-' if inet == '4': data_lease['mac'] = lease['hw-address'] - data_lease['start'] = lease['start_timestamp'].timestamp() + data_lease['start'] = lease['start_time'].timestamp() if inet == '6': - data_lease['last_communication'] = lease['start_timestamp'].timestamp() + data_lease['last_communication'] = lease['start_time'].timestamp() data_lease['duid'] = _format_hex_string(lease['duid']) data_lease['type'] = lease['type'] @@ -512,21 +638,17 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list prefix_len = lease['prefix-len'] data_lease['ip'] += f'/{prefix_len}' - data_lease['remaining'] = '-' - - if lease['valid-lft'] > 0: - data_lease['remaining'] = lease['expire_timestamp'] - datetime.now( - timezone.utc - ) + data_lease['remaining'] = '' - if data_lease['remaining'].days >= 0: - # substraction gives us a timedelta object which can't be formatted with strftime - # so we use str(), split gets rid of the microseconds - data_lease['remaining'] = str(data_lease['remaining']).split('.')[0] + now = datetime.now(timezone.utc) + if lease['valid-lft'] > 0 and lease['expire_time'] > now: + # substraction gives us a timedelta object which can't be formatted + # with strftime so we use str(), split gets rid of the microseconds + data_lease['remaining'] = str(lease['expire_time'] - now).split('.')[0] # Do not add old leases if ( - data_lease['remaining'] + data_lease['remaining'] != '' and data_lease['pool'] in pools and data_lease['state'] != 'free' and (not state or state == 'all' or data_lease['state'] in state) diff --git a/python/vyos/limericks.py b/python/vyos/limericks.py index 3c6744816..0c02d5292 100644 --- a/python/vyos/limericks.py +++ b/python/vyos/limericks.py @@ -1,4 +1,4 @@ -# Copyright 2015, 2018 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/load_config.py b/python/vyos/load_config.py index b910a2f92..f65e887f0 100644 --- a/python/vyos/load_config.py +++ b/python/vyos/load_config.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/logger.py b/python/vyos/logger.py index f7cc964d5..207f95c1b 100644 --- a/python/vyos/logger.py +++ b/python/vyos/logger.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/migrate.py b/python/vyos/migrate.py index 9d1613676..c06f6a76c 100644 --- a/python/vyos/migrate.py +++ b/python/vyos/migrate.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 29f8e961b..7be957a0c 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 7b11d36dd..7f1fc6b4f 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 55dc02631..4598c5daa 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/python/vyos/priority.py b/python/vyos/priority.py index ab4e6d411..e61281d3c 100644 --- a/python/vyos/priority.py +++ b/python/vyos/priority.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/progressbar.py b/python/vyos/progressbar.py index 8d1042672..eb8ed474a 100644 --- a/python/vyos/progressbar.py +++ b/python/vyos/progressbar.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/proto/__init__.py b/python/vyos/proto/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python/vyos/proto/__init__.py diff --git a/python/vyos/proto/generate_dataclass.py b/python/vyos/proto/generate_dataclass.py new file mode 100755 index 000000000..64485cd10 --- /dev/null +++ b/python/vyos/proto/generate_dataclass.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# 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 argparse +import os + +from google.protobuf.descriptor_pb2 import FileDescriptorSet # pylint: disable=no-name-in-module +from google.protobuf.descriptor_pb2 import FieldDescriptorProto # pylint: disable=no-name-in-module +from humps import decamelize + +HEADER = """\ +from enum import IntEnum +from dataclasses import dataclass +from dataclasses import field +""" + + +def normalize(s: str) -> str: + """Decamelize and avoid syntactic collision""" + t = decamelize(s) + return t + '_' if t in ['from'] else t + + +def generate_dataclass(descriptor_proto): + class_name = descriptor_proto.name + fields = [] + for field_p in descriptor_proto.field: + field_name = field_p.name + field_type, field_default = get_type(field_p.type, field_p.type_name) + match field_p.label: + case FieldDescriptorProto.LABEL_REPEATED: + field_type = f'list[{field_type}] = field(default_factory=list)' + case FieldDescriptorProto.LABEL_OPTIONAL: + field_type = f'{field_type} = None' + case _: + field_type = f'{field_type} = {field_default}' + + fields.append(f' {field_name}: {field_type}') + + code = f""" +@dataclass +class {class_name}: +{chr(10).join(fields) if fields else ' pass'} +""" + + return code + + +def generate_request(descriptor_proto): + class_name = descriptor_proto.name + fields = [] + f_vars = [] + for field_p in descriptor_proto.field: + field_name = field_p.name + field_type, field_default = get_type(field_p.type, field_p.type_name) + match field_p.label: + case FieldDescriptorProto.LABEL_REPEATED: + field_type = f'list[{field_type}] = []' + case FieldDescriptorProto.LABEL_OPTIONAL: + field_type = f'{field_type} = None' + case _: + field_type = f'{field_type} = {field_default}' + + fields.append(f'{normalize(field_name)}: {field_type}') + f_vars.append(f'{normalize(field_name)}') + + fields.insert(0, 'token: str = None') + + code = f""" +def set_request_{decamelize(class_name)}({', '.join(fields)}): + reqi = {class_name} ({', '.join(f_vars)}) + req = Request({decamelize(class_name)}=reqi) + req_env = RequestEnvelope(token, req) + return req_env +""" + + return code + + +def generate_nested_dataclass(descriptor_proto): + out = '' + for nested_p in descriptor_proto.nested_type: + out = out + generate_dataclass(nested_p) + + return out + + +def generate_nested_request(descriptor_proto): + out = '' + for nested_p in descriptor_proto.nested_type: + out = out + generate_request(nested_p) + + return out + + +def generate_enum_dataclass(descriptor_proto): + code = '' + for enum_p in descriptor_proto.enum_type: + enums = [] + enum_name = enum_p.name + for enum_val in enum_p.value: + enums.append(f' {enum_val.name} = {enum_val.number}') + + code += f""" +class {enum_name}(IntEnum): +{chr(10).join(enums)} +""" + + return code + + +def get_type(field_type, type_name): + res = 'Any', None + match field_type: + case FieldDescriptorProto.TYPE_STRING: + res = 'str', '""' + case FieldDescriptorProto.TYPE_INT32 | FieldDescriptorProto.TYPE_INT64: + res = 'int', 0 + case FieldDescriptorProto.TYPE_FLOAT | FieldDescriptorProto.TYPE_DOUBLE: + res = 'float', 0.0 + case FieldDescriptorProto.TYPE_BOOL: + res = 'bool', False + case FieldDescriptorProto.TYPE_MESSAGE | FieldDescriptorProto.TYPE_ENUM: + res = type_name.split('.')[-1], None + case _: + pass + + return res + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('descriptor_file', help='protobuf .desc file') + parser.add_argument('--out-dir', help='directory to write generated file') + args = parser.parse_args() + desc_file = args.descriptor_file + out_dir = args.out_dir + + with open(desc_file, 'rb') as f: + descriptor_set_data = f.read() + + descriptor_set = FileDescriptorSet() + descriptor_set.ParseFromString(descriptor_set_data) + + for file_proto in descriptor_set.file: + f = f'{file_proto.name.replace(".", "_")}.py' + f = os.path.join(out_dir, f) + dataclass_code = '' + nested_code = '' + enum_code = '' + request_code = '' + with open(f, 'w') as f: + enum_code += generate_enum_dataclass(file_proto) + for message_proto in file_proto.message_type: + dataclass_code += generate_dataclass(message_proto) + nested_code += generate_nested_dataclass(message_proto) + enum_code += generate_enum_dataclass(message_proto) + request_code += generate_nested_request(message_proto) + + f.write(HEADER) + f.write(enum_code) + f.write(nested_code) + f.write(dataclass_code) + f.write(request_code) diff --git a/python/vyos/proto/vycall_pb2.py b/python/vyos/proto/vycall_pb2.py new file mode 100644 index 000000000..95214d2a6 --- /dev/null +++ b/python/vyos/proto/vycall_pb2.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: vycall.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cvycall.proto\"&\n\x06Status\x12\x0f\n\x07success\x18\x01 \x02(\x08\x12\x0b\n\x03out\x18\x02 \x02(\t\"Y\n\x04\x43\x61ll\x12\x13\n\x0bscript_name\x18\x01 \x02(\t\x12\x11\n\ttag_value\x18\x02 \x01(\t\x12\x11\n\targ_value\x18\x03 \x01(\t\x12\x16\n\x05reply\x18\x04 \x01(\x0b\x32\x07.Status\"~\n\x06\x43ommit\x12\x12\n\nsession_id\x18\x01 \x02(\t\x12\x0f\n\x07\x64ry_run\x18\x04 \x02(\x08\x12\x0e\n\x06\x61tomic\x18\x05 \x02(\x08\x12\x12\n\nbackground\x18\x06 \x02(\x08\x12\x15\n\x04init\x18\x07 \x01(\x0b\x32\x07.Status\x12\x14\n\x05\x63\x61lls\x18\x08 \x03(\x0b\x32\x05.Call') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vycall_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _STATUS._serialized_start=16 + _STATUS._serialized_end=54 + _CALL._serialized_start=56 + _CALL._serialized_end=145 + _COMMIT._serialized_start=147 + _COMMIT._serialized_end=273 +# @@protoc_insertion_point(module_scope) diff --git a/python/vyos/proto/vyconf_client.py b/python/vyos/proto/vyconf_client.py new file mode 100644 index 000000000..a3ba9864c --- /dev/null +++ b/python/vyos/proto/vyconf_client.py @@ -0,0 +1,89 @@ +# Copyright 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 socket +from dataclasses import asdict + +from vyos.proto import vyconf_proto +from vyos.proto import vyconf_pb2 + +from google.protobuf.json_format import MessageToDict +from google.protobuf.json_format import ParseDict + +socket_path = '/var/run/vyconfd.sock' + + +def send_socket(msg: bytearray) -> bytes: + data = bytes() + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(socket_path) + client.sendall(msg) + + data_length = client.recv(4) + if data_length: + length = int.from_bytes(data_length) + data = client.recv(length) + + client.close() + + return data + + +def request_to_msg(req: vyconf_proto.RequestEnvelope) -> vyconf_pb2.RequestEnvelope: + # pylint: disable=no-member + + msg = vyconf_pb2.RequestEnvelope() + msg = ParseDict(asdict(req), msg, ignore_unknown_fields=True) + return msg + + +def msg_to_response(msg: vyconf_pb2.Response) -> vyconf_proto.Response: + # pylint: disable=no-member + + d = MessageToDict( + msg, preserving_proto_field_name=True, use_integers_for_enums=True + ) + + response = vyconf_proto.Response(**d) + return response + + +def write_request(req: vyconf_proto.RequestEnvelope) -> bytearray: + req_msg = request_to_msg(req) + encoded_data = req_msg.SerializeToString() + byte_size = req_msg.ByteSize() + length_bytes = byte_size.to_bytes(4) + arr = bytearray(length_bytes) + arr.extend(encoded_data) + + return arr + + +def read_response(msg: bytes) -> vyconf_proto.Response: + response_msg = vyconf_pb2.Response() # pylint: disable=no-member + response_msg.ParseFromString(msg) + response = msg_to_response(response_msg) + + return response + + +def send_request(name, *args, **kwargs): + func = getattr(vyconf_proto, f'set_request_{name}') + request_env = func(*args, **kwargs) + msg = write_request(request_env) + response_msg = send_socket(msg) + response = read_response(response_msg) + + return response diff --git a/python/vyos/proto/vyconf_pb2.py b/python/vyos/proto/vyconf_pb2.py new file mode 100644 index 000000000..4bf0eb2e0 --- /dev/null +++ b/python/vyos/proto/vyconf_pb2.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: vyconf.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cvyconf.proto\"\xae\x15\n\x07Request\x12!\n\x06prompt\x18\x01 \x01(\x0b\x32\x0f.Request.PromptH\x00\x12.\n\rsetup_session\x18\x02 \x01(\x0b\x32\x15.Request.SetupSessionH\x00\x12\x1b\n\x03set\x18\x03 \x01(\x0b\x32\x0c.Request.SetH\x00\x12!\n\x06\x64\x65lete\x18\x04 \x01(\x0b\x32\x0f.Request.DeleteH\x00\x12!\n\x06rename\x18\x05 \x01(\x0b\x32\x0f.Request.RenameH\x00\x12\x1d\n\x04\x63opy\x18\x06 \x01(\x0b\x32\r.Request.CopyH\x00\x12#\n\x07\x63omment\x18\x07 \x01(\x0b\x32\x10.Request.CommentH\x00\x12!\n\x06\x63ommit\x18\x08 \x01(\x0b\x32\x0f.Request.CommitH\x00\x12%\n\x08rollback\x18\t \x01(\x0b\x32\x11.Request.RollbackH\x00\x12\x1f\n\x05merge\x18\n \x01(\x0b\x32\x0e.Request.MergeH\x00\x12\x1d\n\x04save\x18\x0b \x01(\x0b\x32\r.Request.SaveH\x00\x12*\n\x0bshow_config\x18\x0c \x01(\x0b\x32\x13.Request.ShowConfigH\x00\x12!\n\x06\x65xists\x18\r \x01(\x0b\x32\x0f.Request.ExistsH\x00\x12&\n\tget_value\x18\x0e \x01(\x0b\x32\x11.Request.GetValueH\x00\x12(\n\nget_values\x18\x0f \x01(\x0b\x32\x12.Request.GetValuesH\x00\x12.\n\rlist_children\x18\x10 \x01(\x0b\x32\x15.Request.ListChildrenH\x00\x12)\n\x0brun_op_mode\x18\x11 \x01(\x0b\x32\x12.Request.RunOpModeH\x00\x12#\n\x07\x63onfirm\x18\x12 \x01(\x0b\x32\x10.Request.ConfirmH\x00\x12\x43\n\x18\x65nter_configuration_mode\x18\x13 \x01(\x0b\x32\x1f.Request.EnterConfigurationModeH\x00\x12\x41\n\x17\x65xit_configuration_mode\x18\x14 \x01(\x0b\x32\x1e.Request.ExitConfigurationModeH\x00\x12%\n\x08validate\x18\x15 \x01(\x0b\x32\x11.Request.ValidateH\x00\x12%\n\x08teardown\x18\x16 \x01(\x0b\x32\x11.Request.TeardownH\x00\x12\x30\n\x0ereload_reftree\x18\x17 \x01(\x0b\x32\x16.Request.ReloadReftreeH\x00\x12\x1d\n\x04load\x18\x18 \x01(\x0b\x32\r.Request.LoadH\x00\x12#\n\x07\x64iscard\x18\x19 \x01(\x0b\x32\x10.Request.DiscardH\x00\x12\x32\n\x0fsession_changed\x18\x1a \x01(\x0b\x32\x17.Request.SessionChangedH\x00\x12/\n\x0esession_of_pid\x18\x1b \x01(\x0b\x32\x15.Request.SessionOfPidH\x00\x12\x37\n\x12session_update_pid\x18\x1c \x01(\x0b\x32\x19.Request.SessionUpdatePidH\x00\x12(\n\nget_config\x18\x1d \x01(\x0b\x32\x12.Request.GetConfigH\x00\x1a\x08\n\x06Prompt\x1aP\n\x0cSetupSession\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x12\x19\n\x11\x43lientApplication\x18\x02 \x01(\t\x12\x12\n\nOnBehalfOf\x18\x03 \x01(\x05\x1a!\n\x0cSessionOfPid\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x1a%\n\x10SessionUpdatePid\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x1a\x1a\n\tGetConfig\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x1e\n\x08Teardown\x12\x12\n\nOnBehalfOf\x18\x01 \x01(\x05\x1a\x46\n\x08Validate\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1a\x13\n\x03Set\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x16\n\x06\x44\x65lete\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x18\n\x07\x44iscard\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x1f\n\x0eSessionChanged\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x35\n\x06Rename\x12\x11\n\tEditLevel\x18\x01 \x03(\t\x12\x0c\n\x04\x46rom\x18\x02 \x02(\t\x12\n\n\x02To\x18\x03 \x02(\t\x1a\x33\n\x04\x43opy\x12\x11\n\tEditLevel\x18\x01 \x03(\t\x12\x0c\n\x04\x46rom\x18\x02 \x02(\t\x12\n\n\x02To\x18\x03 \x02(\t\x1a(\n\x07\x43omment\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12\x0f\n\x07\x43omment\x18\x02 \x02(\t\x1aR\n\x06\x43ommit\x12\x0f\n\x07\x43onfirm\x18\x01 \x01(\x08\x12\x16\n\x0e\x43onfirmTimeout\x18\x02 \x01(\x05\x12\x0f\n\x07\x43omment\x18\x03 \x01(\t\x12\x0e\n\x06\x44ryRun\x18\x04 \x01(\x08\x1a\x1c\n\x08Rollback\x12\x10\n\x08Revision\x18\x01 \x02(\x05\x1aO\n\x04Load\x12\x10\n\x08Location\x18\x01 \x02(\t\x12\x0e\n\x06\x63\x61\x63hed\x18\x02 \x02(\x08\x12%\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x15.Request.ConfigFormat\x1aU\n\x05Merge\x12\x10\n\x08Location\x18\x01 \x02(\t\x12\x13\n\x0b\x64\x65structive\x18\x02 \x02(\x08\x12%\n\x06\x66ormat\x18\x03 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a?\n\x04Save\x12\x10\n\x08Location\x18\x01 \x02(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a\x41\n\nShowConfig\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a\x16\n\x06\x45xists\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x46\n\x08GetValue\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aG\n\tGetValues\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aJ\n\x0cListChildren\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aG\n\tRunOpMode\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1a\t\n\x07\x43onfirm\x1a\x46\n\x16\x45nterConfigurationMode\x12\x11\n\tExclusive\x18\x01 \x02(\x08\x12\x19\n\x11OverrideExclusive\x18\x02 \x02(\x08\x1a\x17\n\x15\x45xitConfigurationMode\x1a#\n\rReloadReftree\x12\x12\n\nOnBehalfOf\x18\x01 \x01(\x05\"#\n\x0c\x43onfigFormat\x12\t\n\x05\x43URLY\x10\x00\x12\x08\n\x04JSON\x10\x01\")\n\x0cOutputFormat\x12\x0c\n\x08OutPlain\x10\x00\x12\x0b\n\x07OutJSON\x10\x01\x42\x05\n\x03msg\";\n\x0fRequestEnvelope\x12\r\n\x05token\x18\x01 \x01(\t\x12\x19\n\x07request\x18\x02 \x02(\x0b\x32\x08.Request\"S\n\x08Response\x12\x17\n\x06status\x18\x01 \x02(\x0e\x32\x07.Errnum\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0f\n\x07warning\x18\x04 \x01(\t*\xd2\x01\n\x06\x45rrnum\x12\x0b\n\x07SUCCESS\x10\x00\x12\x08\n\x04\x46\x41IL\x10\x01\x12\x10\n\x0cINVALID_PATH\x10\x02\x12\x11\n\rINVALID_VALUE\x10\x03\x12\x16\n\x12\x43OMMIT_IN_PROGRESS\x10\x04\x12\x18\n\x14\x43ONFIGURATION_LOCKED\x10\x05\x12\x12\n\x0eINTERNAL_ERROR\x10\x06\x12\x15\n\x11PERMISSION_DENIED\x10\x07\x12\x17\n\x13PATH_ALREADY_EXISTS\x10\x08\x12\x16\n\x12UNCOMMITED_CHANGES\x10\t') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vyconf_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _ERRNUM._serialized_start=2900 + _ERRNUM._serialized_end=3110 + _REQUEST._serialized_start=17 + _REQUEST._serialized_end=2751 + _REQUEST_PROMPT._serialized_start=1237 + _REQUEST_PROMPT._serialized_end=1245 + _REQUEST_SETUPSESSION._serialized_start=1247 + _REQUEST_SETUPSESSION._serialized_end=1327 + _REQUEST_SESSIONOFPID._serialized_start=1329 + _REQUEST_SESSIONOFPID._serialized_end=1362 + _REQUEST_SESSIONUPDATEPID._serialized_start=1364 + _REQUEST_SESSIONUPDATEPID._serialized_end=1401 + _REQUEST_GETCONFIG._serialized_start=1403 + _REQUEST_GETCONFIG._serialized_end=1429 + _REQUEST_TEARDOWN._serialized_start=1431 + _REQUEST_TEARDOWN._serialized_end=1461 + _REQUEST_VALIDATE._serialized_start=1463 + _REQUEST_VALIDATE._serialized_end=1533 + _REQUEST_SET._serialized_start=1535 + _REQUEST_SET._serialized_end=1554 + _REQUEST_DELETE._serialized_start=1556 + _REQUEST_DELETE._serialized_end=1578 + _REQUEST_DISCARD._serialized_start=1580 + _REQUEST_DISCARD._serialized_end=1604 + _REQUEST_SESSIONCHANGED._serialized_start=1606 + _REQUEST_SESSIONCHANGED._serialized_end=1637 + _REQUEST_RENAME._serialized_start=1639 + _REQUEST_RENAME._serialized_end=1692 + _REQUEST_COPY._serialized_start=1694 + _REQUEST_COPY._serialized_end=1745 + _REQUEST_COMMENT._serialized_start=1747 + _REQUEST_COMMENT._serialized_end=1787 + _REQUEST_COMMIT._serialized_start=1789 + _REQUEST_COMMIT._serialized_end=1871 + _REQUEST_ROLLBACK._serialized_start=1873 + _REQUEST_ROLLBACK._serialized_end=1901 + _REQUEST_LOAD._serialized_start=1903 + _REQUEST_LOAD._serialized_end=1982 + _REQUEST_MERGE._serialized_start=1984 + _REQUEST_MERGE._serialized_end=2069 + _REQUEST_SAVE._serialized_start=2071 + _REQUEST_SAVE._serialized_end=2134 + _REQUEST_SHOWCONFIG._serialized_start=2136 + _REQUEST_SHOWCONFIG._serialized_end=2201 + _REQUEST_EXISTS._serialized_start=2203 + _REQUEST_EXISTS._serialized_end=2225 + _REQUEST_GETVALUE._serialized_start=2227 + _REQUEST_GETVALUE._serialized_end=2297 + _REQUEST_GETVALUES._serialized_start=2299 + _REQUEST_GETVALUES._serialized_end=2370 + _REQUEST_LISTCHILDREN._serialized_start=2372 + _REQUEST_LISTCHILDREN._serialized_end=2446 + _REQUEST_RUNOPMODE._serialized_start=2448 + _REQUEST_RUNOPMODE._serialized_end=2519 + _REQUEST_CONFIRM._serialized_start=1799 + _REQUEST_CONFIRM._serialized_end=1808 + _REQUEST_ENTERCONFIGURATIONMODE._serialized_start=2532 + _REQUEST_ENTERCONFIGURATIONMODE._serialized_end=2602 + _REQUEST_EXITCONFIGURATIONMODE._serialized_start=2604 + _REQUEST_EXITCONFIGURATIONMODE._serialized_end=2627 + _REQUEST_RELOADREFTREE._serialized_start=2629 + _REQUEST_RELOADREFTREE._serialized_end=2664 + _REQUEST_CONFIGFORMAT._serialized_start=2666 + _REQUEST_CONFIGFORMAT._serialized_end=2701 + _REQUEST_OUTPUTFORMAT._serialized_start=2703 + _REQUEST_OUTPUTFORMAT._serialized_end=2744 + _REQUESTENVELOPE._serialized_start=2753 + _REQUESTENVELOPE._serialized_end=2812 + _RESPONSE._serialized_start=2814 + _RESPONSE._serialized_end=2897 +# @@protoc_insertion_point(module_scope) diff --git a/python/vyos/proto/vyconf_proto.py b/python/vyos/proto/vyconf_proto.py new file mode 100644 index 000000000..ec62a6e35 --- /dev/null +++ b/python/vyos/proto/vyconf_proto.py @@ -0,0 +1,379 @@ +from enum import IntEnum +from dataclasses import dataclass +from dataclasses import field + +class Errnum(IntEnum): + SUCCESS = 0 + FAIL = 1 + INVALID_PATH = 2 + INVALID_VALUE = 3 + COMMIT_IN_PROGRESS = 4 + CONFIGURATION_LOCKED = 5 + INTERNAL_ERROR = 6 + PERMISSION_DENIED = 7 + PATH_ALREADY_EXISTS = 8 + UNCOMMITED_CHANGES = 9 + +class ConfigFormat(IntEnum): + CURLY = 0 + JSON = 1 + +class OutputFormat(IntEnum): + OutPlain = 0 + OutJSON = 1 + +@dataclass +class Prompt: + pass + +@dataclass +class SetupSession: + ClientPid: int = 0 + ClientApplication: str = None + OnBehalfOf: int = None + +@dataclass +class SessionOfPid: + ClientPid: int = 0 + +@dataclass +class SessionUpdatePid: + ClientPid: int = 0 + +@dataclass +class GetConfig: + dummy: int = None + +@dataclass +class Teardown: + OnBehalfOf: int = None + +@dataclass +class Validate: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class Set: + Path: list[str] = field(default_factory=list) + +@dataclass +class Delete: + Path: list[str] = field(default_factory=list) + +@dataclass +class Discard: + dummy: int = None + +@dataclass +class SessionChanged: + dummy: int = None + +@dataclass +class Rename: + EditLevel: list[str] = field(default_factory=list) + From: str = "" + To: str = "" + +@dataclass +class Copy: + EditLevel: list[str] = field(default_factory=list) + From: str = "" + To: str = "" + +@dataclass +class Comment: + Path: list[str] = field(default_factory=list) + Comment: str = "" + +@dataclass +class Commit: + Confirm: bool = None + ConfirmTimeout: int = None + Comment: str = None + DryRun: bool = None + +@dataclass +class Rollback: + Revision: int = 0 + +@dataclass +class Load: + Location: str = "" + cached: bool = False + format: ConfigFormat = None + +@dataclass +class Merge: + Location: str = "" + destructive: bool = False + format: ConfigFormat = None + +@dataclass +class Save: + Location: str = "" + format: ConfigFormat = None + +@dataclass +class ShowConfig: + Path: list[str] = field(default_factory=list) + format: ConfigFormat = None + +@dataclass +class Exists: + Path: list[str] = field(default_factory=list) + +@dataclass +class GetValue: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class GetValues: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class ListChildren: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class RunOpMode: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class Confirm: + pass + +@dataclass +class EnterConfigurationMode: + Exclusive: bool = False + OverrideExclusive: bool = False + +@dataclass +class ExitConfigurationMode: + pass + +@dataclass +class ReloadReftree: + OnBehalfOf: int = None + +@dataclass +class Request: + prompt: Prompt = None + setup_session: SetupSession = None + set: Set = None + delete: Delete = None + rename: Rename = None + copy: Copy = None + comment: Comment = None + commit: Commit = None + rollback: Rollback = None + merge: Merge = None + save: Save = None + show_config: ShowConfig = None + exists: Exists = None + get_value: GetValue = None + get_values: GetValues = None + list_children: ListChildren = None + run_op_mode: RunOpMode = None + confirm: Confirm = None + enter_configuration_mode: EnterConfigurationMode = None + exit_configuration_mode: ExitConfigurationMode = None + validate: Validate = None + teardown: Teardown = None + reload_reftree: ReloadReftree = None + load: Load = None + discard: Discard = None + session_changed: SessionChanged = None + session_of_pid: SessionOfPid = None + session_update_pid: SessionUpdatePid = None + get_config: GetConfig = None + +@dataclass +class RequestEnvelope: + token: str = None + request: Request = None + +@dataclass +class Response: + status: Errnum = None + output: str = None + error: str = None + warning: str = None + +def set_request_prompt(token: str = None): + reqi = Prompt () + req = Request(prompt=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_setup_session(token: str = None, client_pid: int = 0, client_application: str = None, on_behalf_of: int = None): + reqi = SetupSession (client_pid, client_application, on_behalf_of) + req = Request(setup_session=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_of_pid(token: str = None, client_pid: int = 0): + reqi = SessionOfPid (client_pid) + req = Request(session_of_pid=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_update_pid(token: str = None, client_pid: int = 0): + reqi = SessionUpdatePid (client_pid) + req = Request(session_update_pid=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_config(token: str = None, dummy: int = None): + reqi = GetConfig (dummy) + req = Request(get_config=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_teardown(token: str = None, on_behalf_of: int = None): + reqi = Teardown (on_behalf_of) + req = Request(teardown=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_validate(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = Validate (path, output_format) + req = Request(validate=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_set(token: str = None, path: list[str] = []): + reqi = Set (path) + req = Request(set=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_delete(token: str = None, path: list[str] = []): + reqi = Delete (path) + req = Request(delete=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_discard(token: str = None, dummy: int = None): + reqi = Discard (dummy) + req = Request(discard=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_changed(token: str = None, dummy: int = None): + reqi = SessionChanged (dummy) + req = Request(session_changed=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_rename(token: str = None, edit_level: list[str] = [], from_: str = "", to: str = ""): + reqi = Rename (edit_level, from_, to) + req = Request(rename=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_copy(token: str = None, edit_level: list[str] = [], from_: str = "", to: str = ""): + reqi = Copy (edit_level, from_, to) + req = Request(copy=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_comment(token: str = None, path: list[str] = [], comment: str = ""): + reqi = Comment (path, comment) + req = Request(comment=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_commit(token: str = None, confirm: bool = None, confirm_timeout: int = None, comment: str = None, dry_run: bool = None): + reqi = Commit (confirm, confirm_timeout, comment, dry_run) + req = Request(commit=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_rollback(token: str = None, revision: int = 0): + reqi = Rollback (revision) + req = Request(rollback=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_load(token: str = None, location: str = "", cached: bool = False, format: ConfigFormat = None): + reqi = Load (location, cached, format) + req = Request(load=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_merge(token: str = None, location: str = "", destructive: bool = False, format: ConfigFormat = None): + reqi = Merge (location, destructive, format) + req = Request(merge=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_save(token: str = None, location: str = "", format: ConfigFormat = None): + reqi = Save (location, format) + req = Request(save=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_show_config(token: str = None, path: list[str] = [], format: ConfigFormat = None): + reqi = ShowConfig (path, format) + req = Request(show_config=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_exists(token: str = None, path: list[str] = []): + reqi = Exists (path) + req = Request(exists=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_value(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = GetValue (path, output_format) + req = Request(get_value=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_values(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = GetValues (path, output_format) + req = Request(get_values=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_list_children(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = ListChildren (path, output_format) + req = Request(list_children=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_run_op_mode(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = RunOpMode (path, output_format) + req = Request(run_op_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_confirm(token: str = None): + reqi = Confirm () + req = Request(confirm=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_enter_configuration_mode(token: str = None, exclusive: bool = False, override_exclusive: bool = False): + reqi = EnterConfigurationMode (exclusive, override_exclusive) + req = Request(enter_configuration_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_exit_configuration_mode(token: str = None): + reqi = ExitConfigurationMode () + req = Request(exit_configuration_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_reload_reftree(token: str = None, on_behalf_of: int = None): + reqi = ReloadReftree (on_behalf_of) + req = Request(reload_reftree=reqi) + req_env = RequestEnvelope(token, req) + return req_env diff --git a/python/vyos/qos/__init__.py b/python/vyos/qos/__init__.py index a2980ccde..4bffda2d2 100644 --- a/python/vyos/qos/__init__.py +++ b/python/vyos/qos/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py index b477b5b5e..487249714 100644 --- a/python/vyos/qos/base.py +++ b/python/vyos/qos/base.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py index ca5a26917..05a737649 100644 --- a/python/vyos/qos/cake.py +++ b/python/vyos/qos/cake.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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,7 +54,16 @@ class CAKE(QoSBase): f'Invalid flow isolation parameter: {config["flow_isolation"]}' ) + if 'ack_filter' in config: + if 'aggressive' in config['ack_filter']: + tmp += ' ack-filter-aggressive' + else: + tmp += ' ack-filter' + else: + tmp += ' no-ack-filter' + tmp += ' nat' if 'flow_isolation_nat' in config else ' nonat' + tmp += ' no-split-gso' if 'no_split_gso' in config else ' split-gso' self._cmd(tmp) diff --git a/python/vyos/qos/droptail.py b/python/vyos/qos/droptail.py index 427d43d19..223ab1e64 100644 --- a/python/vyos/qos/droptail.py +++ b/python/vyos/qos/droptail.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/fairqueue.py b/python/vyos/qos/fairqueue.py index f41d098fb..8f4fe2d47 100644 --- a/python/vyos/qos/fairqueue.py +++ b/python/vyos/qos/fairqueue.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/fqcodel.py b/python/vyos/qos/fqcodel.py index cd2340aa2..d574226ef 100644 --- a/python/vyos/qos/fqcodel.py +++ b/python/vyos/qos/fqcodel.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/limiter.py b/python/vyos/qos/limiter.py index 3f5c11112..dce376d3e 100644 --- a/python/vyos/qos/limiter.py +++ b/python/vyos/qos/limiter.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/netem.py b/python/vyos/qos/netem.py index 8bdef300b..8fdd75387 100644 --- a/python/vyos/qos/netem.py +++ b/python/vyos/qos/netem.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py index 66d27a639..5f373f696 100644 --- a/python/vyos/qos/priority.py +++ b/python/vyos/qos/priority.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/randomdetect.py b/python/vyos/qos/randomdetect.py index a3a39da36..63445bb62 100644 --- a/python/vyos/qos/randomdetect.py +++ b/python/vyos/qos/randomdetect.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/ratelimiter.py b/python/vyos/qos/ratelimiter.py index a4f80a1be..b0d7b3072 100644 --- a/python/vyos/qos/ratelimiter.py +++ b/python/vyos/qos/ratelimiter.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py index 509c4069f..d07dc0f52 100644 --- a/python/vyos/qos/roundrobin.py +++ b/python/vyos/qos/roundrobin.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py index 9f92ccd8b..3840e7d0e 100644 --- a/python/vyos/qos/trafficshaper.py +++ b/python/vyos/qos/trafficshaper.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/raid.py b/python/vyos/raid.py index 7fb794817..4ae63a100 100644 --- a/python/vyos/raid.py +++ b/python/vyos/raid.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/remote.py b/python/vyos/remote.py index c54fb6031..b73f486c0 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -22,6 +22,7 @@ import stat import sys import tempfile import urllib.parse +import gzip from contextlib import contextmanager from pathlib import Path @@ -44,6 +45,7 @@ from vyos.utils.misc import begin from vyos.utils.process import cmd, rc_cmd from vyos.version import get_version from vyos.base import Warning +from vyos.defaults import directories CHUNK_SIZE = 8192 @@ -478,3 +480,45 @@ def get_remote_config(urlstring, source_host='', source_port=0): return f.read() finally: os.remove(temp) + + +def get_config_file(file_in: str, file_out: str, source_host='', source_port=0): + protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] + config_dir = directories['config'] + + with tempfile.NamedTemporaryFile() as tmp_file: + if any(file_in.startswith(f'{x}://') for x in protocols): + try: + download( + tmp_file.name, + file_in, + check_space=True, + source_host='', + source_port=0, + raise_error=True, + ) + except Exception as e: + return e + file_name = tmp_file.name + else: + full_path = os.path.realpath(file_in) + if os.path.isfile(full_path): + file_in = full_path + else: + file_in = os.path.join(config_dir, file_in) + if not os.path.isfile(file_in): + return ValueError(f'No such file {file_in}') + + file_name = file_in + + if file_in.endswith('.gz'): + try: + with gzip.open(file_name, 'rb') as f_in: + with open(file_out, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + except Exception as e: + return e + else: + shutil.copyfile(file_name, file_out) + + return None diff --git a/python/vyos/snmpv3_hashgen.py b/python/vyos/snmpv3_hashgen.py index 324c3274d..57dba07a0 100644 --- a/python/vyos/snmpv3_hashgen.py +++ b/python/vyos/snmpv3_hashgen.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/__init__.py b/python/vyos/system/__init__.py index 0c91330ba..42af8e3e8 100644 --- a/python/vyos/system/__init__.py +++ b/python/vyos/system/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/compat.py b/python/vyos/system/compat.py index d35bddea2..23a34d38a 100644 --- a/python/vyos/system/compat.py +++ b/python/vyos/system/compat.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/disk.py b/python/vyos/system/disk.py index c8908cd5c..268a3b195 100644 --- a/python/vyos/system/disk.py +++ b/python/vyos/system/disk.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/grub.py b/python/vyos/system/grub.py index de8303ee2..0f04fa5e9 100644 --- a/python/vyos/system/grub.py +++ b/python/vyos/system/grub.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/grub_util.py b/python/vyos/system/grub_util.py index 4a3d8795e..e534334e6 100644 --- a/python/vyos/system/grub_util.py +++ b/python/vyos/system/grub_util.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -56,13 +56,12 @@ def set_kernel_cmdline_options(cmdline_options: str, version: str = '', @image.if_not_live_boot def update_kernel_cmdline_options(cmdline_options: str, - root_dir: str = '') -> None: + root_dir: str = '', + version = image.get_running_image()) -> None: """Update Kernel custom cmdline options""" if not root_dir: root_dir = disk.find_persistence() - version = image.get_running_image() - boot_opts_current = grub.get_boot_opts(version, root_dir) boot_opts_proposed = grub.BOOT_OPTS_STEM + f'{version} {cmdline_options}' diff --git a/python/vyos/system/image.py b/python/vyos/system/image.py index aae52e770..ed8a96fbb 100644 --- a/python/vyos/system/image.py +++ b/python/vyos/system/image.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/system/raid.py b/python/vyos/system/raid.py index 5b33d34da..c03764ad1 100644 --- a/python/vyos/system/raid.py +++ b/python/vyos/system/raid.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/template.py b/python/vyos/template.py index be9f781a6..824d42136 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -36,6 +36,7 @@ DEFAULT_TEMPLATE_DIR = directories["templates"] # Holds template filters registered via register_filter() _FILTERS = {} _TESTS = {} +_CLEVER_FUNCTIONS = {} # reuse Environments with identical settings to improve performance @functools.lru_cache(maxsize=2) @@ -58,6 +59,7 @@ def _get_environment(location=None): ) env.filters.update(_FILTERS) env.tests.update(_TESTS) + env.globals.update(_CLEVER_FUNCTIONS) return env @@ -77,7 +79,7 @@ def register_filter(name, func=None): "Filters can only be registered before rendering the first template" ) if name in _FILTERS: - raise ValueError(f"A filter with name {name!r} was registered already") + raise ValueError(f"A filter with name {name!r} was already registered") _FILTERS[name] = func return func @@ -97,10 +99,30 @@ def register_test(name, func=None): "Tests can only be registered before rendering the first template" ) if name in _TESTS: - raise ValueError(f"A test with name {name!r} was registered already") + raise ValueError(f"A test with name {name!r} was already registered") _TESTS[name] = func return func +def register_clever_function(name, func=None): + """Register a function to be available as test in templates under given name. + + It can also be used as a decorator, see below in this module for examples. + + :raise RuntimeError: + when trying to register a test after a template has been rendered already + :raise ValueError: when trying to register a name which was taken already + """ + if func is None: + return functools.partial(register_clever_function, name) + if _get_environment.cache_info().currsize: + raise RuntimeError( + "Clever functions can only be registered before rendering the" \ + "first template") + if name in _CLEVER_FUNCTIONS: + raise ValueError(f"A clever function with name {name!r} was already "\ + "registered") + _CLEVER_FUNCTIONS[name] = func + return func def render_to_string(template, content, formater=None, location=None): """Render a template from the template directory, raise on any errors. @@ -150,6 +172,8 @@ def render( # As we are opening the file with 'w', we are performing the rendering before # calling open() to not accidentally erase the file if rendering fails rendered = render_to_string(template, content, formater, location) + # Remove any trailing character and always add a new line at the end + rendered = rendered.rstrip() + "\n" # Write to file with open(destination, "w") as file: @@ -390,28 +414,6 @@ def compare_netmask(netmask1, netmask2): except: return False -@register_filter('isc_static_route') -def isc_static_route(subnet, router): - # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server - # Option format is: - # <netmask>, <network-byte1>, <network-byte2>, <network-byte3>, <router-byte1>, <router-byte2>, <router-byte3> - # where bytes with the value 0 are omitted. - from ipaddress import ip_network - net = ip_network(subnet) - # add netmask - string = str(net.prefixlen) + ',' - # add network bytes - if net.prefixlen: - width = net.prefixlen // 8 - if net.prefixlen % 8: - width += 1 - string += ','.join(map(str,tuple(net.network_address.packed)[:width])) + ',' - - # add router bytes - string += ','.join(router.split('.')) - - return string - @register_filter('is_file') def is_file(filename): if os.path.exists(filename): @@ -580,6 +582,10 @@ def snmp_auth_oid(type): } return OIDs[type] +@register_filter('quoted_join') +def quoted_join(input_list, join_str, quote='"'): + return str(join_str).join(f'{quote}{elem}{quote}' for elem in input_list) + @register_filter('nft_action') def nft_action(vyos_action): if vyos_action == 'accept': @@ -612,12 +618,17 @@ def nft_default_rule(fw_conf, fw_name, family): return " ".join(output) @register_filter('nft_state_policy') -def nft_state_policy(conf, state): +def nft_state_policy(conf, state, bridge=False): out = [f'ct state {state}'] + action = conf['action'] if 'action' in conf else None + + if bridge and action == 'reject': + action = 'drop' # T7148 - Bridge cannot use reject + if 'log' in conf: log_state = state[:3].upper() - log_action = (conf['action'] if 'action' in conf else 'accept')[:1].upper() + log_action = (action if action else 'accept')[:1].upper() out.append(f'log prefix "[STATE-POLICY-{log_state}-{log_action}]"') if 'log_level' in conf: @@ -626,8 +637,8 @@ def nft_state_policy(conf, state): out.append('counter') - if 'action' in conf: - out.append(conf['action']) + if action: + out.append(action) return " ".join(out) @@ -667,6 +678,29 @@ def nft_nested_group(out_list, includes, groups, key): add_includes(name) return out_list +@register_filter('nft_accept_invalid') +def nft_accept_invalid(ether_type): + ether_type_mapping = { + 'dhcp': 'udp sport 67 udp dport 68', + 'arp': 'arp', + 'pppoe-discovery': '0x8863', + 'pppoe': '0x8864', + '802.1q': '8021q', + '802.1ad': '8021ad', + 'wol': '0x0842', + } + if ether_type not in ether_type_mapping: + raise RuntimeError(f'Ethernet type "{ether_type}" not found in ' \ + 'available ethernet types!') + out = 'ct state invalid ' + + if ether_type != 'dhcp': + out += 'ether type ' + + out += f'{ether_type_mapping[ether_type]} counter accept' + + return out + @register_filter('nat_rule') def nat_rule(rule_conf, rule_id, nat_type, ipv6=False): from vyos.nat import parse_nat_rule @@ -721,7 +755,7 @@ def conntrack_rule(rule_conf, rule_id, action, ipv6=False): if port[0] == '!': operator = '!=' port = port[1:] - output.append(f'th {prefix}port {operator} {port}') + output.append(f'th {prefix}port {operator} {{ {port} }}') if 'group' in side_conf: group = side_conf['group'] @@ -779,6 +813,11 @@ def conntrack_ct_policy(protocol_conf): return ", ".join(output) +@register_filter('wlb_nft_rule') +def wlb_nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + from vyos.wanloadbalance import nft_rule as wlb_nft_rule + return wlb_nft_rule(rule_conf, rule_id, local, exclude, limit, weight, health_state, action, restore_mark) + @register_filter('range_to_regex') def range_to_regex(num_range): """Convert range of numbers or list of ranges @@ -871,10 +910,77 @@ def kea_high_availability_json(config): return dumps(data) +@register_filter('kea_dynamic_dns_update_main_json') +def kea_dynamic_dns_update_main_json(config): + from vyos.kea import kea_parse_ddns_settings + from json import dumps + + data = kea_parse_ddns_settings(config) + + if len(data) == 0: + return '' + + return dumps(data, indent=8)[1:-1] + ',' + +@register_filter('kea_dynamic_dns_update_tsig_key_json') +def kea_dynamic_dns_update_tsig_key_json(config): + from vyos.kea import kea_parse_tsig_algo + from json import dumps + out = [] + + if 'tsig_key' not in config: + return dumps(out) + + tsig_keys = config['tsig_key'] + + for tsig_key_name, tsig_key_config in tsig_keys.items(): + tsig_key = { + 'name': tsig_key_name, + 'algorithm': kea_parse_tsig_algo(tsig_key_config['algorithm']), + 'secret': tsig_key_config['secret'] + } + out.append(tsig_key) + + return dumps(out, indent=12) + +@register_filter('kea_dynamic_dns_update_domains') +def kea_dynamic_dns_update_domains(config, type_key): + from json import dumps + out = [] + + if type_key not in config: + return dumps(out) + + domains = config[type_key] + + for domain_name, domain_config in domains.items(): + domain = { + 'name': domain_name, + + } + if 'key_name' in domain_config: + domain['key-name'] = domain_config['key_name'] + + if 'dns_server' in domain_config: + dns_servers = [] + for dns_server_config in domain_config['dns_server'].values(): + dns_server = { + 'ip-address': dns_server_config['address'] + } + if 'port' in dns_server_config: + dns_server['port'] = int(dns_server_config['port']) + dns_servers.append(dns_server) + domain['dns-servers'] = dns_servers + + out.append(domain) + + return dumps(out, indent=12) + @register_filter('kea_shared_network_json') def kea_shared_network_json(shared_networks): from vyos.kea import kea_parse_options from vyos.kea import kea_parse_subnet + from vyos.kea import kea_parse_ddns_settings from json import dumps out = [] @@ -885,9 +991,13 @@ def kea_shared_network_json(shared_networks): network = { 'name': name, 'authoritative': ('authoritative' in config), - 'subnet4': [] + 'subnet4': [], + 'user-context': {} } + if 'dynamic_dns_update' in config: + network.update(kea_parse_ddns_settings(config['dynamic_dns_update'])) + if 'option' in config: network['option-data'] = kea_parse_options(config['option']) @@ -897,6 +1007,9 @@ def kea_shared_network_json(shared_networks): if 'bootfile_server' in config['option']: network['next-server'] = config['option']['bootfile_server'] + if 'ping_check' in config: + network['user-context']['enable-ping-check'] = True + if 'subnet' in config: for subnet, subnet_config in config['subnet'].items(): if 'disable' in subnet_config: @@ -988,3 +1101,39 @@ def vyos_defined(value, test_value=None, var_type=None): else: # Valid value and is matching optional argument if provided - return true return True + +@register_clever_function('get_default_port') +def get_default_port(service): + """ + Jinja2 plugin to retrieve common service port number from vyos.defaults + class from a Jinja2 template. This removes the need to hardcode, or pass in + the data using the general dictionary. + + Added to remove code complexity and make it easier to read. + + Example: + {{ get_default_port('certbot_haproxy') }} + """ + from vyos.defaults import internal_ports + if service not in internal_ports: + raise RuntimeError(f'Service "{service}" not found in internal ' \ + 'vyos.defaults.internal_ports dict!') + return internal_ports[service] + +@register_clever_function('get_default_config_file') +def get_default_config_file(filename): + """ + Jinja2 plugin to retrieve a common configuration file path from + vyos.defaults class from a Jinja2 template. This removes the need to + hardcode, or pass in the data using the general dictionary. + + Added to remove code complexity and make it easier to read. + + Example: + {{ get_default_config_file('certbot_haproxy') }} + """ + from vyos.defaults import config_files + if filename not in config_files: + raise RuntimeError(f'Configuration file "{filename}" not found in '\ + 'internal vyos.defaults.config_files dict!') + return config_files[filename] diff --git a/python/vyos/tpm.py b/python/vyos/tpm.py index a24f149fd..663490dec 100644 --- a/python/vyos/tpm.py +++ b/python/vyos/tpm.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/python/vyos/utils/__init__.py b/python/vyos/utils/__init__.py index 3759b2125..280cde17f 100644 --- a/python/vyos/utils/__init__.py +++ b/python/vyos/utils/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/assertion.py b/python/vyos/utils/assertion.py index c7fa220c3..aa0614743 100644 --- a/python/vyos/utils/assertion.py +++ b/python/vyos/utils/assertion.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index a0b3e1cae..6e816af71 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -1,6 +1,6 @@ # authutils -- miscelanneous functions for handling passwords and publis keys # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright 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; @@ -13,10 +13,80 @@ # You should have received a copy of the GNU Lesser General Public License along with this library; # if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +import cracklib +import math import re +import string +from enum import StrEnum +from decimal import Decimal from vyos.utils.process import cmd + +DEFAULT_PASSWORD: str = 'vyos' +LOW_ENTROPY_MSG: str = 'should be at least 8 characters long;' +WEAK_PASSWORD_MSG: str = 'The password complexity is too low - @MSG@' +CRACKLIB_ERROR_MSG: str = 'A following error occurred: @MSG@\n' \ + 'Possibly the cracklib database is corrupted or is missing. ' \ + 'Try reinstalling the python3-cracklib package.' + +class EPasswdStrength(StrEnum): + WEAK = 'Weak' + DECENT = 'Decent' + STRONG = 'Strong' + ERROR = 'Cracklib Error' + + +def calculate_entropy(charset: str, passwd: str) -> float: + """ + Calculate the entropy of a password based on the set of characters used + Uses E = log2(R**L) formula, where + - R is the range (length) of the character set + - L is the length of password + """ + return math.log(math.pow(len(charset), len(passwd)), 2) + +def evaluate_strength(passwd: str) -> dict[str, str]: + """ Evaluates password strength and returns a check result dict """ + charset = (cracklib.ASCII_UPPERCASE + cracklib.ASCII_LOWERCASE + + string.punctuation + string.digits) + + result = { + 'strength': '', + 'error': '', + } + + try: + cracklib.FascistCheck(passwd) + except ValueError as e: + # The password is vulnerable to dictionary attack no matter the entropy + if 'is' in str(e): + msg = str(e).replace('is', 'should not be') + else: + msg = f'should not be {e}' + result.update(strength=EPasswdStrength.WEAK) + result.update(error=WEAK_PASSWORD_MSG.replace('@MSG@', msg)) + except Exception as e: + result.update(strength=EPasswdStrength.ERROR) + result.update(error=CRACKLIB_ERROR_MSG.replace('@MSG@', str(e))) + else: + # Now check the password's entropy + # Cast to Decimal for more precise rounding + entropy = Decimal.from_float(calculate_entropy(charset, passwd)) + + match round(entropy): + case e if e in range(0, 59): + result.update(strength=EPasswdStrength.WEAK) + result.update( + error=WEAK_PASSWORD_MSG.replace('@MSG@', LOW_ENTROPY_MSG) + ) + case e if e in range(60, 119): + result.update(strength=EPasswdStrength.DECENT) + case e if e >= 120: + result.update(strength=EPasswdStrength.STRONG) + + return result + def make_password_hash(password): """ Makes a password hash for /etc/shadow using mkpasswd """ diff --git a/python/vyos/utils/backend.py b/python/vyos/utils/backend.py new file mode 100644 index 000000000..d302a2efd --- /dev/null +++ b/python/vyos/utils/backend.py @@ -0,0 +1,94 @@ +# Copyright 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/>. + +# N.B. the following is a temporary addition for running smoketests under +# vyconf and is not to be called explicitly, at the risk of catastophe. + +# pylint: disable=wrong-import-position + +from pathlib import Path + +from vyos.utils.io import ask_yes_no +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active + +VYCONF_SENTINEL = '/run/vyconf_backend' + +MSG_ENABLE_VYCONF = 'This will enable the vyconf backend for testing. Proceed?' +MSG_DISABLE_VYCONF = ( + 'This will restore the legacy backend; it requires a reboot. Proceed?' +) + +# read/set immutable file attribute without popen: +# https://www.geeklab.info/2021/04/chattr-and-lsattr-in-python/ +import fcntl # pylint: disable=C0411 # noqa: E402 +from array import array # pylint: disable=C0411 # noqa: E402 + +# FS constants - see /uapi/linux/fs.h in kernel source +# or <elixir.free-electrons.com/linux/latest/source/include/uapi/linux/fs.h> +FS_IOC_GETFLAGS = 0x80086601 +FS_IOC_SETFLAGS = 0x40086602 +FS_IMMUTABLE_FL = 0x010 + + +def chattri(filename: str, value: bool): + with open(filename, 'r') as f: + arg = array('L', [0]) + fcntl.ioctl(f.fileno(), FS_IOC_GETFLAGS, arg, True) + if value: + arg[0] = arg[0] | FS_IMMUTABLE_FL + else: + arg[0] = arg[0] & ~FS_IMMUTABLE_FL + fcntl.ioctl(f.fileno(), FS_IOC_SETFLAGS, arg, True) + + +def lsattri(filename: str) -> bool: + with open(filename, 'r') as f: + arg = array('L', [0]) + fcntl.ioctl(f.fileno(), FS_IOC_GETFLAGS, arg, True) + return bool(arg[0] & FS_IMMUTABLE_FL) + + +# End: read/set immutable file attribute without popen + + +def vyconf_backend() -> bool: + return Path(VYCONF_SENTINEL).exists() and lsattri(VYCONF_SENTINEL) + + +def set_vyconf_backend(value: bool, no_prompt: bool = False): + vyconfd_service = 'vyconfd.service' + commitd_service = 'vyos-commitd.service' + http_api_service = 'vyos-http-api.service' + match value: + case True: + if vyconf_backend(): + return + if not no_prompt and not ask_yes_no(MSG_ENABLE_VYCONF): + return + Path(VYCONF_SENTINEL).touch() + chattri(VYCONF_SENTINEL, True) + call(f'systemctl restart {vyconfd_service}') + call(f'systemctl restart {commitd_service}') + if is_systemd_service_active(http_api_service): + call(f'systemctl restart {http_api_service}') + case False: + if not vyconf_backend(): + return + if not no_prompt and not ask_yes_no(MSG_DISABLE_VYCONF): + return + chattri(VYCONF_SENTINEL, False) + Path(VYCONF_SENTINEL).unlink() + call('/sbin/shutdown -r now') diff --git a/python/vyos/utils/boot.py b/python/vyos/utils/boot.py index 708bef14d..f804cd94e 100644 --- a/python/vyos/utils/boot.py +++ b/python/vyos/utils/boot.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index 105aed8c2..4147c7fba 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,8 +13,13 @@ # 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/>. +# pylint: disable=import-outside-toplevel + +from typing import IO + + def commit_in_progress(): - """ Not to be used in normal op mode scripts! """ + """Not to be used in normal op mode scripts!""" # The CStore backend locks the config by opening a file # The file is not removed after commit, so just checking @@ -36,7 +41,9 @@ def commit_in_progress(): from vyos.defaults import commit_lock if getuser() != 'root': - raise OSError('This functions needs to be run as root to return correct results!') + raise OSError( + 'This functions needs to be run as root to return correct results!' + ) for proc in process_iter(): try: @@ -45,7 +52,7 @@ def commit_in_progress(): for f in files: if f.path == commit_lock: return True - except NoSuchProcess as err: + except NoSuchProcess: # Process died before we could examine it pass # Default case @@ -53,8 +60,71 @@ def commit_in_progress(): def wait_for_commit_lock(): - """ Not to be used in normal op mode scripts! """ + """Not to be used in normal op mode scripts!""" from time import sleep + # Very synchronous approach to multiprocessing while commit_in_progress(): sleep(1) + + +# For transitional compatibility with the legacy commit locking mechanism, +# we require a lockf/fcntl (POSIX-type) lock, hence the following in place +# of vyos.utils.locking + + +def acquire_commit_lock_file() -> tuple[IO, str]: + import fcntl + from pathlib import Path + from vyos.defaults import commit_lock + + try: + # pylint: disable=consider-using-with + lock_fd = Path(commit_lock).open('w') + except IOError as e: + out = f'Critical error opening commit lock file {e}' + return None, out + + try: + fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return lock_fd, '' + except IOError: + out = 'Configuration system locked by another commit in progress' + lock_fd.close() + return None, out + + +def release_commit_lock_file(file_descr): + import fcntl + + if file_descr is None: + return + fcntl.lockf(file_descr, fcntl.LOCK_UN) + file_descr.close() + + +def call_commit_hooks(which: str): + import re + import os + from pathlib import Path + from vyos.defaults import commit_hooks + from vyos.utils.process import rc_cmd + + if which not in list(commit_hooks): + raise ValueError(f'no entry {which} in commit_hooks') + + hook_dir = commit_hooks[which] + file_list = list(Path(hook_dir).glob('*')) + regex = re.compile('^[a-zA-Z0-9._-]+$') + hook_list = sorted([str(f) for f in file_list if regex.match(f.name)]) + err = False + out = '' + for runf in hook_list: + try: + e, o = rc_cmd(runf) + except FileNotFoundError: + continue + err = err | bool(e) + out = out + o + + return out, int(err) diff --git a/python/vyos/utils/config.py b/python/vyos/utils/config.py index deda13c13..1f067e91e 100644 --- a/python/vyos/utils/config.py +++ b/python/vyos/utils/config.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/configfs.py b/python/vyos/utils/configfs.py index 8617f0129..307e1446c 100644 --- a/python/vyos/utils/configfs.py +++ b/python/vyos/utils/configfs.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py index 2f587405d..ea07f9514 100644 --- a/python/vyos/utils/convert.py +++ b/python/vyos/utils/convert.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py index 3bea5ac12..0f47123a4 100644 --- a/python/vyos/utils/cpu.py +++ b/python/vyos/utils/cpu.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright 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 @@ -26,6 +26,7 @@ It has special cases for x86_64 and MAY work correctly on other architectures, but nothing is certain. """ +import os import re def _read_cpuinfo(): @@ -99,3 +100,23 @@ def get_core_count(): core_count += 1 return core_count + + +def get_available_cpus(): + """ List of cpus with ids that are available in the system + Uses 'lscpu' command + + Returns: list[dict[str, str | int | bool]]: cpus details + """ + import json + + from vyos.utils.process import cmd + + out = json.loads(cmd('lscpu --extended -b --json')) + + return out['cpus'] + + +def get_half_cpus(): + """ return 1/2 of the numbers of available CPUs """ + return max(1, os.cpu_count() // 2) diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py index 1a7a6b96f..a43430eed 100644 --- a/python/vyos/utils/dict.py +++ b/python/vyos/utils/dict.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -211,6 +211,39 @@ def dict_set(key_path, value, dict_object): dynamic_dict = dynamic_dict[path_list[i]] dynamic_dict[path_list[len(path_list)-1]] = value +def dict_set_nested(key_path, value, dict_object): + """ + Set value to Python dictionary (dict_object) using a path to the key + delimited by dot ('.'). The key will be added if it does not exist. + Missing keys along the path will be created as nested dictionaries. + + Parameters + ---------- + key_path : str + Dot-delimited path to the key (e.g. "this.is.a.path"). + value : any + The value to set at the final key in the path. + dict_object : dict + Dictionary to modify. Will be updated in place. + + Examples + -------- + d = {} + dict_set_nested("this.is.a.path", 42, d) + # {'this': {'is': {'a': {'path': 42}}}} + + d = {"existing": {"branch": {}}} + dict_set_nested("existing.branch.leaf", "value", d) + # {'existing': {'branch': {'leaf': 'value'}}} + """ + path_list = key_path.split(".") + dynamic_dict = dict_object + for i in range(0, len(path_list) - 1): + if path_list[i] not in dynamic_dict or not isinstance(dynamic_dict[path_list[i]], dict): + dynamic_dict[path_list[i]] = {} + dynamic_dict = dynamic_dict[path_list[i]] + dynamic_dict[path_list[-1]] = value + def dict_delete(key_path, dict_object): """ Delete key in Python dictionary (dict_object) using path to key delimited by dot (.). """ @@ -347,8 +380,6 @@ class FixedDict(dict): raise ConfigError(f'Option "{k}" has no defined default') """ - from vyos import ConfigError - def __init__(self, **options): self._allowed = options.keys() super().__init__(**options) @@ -368,7 +399,7 @@ class FixedDict(dict): >>> d {'key': 'value'} """ + from vyos import ConfigError if k not in self._allowed: raise ConfigError(f'Option "{k}" has no defined default') super().__setitem__(k, v) - diff --git a/python/vyos/utils/disk.py b/python/vyos/utils/disk.py index d4271ebe1..b822badde 100644 --- a/python/vyos/utils/disk.py +++ b/python/vyos/utils/disk.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/error.py b/python/vyos/utils/error.py index 8d4709bff..75ad813f3 100644 --- a/python/vyos/utils/error.py +++ b/python/vyos/utils/error.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index eaebb57a3..1e2de2b39 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -28,22 +28,28 @@ def file_is_persistent(path): absolute = os.path.abspath(os.path.dirname(path)) return re.match(location,absolute) -def read_file(fname, defaultonfailure=None): +def read_file(fname, defaultonfailure=None, sudo=False): """ read the content of a file, stripping any end characters (space, newlines) should defaultonfailure be not None, it is returned on failure to read """ try: - """ Read a file to string """ - with open(fname, 'r') as f: - data = f.read().strip() - return data + # Some files can only be read by root - emulate sudo cat call + if sudo: + from vyos.utils.process import cmd + data = cmd(['sudo', 'cat', fname]) + else: + # If not sudo, just read the file + with open(fname, 'r') as f: + data = f.read() + return data.strip() except Exception as e: if defaultonfailure is not None: return defaultonfailure raise e -def write_file(fname, data, defaultonfailure=None, user=None, group=None, mode=None, append=False): +def write_file(fname, data, defaultonfailure=None, user=None, group=None, + mode=None, append=False, trailing_newline=False): """ Write content of data to given fname, should defaultonfailure be not None, it is returned on failure to read. @@ -60,6 +66,9 @@ def write_file(fname, data, defaultonfailure=None, user=None, group=None, mode=N bytes = 0 with open(fname, 'w' if not append else 'a') as f: bytes = f.write(data) + if trailing_newline and not data.endswith('\n'): + f.write('\n') + bytes += 1 chown(fname, user, group) chmod(fname, mode) return bytes @@ -83,36 +92,6 @@ def read_json(fname, defaultonfailure=None): return defaultonfailure raise e -def chown(path, user=None, group=None, recursive=False): - """ change file/directory owner """ - from pwd import getpwnam - from grp import getgrnam - - if user is None and group is None: - return False - - # path may also be an open file descriptor - if not isinstance(path, int) and not os.path.exists(path): - return False - - # keep current value if not specified otherwise - uid = -1 - gid = -1 - - if user: - uid = getpwnam(user).pw_uid - if group: - gid = getgrnam(group).gr_gid - - if recursive: - for dirpath, dirnames, filenames in os.walk(path): - os.chown(dirpath, uid, gid) - for filename in filenames: - os.chown(os.path.join(dirpath, filename), uid, gid) - else: - os.chown(path, uid, gid) - return True - def chmod(path, bitmask): # path may also be an open file descriptor @@ -166,12 +145,6 @@ def file_permissions(path): """ Return file permissions in string format, e.g '0755' """ return oct(os.stat(path).st_mode)[4:] -def makedir(path, user=None, group=None): - if os.path.exists(path): - return - os.makedirs(path, mode=0o755) - chown(path, user, group) - def wait_for_inotify(file_path, pre_hook=None, event_type=None, timeout=None, sleep_interval=0.1): """ Waits for an inotify event to occur """ if not os.path.dirname(file_path): diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 205210b66..0883376d1 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 847f80108..4d8544670 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,6 +15,10 @@ import os +# A list of used Kernel constants +# https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/drivers/net/wireguard/messages.h?h=linux-6.6.y#n45 +WIREGUARD_REKEY_AFTER_TIME = 120 + def check_kmod(k_mod): """ Common utility function to load required kernel modules on demand """ from vyos import ConfigError diff --git a/python/vyos/utils/list.py b/python/vyos/utils/list.py index 63ef720ab..931084e7c 100644 --- a/python/vyos/utils/list.py +++ b/python/vyos/utils/list.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/locking.py b/python/vyos/utils/locking.py index 63cb1a816..f4cd6fd41 100644 --- a/python/vyos/utils/locking.py +++ b/python/vyos/utils/locking.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/misc.py b/python/vyos/utils/misc.py index ac8011b8d..c7f7e7343 100644 --- a/python/vyos/utils/misc.py +++ b/python/vyos/utils/misc.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -48,11 +48,11 @@ def install_into_config(conf, config_paths, override_prompt=True): for path in config_paths: if override_prompt and conf.exists(path) and not conf.is_multi(path): - if not ask_yes_no(f'Config node "{node}" already exists. Do you want to overwrite it?'): + if not ask_yes_no(f'Config node "{path}" already exists. Do you want to overwrite it?'): continue try: - cmd(f'/usr/libexec/vyos/vyconf/vy_set {path}') + cmd(f'/opt/vyatta/sbin/my_set {path}') count += 1 except: failed.append(path) diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index dc0c0a6d6..93f780d8b 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,9 +13,10 @@ # 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/>. +from socket import AF_INET +from socket import AF_INET6 + def _are_same_ip(one, two): - from socket import AF_INET - from socket import AF_INET6 from socket import inet_pton from vyos.template import is_ipv4 # compare the binary representation of the IP @@ -80,7 +81,10 @@ def get_interface_vrf(interface): """ Returns VRF of given interface """ from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config - tmp = get_interface_config(interface) + if isinstance(interface, str): + tmp = get_interface_config(interface) + elif isinstance(interface, dict): + tmp = interface if dict_search('linkinfo.info_slave_kind', tmp) == 'vrf': return tmp['master'] return 'default' @@ -256,40 +260,60 @@ def mac2eui64(mac, prefix=None): except: # pylint: disable=bare-except return -def check_port_availability(ipaddress, port, protocol): +def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') -> bool: """ - Check if port is available and not used by any service - Return False if a port is busy or IP address does not exists + Check if given port is available and not used by any service. + Should be used carefully for services that can start listening dynamically, because IP address may be dynamic too + + Args: + address: IPv4 or IPv6 address - if None, checks on all interfaces + port: TCP/UDP port number. + + + Returns: + False if a port is busy or IP address does not exists + True if a port is free and IP address exists """ - from socketserver import TCPServer, UDPServer + import socket from ipaddress import ip_address + # treat None as "any address" + address = address or '::' + # verify arguments try: - ipaddress = ip_address(ipaddress).compressed - except: - raise ValueError(f'The {ipaddress} is not a valid IPv4 or IPv6 address') + address = ip_address(address).compressed + except ValueError: + raise ValueError(f'{address} is not a valid IPv4 or IPv6 address') if port not in range(1, 65536): - raise ValueError(f'The port number {port} is not in the 1-65535 range') + raise ValueError(f'Port {port} is not in range 1-65535') if protocol not in ['tcp', 'udp']: - raise ValueError(f'The protocol {protocol} is not supported. Only tcp and udp are allowed') + raise ValueError(f'{protocol} is not supported - only tcp and udp are allowed') - # check port availability + protocol = socket.SOCK_STREAM if protocol == 'tcp' else socket.SOCK_DGRAM try: - if protocol == 'tcp': - server = TCPServer((ipaddress, port), None, bind_and_activate=True) - if protocol == 'udp': - server = UDPServer((ipaddress, port), None, bind_and_activate=True) - server.server_close() - except Exception as e: - # errno.h: - #define EADDRINUSE 98 /* Address already in use */ - if e.errno == 98: + addr_info = socket.getaddrinfo(address, port, socket.AF_UNSPEC, protocol) + except socket.gaierror as e: + print(f'Invalid address: {address}') + return False + + for family, socktype, proto, canonname, sockaddr in addr_info: + try: + with socket.socket(family, socktype, proto) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(sockaddr) + # port is free to use + return True + except OSError: + # port is already in use return False - return True + # if we reach this point, no socket was tested and we assume the port is + # already in use - better safe then sorry + return False + def is_listen_port_bind_service(port: int, service: str) -> bool: """Check if listen port bound to expected program name @@ -327,7 +351,7 @@ def is_ipv6_link_local(addr): def is_addr_assigned(ip_address, vrf=None, return_ifname=False, include_vrf=False) -> bool | str: """ Verify if the given IPv4/IPv6 address is assigned to any interface """ - from netifaces import interfaces + from netifaces import interfaces # pylint: disable = no-name-in-module from vyos.utils.network import get_interface_config from vyos.utils.dict import dict_search @@ -396,6 +420,21 @@ def is_wireguard_key_pair(private_key: str, public_key:str) -> bool: else: return False +def get_wireguard_peers(ifname: str) -> list: + """ + Return list of configured Wireguard peers for interface + :param ifname: Interface name + :type ifname: str + :return: list of public keys + :rtype: list + """ + if not interface_exists(ifname): + return [] + + from vyos.utils.process import cmd + peers = cmd(f'wg show {ifname} peers') + return peers.splitlines() + def is_subnet_connected(subnet, primary=False): """ Verify is the given IPv4/IPv6 subnet is connected to any interface on this @@ -410,10 +449,8 @@ def is_subnet_connected(subnet, primary=False): from ipaddress import ip_address from ipaddress import ip_network - from netifaces import ifaddresses - from netifaces import interfaces - from netifaces import AF_INET - from netifaces import AF_INET6 + from netifaces import ifaddresses # pylint: disable = no-name-in-module + from netifaces import interfaces # pylint: disable = no-name-in-module from vyos.template import is_ipv6 @@ -447,9 +484,7 @@ def is_subnet_connected(subnet, primary=False): def is_afi_configured(interface: str, afi): """ Check if given address family is configured, or in other words - an IP address is assigned to the interface. """ - from netifaces import ifaddresses - from netifaces import AF_INET - from netifaces import AF_INET6 + from netifaces import ifaddresses # pylint: disable = no-name-in-module if afi not in [AF_INET, AF_INET6]: raise ValueError('Address family must be in [AF_INET, AF_INET6]') @@ -599,3 +634,35 @@ def get_nft_vrf_zone_mapping() -> dict: for (vrf_name, vrf_id) in vrf_list: output.append({'interface' : vrf_name, 'vrf_tableid' : vrf_id}) return output + +def is_valid_ipv4_address_or_range(addr: str) -> bool: + """ + Validates if the provided address is a valid IPv4, CIDR or IPv4 range + :param addr: address to test + :return: bool: True if provided address is valid + """ + from ipaddress import ip_network + try: + if '-' in addr: # If we are checking a range, validate both address's individually + split = addr.split('-') + return is_valid_ipv4_address_or_range(split[0]) and is_valid_ipv4_address_or_range(split[1]) + else: + return ip_network(addr).version == 4 + except: + return False + +def is_valid_ipv6_address_or_range(addr: str) -> bool: + """ + Validates if the provided address is a valid IPv4, CIDR or IPv4 range + :param addr: address to test + :return: bool: True if provided address is valid + """ + from ipaddress import ip_network + try: + if '-' in addr: # If we are checking a range, validate both address's individually + split = addr.split('-') + return is_valid_ipv6_address_or_range(split[0]) and is_valid_ipv6_address_or_range(split[1]) + else: + return ip_network(addr).version == 6 + except: + return False diff --git a/python/vyos/utils/permission.py b/python/vyos/utils/permission.py index d938b494f..08bb7bdbc 100644 --- a/python/vyos/utils/permission.py +++ b/python/vyos/utils/permission.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,23 +15,37 @@ import os -def chown(path, user, group): +def chown(path, user=None, group=None, recursive=False): """ change file/directory owner """ from pwd import getpwnam from grp import getgrnam - if user is None or group is None: + if user is None and group is None: return False # path may also be an open file descriptor if not isinstance(path, int) and not os.path.exists(path): return False - uid = getpwnam(user).pw_uid - gid = getgrnam(group).gr_gid - os.chown(path, uid, gid) + # keep current value if not specified otherwise + uid = -1 + gid = -1 + + if user: + uid = getpwnam(user).pw_uid + if group: + gid = getgrnam(group).gr_gid + + if recursive: + for dirpath, dirnames, filenames in os.walk(path): + os.chown(dirpath, uid, gid) + for filename in filenames: + os.chown(os.path.join(dirpath, filename), uid, gid) + else: + os.chown(path, uid, gid) return True + def chmod(path, bitmask): # path may also be an open file descriptor if not isinstance(path, int) and not os.path.exists(path): diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index 054088325..86a2747af 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -14,6 +14,7 @@ # License along with this library. If not, see <http://www.gnu.org/licenses/>. import os +import shlex from subprocess import Popen from subprocess import PIPE @@ -21,20 +22,17 @@ from subprocess import STDOUT from subprocess import DEVNULL -def get_wrapper(vrf, netns, auth): - wrapper = '' +def get_wrapper(vrf, netns): + wrapper = None if vrf: - wrapper = f'ip vrf exec {vrf} ' + wrapper = ['ip', 'vrf', 'exec', vrf] elif netns: - wrapper = f'ip netns exec {netns} ' - if auth: - wrapper = f'{auth} {wrapper}' + wrapper = ['ip', 'netns', 'exec', netns] return wrapper def popen(command, flag='', shell=None, input=None, timeout=None, env=None, - stdout=PIPE, stderr=PIPE, decode='utf-8', auth='', vrf=None, - netns=None): + stdout=PIPE, stderr=PIPE, decode='utf-8', vrf=None, netns=None): """ popen is a wrapper helper around subprocess.Popen with it default setting it will return a tuple (out, err) @@ -75,28 +73,33 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, if not debug.enabled(flag): flag = 'command' + use_shell = shell + stdin = None + if shell is None: + use_shell = False + if ' ' in command: + use_shell = True + if env: + use_shell = True + # Must be run as root to execute command in VRF or network namespace + wrapper = get_wrapper(vrf, netns) if vrf or netns: if os.getuid() != 0: raise OSError( 'Permission denied: cannot execute commands in VRF and netns contexts as an unprivileged user' ) - wrapper = get_wrapper(vrf, netns, auth) - command = f'{wrapper} {command}' + if use_shell: + command = f'{shlex.join(wrapper)} {command}' + else: + if type(command) is not list: + command = [command] + command = wrapper + command - cmd_msg = f"cmd '{command}'" + cmd_msg = f"cmd '{command}'" if use_shell else f"cmd '{shlex.join(command)}'" debug.message(cmd_msg, flag) - use_shell = shell - stdin = None - if shell is None: - use_shell = False - if ' ' in command: - use_shell = True - if env: - use_shell = True - if input: stdin = PIPE input = input.encode() if type(input) is str else input @@ -155,7 +158,7 @@ def run(command, flag='', shell=None, input=None, timeout=None, env=None, def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, stdout=PIPE, stderr=PIPE, decode='utf-8', raising=None, message='', - expect=[0], auth='', vrf=None, netns=None): + expect=[0], vrf=None, netns=None): """ A wrapper around popen, which returns the stdout and will raise the error code of a command @@ -171,12 +174,11 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, - auth=auth, vrf=vrf, netns=netns, ) if code not in expect: - wrapper = get_wrapper(vrf, netns, auth='') + wrapper = get_wrapper(vrf, netns) command = f'{wrapper} {command}' feedback = message + '\n' if message else '' feedback += f'failed to run command: {command}\n' diff --git a/python/vyos/utils/serial.py b/python/vyos/utils/serial.py index b646f881e..36b483e91 100644 --- a/python/vyos/utils/serial.py +++ b/python/vyos/utils/serial.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -116,3 +116,16 @@ def restart_login_consoles(prompt_user=False, quiet=True, devices: List[str]=[]) cmd(f'systemctl stop {unit_name}') return True + +def is_tty(name: str) -> bool: + """ Check if a given device file (e.g. /dev/ttyS0) is a TTY (teletypewriter) + device in Linux + """ + import os + path_tty = f'/dev/{name}' + if os.path.exists(path_tty): + with open(path_tty, 'rb') as f: + fd = f.fileno() + # True if filename is a TTY + return os.isatty(fd) + return False diff --git a/python/vyos/utils/session.py b/python/vyos/utils/session.py new file mode 100644 index 000000000..bc5240fc7 --- /dev/null +++ b/python/vyos/utils/session.py @@ -0,0 +1,25 @@ +# Copyright 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/>. + +# pylint: disable=import-outside-toplevel + + +def in_config_session(): + """Vyatta bash completion uses the following environment variable for + indication of the config mode environment, independent of legacy backend + initialization of Cstore""" + from os import environ + + return '_OFR_CONFIGURE' in environ diff --git a/python/vyos/utils/strip_config.py b/python/vyos/utils/strip_config.py index 7a9c78c9f..17f6867cb 100644 --- a/python/vyos/utils/strip_config.py +++ b/python/vyos/utils/strip_config.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 # -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py index 6c112334b..e2197daf2 100644 --- a/python/vyos/utils/system.py +++ b/python/vyos/utils/system.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/utils/vti_updown_db.py b/python/vyos/utils/vti_updown_db.py index b491fc6f2..f4dd24007 100644 --- a/python/vyos/utils/vti_updown_db.py +++ b/python/vyos/utils/vti_updown_db.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/version.py b/python/vyos/version.py index 86e96d0ec..01986e4da 100644 --- a/python/vyos/version.py +++ b/python/vyos/version.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/vpp/__init__.py b/python/vyos/vpp/__init__.py new file mode 100644 index 000000000..3352914f1 --- /dev/null +++ b/python/vyos/vpp/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from .control_vpp import VPPControl + +__all__ = ['VPPControl'] diff --git a/python/vyos/vpp/acl/__init__.py b/python/vyos/vpp/acl/__init__.py new file mode 100644 index 000000000..6bd6cdb23 --- /dev/null +++ b/python/vyos/vpp/acl/__init__.py @@ -0,0 +1,3 @@ +from .acl import Acl + +__all__ = ['Acl'] diff --git a/python/vyos/vpp/acl/acl.py b/python/vyos/vpp/acl/acl.py new file mode 100644 index 000000000..57d36ac91 --- /dev/null +++ b/python/vyos/vpp/acl/acl.py @@ -0,0 +1,106 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +NO_ACL_INDEX = 0xFFFFFFFF + + +class Acl: + def __init__(self): + self.vpp = VPPControl() + + def get_acl_index_by_tag(self, tag): + """Get ACL index by tag name + https://github.com/FDio/vpp/blob/21c641f9356da5137760cdc799127064c8c1fd31/src/plugins/acl/acl.api + """ + for acl in self.vpp.api.acl_dump(acl_index=NO_ACL_INDEX): + if acl.tag == tag: + return acl.acl_index + return NO_ACL_INDEX + + def add_replace_acl(self, tag, rules): + """Add new ACL or replace existing one""" + self.vpp.api.acl_add_replace( + tag=tag, + acl_index=self.get_acl_index_by_tag(tag), + count=len(rules), + r=rules, + ) + + def delete_acl(self, tag): + """Delete existing ACL""" + self.vpp.api.acl_del(acl_index=self.get_acl_index_by_tag(tag)) + + def add_acl_interface(self, interface, input_tags, output_tags): + """Add or replace ACLs on interface""" + acls = [] + for tag in input_tags: + acl_index = self.get_acl_index_by_tag(tag) + acls.append(acl_index) + for tag in output_tags: + acl_index = self.get_acl_index_by_tag(tag) + acls.append(acl_index) + self.vpp.api.acl_interface_set_acl_list( + sw_if_index=self.vpp.get_sw_if_index(interface), + count=len(acls), + n_input=len(input_tags), + acls=acls, + ) + + def delete_acl_interface(self, interface): + """Delete ACLs from interface""" + self.vpp.api.acl_interface_set_acl_list( + sw_if_index=self.vpp.get_sw_if_index(interface), + count=0, + ) + + def get_macip_acl_index_by_tag(self, tag): + """Get macip ACL by tag name""" + for acl in self.vpp.api.macip_acl_dump(): + if acl.tag == tag: + return acl.acl_index + return NO_ACL_INDEX + + def add_replace_acl_macip(self, tag, rules): + """Add or replace existing macip ACL""" + self.vpp.api.macip_acl_add_replace( + tag=tag, + acl_index=self.get_macip_acl_index_by_tag(tag), + count=len(rules), + r=rules, + ) + + def delete_acl_macip(self, tag): + """Delete existing macip ACL""" + self.vpp.api.macip_acl_del(acl_index=self.get_macip_acl_index_by_tag(tag)) + + def add_acl_macip_interface(self, interface, tag): + """Add or replace macip ACLs on interface""" + self.vpp.api.macip_acl_interface_add_del( + sw_if_index=self.vpp.get_sw_if_index(interface), + acl_index=self.get_macip_acl_index_by_tag(tag), + is_add=True, + ) + + def delete_acl_macip_interface(self, interface): + """Delete macip ACLs from interface""" + self.vpp.api.macip_acl_interface_add_del( + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=False, + ) diff --git a/python/vyos/vpp/config_deps.py b/python/vyos/vpp/config_deps.py new file mode 100644 index 000000000..dffd865d1 --- /dev/null +++ b/python/vyos/vpp/config_deps.py @@ -0,0 +1,76 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +def deps_xconnect_dict(conf) -> dict[str, list[str]]: + """Get a dict of all xconnect interface members: + + keys: members + + values: xconnect interfaces + + Args: + conf (config): VyOS config object + + Returns: + dict[str, list[str]]: dict of members + """ + xconn_members_dict: dict[str, list[str]] = {} + config = conf.get_config_dict( + ['vpp', 'interfaces', 'xconnect'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + for xconn_name, xconn_config in config.items(): + for member_name in xconn_config.get('member', {}).get('interface', []): + xconn_ifaces_list = xconn_members_dict.get(xconn_name, []) + xconn_ifaces_list.append(xconn_name) + xconn_members_dict.update({member_name: xconn_ifaces_list}) + + return xconn_members_dict + + +def deps_bridge_dict(conf) -> dict[str, list[str]]: + """Get a dict of all bridge interface members: + + keys: members + + values: bridge interfaces + + Args: + conf (config): VyOS config object + + Returns: + dict[str, list[str]]: dict of members + """ + bridge_members_dict: dict[str, list[str]] = {} + config = conf.get_config_dict( + ['vpp', 'interfaces', 'bridge'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + for bridge_name, bridge_config in config.items(): + for member_name in bridge_config.get('member', {}).get('interface', []): + bridge_ifaces_list = bridge_members_dict.get(bridge_name, []) + bridge_ifaces_list.append(bridge_name) + bridge_members_dict.update({member_name: bridge_ifaces_list}) + + return bridge_members_dict diff --git a/python/vyos/vpp/config_filter.py b/python/vyos/vpp/config_filter.py new file mode 100644 index 000000000..dc864930b --- /dev/null +++ b/python/vyos/vpp/config_filter.py @@ -0,0 +1,58 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config + + +def iface_filter_eth(config: Config, iface: str) -> None: + """Filter out unsupported config nodes from Ethernet interface config + + Args: + config (Config): config object + iface (str): Ethernet interface name to filter + """ + allowed_nodes: list[str] = [ + 'address', + 'description', + 'dhcp-options', + 'dhcpv6-options', + 'disable', + 'eapol', + 'hw-id', + 'ip', + 'ipv6', + 'mtu', + 'redirect', + 'vif', + 'vif-s', + 'vrf', + ] + + # get list of config nides in a session configuration + iface_nodes = config._session_config.list_nodes(['interfaces', 'ethernet', iface]) + + # clean cached session config + if False in config._dict_cache: + del config._dict_cache[False] + + # remove unsupported config nodes + for cfg_node in iface_nodes: + if cfg_node not in allowed_nodes: + config._session_config.delete(['interfaces', 'ethernet', iface, cfg_node]) + print( + f'WARNING: {cfg_node} option in {iface} settings is not supported by VPP interfaces. It will be ignored.' + ) diff --git a/python/vyos/vpp/config_resource_checks/__init__.py b/python/vyos/vpp/config_resource_checks/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/__init__.py diff --git a/python/vyos/vpp/config_resource_checks/cpu.py b/python/vyos/vpp/config_resource_checks/cpu.py new file mode 100644 index 000000000..b319608e4 --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/cpu.py @@ -0,0 +1,76 @@ +# Used for validating estimated CPU/physical cores use +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.utils.cpu import get_available_cpus, get_core_count + +from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map + + +# Get default value for reserved cpu cores +reserved_cpus = default_resource_map.get('reserved_cpu_cores') + + +def available_cores_count(cpu_settings: dict) -> int: + core_count = get_core_count() + + if cpu_settings.get('main_core'): + core_count -= 1 + + skip_cores = int(cpu_settings.get('skip_cores', 0)) + # The default settings assume that + # at least 2 CPU cores should remain reserved for system use + # (only in case of current runtime is not smoke test) + if skip_cores < reserved_cpus: + core_count -= reserved_cpus + else: + core_count -= skip_cores + + return core_count + + +def available_cores_list(skip_cores: int) -> list: + # Available cores are all CPU cores without first N skipped cores that will not be used + # Get all available physical cores - use set to filter out unique values + cpu_cores = set(map(lambda el: el['cpu'], get_available_cpus())) + cpu_cores = list(cpu_cores) + + return cpu_cores[skip_cores:] + + +def worker_cores_list(iface: str, worker_ranges: list) -> list: + all_core_numbers = [] + for worker_range in worker_ranges: + core_numbers = worker_range.split('-') + + if int(core_numbers[0]) > int(core_numbers[-1]): + raise ValueError( + f'Range for "{iface} workers {worker_range}" is not correct' + ) + + all_core_numbers.extend(range(int(core_numbers[0]), int(core_numbers[-1]) + 1)) + + # Check for duplicates + duplicates = set( + [str(x) for n, x in enumerate(all_core_numbers) if x in all_core_numbers[:n]] + ) + if duplicates: + raise ValueError( + f'Some workers in "{iface} workers" are duplicated: #{",".join(list(duplicates))}' + ) + + return all_core_numbers diff --git a/python/vyos/vpp/config_resource_checks/memory.py b/python/vyos/vpp/config_resource_checks/memory.py new file mode 100644 index 000000000..9391e21e1 --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/memory.py @@ -0,0 +1,189 @@ +# Used for memory consumption calculations +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import re +import psutil + +from vyos.utils.process import cmd +from vyos.vpp.utils import ( + human_memory_to_bytes, + human_page_memory_to_bytes, +) +from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map + + +def classify_page_size(page_size_bytes: int) -> str: + """ + Returns one of: '4K', '2M', '1G' based on page size. + """ + if page_size_bytes == 1 << 30: + return '1G' + if page_size_bytes == 2 << 20: + return '2M' + return '4K' + + +def get_hugepages_info() -> dict: + """ + Returns the information about HugePages + retrieved from /sys/kernel/mm/hugepages + """ + base_path = '/sys/kernel/mm/hugepages' + info = {} + + for entry in os.listdir(base_path): + page_size_kb = entry[10:] + page_size = human_page_memory_to_bytes(page_size_kb) + key = classify_page_size(page_size) + info[key] = {} + + with open(os.path.join(base_path, entry, 'nr_hugepages')) as f: + count = int(f.read().strip()) + info[key]['pages'] = count + info[key]['memory'] = page_size * count + + return info + + +def get_available_memory() -> dict: + memory = {size: info.get('memory') for size, info in get_hugepages_info().items()} + memory['4K'] = psutil.virtual_memory().available + + return memory + + +def get_vpp_used_memory() -> int: + """ + Returns memory currently used by VPP in bytes (RSS value) + """ + try: + out = cmd('ps -o rss= -p $(pidof vpp)') + except OSError: + out = 0 + return int(out) << 10 + + +def get_numa_count(): + """ + Run `numactl --hardware` and parse the 'available:' line. + """ + out = cmd('numactl --hardware') + # e.g. "available: 2 nodes (0-1)" + m = re.search(r'available:\s*(\d+)\s+nodes', out) + return int(m.group(1)) if m else 0 + + +def buffer_page_size(settings: dict) -> int: + page_size = settings.get('buffers', {}).get('page_size', 'default') + return human_page_memory_to_bytes(page_size) + + +def buffer_size(settings: dict) -> int: + numa_count = get_numa_count() + buffers_per_numa = int( + settings.get('buffers', {}).get( + 'buffers_per_numa', default_resource_map.get('buffers_per_numa') + ) + ) + data_size = int( + settings.get('buffers', {}).get( + 'data_size', default_resource_map.get('data_size') + ) + ) + buffers_memory = buffers_per_numa * data_size * numa_count + return buffers_memory + + +def main_heap_page_size(settings: dict) -> int: + heap_page_size = settings.get('memory', {}).get( + 'main_heap_page_size', default_resource_map.get('main_heap_page_size') + ) + return human_page_memory_to_bytes(heap_page_size) + + +def memory_main_heap(settings: dict) -> int: + heap_size = settings.get('memory', {}).get( + 'main_heap_size', default_resource_map.get('main_heap_size') + ) + return human_memory_to_bytes(heap_size) + + +def ipv6_heap_size(settings: dict) -> int: + heap_size = settings.get('ipv6', {}).get( + 'heap_size', default_resource_map.get('ipv6_heap_size') + ) + return human_memory_to_bytes(heap_size) + + +def total_heap_size(heap_size: int, heap_page_size: int) -> int: + return (heap_size + heap_page_size - 1) & ~(heap_page_size - 1) + + +def statseg_size(settings: dict) -> int: + statseg_memory = settings.get('statseg', {}).get( + 'size', default_resource_map.get('statseg_heap_size') + ) + return human_memory_to_bytes(statseg_memory) + + +def statseg_page_size(settings: dict) -> int: + page_size = settings.get('statseg', {}).get('page_size') + return human_page_memory_to_bytes(page_size) + + +def total_statseg_size(_statseg_size: int, _statseg_page: int) -> int: + return (_statseg_size + _statseg_page - 1) & ~(_statseg_page - 1) + + +def total_memory_required(settings: dict) -> dict: + memory = {'2M': 0, '1G': 0, '4K': 0} + + mem_stats = { + 'memory_buffers': (buffer_size(settings), buffer_page_size(settings)), + 'netlink_buffer_size': ( + int( + settings.get('lcp', {}) + .get('netlink', {}) + .get( + 'rx_buffer_size', default_resource_map.get('netlink_rx_buffer_size') + ) + ), + 0, + ), + 'heap_size': ( + total_heap_size( + heap_size=memory_main_heap(settings), + heap_page_size=main_heap_page_size(settings), + ), + main_heap_page_size(settings), + ), + 'statseg_size': ( + total_statseg_size( + _statseg_size=statseg_size(settings), + _statseg_page=statseg_page_size(settings), + ), + statseg_page_size(settings), + ), + 'ipv6_heap_size': (ipv6_heap_size(settings), 0), + } + + for memory_size, page_size in mem_stats.values(): + memory[classify_page_size(page_size)] += memory_size + + return memory diff --git a/python/vyos/vpp/config_resource_checks/resource_defaults.py b/python/vyos/vpp/config_resource_checks/resource_defaults.py new file mode 100644 index 000000000..d8316fcaf --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/resource_defaults.py @@ -0,0 +1,43 @@ +# Default values for resource consumption checks +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +default_resource_map = { + # Default amount of buffers per NUMA (populated CPU socket) + 'buffers_per_numa': 16384, + # Default size of buffer (in bytes) + 'data_size': 2048, + # Default hugepage size for VPP + 'hugepage_size': '2M', + # Default amount of memory allocated for VPP exclusive usage + 'main_heap_size': '3G', + # Default main heap page size + 'main_heap_page_size': '2M', + # Default size of buffers transferred via netlink + 'netlink_rx_buffer_size': 212992, + # Default amount of memory allocated for VPP stats segment usage + 'statseg_heap_size': '128M', + # Minimal amount of memory required to start VPP + 'min_memory': '8G', + # Minimal number of physical CPU cores required to start VPP + 'min_cpus': 4, + # Reserve at least 2 physical cores + 'reserved_cpu_cores': 2, + # Default heap size for IPv6 + 'ipv6_heap_size': '32M', +} diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py new file mode 100644 index 000000000..cf0b923bb --- /dev/null +++ b/python/vyos/vpp/config_verify.py @@ -0,0 +1,444 @@ +# Used for verifying configuration vpp interfaces +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import psutil + +from vyos import ConfigError +from vyos.utils.cpu import get_core_count as total_core_count + +from vyos.vpp.control_host import get_eth_driver +from vyos.vpp.config_resource_checks import cpu as cpu_checks, memory as mem_checks +from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map +from vyos.vpp.utils import human_memory_to_bytes, bytes_to_human_memory + + +def verify_vpp_remove_kernel_interface(config: dict): + """Common verify for removed kernel-interfaces. + Verify that removed kernel interface are not used in 'vpp kernel-interfaces'. + + Example: + delete vpp interfaces gre|vxlan <tag>X kernel-interface vpp-tunX + set vpp kernel-interface vpp-tunX + """ + if ( + 'remove' in config + and 'kernel_interface_removed' in config + and 'vpp_kernel_interfaces' in config + ): + removed_interfaces = config['kernel_interface_removed'] + used_interfaces = config['vpp_kernel_interfaces'] + + for interface in removed_interfaces: + if interface in used_interfaces: + raise ConfigError( + f'"{interface}" is still in use within "vpp kernel-interfaces". ' + 'Please remove it before proceeding.' + ) + + +def verify_vpp_change_kernel_interface(config: dict): + """Common verify for changed kernel-interface + + Example: + set vpp interfaces gre|vxlan <tag> kernel-interface vpp-tunX' + commit + set vpp interfaces gre|vxlan <tag> kernel-interface vpp-tunY' + commit + + check if we have kernel interface config 'vpp kernel-interface vpp-tunX' + """ + kernel_interface_removed = config.get('kernel_interface_removed', []) + vpp_kernel_interfaces = config.get('vpp_kernel_interfaces', {}) + + for interface in kernel_interface_removed: + if interface in vpp_kernel_interfaces: + raise ConfigError( + f'interface "{interface}" is still in use within "vpp kernel-interfaces". ' + f'Please remove it "vpp kernel-interface {interface}" before proceeding.' + ) + + +def verify_vpp_exists_kernel_interface(config: dict): + """Verify is a kernel-interface already created by another VPP LCP pair + + Example: + set vpp interfaces vxlan vxlan10 kernel-interface vpp-tun10' + commit + set vpp interfaces vxlan vxlan20 kernel-interface vpp-tun10' + commit + """ + kernel_interface = config.get('kernel_interface', '') + vpp_interface = config.get('ifname', '') + candidate_kernel_interfaces = config.get('candidate_kernel_interfaces', []) + + for candidate_kernel_iface in candidate_kernel_interfaces: + if ( + vpp_interface != candidate_kernel_iface[0] + and kernel_interface == candidate_kernel_iface[1] + ): + raise ConfigError( + f'Kernel interface "{kernel_interface}" is already configured for {candidate_kernel_iface[0]}. ' + 'Duplicates are not allowed.' + ) + + +def verify_vpp_remove_xconnect_interface(config: dict): + if not config.get('remove'): + return + for xconn_member, xconn_iface in config.get('xconn_members').items(): + if xconn_member == config.get('ifname'): + raise ConfigError( + f'interface "{xconn_member}" is still in use within "vpp interfaces xconnect". ' + f'Please remove it from "vpp interface xconnect {xconn_iface}" before proceeding.' + ) + + +def verify_vpp_tunnel_source_address(config: dict): + from vyos.utils.network import is_intf_addr_assigned + + address = config.get('source_address') + for iface in config.get('vpp_ether_vif_ifaces', []): + if is_intf_addr_assigned(iface, address): + return True + + raise ConfigError( + f'Source address "{address}" is not assigned on any Ethernet or VIF interface!' + ) + + +def verify_dev_driver(iface_name: str, driver_type: str) -> bool: + # Lists of drivers compatible with DPDK and XDP + drivers_dpdk: list[str] = [ + 'atlantic', + 'bnx2x', + 'e1000', + 'ena', + 'gve', + 'hv_netvsc', + 'i40e', + 'ice', + 'igc', + 'ixgbe', + 'liquidio', + 'mlx4_core', + 'mlx5_core', + 'qede', + 'sfc', + 'tap', + 'tun', + 'virtio_net', + 'vmxnet3', + ] + + drivers_xdp: list[str] = [ + 'atlantic', + 'ena', + 'gve', + 'hv_netvsc', + 'i40e', + 'ice', + 'igb', + 'igc', + 'ixgbe', + 'mlx4_core', + 'mlx5_core', + 'qede', + 'sfc', + 'tap', + 'tun', + 'virtio_net', + 'vmxnet3', + ] + + driver: str = get_eth_driver(iface_name) + + if driver_type == 'dpdk': + if driver in drivers_dpdk: + return True + elif driver_type == 'xdp': + if driver in drivers_xdp: + return True + else: + raise ConfigError(f'"Driver type {driver_type} is wrong') + + return False + + +def verify_vpp_minimum_cpus(): + """ + Verify that the host system has enough physical CPU cores + Current minimal requirement is 4 + """ + min_cpus = default_resource_map.get('min_cpus') + if total_core_count() < min_cpus: + raise ConfigError( + 'This system does not meet minimal requirements for VPP. ' + f'Minimum {min_cpus} CPU cores are required.' + ) + + +def verify_vpp_minimum_memory(): + """ + Verify that the host system has enough RAM + Calculate by retrieving the amount of physical memory + And the minimal requirement (currently 8 GB). Round before comparing - + To avoid situations like when a machine nominally has 8192 MB (8 giga/gibibytes) + But the OS sees only 7.75 GB, creating a fail condition for this check + """ + min_mem = default_resource_map.get('min_memory') + total_memory = round(psutil.virtual_memory().total / (1024**3)) + min_memory = round(human_memory_to_bytes(min_mem) / (1024**3)) + + if total_memory < min_memory: + raise ConfigError( + 'This system does not meet minimal requirements for VPP. ' + f'Minimum {min_memory} GB of RAM are required.' + ) + + +def verify_vpp_main_heap_size(settings: dict): + main_heap_size = mem_checks.memory_main_heap(settings) + main_heap_page_size = mem_checks.main_heap_page_size(settings) + + if main_heap_size < 1 << 30: + raise ConfigError('The main heap size must be greater than or equal to 1G') + + readable_heap_page = bytes_to_human_memory(main_heap_page_size, 'K') + + if main_heap_page_size > main_heap_size: + raise ConfigError( + f'The main heap size must be greater than or equal to page-size ({readable_heap_page})' + ) + + +def verify_vpp_memory(config: dict): + memory_required = mem_checks.total_memory_required(config['settings']) + memory_available = mem_checks.get_available_memory() + + # Check if there is a config currently active + # If yes, calculate how much memory it consumes (only for 4k pages) + # and exclude it from required memory + if config.get('effective'): + memory_effective = mem_checks.total_memory_required( + config['effective']['settings'] + ) + + # If we want to reduce memory configs then we don't need + # to check 4K memory type + if memory_effective['4K'] >= memory_required['4K']: + del memory_required['4K'] + else: + # Get memory currently used by VPP and add it to available memory + memory_used = mem_checks.get_vpp_used_memory() + memory_available['4K'] += memory_used + + memory_required_gb = {k: round(v / 1024**3, 1) for k, v in memory_required.items()} + memory_available_gb = { + k: round(v / 1024**3, 1) for k, v in memory_available.items() + } + + errors = {} + for page_size, req_gb in memory_required_gb.items(): + avail_gb = memory_available_gb.get(page_size, 0) + + if req_gb > avail_gb: + label = 'System' if page_size == '4K' else f'{page_size} HugePages' + errors[page_size] = ( + f'{label} memory: available {avail_gb} GB, ' + f'required {memory_required_gb[page_size]} GB' + ) + + if errors: + raise ConfigError( + 'Not enough free memory to start VPP! '.ljust(72) + + '. '.join([line.ljust(72) for line in errors.values()]) + + ( + 'To add HugePages memory please use command '.ljust(72) + + '"set system option kernel memory hugepage-size ..." and reboot!' + if any(k in errors for k in ('2M', '1G')) + else '' + ) + ) + + +def verify_vpp_settings_cpu_skip_cores(skip_cores: int): + cpu_cores = total_core_count() + + # The number of skipped cores must not be greater than + # available CPU cores in the system - 1 for main thread + if skip_cores > (cpu_cores - 1): + raise ConfigError( + f'The system does not have enough available CPUs to skip ' + f'(reduce "cpu skip-cores" to {cpu_cores} or less)' + ) + + +def verify_vpp_settings_cpu_and_corelist_workers(settings: dict): + """ + `set vpp settings cpu workers` and `set vpp settings cpu corelist-workers` + are mutually exclusive! + """ + if ( + 'corelist_workers' in settings or 'workers' in settings + ) and 'main_core' not in settings: + raise ConfigError('"cpu main-core" is required but not set!') + + if 'corelist_workers' in settings and 'workers' in settings: + raise ConfigError( + '"cpu corelist-workers" and "cpu workers" cannot be used at the same time!' + ) + + +def verify_vpp_cpu_main_core(cpu_settings: dict) -> None: + """Check that the main core is available""" + skip_cores = int(cpu_settings.get('skip_cores', 0)) + available_cores = cpu_checks.available_cores_list(skip_cores) + main_core = int(cpu_settings['main_core']) + + if main_core not in available_cores: + raise ConfigError( + 'Cannot set main core for VPP process: ' + f'CPU#{main_core} is not available.' + ) + + +def verify_vpp_settings_cpu_workers(cpu_settings: dict) -> int: + """ + Verify that the system has enough available CPU cores + to run a given amount of worker processes (1 worker/core) + """ + workers = int(cpu_settings.get('workers', 0)) + available_cores = cpu_checks.available_cores_count(cpu_settings) + + if workers > available_cores: + raise ConfigError( + f'Not enough free CPU cores for {workers} VPP workers ' + f'(reduce to {available_cores} or less)' + ) + + return workers + + +def verify_vpp_settings_cpu_corelist_workers(cpu_settings: dict) -> int: + """ + Verify that the CPU cores provided to the config are free and can be used by VPP + """ + workers = cpu_settings.get('corelist_workers') + main_core = int(cpu_settings.get('main_core')) + skip_cores = int(cpu_settings.get('skip_cores', 0)) + available_cores = cpu_checks.available_cores_list(skip_cores) + try: + all_core_nums = cpu_checks.worker_cores_list( + iface='cpu corelist', worker_ranges=workers + ) + except ValueError as e: + raise ConfigError(str(e)) + + error_msg = 'Cannot set VPP "cpu corelist-workers"' + + if main_core in all_core_nums: + raise ConfigError( + f'CPU#{main_core} is set as main core and should not ' + 'be included to the corelist-workers' + ) + + invalid_cores = [str(el) for el in all_core_nums if el not in available_cores] + if invalid_cores: + raise ConfigError( + f'{error_msg}: CPU# {",".join(invalid_cores)} are not available.' + ) + + if len(all_core_nums) > cpu_checks.available_cores_count(cpu_settings): + raise ConfigError(f'{error_msg}: Not enough free CPUs in the system.') + + return len(all_core_nums) + + +def verify_vpp_nat44_workers(workers: int, nat44_workers: list): + if workers < 1: + raise ConfigError( + '"nat44 workers" requires cpu workers or corelist-workers to be set!' + ) + try: + nat_workers = cpu_checks.worker_cores_list( + iface='nat44', worker_ranges=nat44_workers + ) + except ValueError as e: + raise ConfigError(str(e)) + + invalid_workers = [str(el) for el in nat_workers if el not in range(workers)] + if invalid_workers: + raise ConfigError( + f'Cannot set VPP "nat44 workers": worker(s) #{",".join(invalid_workers)} not available. ' + f'Available worker ids: {",".join(map(str, range(workers)))}' + ) + + +def verify_vpp_statseg_size(settings: dict): + statseg_size = mem_checks.statseg_size(settings) + + if 'size' in settings.get('statseg'): + if statseg_size < 128 << 20: + raise ConfigError('The statseg size must be greater than or equal to 128M') + + if 'page_size' in settings['statseg']: + statseg_page_size = mem_checks.statseg_page_size(settings) + if statseg_page_size > statseg_size: + readable_statseg_page = bytes_to_human_memory(statseg_page_size, 'K') + raise ConfigError( + f'The statseg size must be greater than or equal to page-size ({readable_statseg_page})' + ) + + +def verify_vpp_interfaces_dpdk_num_queues(qtype: str, num_queues: int, workers: int): + """ + Verify that VPP has enough workers to run the given amount of RX/TX queues + 1 queue per 1 worker is assumed as default + """ + + if num_queues > workers: + raise ConfigError( + f'The number of {qtype} queues cannot be greater than the number of configured VPP workers: ' + f'workers: {workers}, queues: {num_queues}' + ) + + +def verify_routes_count(settings: dict, workers: int): + """ + Maximum routes count depending on main heap size, + statistics segment size and workers + """ + counters = 2 # 2 counters for each route + bytes = 16 # each counter consumes 16 bytes + statseg_scale = 2 + statseg_size = settings['statseg']['size'] + statseg_size_in_bytes = human_memory_to_bytes(statseg_size) + main_heap = settings['memory']['main_heap_size'] + main_heap_in_gb = human_memory_to_bytes(main_heap) >> 30 + + formula = (workers + 1) * counters * bytes * statseg_scale + routes_count_statseg = statseg_size_in_bytes / formula + routes_count_statseg = round(routes_count_statseg / 1_000_000, 2) + routes_count_mh = main_heap_in_gb * 2 + routes_count_min = min(routes_count_statseg, routes_count_mh) + Warning( + f'NOTE: Current dataplane capacity (estimated): {routes_count_min} M IPv4 routes. ' + 'Exceeding these values will lead to a dataplane out-of-memory condition and a crash. ' + 'Extensive use of features like ACLs, NAT and others may reduce the numbers above. ' + 'Please read the documentation for details: https://docs.vyos.io/' + ) diff --git a/python/vyos/vpp/configdb.py b/python/vyos/vpp/configdb.py new file mode 100644 index 000000000..13c1cd3a0 --- /dev/null +++ b/python/vyos/vpp/configdb.py @@ -0,0 +1,206 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from json import loads as json_loads, dumps as json_dumps +from pathlib import Path +from typing import Any + +STORAGE_LOCATION = '/run/vpp' + + +class JSONStorage: + def __init__(self, name: str = '') -> None: + """Initiate a file storage + + Args: + name (str, optional): Unique storage name. Defaults to '' (generate a name). + + Raises: + err: In case a file for storage cannot be created + """ + # If a name is not provided, this is a temporary one-time storage + # this use case is strange, but let's allow this + if not name: + self.__temporary = True + self.__cache: dict[Any, Any] = {} + self.__locked = False + return + self.__temporary = False + + self.__storage = Path(f'{STORAGE_LOCATION}/{name}.json') + self.__lock_file = Path(f'{STORAGE_LOCATION}/{name}.lock') + + # prepare a folder + storage_dir = Path(STORAGE_LOCATION) + if not storage_dir.exists(): + storage_dir.mkdir(parents=True) + + # initialize lock status + self.__locked = False + if self.__storage_locked(): + raise FileExistsError(f'Cannot open locked storage: {self.__storage}') + self.__lock_file.touch() + + if not self.__storage.exists(): + try: + self.__storage.touch() + except Exception as err: + print(f'Unable to initiate storage: {err}') + raise err + # prepare an empty cache + self.__cache: dict[Any, Any] = {} + else: + # load a cache from file + self.__cache = self.__load_file() + + def __del__(self) -> None: + """Dump data to persistent storage and unlock it""" + if self.__temporary: + return + # dump a cache to storage + if self.__cache: + self.__dump_file() + # or remove a file + else: + self.__storage.unlink() + # unlock a storage + self.__lock_file.unlink() + + def __check_types(self, data: Any) -> None: + """Check if all the data have supported types + + Args: + data (Any): object to validate + + Raises: + TypeError: If a data type is not supported + """ + if isinstance(data, str | int | float | bool | None): + return + if isinstance(data, list): + for item in data: + self.__check_types(item) + return + if isinstance(data, dict): + for item in data.values(): + self.__check_types(item) + return + raise TypeError(f'Object type "{type(data)}" is not allowed') + + def __load_file(self) -> dict[Any, Any]: + """Read a file to a dictionary + + Returns: + dict[Any, Any]: loaded dict object + """ + data: bytes = self.__storage.read_bytes() + return json_loads(data) + + def __dump_file(self) -> None: + """Dump cache to a file""" + data: str = json_dumps(self.__cache) + self.__storage.write_text(data) + + def __lock(self) -> None: + """Lock storage + + Raises: + FileExistsError: Raised if a storage is already locked + """ + if self.__locked: + raise FileExistsError(f'Access is already locked: {self.__storage}') + self.__locked = True + + def __unlock(self) -> None: + """Unlock storage + + Raises: + FileNotFoundError: Raised if a storage is already unlocked + """ + if not self.__locked: + raise FileNotFoundError(f'Access is already unlocked: {self.__storage}') + self.__locked = False + + def __storage_locked(self) -> bool: + """Check if a storage is locked + + Returns: + bool: Lock status + """ + if self.__lock_file.exists(): + return True + return False + + def delete(self, key: Any = None) -> None: + """Delete data from a storage or a full storage + + Raises: + FileExistsError: Raised if a storage is locked + """ + if self.__locked: + raise FileExistsError( + f'Storage locked and delete operation cannot be performed: {self.__storage}' + ) + if key: + if key not in self.__cache: + raise ValueError( + f'Object {key} does not exist in storage {self.__storage}' + ) + del self.__cache[key] + else: + self.__cache = {} + + def write(self, key: Any, value: Any) -> None: + # Check types first + self.__check_types(key) + self.__check_types(value) + # check lock status + if self.__locked: + raise FileExistsError( + f'Storage is locked and cannot be written: {self.__storage}' + ) + # write a data to a cache + self.__lock() + self.__cache[key] = value + self.__unlock() + + def read(self, key: Any, default: Any = None) -> Any: + """Read data from a storage + + Args: + key (Any): key name + default (Any, optional): Value to return if a key does not exist. Defaults to None. + + Raises: + FileExistsError: Raised if a storage is locked + + Returns: + Any: Value to return + """ + # Check types first + self.__check_types(key) + # check lock status + if self.__locked: + raise FileExistsError( + f'Storage is locked and it is not safe to read: {self.__storage}' + ) + # read a data from cache + self.__lock() + data: Any | None = self.__cache.get(key, default) + self.__unlock() + + return data diff --git a/python/vyos/vpp/control_host.py b/python/vyos/vpp/control_host.py new file mode 100644 index 000000000..6a6b9d72f --- /dev/null +++ b/python/vyos/vpp/control_host.py @@ -0,0 +1,375 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from pathlib import Path +from re import fullmatch as re_fullmatch +from subprocess import run +from time import sleep + +from pyroute2 import IPRoute + +from vyos.vpp.utils import EthtoolGDrvinfo + + +def pci_rescan(pci_addr: str = '') -> None: + """Rescan PCI device by removing it and rescan PCI bus + + If PCI address is not defined - just rescan PCI bus + + Args: + address (str, optional): PCI address of device. Defaults to ''. + """ + device_file = Path(f'/sys/bus/pci/devices/{pci_addr}/remove') + if pci_addr: + if device_file.exists(): + device_file.write_text('1') + # wait 10 seconds max until device will be removed + attempts = 100 + while device_file.exists() and attempts: + attempts -= 1 + sleep(0.1) + if device_file.exists(): + raise TimeoutError( + f'Timeout was reached for removing PCI device {pci_addr}' + ) + else: + raise FileNotFoundError(f'PCI device {pci_addr} does not exist') + rescan_file = Path('/sys/bus/pci/rescan') + rescan_file.write_text('1') + if pci_addr: + # wait 10 seconds max until device will be installed + attempts = 100 + while not device_file.exists() and attempts: + attempts -= 1 + sleep(0.1) + if not device_file.exists(): + raise TimeoutError( + f'Timeout was reached for installing PCI device {pci_addr}' + ) + + +def unbind_driver(bus_id: str, device_id: str) -> bool: + """Unbind a driver from a device + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + + Returns: + bool: True if a driver has been unbound, False otherwise + """ + device_resolved: str = ( + Path(f'/sys/bus/{bus_id}/devices/{device_id}').resolve().as_posix() + ) + if not Path(f'{device_resolved}/driver').exists(): + return False + + Path(f'{device_resolved}/driver/unbind').write_text(device_id) + return True + + +def probe_driver(bus_id: str, device_id: str) -> None: + """Probe driver for a device on a bus + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + """ + Path(f'/sys/bus/{bus_id}/drivers_probe').write_text(device_id) + + +def load_kernel_module(module_name: str) -> None: + """Load a kernel module + + Args: + module_name (str): module name + """ + # check if a module already loaded + if Path(f'/sys/module/{module_name}').exists(): + return + + # execute modprobe with the specified module name + run(['/usr/sbin/modprobe', '-q', module_name], check=True) + + +def override_driver(bus_id: str, device_id: str, driver_name: str = '') -> None: + """Override a driver for a device + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + driver_name (str, optional): Kernel module (driver) name. Defaults to '' - clear an override. + + Raises: + FileNotFoundError: A device does not support driver override + ChildProcessError: Failed to override a driver + """ + device_resolved: str = ( + Path(f'/sys/bus/{bus_id}/devices/{device_id}').resolve().as_posix() + ) + # check if a device supports driver override + if not Path(f'{device_resolved}/driver_override').exists(): + raise FileNotFoundError(f'{device_resolved} does not support driver override') + + if driver_name: + load_kernel_module(driver_name) + + unbind_driver(bus_id, device_id) + + # vfio-pci needs special approach + if driver_name == 'vfio-pci': + vendor: str = Path(f'{device_resolved}/vendor').read_text() + device: str = Path(f'{device_resolved}/device').read_text() + Path('/sys/module/vfio_pci/drivers/pci:vfio-pci/new_id').write_text( + f'{vendor} {device}' + ) + + # override a driver + Path(f'{device_resolved}/driver_override').write_text(f'{driver_name}\n') + + # probe a driver + probe_driver(bus_id, device_id) + + # check the result + if not Path(f'{device_resolved}/driver').exists(): + raise ChildProcessError( + f'Failed to override a driver to {driver_name} for {bus_id}, {device_id}' + ) + + +def get_bus_name(iface: str) -> str: + """Get bus name + Works for PCI, VMbus, maybe something else. + Does not work for Virtio and other virtual devices + (however, it does not seem we need this for such kind of devices). + + Args: + iface (str): interface name + + Returns: + str: bus name + """ + device_resolved: Path = Path(f'/sys/class/net/{iface}/device').resolve() + + # Iterate upwards until a `bus` directory is found + current_path: Path = device_resolved + while True: + # Check if a bus info is available + subsystem_path = Path(f'{current_path}/subsystem') + if subsystem_path.is_symlink(): + # Read the link to determine the bus type + bus_path = subsystem_path.resolve() + # Check if the parent directory is a 'bus' directory in '/sys/bus/' + if bus_path.parent.name == 'bus': + # Return only the last name of the path, e.g., 'pci' + return bus_path.name + + # Move up one directory level + current_path = current_path.parent + if current_path == Path('/sys'): + break # Stop if we reach the root of /sys without finding a bus type + + return '' # Return None if no bus type was found + + +def get_eth_name(dev_id: str) -> str: + """Find Ethernet interface name by PCI address or UUID + + Args: + dev_id (str): PCI address or UUID + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: Ethernet interface name + """ + # find all PCI devices with eth* names + net_devs: dict[str, str] = {} + net_devs_dir = Path('/sys/class/net') + regex_filter = r'^/sys/devices/pci[\w/:\.]+/(?P<pci_addr>\w+:\w+:\w+\.\w+)/[\w/:\.]+/(?P<iface_name>eth\d+)$' + for dir in net_devs_dir.iterdir(): + # PCI devices + real_dir: str = dir.resolve().as_posix() + re_obj = re_fullmatch(regex_filter, real_dir) + if re_obj: + iface_name: str = re_obj.group('iface_name') + iface_addr: str = re_obj.group('pci_addr') + net_devs.update({iface_addr: iface_name}) + # UUID devices + else: + try: + bus_type: str = get_bus_name(dir.name) + iface_addr = EthtoolGDrvinfo(dir.name).bus_info_expand(bus_type) + net_devs.update({iface_addr: dir.name}) + except FileNotFoundError: + pass + + # match to provided PCI address or UUID and return a name if found + if dev_id in net_devs: + return net_devs[dev_id] + # raise error if device was not found + raise FileNotFoundError( + f'A device with ID {dev_id} not found in ethernet interfaces' + ) + + +def get_dev_id(iface: str) -> str: + """Get device ID by its interface name + + Args: + iface (str): interface name + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: device ID (PCI address or UUID) + """ + try: + # Try to get details via ethtool first + ethtool_info = EthtoolGDrvinfo(iface) + # For devices represented by UUID we need to expand them + # to their full representation + if ethtool_info.driver == 'hv_netvsc': + return ethtool_info.bus_info_expand('vmbus') + return ethtool_info.bus_info + except Exception: + # raise error if a device ID was not found + raise FileNotFoundError(f'Cannot find device ID for interface {iface}') + + +def get_eth_driver(iface: str) -> str: + """Find kernel module used for Ethernet interface + + Args: + iface (str): Ethernet interface name + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: kernel module name + """ + iface_driver: str = '' + driver_dir = Path(f'/sys/class/net/{iface}/device/driver/module') + if not driver_dir.exists(): + # raise error if device was not found + raise FileNotFoundError(f'PCI device {iface} not found in ethernet interfaces') + + iface_driver: str = driver_dir.resolve().name + return iface_driver + + +def unsafe_noiommu_mode(status: bool) -> None: + """Control unsafe_noiommu_mode parameter of vfio module + + Args: + status (bool): Target status + + Raises: + ChildProcessError: Raised if failed to set unsafe_noiommu_mode + """ + param_path = Path('/sys/module/vfio/parameters/enable_unsafe_noiommu_mode') + current_status: str = param_path.read_text().strip() + target_status: str = 'Y' if status else 'N' + if current_status != target_status: + param_path.write_text(target_status) + if param_path.read_text().strip() != target_status: + raise ChildProcessError('Failed to set unsafe_noiommu_mode') + + +def rename_iface(name_old: str, name_new: str) -> None: + """Rename interface + + Args: + name_old (str): old name + name_new (str): new name + """ + run(['ip', 'link', 'set', name_old, 'down']) + rename_cmd: list[str] = ['ip', 'link', 'set', name_old, 'name', name_new] + run(rename_cmd) + + +def set_promisc(iface_name: str, operation: str) -> None: + """Set promisc mode for interface + + Args: + iface_name (str): name of an interface + operation (str): operation (on, off) + """ + run(['ip', 'link', 'set', iface_name, 'promisc', operation]) + + +def set_mtu(iface_name: str, mtu: int) -> None: + """Set MTU for interface + + Args: + iface_name (str): name of an interface + mtu (int): MTU + """ + run(['ip', 'link', 'set', iface_name, 'mtu', str(mtu)]) + + +def get_eth_mac(iface_name: str) -> str: + """Get MAC address of an interface + + Args: + iface_name (str): name of an interface + + Raises: + FileNotFoundError: interface was not found + + Returns: + str: MAC address + """ + dev_addr_path = Path(f'/sys/class/net/{iface_name}/address') + if dev_addr_path.exists(): + return dev_addr_path.read_text().strip() + else: + # raise error if device was not found + raise FileNotFoundError(f'Interface {iface_name} not found') + + +def xdp_remove(iface_name: str) -> None: + """Remove XDP BPF program from an interfce + + Args: + iface_name (str): name of an interface + """ + run(['ip', 'link', 'set', iface_name, 'xdp', 'off']) + + +def set_status(iface_name: str, status: str) -> None: + """Set interface status + + Args: + iface_name (str): name of an interface + status (str): status - "up" or "down" + """ + run(['ip', 'link', 'set', iface_name, status]) + + +def flush_ip(iface_name: str) -> None: + """Flush IP addresses from an interface + + Args: + iface_name (str): name of an interface + """ + iproute = IPRoute() + iproute.flush_addr(label=iface_name) diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py new file mode 100644 index 000000000..596ef4f12 --- /dev/null +++ b/python/vyos/vpp/control_vpp.py @@ -0,0 +1,488 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from collections.abc import Callable +from functools import wraps +from re import search as re_search, MULTILINE as re_M +from systemd import journal +from time import sleep +from typing import TypeVar, ParamSpec, Literal + +from vpp_papi import VPPApiClient +from vpp_papi import VPPIOError, VPPValueError + +# define types for static type checkers +AnyType = TypeVar('AnyType') +AnyParam = ParamSpec('AnyParam') + + +class VPPControl: + """Control VPP network stack""" + + class _Decorators: + """Decorators for VPPControl""" + + @classmethod + def api_call( + cls, decorated_func: Callable[AnyParam, AnyType] + ) -> Callable[AnyParam, AnyType]: + """Check if API is connected before API call + + Args: + decorated_func: function to decorate + + Raises: + VPPIOError: Connection to API is not established + """ + + @wraps(decorated_func) + def api_safe_wrapper( + cls, *args: AnyParam.args, **kwargs: AnyParam.kwargs + ) -> AnyType: + if not cls.connected: + raise VPPIOError(2, 'VPP API is not connected') + return decorated_func(cls, *args, **kwargs) + + return api_safe_wrapper + + @classmethod + def check_retval( + cls, decorated_func: Callable[AnyParam, AnyType] + ) -> Callable[AnyParam, AnyType]: + """Check retval from API response + + Args: + decorated_func: function to decorate + + Raises: + VPPValueError: raised when retval is not 0 + """ + + @wraps(decorated_func) + def check_retval_wrapper( + cls, *args: AnyParam.args, **kwargs: AnyParam.kwargs + ) -> AnyType: + return_value = decorated_func(cls, *args, **kwargs) + if not return_value.retval == 0: + raise VPPValueError(f'VPP API call failed: {return_value.retval}') + return return_value + + return check_retval_wrapper + + def __init__(self, attempts: int = 5, interval: int = 1000) -> None: + """Create VPP API connection + + Args: + attempts (int, optional): attempts to connect. Defaults to 5. + interval (int, optional): interval between attempts in ms. Defaults to 1000. + + Raises: + VPPIOError: Connection to API cannot be established + """ + self.__vpp_api_client = VPPApiClient() + # connect with interval + while attempts: + try: + attempts -= 1 + self.__vpp_api_client.connect('vpp-vyos') + break + except (ConnectionRefusedError, FileNotFoundError) as err: + error_message = f'VPP API connection timeout: {err}' + journal.send(error_message, priority=journal.LOG_ERR) + sleep(interval / 1000) + # raise exception if connection was not successful in the end + if not self.__vpp_api_client.transport.connected: + raise VPPIOError(2, 'Cannot connect to VPP API') + + def __del__(self) -> None: + """Disconnect from VPP API (destructor)""" + self.disconnect() + + def disconnect(self) -> None: + """Disconnect from VPP API""" + if self.__vpp_api_client.transport.connected: + self.__vpp_api_client.disconnect() + + @_Decorators.check_retval + @_Decorators.api_call + def cli_cmd(self, command: str): + """Send raw CLI command + + Args: + command (str): command to send + + Returns: + vpp_papi.vpp_serializer.cli_inband_reply: CLI reply class + """ + return self.__vpp_api_client.api.cli_inband(cmd=command) + + @_Decorators.api_call + def get_mac(self, ifname: str) -> str: + """Find MAC address by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + str: MAC address + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.l2_address.mac_string + return '' + + @_Decorators.api_call + def get_sw_if_index(self, ifname: str) -> int | None: + """Find interface index by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + int | None: Interface index or None (if was not fount) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.sw_if_index + return None + + @_Decorators.api_call + def get_interface_name(self, index: int) -> str | None: + """Find interface name by interface index in VPP + + Args: + index (int): interface index inside VPP + + Returns: + str | None: Interface name or None (if was not found) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.sw_if_index == index: + return iface.interface_name + return None + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_pair_add( + self, + iface_name_vpp: str, + iface_name_kernel: str, + iface_type: Literal['tun', 'tap', ''] = '', + ) -> None: + """Create LCP interface pair between VPP and kernel + + Args: + iface_name_vpp (str): interface name in VPP + iface_name_kernel (str): interface name in kernel + iface_type (Literal['tun', 'tap', ''], optional): Use explicit interface type in kernel. Defaults to ''. + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + api_call_args: dict[str, bool | int | str] = { + 'is_add': True, + 'sw_if_index': iface_index, + 'host_if_name': iface_name_kernel, + } + if iface_type: + iface_type_resolve = {'tun': 1, 'tap': 0} + api_call_args['host_if_type'] = iface_type_resolve[iface_type] + return self.__vpp_api_client.api.lcp_itf_pair_add_del_v2(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_pair_del(self, iface_name_vpp: str, iface_name_kernel: str) -> None: + """Delete LCP interface pair between VPP and kernel + + Args: + iface_name_vpp (str): interface name in VPP + iface_name_kernel (str): interface name in kernel + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + return self.__vpp_api_client.api.lcp_itf_pair_add_del_v2( + is_add=False, sw_if_index=iface_index, host_if_name=iface_name_kernel + ) + + @_Decorators.api_call + def lcp_pair_find( + self, + kernel_name: str = '', + vpp_index_hw: int | None = None, + vpp_index_kernel: int | None = None, + vpp_name_hw: str = '', + vpp_name_kernel: str = '', + ) -> dict[str, str | int] | None: + """Find LCP pair details + + Args: + kernel_name (str, optional): Interface name in the kernel. Defaults to ''. + vpp_index_hw (int | None, optional): Interface index in VPP (hardware). Defaults to None. + vpp_index_kernel (int | None, optional): Interface index in VPP (kernel). Defaults to None. + vpp_name_hw (str, optional): Interface name in VPP (hardware). Defaults to ''. + vpp_name_kernel (str, optional): Interface name in VPP (to kernel). Defaults to ''. + + Returns: + dict[str, str | int] | None: LCP pair details + """ + filter_dict = {} + for filter_name, filter_value in locals().items(): + if filter_value: + filter_dict[filter_name] = filter_value + + # Get list of pairs + lcp_pairs = self.lcp_pairs_list() + + # Check each pair + for pair in lcp_pairs: + pair_found = False + # For each item provided in function arguments + for filter_name, filter_value in filter_dict.items(): + # Stop if filter value is not as in a current pair + if filter_name in pair and pair[filter_name] != filter_value: + pair_found = False + break + # Set flag to True and check the next filter value + pair_found = True + + if pair_found: + return pair + + return None + + @_Decorators.api_call + def lcp_pairs_list(self) -> list[dict[str, str | int]]: + """List all LCP pairs + + Returns: + list[dict[str, str | int]]: LCP pairs details + """ + lcp_pairs_details = [] + + lcp_pairs = self.__vpp_api_client.api.lcp_itf_pair_get()[1] + vpp_ifaces = self.__vpp_api_client.api.sw_interface_dump() + for pair in lcp_pairs: + pair_details = { + 'kernel_name': pair.host_if_name, + 'vpp_index_hw': pair.phy_sw_if_index, + 'vpp_index_kernel': pair.host_sw_if_index, + } + for vpp_iface in vpp_ifaces: + if vpp_iface.sw_if_index == pair_details['vpp_index_hw']: + pair_details['vpp_name_hw'] = vpp_iface.interface_name + if vpp_iface.sw_if_index == pair_details['vpp_index_kernel']: + pair_details['vpp_name_kernel'] = vpp_iface.interface_name + + lcp_pairs_details.append(pair_details) + + return lcp_pairs_details + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_resync(self) -> None: + """Resynchronize objects between kernel and VPP via Netlink + + This clears all routes in VPP configured by LCP and re-creates them + based on the current state of the kernel. + """ + return self.__vpp_api_client.api.lcp_nl_resync() + + @_Decorators.check_retval + @_Decorators.api_call + def iface_rxmode(self, iface_name: str, rx_mode: str) -> None: + """Set interface rx-mode in VPP + + Args: + iface_name (str): interface name in VPP + rx_mode (str): mode (polling, interrupt, adaptive) + """ + modes_dict: dict[str, int] = {'polling': 1, 'interrupt': 2, 'adaptive': 3} + if rx_mode not in modes_dict: + raise VPPValueError(f'Mode {rx_mode} is not known') + iface_index = self.get_sw_if_index(iface_name) + return self.__vpp_api_client.api.sw_interface_set_rx_mode( + sw_if_index=iface_index, mode=modes_dict[rx_mode] + ) + + @_Decorators.api_call + def get_pci_addr(self, ifname: str) -> str: + """Find PCI address of interface by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + str: PCI address + """ + hw_info = self.cli_cmd(f'show hardware-interfaces {ifname}').reply + + regex_filter = r'^\s+pci: device (?P<device>\w+:\w+) subsystem (?P<subsystem>\w+:\w+) address (?P<address>\w+:\w+:\w+\.\w+) numa (?P<numa>\w+)$' + re_obj = re_search(regex_filter, hw_info, re_M) + + # return empty string if no interface or no PCI info was found + if not hw_info or not re_obj: + return '' + + address = re_obj.groupdict().get('address', '') + + # we need to modify address to match kernel style + # for example: 0000:06:14.00 -> 0000:06:14.0 + address_chunks: list[str] = address.split('.') + address_normalized: str = f'{address_chunks[0]}.{int(address_chunks[1])}' + + return address_normalized + + @_Decorators.check_retval + @_Decorators.api_call + def xdp_iface_create( + self, + host_if: str, + name: str, + rxq_num: int = 0, + rxq_size: int = 0, + txq_size: int = 0, + mode: Literal['auto', 'copy', 'zero-copy'] = 'auto', + flags: Literal['no_syscall_lock', ''] = '', + ) -> None: + """Create XDP interface + + Args: + host_if (str): name of an interface in kernel + name (str): name of an interface in VPP + rxq_num (int, optional): Number of receive queues to connect to. Defaults to 0 (all). + rxq_size (int, optional): Size of receive queue. Defaults to 0. + txq_size (int, optional): Size of tranceive queue. Defaults to 0. + mode (Literal['auto', 'copy', 'zero-copy', optional): Zero-copy mode. Defaults to 'auto'. + flags (Literal['no_syscall_lock', ''], optional): Syscall lock mode. Defaults to ''. + """ + api_call_args: dict[str, int | str] = { + 'host_if': host_if, + 'name': name, + 'rxq_num': rxq_num, + 'rxq_size': rxq_size, + 'txq_size': txq_size, + } + if mode != 'auto': + mode_resolve: dict[str, int] = {'auto': 0, 'copy': 1, 'zero-copy': 2} + api_call_args['mode'] = mode_resolve[mode] + if flags == 'no_systcall_lock': + api_call_args['flags'] = 1 + return self.__vpp_api_client.api.af_xdp_create_v3(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def xdp_iface_delete(self, iface_name_vpp: str) -> None: + """Delete XDP interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + api_call_args: dict[str, int] = {'sw_if_index': iface_index} + return self.__vpp_api_client.api.af_xdp_delete(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def set_iface_mac(self, iface_name_vpp: str, mac_address: str) -> None: + """Set MAC address of an interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + mac_address (str): MAC address + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + api_call_args: dict[str, str | int] = { + 'sw_if_index': iface_index, + 'mac_address': mac_address, + } + return self.__vpp_api_client.api.sw_interface_set_mac_address(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def set_iface_mtu(self, iface_name_vpp: str, mtu: int) -> None: + """Set MTU for interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + mtu (int): MTU + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + api_call_args: dict[str, str | int] = {'sw_if_index': iface_index, 'mtu': mtu} + return self.__vpp_api_client.api.hw_interface_set_mtu(**api_call_args) + + @_Decorators.api_call + def get_sw_if_dev_type(self, ifname: str) -> int | None: + """Find interface device type by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + int | None: Interface device type or None (if was not fount) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.interface_dev_type + return None + + @_Decorators.api_call + def enable_disable_nat44_forwarding(self, enable: bool) -> None: + """Enable/disable NAT44 forwarding + + Args: + enable (bool): True if enable, False if disable + """ + self.__vpp_api_client.api.nat44_forwarding_enable_disable(enable=enable) + + @_Decorators.api_call + def set_nat44_session_limit(self, session_limit: int) -> None: + """Set NAT44 session limit + + Args: + session_limit (int): Maximum number of sessions per thread + """ + self.__vpp_api_client.api.nat44_set_session_limit( + session_limit=session_limit, + ) + + @_Decorators.api_call + def set_nat_workers(self, workers: int) -> None: + """Set NAT44 session limit + + Args: + workers (int): Bitmask of workers list + """ + self.__vpp_api_client.api.nat_set_workers(worker_mask=workers) + + @property + def connected(self) -> bool: + """Check if VPP API is connected + + Returns: + bool: True if connected, False if not + """ + return self.__vpp_api_client.transport.connected + + @property + @_Decorators.api_call + def api(self): + """Call API + + Returns: + Callable[AnyParam, AnyType]: API functions + """ + return self.__vpp_api_client.api diff --git a/python/vyos/vpp/interface/__init__.py b/python/vyos/vpp/interface/__init__.py new file mode 100644 index 000000000..903b7cbfc --- /dev/null +++ b/python/vyos/vpp/interface/__init__.py @@ -0,0 +1,42 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from .bond import BondInterface +from .bridge import BridgeInterface +from .ethernet import EthernetInterface +from .geneve import GeneveInterface +from .gre import GREInterface +from .interface import Interface +from .ipip import IPIPInterface +from .loopback import LoopbackInterface +from .vxlan import VXLANInterface +from .wireguard import WireguardInterface +from .xconnect import XconnectInterface + +__all__ = [ + 'BondInterface', + 'BridgeInterface', + 'EthernetInterface', + 'GeneveInterface', + 'GREInterface', + 'Interface', + 'IPIPInterface', + 'LoopbackInterface', + 'VXLANInterface', + 'WireguardInterface', + 'XconnectInterface', +] diff --git a/python/vyos/vpp/interface/bond.py b/python/vyos/vpp/interface/bond.py new file mode 100644 index 000000000..604ba2ae5 --- /dev/null +++ b/python/vyos/vpp/interface/bond.py @@ -0,0 +1,118 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp.control_host import set_promisc +from vyos.vpp.interface.interface import Interface + + +class BondInterface(Interface): + def __init__( + self, + ifname, + mode: str = '', + load_balance: int = 0, + mac: str = '', + kernel_interface: str = '', + state: str = 'up', + ): + super().__init__(ifname) + self.instance = int(ifname.removeprefix('bond')) + self.ifname = f'BondEthernet{self.instance}' + self.mode = mode + self.load_balance = load_balance + self.mac = mac + self.kernel_interface = kernel_interface + self.state = state + + def add(self): + """Create Bond interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/bonding/bond.api + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.add() + """ + # Create interface 'bondX' + create_args = { + 'id': self.instance, + 'mode': self.mode, + 'lb': self.load_balance, + } + if self.mac: + create_args.update({'use_custom_mac': True, 'mac_address': self.mac}) + self.vpp.api.bond_create2(**create_args) + if self.kernel_interface: + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + # Set interface state + self.set_state(self.state) + + def delete(self): + """Delete Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.delete() + """ + bond_if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.bond_delete(sw_if_index=bond_if_index) + + def add_member(self, interface): + """Add member to Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.add_member(interface='eth0') + """ + bond_if_index = self.vpp.get_sw_if_index(f'BondEthernet{self.instance}') + member_if_index = self.vpp.get_sw_if_index(interface) + member_if_type = self.vpp.get_sw_if_dev_type(interface) + self.vpp.api.bond_add_member( + bond_sw_if_index=bond_if_index, sw_if_index=member_if_index + ) + self.vpp.api.sw_interface_set_promisc( + sw_if_index=member_if_index, promisc_on=True + ) + if member_if_type == 'AF_XDP interface': + set_promisc(f'defunct_{interface}', 'on') + + def detach_member(self, interface): + """Detach member from Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.detach_member(interface='eth0') + """ + member_if_index = self.vpp.get_sw_if_index(interface) + self.vpp.api.bond_detach_member(sw_if_index=member_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/bridge.py b/python/vyos/vpp/interface/bridge.py new file mode 100644 index 000000000..25f0b4660 --- /dev/null +++ b/python/vyos/vpp/interface/bridge.py @@ -0,0 +1,128 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class BridgeInterface: + def __init__( + self, + ifname: str, + flood: bool = True, + forward: bool = True, + learn: bool = True, + uu_flood: bool = True, + arp_term: bool = False, + ): + self.ifname = ifname + self.interface_suffix = int(self.ifname.replace('br', '')) + self.flood = flood + self.forward = forward + self.learn = learn + self.uu_flood = uu_flood + self.arp_term = arp_term + self.vpp = VPPControl() + + def add(self): + """Create Bridge interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/l2/l2.api + + Bridge-domain 0 is reserved for the default bridge-domain. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.add() + """ + self.vpp.api.bridge_domain_add_del_v2( + is_add=True, + bd_id=self.interface_suffix, + flood=self.flood, + forward=self.forward, + learn=self.learn, + uu_flood=self.uu_flood, + arp_term=self.arp_term, + ) + + def delete(self): + """Delete Bridge interface + + Bridge-members must be detached before deleting the bridge interface. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.delete() + """ + self.vpp.api.bridge_domain_add_del_v2(is_add=False, bd_id=self.interface_suffix) + + def add_member(self, member: str | int, port_type: int = 0): + """Add member to Bridge interface + + Attaches a VPP interface to the Bridge interface specified by `interface_suffix`. + The `member` parameter can be either the name (str) or the index (int) of the network + VPP interface to be added as a member to the bridge. + + Args: + member (str or int): The name or index of the VPP network interface + to be added as a member to the bridge. + port_type: 0 - Normal port, 1 - BVI port + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.add_member(member='eth0') + """ + bridge_index = self.interface_suffix + # If the 'member' is an Integer or digit, assume it's an interface index + if isinstance(member, int): + member_if_index = member + elif member.isdigit(): + member_if_index = int(member) + else: + member_if_index = self.vpp.get_sw_if_index(member) + + return self.vpp.api.sw_interface_set_l2_bridge( + rx_sw_if_index=member_if_index, bd_id=bridge_index, port_type=port_type + ) + + def detach_member(self, member: str | int): + """Detach member from Bridge interface. + Bridge-domain 0 is reserved for the default bridge-domain. + The `member` parameter can be either the name (str) or the index (int) + of the network VPP interface + + Args: + member (str or int): The name or index of the VPP network interface + to be detached from the bridge. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.detach_member(member='eth0') + """ + # If the 'member' is an Integer or digit, assume it's an interface index + if isinstance(member, int): + member_if_index = member + elif member.isdigit(): + member_if_index = int(member) + else: + member_if_index = self.vpp.get_sw_if_index(member) + + return self.vpp.api.sw_interface_set_l2_bridge( + rx_sw_if_index=member_if_index, bd_id=0, port_type=0 + ) diff --git a/python/vyos/vpp/interface/ethernet.py b/python/vyos/vpp/interface/ethernet.py new file mode 100644 index 000000000..523211db8 --- /dev/null +++ b/python/vyos/vpp/interface/ethernet.py @@ -0,0 +1,53 @@ +# VyOS implementation of VPP Ethernet interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class EthernetInterface: + """Interface Ethernet""" + + def __init__(self, ifname, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('eth')) + self.ifname = ifname + self.kernel_interface = kernel_interface + self.vpp = VPPControl() + + def add(self): + pass + + def delete(self): + pass + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import EthernetInterface + a = EthernetInterface(ifname='eth0') + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import EthernetInterface + a = EthernetInterface(ifname='eth0') + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/geneve.py b/python/vyos/vpp/interface/geneve.py new file mode 100644 index 000000000..2d6347f12 --- /dev/null +++ b/python/vyos/vpp/interface/geneve.py @@ -0,0 +1,90 @@ +# VyOS implementation of Geneve interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +def show(): + """Show Geneve interface + Example: + from vyos.vpp.interface import geneve + geneve.show() + """ + vpp = VPPControl() + return vpp.api.geneve_tunnel_dump() + + +class GeneveInterface: + def __init__(self, ifname, source_address, remote, vni, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('geneve')) + self.ifname = f'geneve_tunnel{self.instance}' + self.src_address = source_address + self.dst_address = remote + self.vni = vni + self.kernel_interface = kernel_interface + self.vpp = VPPControl() + + def add(self): + """Create Geneve interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/geneve/geneve.api + + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='geneve25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.add() + """ + return self.vpp.api.geneve_add_del_tunnel2( + is_add=True, + local_address=self.src_address, + remote_address=self.dst_address, + vni=self.vni, + l3_mode=False, + ) + + def delete(self): + """Delete Geneve interface + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.delete() + """ + return self.vpp.api.geneve_add_del_tunnel2( + is_add=False, + local_address=self.src_address, + remote_address=self.dst_address, + vni=self.vni, + l3_mode=False, + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py new file mode 100644 index 000000000..bc278d2a0 --- /dev/null +++ b/python/vyos/vpp/interface/gre.py @@ -0,0 +1,140 @@ +# VyOS implementation of VPP GRE interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface + + +def show(): + """Show GRE interface + Example: + from vyos.vpp.interface import gre + gre.show() + """ + vpp = VPPControl() + return vpp.api.gre_tunnel_dump() + + +class GREInterface(Interface): + """ + Class representing a GRE (Generic Routing Encapsulation) interface. + + Attributes: + ifname (str): The interface name. + source_address (str): The source IP address for the GRE tunnel. + remote (str): The remote IP address for the GRE tunnel. + tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. + mode (str): The mode of the GRE tunnel. Options are 'point-to-point' and 'point-to-multipoint'. Defaults to 'point-to-point'. + kernel_interface (str): The associated kernel interface. Defaults to an empty string. + instance (int): The instance number derived from the interface name. + vpp (VPPControl): An instance of the VPPControl class for interacting with the VPP API. + """ + + # Mapping of tunnel types https://github.com/FDio/vpp/blob/stable/2406/src/plugins/gre/gre.api#L25-L35 + TUNNEL_TYPE_MAP = { + 'l3': 0, + 'teb': 1, + 'erspan': 2, + } + + MODE_MAP = { + 'point-to-point': 0, + 'point-to-multipoint': 1, + } + + def __init__( + self, + ifname, + source_address, + remote, + tunnel_type: str = 'l3', + mode: str = 'point-to-point', + kernel_interface: str = '', + state: str = 'up', + ): + """ + Initialize a GREInterface instance. + + Args: + ifname (str): The interface name. + source_address (str): The source IP address for the GRE tunnel. + remote (str): The remote IP address for the GRE tunnel. + mode (str): The mode of the GRE tunnel. Options are 'point-to-point' and 'point-to-multipoint'. Defaults to 'point-to-point'. + tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. + kernel_interface (str): The associated kernel interface. Defaults to an empty string. + state (str): The state of the interface. Defaults to 'up'. + """ + super().__init__(ifname) + self.instance = int(ifname.removeprefix('gre')) + self.ifname = ifname + self.src_address = source_address + self.dst_address = remote + self.tunnel_type = self.TUNNEL_TYPE_MAP[tunnel_type] + self.mode = self.MODE_MAP[mode] + self.kernel_interface = kernel_interface + self.initial_state = state + + def add(self): + """Create GRE interface + https://github.com/FDio/vpp/blob/stable/2406/src/plugins/gre/gre.api + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25', tunnel_type='l3') + a.add() + """ + self.vpp.api.gre_tunnel_add_del( + is_add=True, + tunnel={ + 'src': self.src_address, + 'dst': self.dst_address, + 'instance': self.instance, + 'mode': self.mode, + 'type': self.tunnel_type, + }, + ) + # Set interface state + self.set_state(self.initial_state) + + def delete(self): + """Delete GRE interface + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.delete() + """ + return self.vpp.api.gre_tunnel_add_del( + is_add=False, tunnel={'src': self.src_address, 'dst': self.dst_address} + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/interface.py b/python/vyos/vpp/interface/interface.py new file mode 100644 index 000000000..05c1d14d6 --- /dev/null +++ b/python/vyos/vpp/interface/interface.py @@ -0,0 +1,49 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class Interface: + def __init__(self, ifname): + self.ifname = ifname + self.vpp = VPPControl() + + def set_state(self, state: str): + """Set interface state to UP or DOWN + Args: + state (str): The state of the interface. Options are 'up' and 'down'. + Example: + from vyos.vpp.interface import Interface + a = Interface(ifname='eth0') + a.set_state(state='up') + """ + if state not in ['up', 'down']: + raise ValueError(f"Invalid state: {state}") + state_flag = 1 if state == 'up' else 0 + if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.sw_interface_set_flags(sw_if_index=if_index, flags=state_flag) + + def get_state(self): + """Get interface state + Example: + from vyos.vpp.interface import Interface + a = Interface(ifname='eth0') + a.get_state() + """ + if_index = self.vpp.get_sw_if_index(self.ifname) + return self.vpp.api.sw_interface_dump(sw_if_index=if_index)[0]['flags'] diff --git a/python/vyos/vpp/interface/ipip.py b/python/vyos/vpp/interface/ipip.py new file mode 100644 index 000000000..e6ed44537 --- /dev/null +++ b/python/vyos/vpp/interface/ipip.py @@ -0,0 +1,94 @@ +# VyOS implementation of VPP IPIP interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface + + +def show(): + """Show IPIP interface + Example: + from vyos.vpp.interface import ipip + ipip.show() + """ + vpp = VPPControl() + return vpp.api.ipip_tunnel_dump() + + +class IPIPInterface(Interface): + def __init__( + self, + ifname, + source_address, + remote, + kernel_interface: str = '', + state: str = 'up', + ): + super().__init__(ifname) + self.instance = int(ifname.removeprefix('ipip')) + self.ifname = ifname + self.src_address = source_address + self.dst_address = remote + self.kernel_interface = kernel_interface + self.initial_state = state + + def add(self): + """Create IPIP interface + https://github.com/FDio/vpp/blob/stable/2310/src/vnet/ipip/ipip.api + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.add() + """ + self.vpp.api.ipip_add_tunnel( + tunnel={ + 'src': self.src_address, + 'dst': self.dst_address, + 'instance': self.instance, + }, + ) + # Set interface state + self.set_state(self.initial_state) + + def delete(self): + """Delete IPIP interface + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.delete() + """ + ipip_if_index = self.vpp.get_sw_if_index(f'ipip{self.instance}') + return self.vpp.api.ipip_del_tunnel(sw_if_index=ipip_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/loopback.py b/python/vyos/vpp/interface/loopback.py new file mode 100644 index 000000000..357423780 --- /dev/null +++ b/python/vyos/vpp/interface/loopback.py @@ -0,0 +1,72 @@ +# VyOS implementation of VPP Loopback interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp.interface.interface import Interface + + +class LoopbackInterface(Interface): + """Interface Loopback""" + + def __init__(self, ifname, kernel_interface: str = '', state: str = 'up'): + super().__init__(ifname) + self.instance = int(ifname.removeprefix('lo')) + self.ifname = f'loop{self.instance}' + self.kernel_interface = kernel_interface + self.initial_state = state + + def add(self): + """Create Loopback interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/interface.api + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.add() + """ + self.vpp.api.create_loopback_instance( + is_specified=True, user_instance=self.instance + ) + # Set interface state + self.set_state(self.initial_state) + + def delete(self): + """Delete Loopback interface + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.delete() + """ + loopback_if_index = self.vpp.get_sw_if_index(f'loop{self.instance}') + return self.vpp.api.delete_loopback(sw_if_index=loopback_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py new file mode 100644 index 000000000..4be5b5ec9 --- /dev/null +++ b/python/vyos/vpp/interface/vxlan.py @@ -0,0 +1,106 @@ +# VyOS implementation of VPP VXLAN interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface + + +def show(): + """Show VXLAN interface + Example: + from vyos.vpp.interface import vxlan + vxlan.show() + """ + vpp = VPPControl() + return vpp.api.vxlan_tunnel_dump() + + +class VXLANInterface(Interface): + """Interface VXLAN""" + + def __init__( + self, + ifname, + source_address, + remote, + vni, + kernel_interface: str = '', + state: str = 'up', + ): + super().__init__(ifname) + self.instance = int(ifname.removeprefix('vxlan')) + self.ifname = f'vxlan_tunnel{self.instance}' + self.src_address = source_address + self.dst_address = remote + self.vni = vni + self.kernel_interface = kernel_interface + self.initial_state = state + + def add(self): + """Create VXLAN interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/vxlan/vxlan.api + + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.add() + """ + self.vpp.api.vxlan_add_del_tunnel_v3( + is_add=True, + src_address=self.src_address, + dst_address=self.dst_address, + vni=self.vni, + instance=self.instance, + decap_next_index=1, + is_l3=False, + ) + # Set interface state + self.set_state(self.initial_state) + + def delete(self): + """Delete VXLAN interface + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.delete() + """ + return self.vpp.api.vxlan_add_del_tunnel_v3( + is_add=False, + src_address=self.src_address, + dst_address=self.dst_address, + vni=self.vni, + is_l3=False, + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23, kernel_interface='vpptap10') + a.kernel_add() + """ + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.kernel_delete() + """ + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/wireguard.py b/python/vyos/vpp/interface/wireguard.py new file mode 100644 index 000000000..c2d36e1b0 --- /dev/null +++ b/python/vyos/vpp/interface/wireguard.py @@ -0,0 +1,55 @@ +# VyOS implementation of VPP Wireguard interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class WireguardInterface: + """Interface Wireguard""" + + def __init__(self, ifname, listen_port=51820, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('wg')) + self.ifname = ifname + self.listen_port = listen_port + self.kernel_interface = kernel_interface + self.vpp = VPPControl() + + def add(self): + """Create Wireguard interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/wireguard/wireguard.api + Example: + from vyos.vpp.interface import WireguardInterface + a = WireguardInterface(ifname='wg5', listen_port=51820, generate_key=True) + a.add() + """ + self.vpp.api.wireguard_interface_create( + generate_key=True, + interface={'user_instance': self.instance, 'listen_port': self.listen_port}, + ) + if self.kernel_interface: + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def delete(self): + """Delete Wireguard interface + Example: + from vyos.vpp.interface import WireguardInterface + a = WireguardInterface(ifname='wg5') + a.delete() + """ + wg_if_index = self.vpp.get_sw_if_index(f'wg{self.instance}') + return self.vpp.api.wireguard_interface_delete(sw_if_index=wg_if_index) diff --git a/python/vyos/vpp/interface/xconnect.py b/python/vyos/vpp/interface/xconnect.py new file mode 100644 index 000000000..299a61405 --- /dev/null +++ b/python/vyos/vpp/interface/xconnect.py @@ -0,0 +1,95 @@ +# VyOS implementation of VPP bridge interface +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.utils import iftunnel_transform + + +class XconnectInterface: + def __init__( + self, + ifname: str, + members: list = [], + state: str = 'up', + ): + self.ifname = ifname + self.members = members + self.vpp = VPPControl() + self.initial_state = state + + def add_l2_xconnect(self): + """Add l2 cross connect + Args: + members (list): The list of the xconnect members + Example: + from vyos.vpp.interface import XconnectInterface + a = XconnectInterface(ifname='xcon0', members=['eth0', 'vxlan0']) + a.add_l2_xconnect() + """ + interface_transform_filter = ('vxlan', 'gre') + first_member = self.members[0] + second_member = self.members[1] + # Check if member in required filter to transform 'vxlanX' => 'vxlan_tunnelX' + if first_member.startswith(interface_transform_filter): + first_member = iftunnel_transform(first_member) + if second_member.startswith(interface_transform_filter): + second_member = iftunnel_transform(second_member) + + member_first_if_index = self.vpp.get_sw_if_index(first_member) + member_second_if_index = self.vpp.get_sw_if_index(second_member) + self.vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_first_if_index, + tx_sw_if_index=member_second_if_index, + enable=True, + ) + self.vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_second_if_index, + tx_sw_if_index=member_first_if_index, + enable=True, + ) + + def del_l2_xconnect(self): + """Move l2 cross connect member to mode l3 (delte xconnect) + Args: + members (list): The list of the xconnect members + Example: + from vyos.vpp.interface import XconnectInterface + a = XconnectInterface(ifname='xcon0', members=['eth0', 'vxlan0']) + a.del_l2_xconnect() + """ + interface_transform_filter = ('vxlan', 'gre') + first_member = self.members[0] + second_member = self.members[1] + # Check if member in required filter to transform 'vxlanX' => 'vxlan_tunnelX' + if first_member.startswith(interface_transform_filter): + first_member = iftunnel_transform(first_member) + if second_member.startswith(interface_transform_filter): + second_member = iftunnel_transform(second_member) + + member_first_if_index = self.vpp.get_sw_if_index(first_member) + member_second_if_index = self.vpp.get_sw_if_index(second_member) + self.vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_first_if_index, + tx_sw_if_index=member_second_if_index, + enable=False, + ) + self.vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_second_if_index, + tx_sw_if_index=member_first_if_index, + enable=False, + ) diff --git a/python/vyos/vpp/nat/__init__.py b/python/vyos/vpp/nat/__init__.py new file mode 100644 index 000000000..16685c9cc --- /dev/null +++ b/python/vyos/vpp/nat/__init__.py @@ -0,0 +1,4 @@ +from .nat44 import Nat44 +from .det44 import Det44 + +__all__ = ['Nat44', 'Det44'] diff --git a/python/vyos/vpp/nat/det44.py b/python/vyos/vpp/nat/det44.py new file mode 100644 index 000000000..70f903086 --- /dev/null +++ b/python/vyos/vpp/nat/det44.py @@ -0,0 +1,106 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class Det44: + def __init__(self): + self.vpp = VPPControl() + + def enable_det44_plugin(self): + """Enable DET44 plugin + Example: + from vyos.vpp.nat import Det44 + det44 = Det44() + det44.enable_det44_plugin() + https://github.com/FDio/vpp/blob/stable/2410/src/plugins/nat/det44/det44.api + """ + self.vpp.api.det44_plugin_enable_disable(enable=True) + + def disable_det44_plugin(self): + """Disable DET44 plugin""" + self.vpp.api.det44_plugin_enable_disable(enable=False) + + def add_det44_interface_outside(self, interface_out): + """Add DET44 outside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_inside=False, + is_add=True, + ) + + def delete_det44_interface_outside(self, interface_out): + """Delete DET44 outside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_inside=False, + is_add=False, + ) + + def add_det44_interface_inside(self, interface_in): + """Add DET44 inside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_inside=True, + is_add=True, + ) + + def delete_det44_interface_inside(self, interface_in): + """Delete DET44 inside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_inside=True, + is_add=False, + ) + + def add_det44_mapping(self, in_addr, in_plen, out_addr, out_plen): + """Add DET44 mapping""" + self.vpp.api.det44_add_del_map( + in_addr=in_addr, + in_plen=in_plen, + out_addr=out_addr, + out_plen=out_plen, + is_add=True, + ) + + def delete_det44_mapping(self, in_addr, in_plen, out_addr, out_plen): + """Delete DET44 mapping""" + self.vpp.api.det44_add_del_map( + in_addr=in_addr, + in_plen=in_plen, + out_addr=out_addr, + out_plen=out_plen, + is_add=False, + ) + + def set_det44_timeouts( + self, icmp: int, udp: int, tcp_established: int, tcp_transitory: int + ): + """Set DET44 timeouts + Args: + tcp_established (int): TCP established timeout + tcp_transitory (int): TCP transitory timeout + udp (int): UDP timeout + icmp (int): ICMP timeout + """ + self.vpp.api.det44_set_timeouts( + icmp=icmp, + udp=udp, + tcp_established=tcp_established, + tcp_transitory=tcp_transitory, + ) diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py new file mode 100644 index 000000000..b7e2d7744 --- /dev/null +++ b/python/vyos/vpp/nat/nat44.py @@ -0,0 +1,229 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +# NAT44 flags +NAT_IS_NONE = 0x00 +NAT_IS_TWICE_NAT = 0x01 +NAT_IS_SELF_TWICE_NAT = 0x02 +NAT_IS_OUT2IN_ONLY = 0x04 +NAT_IS_ADDR_ONLY = 0x08 +NAT_IS_OUTSIDE = 0x10 +NAT_IS_INSIDE = 0x20 + +NO_INTERFACE = 0xFFFFFFFF + + +class Nat44: + def __init__(self): + self.vpp = VPPControl() + + def enable_nat44_ed(self): + """Enable NAT44 endpoint dependent plugin + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.enable_nat44_ed() + https://github.com/FDio/vpp/blob/stable/2410/src/plugins/nat/nat44-ed/nat44_ed.api + """ + self.vpp.api.nat44_ed_plugin_enable_disable(enable=True) + + def disable_nat44_ed(self): + """Disable NAT44 endpoint dependent plugin""" + self.vpp.api.nat44_ed_plugin_enable_disable(enable=False) + + def enable_nat44_ei(self): + """Enable NAT44 endpoint independent plugin + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.enable_nat44_ei() + """ + self.vpp.api.nat44_ei_plugin_enable_disable(enable=True) + + def add_nat44_interface_inside(self, interface_in): + """Add NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=NAT_IS_INSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_add=True, + ) + + def delete_nat44_interface_inside(self, interface_in): + """Delete NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=NAT_IS_INSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_add=False, + ) + + def add_nat44_interface_outside(self, interface_out): + """Add NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=NAT_IS_OUTSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_add=True, + ) + + def delete_nat44_interface_outside(self, interface_out): + """Delete NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=NAT_IS_OUTSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_add=False, + ) + + def add_nat44_address_range(self, addresses, twice_nat): + """Add NAT44 address range""" + if '-' not in addresses: + first_ip_address = last_ip_address = addresses + else: + first_ip_address, last_ip_address = addresses.split('-') + self.vpp.api.nat44_add_del_address_range( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + first_ip_address=first_ip_address, + last_ip_address=last_ip_address, + is_add=True, + ) + + def delete_nat44_address_range(self, addresses, twice_nat): + """Delete NAT44 address range""" + if '-' not in addresses: + first_ip_address = last_ip_address = addresses + else: + first_ip_address, last_ip_address = addresses.split('-') + self.vpp.api.nat44_add_del_address_range( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + first_ip_address=first_ip_address, + last_ip_address=last_ip_address, + is_add=False, + ) + + def add_nat44_interface_address(self, interface, twice_nat): + """Add NAT44 interface address""" + self.vpp.api.nat44_add_del_interface_addr( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=True, + ) + + def delete_nat44_interface_address(self, interface, twice_nat): + """Delete NAT44 interface address""" + self.vpp.api.nat44_add_del_interface_addr( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=False, + ) + + def add_nat44_static_mapping( + self, + local_ip, + external_ip, + local_port, + external_port, + protocol, + twice_nat, + self_twice_nat, + out2in, + pool_ip, + ): + """Add NAT44 static mapping""" + flags = NAT_IS_ADDR_ONLY if not (protocol or local_port) else NAT_IS_NONE + flags |= NAT_IS_TWICE_NAT if twice_nat else 0 + flags |= NAT_IS_SELF_TWICE_NAT if self_twice_nat else 0 + flags |= NAT_IS_OUT2IN_ONLY if out2in else 0 + self.vpp.api.nat44_add_del_static_mapping_v2( + local_ip_address=local_ip, + external_ip_address=external_ip, + protocol=protocol, + local_port=local_port, + external_port=external_port, + match_pool=True if pool_ip else False, + pool_ip_address=pool_ip if pool_ip else '', + flags=flags, + is_add=True, + ) + + def delete_nat44_static_mapping( + self, + local_ip, + external_ip, + local_port, + external_port, + protocol, + twice_nat, + self_twice_nat, + out2in, + pool_ip, + ): + """Delete NAT44 static mapping""" + flags = NAT_IS_ADDR_ONLY if not (protocol or local_port) else NAT_IS_NONE + flags |= NAT_IS_TWICE_NAT if twice_nat else 0 + flags |= NAT_IS_SELF_TWICE_NAT if self_twice_nat else 0 + flags |= NAT_IS_OUT2IN_ONLY if out2in else 0 + self.vpp.api.nat44_add_del_static_mapping_v2( + local_ip_address=local_ip, + external_ip_address=external_ip, + protocol=protocol, + local_port=local_port, + external_port=external_port, + match_pool=True if pool_ip else False, + pool_ip_address=pool_ip if pool_ip else '', + flags=flags, + is_add=False, + ) + + def add_nat44_identity_mapping(self, ip_address, protocol, port, interface): + """Add NAT44 identity mapping""" + self.vpp.api.nat44_add_del_identity_mapping( + ip_address=ip_address, + protocol=protocol, + port=port, + sw_if_index=( + self.vpp.get_sw_if_index(interface) if interface else NO_INTERFACE + ), + flags=NAT_IS_ADDR_ONLY if not (protocol or port) else NAT_IS_NONE, + is_add=True, + ) + + def delete_nat44_identity_mapping(self, ip_address, protocol, port, interface): + """Delete NAT44 identity mapping""" + self.vpp.api.nat44_add_del_identity_mapping( + ip_address=ip_address, + protocol=protocol, + port=port, + sw_if_index=( + self.vpp.get_sw_if_index(interface) if interface else NO_INTERFACE + ), + flags=NAT_IS_ADDR_ONLY if not (protocol or port) else NAT_IS_NONE, + is_add=False, + ) + + def set_nat_timeouts(self, icmp, udp, tcp_established, tcp_transitory): + """Set NAT timeouts""" + self.vpp.api.nat_set_timeouts( + icmp=icmp, + udp=udp, + tcp_established=tcp_established, + tcp_transitory=tcp_transitory, + ) + + def enable_ipfix(self): + """Enable NAT44 IPFIX logging""" + self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) diff --git a/python/vyos/vpp/utils.py b/python/vyos/vpp/utils.py new file mode 100644 index 000000000..4e42232cd --- /dev/null +++ b/python/vyos/vpp/utils.py @@ -0,0 +1,417 @@ +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ctypes +import os +import socket +from fcntl import ioctl +from pathlib import Path +from struct import pack + + +mem_shift = {'K': 10, 'KB': 10, 'M': 20, 'MB': 20, 'G': 30, 'GB': 30} + + +def iftunnel_transform(iface: str) -> str: + """Transform interface name from `xxxNN` to `xxx_tunnelNN` + + Args: + iface (str): original interface name + + Raises: + ValueError: Raised if an interface name does not start with a alpha and ends with decimal digit + + Returns: + str: Transformed interface name + """ + # Check format + if not iface[0].isascii() or not iface[-1].isdecimal(): + raise ValueError(f'Wrong interface name format: {iface}') + # Transform + iface_type: str = iface.rstrip('0123456789') + iface_num: str = iface.removeprefix(iface_type) + # Return transformed + return f'{iface_type}_tunnel{iface_num}' + + +def cli_ifaces_list(config_instance, mode: str = 'candidate') -> list[str]: + """List of all VPP interfaces (CLI names) + + Args: + config_instance (VyOS Config): VyOS Config instance + mode (str, optional): `candidate` or `running`. Defaults to 'candidate'. + + Returns: + list[str]: list of interfaces + """ + + effective_mode: bool = True if mode == 'running' else False + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + effective=effective_mode, + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + vpp_ifaces: list[str] = [] + + # Get a list of Ethernet interfaces + for iface in config.get('settings', {}).get('interface', {}).keys(): + vpp_ifaces.append(iface) + + # Get a list of VPP interfaces + for iface_type in config.get('interfaces', {}).keys(): + for iface in config.get('interfaces', {}).get(iface_type, {}).keys(): + vpp_ifaces.append(iface) + + return vpp_ifaces + + +def cli_ethernet_with_vifs_ifaces(config_instance) -> list[str]: + """List of all VPP Ethernet interfaces with VIFs + + Args: + config_instance (VyOS Config): VyOS Config instance + + Returns: + list[str]: list of interfaces + """ + from vyos.configdict import get_interface_dict + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + ifaces: list[str] = [] + + # Get a list of Ethernet interfaces + for iface in config.get('settings', {}).get('interface', {}).keys(): + ifaces.append(iface) + + # Add Ethernet interfaces with VIFs + for iface in ifaces: + _, iface_config = get_interface_dict( + config_instance, ['interfaces', 'ethernet'], ifname=iface + ) + ifaces.extend([f'{iface}.{vif}' for vif in iface_config.get('vif', {})]) + ifaces.extend([f'{iface}.{vif_s}' for vif_s in iface_config.get('vif_s', {})]) + + return ifaces + + +def vpp_ifaces_list(vpp_api) -> list[dict]: + """List interfaces in VPP + + Args: + vpp_api (_type_): VPP API object + + Returns: + list[dict]: list of dictionaries with interfaces + """ + ifaces_list: list[dict] = [] + sw_ifaces_dump = vpp_api.sw_interface_dump() + while sw_ifaces_dump: + iface_details = sw_ifaces_dump.pop() + ifaces_list.append(iface_details._asdict()) + + return ifaces_list + + +def vpp_ip_addresses_by_index(vpp_api, index: str) -> list[str]: + """List of IP addresses for interface by its index in VPP + + Args: + vpp_api (_type_): VPP API object + index (str): interface index in vpp + + Returns: + list[str]: list of IP addresses + """ + ip_addresses_list: list[dict] = [] + ip_address_dump = vpp_api.ip_address_dump(sw_if_index=index) + while ip_address_dump: + ip_address_details = ip_address_dump.pop() + ip_addresses_list.append(str(ip_address_details._asdict().get('prefix'))) + return ip_addresses_list + + +def vpp_ifaces_stats( + iface_name: str = '', +) -> dict[str, dict[str, int | dict[str, int]]]: + from re import compile as re_compile + from vpp_papi import vpp_stats + + def total_value(val_list: vpp_stats.SimpleList) -> int | dict[str, int]: + """Helper for aggregation stats from multiple workers + + Args: + val_list (vpp_stats.SimpleList): list of stats for all workers + + Returns: + int | dict[str, int]: Summary stats + """ + # if all items are int return their sum + if all(isinstance(value, int) for value in val_list): + return sum(val_list) + # if all items are tuple + if all(isinstance(value, tuple) for value in val_list): + combined_stats = {} + # process individual workers + for worker_stats in val_list: + packets, octets = worker_stats + sum_packets = combined_stats.get('packets', 0) + packets + sum_octets = combined_stats.get('octets', 0) + octets + combined_stats = {'packets': sum_packets, 'bytes': sum_octets} + return combined_stats + + # items are something unknown, just return what we received + return val_list + + stats = vpp_stats.VPPStats() + + ifaces_stats: dict[str, dict[str, int | dict[str, int]]] = {} + + # prepare parser for stats output + regex_parser = re_compile(r'^/interfaces/(?P<iface>[^/]+)/(?P<param>[^/]+)') + # get list of available stats and dump them + stats_list: list[str] = stats.ls([f'^/interfaces/{iface_name}']) + stats_dump: list[dict[str, int]] = stats.dump(stats_list) + + # parse outputs and convert it to a dictionary + for stats_key, stats_value in stats_dump.items(): + parsed_key = regex_parser.search(stats_key).groupdict() + iface_name = parsed_key['iface'] + param = parsed_key['param'] + stats_item = {param: total_value(stats_value)} + if iface_name in ifaces_stats: + ifaces_stats[iface_name].update(stats_item) + else: + ifaces_stats[iface_name] = stats_item + + return ifaces_stats + + +def cli_ifaces_lcp_kernel_list( + config_instance, mode: str = 'candidate' +) -> list[tuple[str, str]]: + """List of all VPP kernel-interfaces (CLI names, attached VPP interfaces) + + Args: + config_instance (VyOS Config): VyOS Config instance + mode (str, optional): `candidate` or `running`. Defaults to 'candidate'. + + Returns: + list[tuple[str, str]]: list of interfaces ([(vpp_iface, kernel_iface)]) + """ + + effective_mode: bool = True if mode == 'running' else False + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + effective=effective_mode, + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + lcp_kernel_ifaces: list[tuple[str, str]] = [] + + # Get a list with kernel interfaces + for ifaces_list in config.get('interfaces', {}).values(): + for iface_name, iface_settings in ifaces_list.items(): + if 'kernel_interface' in iface_settings: + lcp_kernel_ifaces.append( + (iface_name, iface_settings['kernel_interface']) + ) + + return lcp_kernel_ifaces + + +def get_default_hugepage_size() -> int: + """ + Retrieve the system's default huge page size. + :return: The default huge page size in bytes. + """ + page_size = None + try: + # default huge page size + memfd = os.memfd_create('tmp', os.MFD_HUGETLB) + st = os.fstat(memfd) + page_size = st.st_blksize + os.close(memfd) + except OSError: + pass + + return page_size + + +def get_default_page_size() -> int: + """ + Retrieve the system's default page size. + :return: The default page size in bytes. + """ + return os.sysconf('SC_PAGESIZE') + + +def get_hugepage_sizes() -> list[int]: + """ + Retrieve all available huge page sizes from the system. + :return: A list of huge page sizes in bytes. + """ + huge_sizes = [] + path = '/sys/kernel/mm/hugepages/' + try: + entries = os.listdir(path) + for entry in entries: + if entry.startswith('hugepages-'): + try: + size_kb = int(entry.replace('hugepages-', '').replace('kB', '')) + huge_sizes.append(size_kb << 10) # Convert KB to bytes + except ValueError: + pass + except FileNotFoundError: + pass + + return huge_sizes + + +def human_memory_to_bytes(value: str) -> int: + """ + Convert a human-readable vpp memory format (K, M, G, xB) to a byte value. + + :param value: The string memory size in vpp human-readable format. + :return: A int representing the value. + """ + value = value.strip().upper() + try: + return int(value) + except ValueError: + for unit in sorted(mem_shift.keys(), key=len, reverse=True): + if value.endswith(unit): + num = value[: -len(unit)] + return int(num) << mem_shift[unit] + + +def bytes_to_human_memory(value: int, unit: str) -> str | None: + """ + Convert a byte value to a human-readable format (K, M, G). + + :param value: The size in bytes. + :param unit: The unit to convert to ('K', 'M', 'G'). + :return: A string representing the value in the specified unit, or None if zero. + """ + unit = unit.upper() + val = value >> mem_shift[unit] + return f'{val}{unit}' if val else None + + +def human_page_memory_to_bytes(value: str) -> int: + """ + Convert a human-readable vpp page size format to a byte value. + + :param value: The string memory size in vpp human-readable format. + :return: A int representing the value. + """ + default = { + 'default': get_default_page_size, + 'default-hugepage': get_default_hugepage_size, + } + try: + return default[value]() + except KeyError: + return human_memory_to_bytes(value) + + +class EthtoolGDrvinfo: + """Return interface details like `ethtol -i` does""" + + # TODO + # this probably need to be replaced with a code generator + # like ctypeslib or C extension + class EthtoolDrvinfo(ctypes.Structure): + _fields_ = [ + ('cmd', ctypes.c_uint32), + ('driver', ctypes.c_char * 32), # Driver short name + ('version', ctypes.c_char * 32), # Driver version + ('fw_version', ctypes.c_char * 32), # Firmware version + # Be careful: bus info can be longer than 32 chars and thus truncated + ('bus_info', ctypes.c_char * 32), # Bus info. + ('erom_version', ctypes.c_char * 32), # Expansion ROM version + ('reserved2', ctypes.c_char * 12), # Reserved for future use + ('n_priv_flags', ctypes.c_uint32), # Number of private flags + ('n_stats', ctypes.c_uint32), # Number of U64 stats + ('testinfo_len', ctypes.c_uint32), # Test info length + ('eedump_len', ctypes.c_uint32), # EEPROM dump length + ('regdump_len', ctypes.c_uint32), # Register dump length + ] + + def __init__(self, iface: str): + # Constants for ethtool + SIOCETHTOOL = 0x8946 # pretend to be ethtool + ETHTOOL_GDRVINFO = 0x00000003 # Command to get driver info + + # Create a dummy socket + sockfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Prepare the request for getting driver info + drvinfo = self.EthtoolDrvinfo(cmd=ETHTOOL_GDRVINFO) + ifreq: bytes = pack('16sP', iface.encode('utf-8'), ctypes.addressof(drvinfo)) + + # Make an ioctl call to get the driver info + try: + ioctl(sockfd, SIOCETHTOOL, ifreq) + except OSError: + raise FileNotFoundError(f'There is no Ethernet device: {iface}') + + # Close the socket + sockfd.close() + + # save the information + self.driver: str = drvinfo.driver.decode('utf-8').strip('\x00') + self.version: str = drvinfo.version.decode('utf-8').strip('\x00') + self.fw_version: str = drvinfo.fw_version.decode('utf-8').strip('\x00') + self.bus_info: str = drvinfo.bus_info.decode('utf-8').strip('\x00') + self.erom_version: str = drvinfo.erom_version.decode('utf-8').strip('\x00') + self.reserved2: str = drvinfo.reserved2.decode('utf-8').strip('\x00') + self.n_priv_flags: int = drvinfo.n_priv_flags + self.testinfo_len: int = drvinfo.testinfo_len + self.eedump_len: int = drvinfo.eedump_len + self.regdump_len: int = drvinfo.regdump_len + + def bus_info_expand(self, bus_name: str) -> str: + bus_path = Path(f'/sys/bus/{bus_name}/devices').glob(f'{self.bus_info}*') + dev_ids = list(bus_path) + if not dev_ids: + raise FileNotFoundError( + f'No matching IDs on the bus: {self.bus_info} on {bus_name}' + ) + if len(dev_ids) > 1: + raise FileNotFoundError( + f'There are more than one matching IDs on the bus: {dev_ids} on {bus_name}' + ) + return dev_ids[0].name diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py new file mode 100644 index 000000000..4b79073ab --- /dev/null +++ b/python/vyos/vyconf_session.py @@ -0,0 +1,233 @@ +# Copyright 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 +import tempfile +from functools import wraps +from typing import Type + +from vyos.proto import vyconf_client +from vyos.migrate import ConfigMigrate +from vyos.migrate import ConfigMigrateError +from vyos.component_version import append_system_version +from vyos.utils.session import in_config_session +from vyos.proto.vyconf_proto import Errnum +from vyos.utils.commit import acquire_commit_lock_file +from vyos.utils.commit import release_commit_lock_file +from vyos.utils.commit import call_commit_hooks +from vyos.remote import get_config_file + + +class VyconfSessionError(Exception): + pass + + +class VyconfSession: + def __init__( + self, token: str = None, pid: int = None, on_error: Type[Exception] = None + ): + self.pid = os.getpid() if pid is None else pid + if token is None: + # CLI applications with arg pid=getppid() allow coordination + # with the ambient session; other uses (such as ConfigSession) + # may default to self pid + out = vyconf_client.send_request('session_of_pid', client_pid=self.pid) + if out.output is None: + out = vyconf_client.send_request('setup_session', client_pid=self.pid) + self.__token = out.output + else: + out = vyconf_client.send_request( + 'session_update_pid', token=token, client_pid=self.pid + ) + if out.status: + raise ValueError(f'No existing session for token: {token}') + self.__token = token + + self.in_config_session = in_config_session() + if self.in_config_session: + out = vyconf_client.send_request( + 'enter_configuration_mode', token=self.__token + ) + if out.status: + raise VyconfSessionError(self.output(out)) + + self.on_error = on_error + + def teardown(self): + vyconf_client.send_request('teardown', token=self.__token) + + def exit_config_mode(self): + if self.session_changed(): + return 'Uncommited changes', Errnum.UNCOMMITED_CHANGES + out = vyconf_client.send_request('exit_configuration_mode', token=self.__token) + return self.output(out), out.status + + def in_session(self) -> bool: + return self.in_config_session + + def session_changed(self) -> bool: + out = vyconf_client.send_request('session_changed', token=self.__token) + return not bool(out.status) + + def get_config(self): + out = vyconf_client.send_request('get_config', token=self.__token) + if out.status: + raise VyconfSessionError(self.output(out)) + return out.output + + @staticmethod + def config_mode(f): + @wraps(f) + def wrapped(self, *args, **kwargs): + msg = 'operation not available outside of config mode' + if not self.in_config_session: + if self.on_error is None: + raise VyconfSessionError(msg) + raise self.on_error(msg) + return f(self, *args, **kwargs) + + return wrapped + + @staticmethod + def raise_exception(f): + @wraps(f) + def wrapped(self, *args, **kwargs): + if self.on_error is None: + return f(self, *args, **kwargs) + o, e = f(self, *args, **kwargs) + if e: + raise self.on_error(o) + return o, e + + return wrapped + + @staticmethod + def output(o): + out = '' + for res in (o.output, o.error, o.warning): + if res is not None: + out = out + res + return out + + @raise_exception + @config_mode + def set(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('set', token=self.__token, path=path) + return self.output(out), out.status + + @raise_exception + @config_mode + def delete(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('delete', token=self.__token, path=path) + return self.output(out), out.status + + @raise_exception + @config_mode + def commit(self) -> tuple[str, int]: + if not self.session_changed(): + out = 'No changes to commit' + return out, 0 + + lock_fd, out = acquire_commit_lock_file() + if lock_fd is None: + return out, Errnum.COMMIT_IN_PROGRESS + + pre_out, _ = call_commit_hooks('pre') + out = vyconf_client.send_request('commit', token=self.__token) + os.environ['COMMIT_STATUS'] = 'FAILURE' if out.status else 'SUCCESS' + post_out, _ = call_commit_hooks('post') + + release_commit_lock_file(lock_fd) + + return pre_out + self.output(out) + post_out, out.status + + @raise_exception + @config_mode + def discard(self) -> tuple[str, int]: + out = vyconf_client.send_request('discard', token=self.__token) + return self.output(out), out.status + + @raise_exception + @config_mode + def load_config( + self, file_name: str, migrate: bool = False, cached: bool = False + ) -> tuple[str, int]: + # pylint: disable=consider-using-with + file_path = tempfile.NamedTemporaryFile(delete=False).name + err = get_config_file(file_name, file_path) + if err: + os.remove(file_path) + return str(err), Errnum.INVALID_VALUE + if not cached: + if migrate: + config_migrate = ConfigMigrate(file_path) + try: + config_migrate.run() + except ConfigMigrateError as e: + os.remove(file_path) + return repr(e), 1 + + out = vyconf_client.send_request( + 'load', token=self.__token, location=file_path, cached=cached + ) + + if not cached: + os.remove(file_path) + + return self.output(out), out.status + + @raise_exception + @config_mode + def merge_config( + self, file_name: str, migrate: bool = False, destructive: bool = False + ) -> tuple[str, int]: + # pylint: disable=consider-using-with + file_path = tempfile.NamedTemporaryFile(delete=False).name + err = get_config_file(file_name, file_path) + if err: + os.remove(file_path) + return str(err), Errnum.INVALID_VALUE + if migrate: + config_migrate = ConfigMigrate(file_path) + try: + config_migrate.run() + except ConfigMigrateError as e: + os.remove(file_path) + return repr(e), 1 + + out = vyconf_client.send_request( + 'merge', token=self.__token, location=file_path, destructive=destructive + ) + + os.remove(file_path) + + return self.output(out), out.status + + @raise_exception + def save_config(self, file: str, append_version: bool = False) -> tuple[str, int]: + file = os.path.realpath(file) + out = vyconf_client.send_request('save', token=self.__token, location=file) + if append_version: + append_system_version(file) + return self.output(out), out.status + + @raise_exception + def show_config(self, path: list[str] = None) -> tuple[str, int]: + if path is None: + path = [] + out = vyconf_client.send_request('show_config', token=self.__token, path=path) + return self.output(out), out.status diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py new file mode 100644 index 000000000..158ee76c8 --- /dev/null +++ b/python/vyos/wanloadbalance.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +# published by the Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# 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 os + +from vyos.defaults import directories +from vyos.utils.process import run + +dhclient_lease = 'dhclient_{0}.lease' + +def nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + output = [] + + if 'inbound_interface' in rule_conf: + ifname = rule_conf['inbound_interface'] + if local and not exclude: + output.append(f'oifname != "{ifname}"') + elif not local: + output.append(f'iifname "{ifname}"') + + if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': + protocol = rule_conf['protocol'] + operator = '' + + if protocol[:1] == '!': + operator = '!=' + protocol = protocol[1:] + + if protocol == 'tcp_udp': + protocol = '{ tcp, udp }' + + output.append(f'meta l4proto {operator} {protocol}') + + for direction in ['source', 'destination']: + if direction not in rule_conf: + continue + + direction_conf = rule_conf[direction] + prefix = direction[:1] + + if 'address' in direction_conf: + operator = '' + address = direction_conf['address'] + if address[:1] == '!': + operator = '!=' + address = address[1:] + output.append(f'ip {prefix}addr {operator} {address}') + + if 'port' in direction_conf: + operator = '' + port = direction_conf['port'] + if port[:1] == '!': + operator = '!=' + port = port[1:] + output.append(f'th {prefix}port {operator} {{ {port} }}') + + if 'source_based_routing' not in rule_conf and not restore_mark: + output.append('ct state new') + + if limit and 'limit' in rule_conf and 'rate' in rule_conf['limit']: + output.append(f'limit rate {rule_conf["limit"]["rate"]}/{rule_conf["limit"]["period"]}') + if 'burst' in rule_conf['limit']: + output.append(f'burst {rule_conf["limit"]["burst"]} packets') + + output.append('counter') + + if restore_mark: + output.append('meta mark set ct mark') + elif weight: + weights, total_weight = wlb_weight_interfaces(rule_conf, health_state) + if len(weights) > 1: # Create weight-based verdict map + vmap_str = ", ".join(f'{weight} : jump wlb_mangle_isp_{ifname}' for ifname, weight in weights) + output.append(f'numgen random mod {total_weight} vmap {{ {vmap_str} }}') + elif len(weights) == 1: # Jump to single ISP + ifname, _ = weights[0] + output.append(f'jump wlb_mangle_isp_{ifname}') + else: # No healthy interfaces + return "" + elif action: + output.append(action) + + return " ".join(output) + +def wlb_weight_interfaces(rule_conf, health_state): + interfaces = [] + + for ifname, if_conf in rule_conf['interface'].items(): + if ifname in health_state and health_state[ifname]['state']: + weight = int(if_conf.get('weight', 1)) + interfaces.append((ifname, weight)) + + if not interfaces: + return [], 0 + + if 'failover' in rule_conf: + for ifpair in sorted(interfaces, key=lambda i: i[1], reverse=True): + return [ifpair], ifpair[1] # Return highest weight interface that is ACTIVE when in failover + + total_weight = sum(weight for _, weight in interfaces) + out = [] + start = 0 + for ifname, weight in sorted(interfaces, key=lambda i: i[1]): # build weight ranges + end = start + weight - 1 + out.append((ifname, f'{start}-{end}' if end > start else start)) + start = weight + + return out, total_weight + +def health_ping_host(host, ifname, count=1, wait_time=0): + cmd_str = f'ping -c {count} -W {wait_time} -I {ifname} {host}' + rc = run(cmd_str) + return rc == 0 + +def health_ping_host_ttl(host, ifname, count=1, ttl_limit=0): + cmd_str = f'ping -c {count} -t {ttl_limit} -I {ifname} {host}' + rc = run(cmd_str) + return rc != 0 + +def parse_dhcp_nexthop(ifname): + lease_file = os.path.join(directories['isc_dhclient_dir'], dhclient_lease.format(ifname)) + + if not os.path.exists(lease_file): + return False + + with open(lease_file, 'r') as f: + for line in f.readlines(): + data = line.replace('\n', '').split('=') + if data[0] == 'new_routers': + return data[1].replace("'", '').split(" ")[0] + + return None + +def parse_ppp_nexthop(ifname): + nexthop_file = os.path.join(directories['ppp_nexthop_dir'], ifname) + + if not os.path.exists(nexthop_file): + return False + + with open(nexthop_file, 'r') as f: + return f.read() diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index 99d8432d2..41a25049e 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -14,6 +14,8 @@ # along with this library. If not, see <http://www.gnu.org/licenses/>. from typing import Optional, Union, TYPE_CHECKING +from typing import Callable +from typing import Any from vyos.xml_ref import definition from vyos.xml_ref import op_definition @@ -89,6 +91,7 @@ def from_source(d: dict, path: list) -> bool: def ext_dict_merge(source: dict, destination: Union[dict, 'ConfigDict']): return definition.ext_dict_merge(source, destination) + def load_op_reference(op_cache=[]): if op_cache: return op_cache[0] @@ -108,5 +111,26 @@ def load_op_reference(op_cache=[]): return op_xml -def get_op_ref_path(path: list) -> list[op_definition.PathData]: - return load_op_reference()._get_op_ref_path(path) + +def walk_op_data(func: Callable[[tuple, dict], Any]): + return load_op_reference().walk(func) + + +def walk_op_node_data(): + return load_op_reference().walk_node_data() + + +def lookup_op_data( + path: list, tag_values: bool = False, last_node_type: str = '' +) -> (dict, list[str]): + return load_op_reference().lookup( + path, tag_values=tag_values, last_node_type=last_node_type + ) + + +def lookup_op_node_data( + path: list, tag_values: bool = False, last_node_type: str = '' +) -> list[op_definition.NodeData]: + return load_op_reference().lookup_node_data( + path, tag_values=tag_values, last_node_type=last_node_type + ) diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py index 4e755ab72..015e7ee6e 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py index 093697993..f0a3ec35b 100755 --- a/python/vyos/xml_ref/generate_cache.py +++ b/python/vyos/xml_ref/generate_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index cd2ac890e..f49b2290b 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -14,10 +14,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 os +import io import re import sys -import json import glob +import json +import atexit from argparse import ArgumentParser from os.path import join @@ -25,23 +28,43 @@ from os.path import abspath from os.path import dirname from xml.etree import ElementTree as ET from xml.etree.ElementTree import Element +from functools import cmp_to_key from typing import TypeAlias from typing import Optional +from op_definition import NodeData +from op_definition import OpKey # pylint: disable=unused-import # noqa: F401 +from op_definition import OpData # pylint: disable=unused-import # noqa: F401 +from op_definition import key_name +from op_definition import key_type +from op_definition import node_data_difference +from op_definition import get_node_data +from op_definition import collapse + _here = dirname(__file__) sys.path.append(join(_here, '..')) -from defaults import directories +# pylint: disable=wrong-import-position,wrong-import-order +from defaults import directories # noqa: E402 -from op_definition import NodeData -from op_definition import PathData -xml_op_cache_json = 'xml_op_cache.json' -xml_op_tmp = join('/tmp', xml_op_cache_json) op_ref_cache = abspath(join(_here, 'op_cache.py')) OptElement: TypeAlias = Optional[Element] -DEBUG = False + + +# It is expected that the node_data help txt contained in top-level nodes, +# shared across files, e.g.'show', will reveal inconsistencies; to list +# differences, use --check-xml-consistency +CHECK_XML_CONSISTENCY = False +err_buf = io.StringIO() + + +def write_err_buf(): + err_buf.seek(0) + out = err_buf.read() + print(out) + err_buf.close() def translate_exec(s: str) -> str: @@ -57,7 +80,7 @@ def translate_position(s: str, pos: list[str]) -> str: # preferred to .format(*list) to avoid collisions with braces for i, p in enumerate(pos): - t = t.replace(f'_place_holder_{i+1}_', p) + t = t.replace(f'_place_holder_{i+1}_', f'{{{{{p}}}}}') return t @@ -65,6 +88,14 @@ def translate_position(s: str, pos: list[str]) -> str: def translate_command(s: str, pos: list[str]) -> str: s = translate_exec(s) s = translate_position(s, pos) + + # If there are any untranslated occurences of '_place_holder_", + # it means the command is incorrect, + # e.g., it references "$6" when it only has five words. + if re.search(r'_place_holder_', s): + print(f'Command translation failed: {s}') + sys.exit(1) + return s @@ -74,101 +105,245 @@ def translate_op_script(s: str) -> str: return s -def insert_node(n: Element, l: list[PathData], path = None) -> None: - # pylint: disable=too-many-locals,too-many-branches +def compare_keys(a, b): + # pylint: disable=too-many-return-statements + match key_type(a), key_type(b): + case None, None: + if key_name(a) == key_name(b): + return 0 + return -1 if key_name(a) < key_name(b) else 1 + case None, _: + return -1 + case _, None: + return 1 + case _, _: + if key_name(a) == key_name(b): + if key_type(a) == key_type(b): + return 0 + return -1 if key_type(a) < key_type(b) else 1 + return -1 if key_name(a) < key_name(b) else 1 + + +def sort_func(obj: dict, key_func): + if not obj or not isinstance(obj, dict): + return obj + k_list = list(obj.keys()) + if not isinstance(k_list[0], tuple): + return obj + k_list = sorted(k_list, key=key_func) + v_list = map(lambda t: sort_func(obj[t], key_func), k_list) + return dict(zip(k_list, v_list)) + + +def sort_op_data(obj): + key_func = cmp_to_key(compare_keys) + return sort_func(obj, key_func) + + +def insert_node( + n: Element, d: dict, path: list[str] = None, parent: NodeData = None, file: str = '' +) -> None: + # pylint: disable=too-many-locals,too-many-branches,too-many-statements prop: OptElement = n.find('properties') children: OptElement = n.find('children') command: OptElement = n.find('command') - # name is not None as required by schema - name: str = n.get('name', 'schema_error') + standalone: OptElement = n.find('standalone') node_type: str = n.tag + + if node_type == 'virtualTagNode': + name = '__virtual_tag' + else: + name = n.get('name') + if not name: + raise ValueError( + 'Node name is required for all node types except <virtualTagNode>' + ) + if path is None: path = [] - path.append(name) + if node_type != 'virtualTagNode': + path.append(name) + if node_type == 'tagNode': path.append(f'{name}-tag_value') + if node_type == 'virtualTagNode': + path.append(f'{parent.name}-tag_value') + help_prop: OptElement = None if prop is None else prop.find('help') help_text = None if help_prop is None else help_prop.text command_text = None if command is None else command.text if command_text is not None: command_text = translate_command(command_text, path) - comp_help = None + try: + standalone_command = translate_command(standalone.find('command').text, path) + except AttributeError: + standalone_command = None + + try: + standalone_help_text = translate_command(standalone.find('help').text, path) + except AttributeError: + standalone_help_text = None + + comp_help = {} if prop is not None: - che = prop.findall("completionHelp") + che = prop.findall('completionHelp') + for c in che: - lists = c.findall("list") - paths = c.findall("path") - scripts = c.findall("script") - - comp_help = {} - list_l = [] - for i in lists: - list_l.append(i.text) - path_l = [] - for i in paths: - path_str = re.sub(r'\s+', '/', i.text) - path_l.append(path_str) - script_l = [] - for i in scripts: - script_str = translate_op_script(i.text) - script_l.append(script_str) - - comp_help['list'] = list_l - comp_help['fs_path'] = path_l - comp_help['script'] = script_l - - for d in l: - if name in list(d): - break - else: - d = {} - l.append(d) + comp_list_els = c.findall('list') + comp_path_els = c.findall('path') + comp_script_els = c.findall('script') + + comp_lists = [] + for i in comp_list_els: + comp_lists.append(i.text) - inner_l = d.setdefault(name, []) + comp_paths = [] + for i in comp_path_els: + comp_paths.append(i.text) - inner_d: PathData = {'node_data': NodeData(node_type=node_type, - help_text=help_text, - comp_help=comp_help, - command=command_text, - path=path)} - inner_l.append(inner_d) + comp_scripts = [] + for i in comp_script_els: + comp_script_str = translate_op_script(i.text) + comp_scripts.append(comp_script_str) + + if comp_lists: + comp_help['list'] = comp_lists + if comp_paths: + comp_help['path'] = comp_paths + if comp_scripts: + comp_help['script'] = comp_scripts + + new_node_data = NodeData() + new_node_data.name = name + new_node_data.node_type = node_type + new_node_data.comp_help = comp_help + new_node_data.help_text = help_text + new_node_data.command = command_text + new_node_data.standalone_help_text = standalone_help_text + new_node_data.standalone_command = standalone_command + new_node_data.path = path + new_node_data.files = [file] + + value = {('__node_data', None): new_node_data} + key = (name, node_type) + + cur_value = d.setdefault(key, value) + + if CHECK_XML_CONSISTENCY: + out = node_data_difference(get_node_data(cur_value), get_node_data(value)) + if out: + err_buf.write(out) + + # track the correct pointer reference: + cur_node_data = cur_value[('__node_data', None)] + + if file not in cur_node_data.files: + cur_node_data.files.append(file) + + if not cur_node_data.comp_help and comp_help: + cur_node_data.comp_help = comp_help + + if not cur_node_data.help_text and help_text: + cur_node_data.help_text = help_text + + if not cur_node_data.command and command_text: + cur_node_data.command = command_text + + if not cur_node_data.standalone_help_text and standalone_help_text: + cur_node_data.standalone_help_text = standalone_help_text + + if not cur_node_data.standalone_command and standalone_command: + cur_node_data.standalone_command = standalone_command + + if parent and key not in parent.children: + parent.children.append(key) if children is not None: - inner_nodes = children.iterfind("*") + inner_nodes = children.iterfind('*') for inner_n in inner_nodes: inner_path = path[:] - insert_node(inner_n, inner_l, inner_path) + insert_node(inner_n, d[key], inner_path, cur_node_data, file) -def parse_file(file_path, l): +def parse_file(file_path, d): tree = ET.parse(file_path) root = tree.getroot() - for n in root.iterfind("*"): - insert_node(n, l) + file = os.path.basename(file_path) + for n in root.iterfind('*'): + insert_node(n, d, file=file) def main(): + # pylint: disable=global-statement + global CHECK_XML_CONSISTENCY + parser = ArgumentParser(description='generate dict from xml defintions') - parser.add_argument('--xml-dir', type=str, required=True, - help='transcluded xml op-mode-definition file') + parser.add_argument( + '--xml-dir', + type=str, + required=True, + help='transcluded xml op-mode-definition file', + ) + parser.add_argument( + '--check-xml-consistency', + action='store_true', + help='check consistency of node data across files', + ) + parser.add_argument( + '--select', + type=str, + help='limit cache to a subset of XML files: "power_ctl | multicast-group | ..."', + ) + + parser.add_argument( + '--export-json', + type=str, + help='Export a JSON version of the cache to a file', + ) args = vars(parser.parse_args()) + if args['check_xml_consistency']: + CHECK_XML_CONSISTENCY = True + atexit.register(write_err_buf) + xml_dir = abspath(args['xml_dir']) - l = [] + op_mode_data = {} - for fname in glob.glob(f'{xml_dir}/*.xml'): - parse_file(fname, l) + select = args['select'] + if select: + select = [item.strip() for item in select.split('|')] - with open(xml_op_tmp, 'w') as f: - json.dump(l, f, indent=2) + for fname in sorted(glob.glob(f'{xml_dir}/*.xml')): + file = os.path.basename(fname) + if not select or os.path.splitext(file)[0] in select: + parse_file(fname, op_mode_data) + + op_mode_data = sort_op_data(op_mode_data) + + res, out, err = collapse(op_mode_data) + if err: + print( + 'Failed to generate operational command definition cache due to duplicate paths.' + ) + print('Found the following duplicate paths:\n') + print(out) + sys.exit(1) + else: + op_mode_data = res with open(op_ref_cache, 'w') as f: - f.write(f'op_reference = {str(l)}') + f.write('from vyos.xml_ref.op_definition import NodeData\n') + f.write(f'op_reference = {str(op_mode_data)}') + + if args['export_json']: + with open(args['export_json'], 'w') as f: + json.dump(op_mode_data, f) + if __name__ == '__main__': main() diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 914f3a105..d4b767324 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,37 +13,244 @@ # 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/>. -from typing import TypedDict from typing import TypeAlias -from typing import Optional from typing import Union +from typing import Optional +from typing import Iterator +from dataclasses import dataclass +from dataclasses import field +from dataclasses import fields +from dataclasses import asdict +from itertools import filterfalse + + +@dataclass +class NodeData: + # pylint: disable=too-many-instance-attributes + name: str = '' + node_type: str = 'node' + help_text: str = '' + comp_help: dict[str, list] = field(default_factory=dict) + command: str = '' + standalone_help_text: Optional[str] = None + standalone_command: Optional[str] = None + path: list[str] = field(default_factory=list) + files: list[str] = field(default_factory=list) + children: list[tuple] = field(default_factory=list) + + +OpKey: TypeAlias = tuple[str, str] +OpData: TypeAlias = dict[OpKey, Union[NodeData, 'OpData']] + + +def key_name(k: OpKey): + return k[0] + + +def key_type(k: OpKey): + return k[1] + + +def key_names(l: list): # noqa: E741 + return list(map(lambda t: t[0], l)) + + +def keys_of_name(s: str, l: list): # noqa: E741 + filter(lambda t: t[0] == s, l) + + +def is_tag_node(t: tuple): + return t[1] == 'tagNode' + + +def subdict_of_name(s: str, d: dict) -> dict: + res = {} + for t, v in d.items(): + if not isinstance(t, tuple): + break + if key_name(t) == s: + res[t] = v + + return res + + +def next_keys(d: dict) -> list: + key_set = set() + for k in list(d.keys()): + if isinstance(d[k], dict): + key_set |= set(d[k].keys()) + return list(key_set) + + +def tuple_paths(d: dict) -> Iterator[list[tuple]]: + def func(d, path): + if isinstance(d, dict): + if not d: + yield path + for k, v in d.items(): + if isinstance(k, tuple) and key_name(k) != '__node_data': + for r in func(v, path + [k]): + yield r + else: + yield path + else: + yield path + for r in func(d, []): + yield r -class NodeData(TypedDict): - node_type: Optional[str] - help_text: Optional[str] - comp_help: Optional[dict[str, list]] - command: Optional[str] - path: Optional[list[str]] +def match_tuple_paths( + path: list[str], paths: list[list[tuple[str, str]]] +) -> list[list[tuple[str, str]]]: + return list(filter(lambda p: key_names(p) == path, paths)) -PathData: TypeAlias = dict[str, Union[NodeData|list['PathData']]] + +def get_node_data(d: dict) -> NodeData: + return d.get(('__node_data', None), {}) + + +def get_node_data_at_path(d: dict, tpath): + if not tpath: + return {} + # operates on actual paths, not names: + if not isinstance(tpath[0], tuple): + raise ValueError('must be path of tuples') + while tpath and d: + d = d.get(tpath[0], {}) + tpath = tpath[1:] + + return get_node_data(d) + + +def node_data_difference(a: NodeData, b: NodeData): + out = '' + for fld in fields(NodeData): + if fld.name in ('children', 'files'): + continue + a_fld = getattr(a, fld.name) + b_fld = getattr(b, fld.name) + if a_fld != b_fld: + out += f'prev: {a.files[-1:]} {a.path} {fld.name}: {a_fld}\n' + out += f'new: {b.files[-1:]} {b.path} {fld.name}: {b_fld}\n' + out += '\n' + + return out + + +def collapse(d: OpData, acc: dict = None) -> tuple[dict, str, bool]: + err = False + inner_err = False + out = '' + inner_out = '' + if acc is None: + acc = {} + if not isinstance(d, dict): + return d + for k, v in d.items(): + if isinstance(k, tuple): + name = key_name(k) + if name != '__node_data': + new_data = get_node_data(v) + if name in list(acc.keys()): + err = True + prev_data = acc[name].get('__node_data', {}) + if prev_data: + out += f'prev: {prev_data["file"]} {prev_data["path"]}\n' + else: + out += '\n' + out += f'new: {new_data.file} {new_data.path}\n\n' + else: + new_data.children = list(map(lambda t: t[0], new_data.children)) + acc[name] = {} + acc[name]['__node_data'] = asdict(new_data) + inner, o, e = collapse(v) + inner_err |= e + inner_out += o + acc[name].update(inner) + else: + name = k + acc[name] = v + + err |= inner_err + out += inner_out + + return acc, out, err class OpXml: def __init__(self): self.op_ref = {} - def define(self, op_ref: list[PathData]) -> None: + def define(self, op_ref: dict) -> None: self.op_ref = op_ref - def _get_op_ref_path(self, path: list[str]) -> list[PathData]: - def _get_path_list(path: list[str], l: list[PathData]) -> list[PathData]: - if not path: - return l - for d in l: - if path[0] in list(d): - return _get_path_list(path[1:], d[path[0]]) - return [] - l = self.op_ref - return _get_path_list(path, l) + def walk(self, func): + def walk_op_data(obj, func): + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(k, tuple): + res = func(k, v) + yield res + yield from walk_op_data(v, func) + + return walk_op_data(self.op_ref, func) + + @staticmethod + def get_node_data_func(k, v): + if key_name(k) == '__node_data': + return v + return None + + def walk_node_data(self): + return filterfalse(lambda x: x is None, self.walk(self.get_node_data_func)) + + def lookup( + self, path: list[str], tag_values: bool = False, last_node_type: str = '' + ) -> (OpData, list[str]): + path = path[:] + + ref_path = [] + + def prune_tree(d: dict, p: list[str]): + p = p[:] + if not d or not isinstance(d, dict) or not p: + return d + op_data: dict = subdict_of_name(p[0], d) + op_keys = list(op_data.keys()) + ref_path.append(p[0]) + if len(p) < 2: + # check last node_type + if last_node_type: + keys = list(filter(lambda t: t[1] == last_node_type, op_keys)) + values = list(map(lambda t: op_data[t], keys)) + return dict(zip(keys, values)) + return op_data + + if p[1] not in key_names(next_keys(op_data)): + # check if tag_values + if tag_values: + p = p[2:] + keys = list(filter(is_tag_node, op_keys)) + values = list(map(lambda t: prune_tree(op_data[t], p), keys)) + return dict(zip(keys, values)) + return {} + + p = p[1:] + op_data = list(map(lambda t: prune_tree(op_data[t], p), op_keys)) + + return dict(zip(op_keys, op_data)) + + return prune_tree(self.op_ref, path), ref_path + + def lookup_node_data( + self, path: list[str], tag_values: bool = False, last_node_type: str = '' + ) -> list[NodeData]: + res = [] + d, ref_path = self.lookup(path, tag_values, last_node_type) + paths = list(tuple_paths(d)) + paths = match_tuple_paths(ref_path, paths) + for p in paths: + res.append(get_node_data_at_path(d, p)) + + return res diff --git a/python/vyos/xml_ref/update_cache.py b/python/vyos/xml_ref/update_cache.py index 0842bcbe9..6643f9dc4 100755 --- a/python/vyos/xml_ref/update_cache.py +++ b/python/vyos/xml_ref/update_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 |
