diff options
Diffstat (limited to 'python')
77 files changed, 4424 insertions, 1585 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/base.py b/python/vyos/base.py index ca96d96ce..3173ddc20 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 2018-2025 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..81d986658 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -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/config.py b/python/vyos/config.py index 1fab46761..546eeceab 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -149,6 +149,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 d518737ca..dd8910afb 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -33,6 +33,8 @@ from urllib.parse import urlunsplit from vyos.config import Config from vyos.configtree import ConfigTree from vyos.configtree import ConfigTreeError +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError from vyos.configtree import show_diff from vyos.load_config import load from vyos.load_config import LoadConfigError @@ -49,8 +51,10 @@ config_json = '/run/vyatta/config/config.json' # created by vyatta-cfg-postinst commit_post_hook_dir = '/etc/commit/post-hooks.d' -commit_hooks = {'commit_revision': '01vyos-commit-revision', - 'commit_archive': '02vyos-commit-archive'} +commit_hooks = { + 'commit_revision': '01vyos-commit-revision', + 'commit_archive': '02vyos-commit-archive', +} DEFAULT_TIME_MINUTES = 10 timer_name = 'commit-confirm' @@ -72,6 +76,7 @@ formatter = logging.Formatter('%(funcName)s: %(levelname)s:%(message)s') ch.setFormatter(formatter) logger.addHandler(ch) + def save_config(target, json_out=None): if json_out is None: cmd = f'{SAVE_CONFIG} {target}' @@ -81,6 +86,7 @@ def save_config(target, json_out=None): if rc != 0: logger.critical(f'save config failed: {out}') + def unsaved_commits(allow_missing_config=False) -> bool: if get_full_version_data()['boot_via'] == 'livecd': return False @@ -92,6 +98,7 @@ def unsaved_commits(allow_missing_config=False) -> bool: os.unlink(tmp_save) return ret + def get_file_revision(rev: int): revision = os.path.join(archive_dir, f'config.boot.{rev}.gz') try: @@ -102,12 +109,15 @@ def get_file_revision(rev: int): return '' return r + def get_config_tree_revision(rev: int): c = get_file_revision(rev) return ConfigTree(c) + def is_node_revised(path: list = [], rev1: int = 1, rev2: int = 0) -> bool: from vyos.configtree import DiffTree + left = get_config_tree_revision(rev1) right = get_config_tree_revision(rev2) diff_tree = DiffTree(left, right) @@ -115,9 +125,11 @@ def is_node_revised(path: list = [], rev1: int = 1, rev2: int = 0) -> bool: return True return False + class ConfigMgmtError(Exception): pass + class ConfigMgmt: def __init__(self, session_env=None, config=None): if session_env: @@ -128,15 +140,20 @@ class ConfigMgmt: if config is None: config = Config() - d = config.get_config_dict(['system', 'config-management'], - key_mangling=('-', '_'), - get_first_key=True) + d = config.get_config_dict( + ['system', 'config-management'], + key_mangling=('-', '_'), + get_first_key=True, + with_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.source_address = d.get('commit_archive', {}).get('source_address', '') + self.reboot_unconfirmed = bool(d.get('commit_confirm') == 'reboot') + self.config_dict = d + if config.exists(['system', 'host-name']): self.hostname = config.return_value(['system', 'host-name']) if config.exists(['system', 'domain-name']): @@ -156,51 +173,73 @@ class ConfigMgmt: # a call to compare without args is edit_level aware edit_level = os.getenv('VYATTA_EDIT_LEVEL', '') - self.edit_path = [l for l in edit_level.split('/') if l] + self.edit_path = [l for l in edit_level.split('/') if l] # noqa: E741 self.active_config = config._running_config self.working_config = config._session_config # Console script functions # - def commit_confirm(self, minutes: int=DEFAULT_TIME_MINUTES, - no_prompt: bool=False) -> Tuple[str,int]: - """Commit with reboot to saved config in 'minutes' minutes if + def commit_confirm( + self, minutes: int = DEFAULT_TIME_MINUTES, no_prompt: bool = False + ) -> Tuple[str, int]: + """Commit with reload/reboot to saved config in 'minutes' minutes if 'confirm' call is not issued. """ if is_systemd_service_active(f'{timer_name}.timer'): msg = 'Another confirm is pending' return msg, 1 - if unsaved_commits(): + if self.reboot_unconfirmed and unsaved_commits(): W = '\nYou should save previous commits before commit-confirm !\n' else: W = '' - prompt_str = f''' + if self.reboot_unconfirmed: + prompt_str = f""" commit-confirm will automatically reboot in {minutes} minutes unless changes -are confirmed.\n -Proceed ?''' +are confirmed. +Proceed ?""" + else: + prompt_str = f""" +commit-confirm will automatically reload previous config in {minutes} minutes +unless changes are confirmed. +Proceed ?""" + prompt_str = W + prompt_str if not no_prompt and not ask_yes_no(prompt_str, default=True): msg = 'commit-confirm canceled' return msg, 1 - action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' + if self.reboot_unconfirmed: + action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' + else: + action = 'sg vyattacfg "/usr/bin/config-mgmt revert_soft"' + cmd = f'sudo systemd-run --quiet --on-active={minutes}m --unit={timer_name} {action}' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigMgmtError(out) # start notify - cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' + if self.reboot_unconfirmed: + cmd = ( + f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py --reboot {minutes}' + ) + else: + cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' + os.system(cmd) - msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' + if self.reboot_unconfirmed: + msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' + else: + msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reload' + return msg, 0 - def confirm(self) -> Tuple[str,int]: - """Do not reboot to saved config following 'commit-confirm'. + def confirm(self) -> Tuple[str, int]: + """Do not reboot/reload to saved/completed config following 'commit-confirm'. Update commit log and archive. """ if not is_systemd_service_active(f'{timer_name}.timer'): @@ -224,12 +263,15 @@ Proceed ?''' self._add_log_entry(**entry) self._update_archive() - msg = 'Reboot timer stopped' + if self.reboot_unconfirmed: + msg = 'Reboot timer stopped' + else: + msg = 'Reload timer stopped' + return msg, 0 - def revert(self) -> Tuple[str,int]: - """Reboot to saved config, dropping commits from 'commit-confirm'. - """ + def revert(self) -> Tuple[str, int]: + """Reboot to saved config, dropping commits from 'commit-confirm'.""" _ = self._read_tmp_log_entry() # archived config will be reverted on boot @@ -239,13 +281,39 @@ Proceed ?''' return '', 0 - def rollback(self, rev: int, no_prompt: bool=False) -> Tuple[str,int]: - """Reboot to config revision 'rev'. - """ + def revert_soft(self) -> Tuple[str, int]: + """Reload last revision, dropping commits from 'commit-confirm'.""" + _ = self._read_tmp_log_entry() + + # 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) + + message = '[commit-confirm] Reverting to previous config now' + os.system('wall -n ' + message) + + mask = os.umask(0o002) + session = ConfigSession(os.getpid(), app='config-mgmt') + + try: + session.load_explicit(revert_ct) + session.commit() + except ConfigSessionError as e: + raise ConfigMgmtError(e) from e + finally: + os.umask(mask) + del session + + return '', 0 + + def rollback(self, rev: int, no_prompt: bool = False) -> Tuple[str, int]: + """Reboot to config revision 'rev'.""" msg = '' if not self._check_revision_number(rev): - msg = f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}' + msg = ( + f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}' + ) return msg, 1 prompt_str = 'Proceed with reboot ?' @@ -274,15 +342,16 @@ Proceed ?''' return msg, 0 def rollback_soft(self, rev: int): - """Rollback without reboot (rollback-soft) - """ + """Rollback without reboot (rollback-soft)""" msg = '' if not self._check_revision_number(rev): - msg = f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}' + msg = ( + f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}' + ) 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.') @@ -292,9 +361,13 @@ Proceed ?''' return msg, 0 - def compare(self, saved: bool=False, commands: bool=False, - rev1: Optional[int]=None, - rev2: Optional[int]=None) -> Tuple[str,int]: + def compare( + self, + saved: bool = False, + commands: bool = False, + rev1: Optional[int] = None, + rev2: Optional[int] = None, + ) -> Tuple[str, int]: """General compare function for config file revisions: revision n vs. revision m; working version vs. active version; or working version vs. saved version. @@ -309,7 +382,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: @@ -317,7 +390,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 = '' @@ -335,7 +408,7 @@ Proceed ?''' return msg, 0 - def wrap_compare(self, options) -> Tuple[str,int]: + def wrap_compare(self, options) -> Tuple[str, int]: """Interface to vyatta-cfg-run: args collected as 'options' to parse for compare. """ @@ -343,7 +416,7 @@ Proceed ?''' r1 = None r2 = None if 'commands' in options: - cmnds=True + cmnds = True options.remove('commands') for i in options: if not i.isnumeric(): @@ -358,8 +431,7 @@ Proceed ?''' # Initialization and post-commit hooks for conf-mode # def initialize_revision(self): - """Initialize config archive, logrotate conf, and commit log. - """ + """Initialize config archive, logrotate conf, and commit log.""" mask = os.umask(0o002) os.makedirs(archive_dir, exist_ok=True) json_dir = os.path.dirname(config_json) @@ -371,8 +443,7 @@ Proceed ?''' self._add_logrotate_conf() - if (not os.path.exists(commit_log_file) or - self._get_number_of_revisions() == 0): + if not os.path.exists(commit_log_file) or self._get_number_of_revisions() == 0: user = self._get_user() via = 'init' comment = '' @@ -399,8 +470,7 @@ Proceed ?''' self._update_archive() def commit_archive(self): - """Upload config to remote archive. - """ + """Upload config to remote archive.""" from vyos.remote import upload hostname = self.hostname @@ -410,20 +480,23 @@ Proceed ?''' source_address = self.source_address if self.effective_locations: - print("Archiving config...") + print('Archiving config...') for location in self.effective_locations: url = urlsplit(location) - _, _, netloc = url.netloc.rpartition("@") + _, _, netloc = url.netloc.rpartition('@') redacted_location = urlunsplit(url._replace(netloc=netloc)) - print(f" {redacted_location}", end=" ", flush=True) - upload(archive_config_file, f'{location}/{remote_file}', - source_host=source_address) + print(f' {redacted_location}', end=' ', flush=True) + upload( + archive_config_file, + f'{location}/{remote_file}', + source_host=source_address, + ) # op-mode functions # def get_raw_log_data(self) -> list: """Return list of dicts of log data: - keys: [timestamp, user, commit_via, commit_comment] + keys: [timestamp, user, commit_via, commit_comment] """ log = self._get_log_entries() res_l = [] @@ -435,20 +508,20 @@ Proceed ?''' @staticmethod def format_log_data(data: list) -> str: - """Return formatted log data as str. - """ + """Return formatted log data as str.""" res_l = [] - for l_no, l in enumerate(data): - time_d = datetime.fromtimestamp(int(l['timestamp'])) - time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") + for l_no, l_val in enumerate(data): + time_d = datetime.fromtimestamp(int(l_val['timestamp'])) + time_str = time_d.strftime('%Y-%m-%d %H:%M:%S') - res_l.append([l_no, time_str, - f"by {l['user']}", f"via {l['commit_via']}"]) + res_l.append( + [l_no, time_str, f"by {l_val['user']}", f"via {l_val['commit_via']}"] + ) - if l['commit_comment'] != 'commit': # default comment - res_l.append([None, l['commit_comment']]) + if l_val['commit_comment'] != 'commit': # default comment + res_l.append([None, l_val['commit_comment']]) - ret = tabulate(res_l, tablefmt="plain") + ret = tabulate(res_l, tablefmt='plain') return ret @staticmethod @@ -459,23 +532,25 @@ Proceed ?''' 'rollback'. """ res_l = [] - for l_no, l in enumerate(data): - time_d = datetime.fromtimestamp(int(l['timestamp'])) - time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") + for l_no, l_val in enumerate(data): + time_d = datetime.fromtimestamp(int(l_val['timestamp'])) + time_str = time_d.strftime('%Y-%m-%d %H:%M:%S') - res_l.append(['\t', l_no, time_str, - f"{l['user']}", f"by {l['commit_via']}"]) + res_l.append( + ['\t', l_no, time_str, f"{l_val['user']}", f"by {l_val['commit_via']}"] + ) - ret = tabulate(res_l, tablefmt="plain") + ret = tabulate(res_l, tablefmt='plain') return ret - def show_commit_diff(self, rev: int, rev2: Optional[int]=None, - commands: bool=False) -> str: + def show_commit_diff( + self, rev: int, rev2: Optional[int] = None, commands: bool = False + ) -> str: """Show commit diff at revision number, compared to previous revision, or to another revision. """ if rev2 is None: - out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev+1)) + out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev + 1)) return out out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2) @@ -500,7 +575,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) @@ -519,8 +594,9 @@ Proceed ?''' conf_file.chmod(0o644) def _archive_active_config(self) -> bool: - save_to_tmp = (boot_configuration_complete() or not - os.path.isfile(archive_config_file)) + save_to_tmp = boot_configuration_complete() or not os.path.isfile( + archive_config_file + ) mask = os.umask(0o113) ext = os.getpid() @@ -560,15 +636,14 @@ Proceed ?''' @staticmethod def _update_archive(): - cmd = f"sudo logrotate -f -s {logrotate_state} {logrotate_conf}" + cmd = f'sudo logrotate -f -s {logrotate_state} {logrotate_conf}' rc, out = rc_cmd(cmd) if rc != 0: logger.critical(f'logrotate failure: {out}') @staticmethod def _get_log_entries() -> list: - """Return lines of commit log as list of strings - """ + """Return lines of commit log as list of strings""" entries = [] if os.path.exists(commit_log_file): with open(commit_log_file) as f: @@ -577,8 +652,8 @@ Proceed ?''' return entries def _get_number_of_revisions(self) -> int: - l = self._get_log_entries() - return len(l) + log_entries = self._get_log_entries() + return len(log_entries) def _check_revision_number(self, rev: int) -> bool: self.num_revisions = self._get_number_of_revisions() @@ -599,9 +674,14 @@ Proceed ?''' user = 'unknown' return user - def _new_log_entry(self, user: str='', commit_via: str='', - commit_comment: str='', timestamp: Optional[int]=None, - tmp_file: str=None) -> Optional[str]: + def _new_log_entry( + self, + user: str = '', + commit_via: str = '', + commit_comment: str = '', + timestamp: Optional[int] = None, + tmp_file: str = None, + ) -> Optional[str]: # Format log entry and return str or write to file. # # Usage is within a post-commit hook, using env values. In case of @@ -647,12 +727,12 @@ Proceed ?''' logger.critical(f'Invalid log format {line}') return {} - timestamp, user, commit_via, commit_comment = ( - tuple(line.strip().strip('|').split('|'))) + timestamp, user, commit_via, commit_comment = tuple( + line.strip().strip('|').split('|') + ) commit_comment = commit_comment.replace('%%', '|') - d = dict(zip(keys, [user, commit_via, - commit_comment, timestamp])) + d = dict(zip(keys, [user, commit_via, commit_comment, timestamp])) return d @@ -662,17 +742,28 @@ Proceed ?''' entry = f.read() os.unlink(tmp_log_entry) except OSError as e: - logger.critical(f'error on file {tmp_log_entry}: {e}') + logger.info(f'error on file {tmp_log_entry}: {e}') + # fail gracefully in corner case: + # delete commit-revisions; commit-confirm + return {} return self._get_log_entry(entry) - def _add_log_entry(self, user: str='', commit_via: str='', - commit_comment: str='', timestamp: Optional[int]=None): + def _add_log_entry( + self, + user: str = '', + commit_via: str = '', + commit_comment: str = '', + timestamp: Optional[int] = None, + ): mask = os.umask(0o113) - entry = self._new_log_entry(user=user, commit_via=commit_via, - commit_comment=commit_comment, - timestamp=timestamp) + entry = self._new_log_entry( + user=user, + commit_via=commit_via, + commit_comment=commit_comment, + timestamp=timestamp, + ) log_entries = self._get_log_entries() log_entries.insert(0, entry) @@ -687,6 +778,7 @@ Proceed ?''' os.umask(mask) + # entry_point for console script # def run(): @@ -706,43 +798,54 @@ def run(): parser = ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand') - commit_confirm = subparsers.add_parser('commit_confirm', - help="Commit with opt-out reboot to saved config") - commit_confirm.add_argument('-t', dest='minutes', type=int, - default=DEFAULT_TIME_MINUTES, - help="Minutes until reboot, unless 'confirm'") - commit_confirm.add_argument('-y', dest='no_prompt', action='store_true', - help="Execute without prompt") - - subparsers.add_parser('confirm', help="Confirm commit") - subparsers.add_parser('revert', help="Revert commit-confirm") - - rollback = subparsers.add_parser('rollback', - help="Rollback to earlier config") - rollback.add_argument('--rev', type=int, - help="Revision number for rollback") - rollback.add_argument('-y', dest='no_prompt', action='store_true', - help="Excute without prompt") - - rollback_soft = subparsers.add_parser('rollback_soft', - help="Rollback to earlier config") - rollback_soft.add_argument('--rev', type=int, - help="Revision number for rollback") - - compare = subparsers.add_parser('compare', - help="Compare config files") - - compare.add_argument('--saved', action='store_true', - help="Compare session config with saved config") - compare.add_argument('--commands', action='store_true', - help="Show difference between commands") - compare.add_argument('--rev1', type=int, default=None, - help="Compare revision with session config or other revision") - compare.add_argument('--rev2', type=int, default=None, - help="Compare revisions") - - wrap_compare = subparsers.add_parser('wrap_compare', - help="Wrapper interface for vyatta-cfg-run") + commit_confirm = subparsers.add_parser( + 'commit_confirm', help='Commit with opt-out reboot to saved config' + ) + commit_confirm.add_argument( + '-t', + dest='minutes', + type=int, + default=DEFAULT_TIME_MINUTES, + help="Minutes until reboot, unless 'confirm'", + ) + commit_confirm.add_argument( + '-y', dest='no_prompt', action='store_true', help='Execute without prompt' + ) + + subparsers.add_parser('confirm', help='Confirm commit') + subparsers.add_parser('revert', help='Revert commit-confirm with reboot') + subparsers.add_parser('revert_soft', help='Revert commit-confirm with reload') + + rollback = subparsers.add_parser('rollback', help='Rollback to earlier config') + rollback.add_argument('--rev', type=int, help='Revision number for rollback') + rollback.add_argument( + '-y', dest='no_prompt', action='store_true', help='Excute without prompt' + ) + + rollback_soft = subparsers.add_parser( + 'rollback_soft', help='Rollback to earlier config' + ) + rollback_soft.add_argument('--rev', type=int, help='Revision number for rollback') + + compare = subparsers.add_parser('compare', help='Compare config files') + + compare.add_argument( + '--saved', action='store_true', help='Compare session config with saved config' + ) + compare.add_argument( + '--commands', action='store_true', help='Show difference between commands' + ) + compare.add_argument( + '--rev1', + type=int, + default=None, + help='Compare revision with session config or other revision', + ) + compare.add_argument('--rev2', type=int, default=None, help='Compare revisions') + + wrap_compare = subparsers.add_parser( + 'wrap_compare', help='Wrapper interface for vyatta-cfg-run' + ) wrap_compare.add_argument('--options', nargs=REMAINDER) args = vars(parser.parse_args()) diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py index cf7c9d543..747af8dbe 100644 --- a/python/vyos/configdep.py +++ b/python/vyos/configdep.py @@ -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..ff0a15933 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -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 diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 5d6ca9be9..4c4ead0a3 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 2021-2025 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 @@ -120,11 +120,14 @@ class ConfigTreeQuery(GenericConfigQuery): 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): + 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) + 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): diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 7d51b94e4..a3be29881 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -21,6 +21,10 @@ import subprocess 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.vyconf_session import VyconfSession + +vyconf_backend = False CLI_SHELL_API = '/bin/cli-shell-api' SET = '/opt/vyatta/sbin/my_set' @@ -32,15 +36,33 @@ SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig'] LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile'] MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py'] SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py'] -INSTALL_IMAGE = ['/usr/libexec/vyos/op_mode/image_installer.py', - '--action', 'add', '--no-prompt', '--image-path'] +INSTALL_IMAGE = [ + '/usr/libexec/vyos/op_mode/image_installer.py', + '--action', + 'add', + '--no-prompt', + '--image-path', +] IMPORT_PKI = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'import'] -IMPORT_PKI_NO_PROMPT = ['/usr/libexec/vyos/op_mode/pki.py', - '--action', 'import', '--no-prompt'] -REMOVE_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py', - '--action', 'delete', '--no-prompt', '--image-name'] -SET_DEFAULT_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py', - '--action', 'set', '--no-prompt', '--image-name'] +IMPORT_PKI_NO_PROMPT = [ + '/usr/libexec/vyos/op_mode/pki.py', + 'import_pki', + '--no-prompt', +] +REMOVE_IMAGE = [ + '/usr/libexec/vyos/op_mode/image_manager.py', + '--action', + 'delete', + '--no-prompt', + '--image-name', +] +SET_DEFAULT_IMAGE = [ + '/usr/libexec/vyos/op_mode/image_manager.py', + '--action', + 'set', + '--no-prompt', + '--image-name', +] 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'] @@ -48,9 +70,20 @@ REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot'] 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'] +TRACEROUTE = [ + '/usr/libexec/vyos/op_mode/mtr_execute.py', + 'mtr', + '--for-api', + '--report-mode', + '--report-cycles', + '1', + '--json', + '--host', +] # Default "commit via" string -APP = "vyos-http-api" +APP = 'vyos-http-api' + # When started as a service rather than from a user shell, # the process lacks the VyOS-specific environment that comes @@ -61,7 +94,7 @@ def inject_vyos_env(env): env['VYATTA_USER_LEVEL_DIR'] = '/opt/vyatta/etc/shell/level/admin' env['VYATTA_PROCESS_CLIENT'] = 'gui2_rest' env['VYOS_HEADLESS_CLIENT'] = 'vyos_http_api' - env['vyatta_bindir']= '/opt/vyatta/bin' + env['vyatta_bindir'] = '/opt/vyatta/bin' env['vyatta_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates' env['vyatta_configdir'] = directories['vyos_configdir'] env['vyatta_datadir'] = '/opt/vyatta/share' @@ -78,7 +111,7 @@ def inject_vyos_env(env): env['vyos_configdir'] = directories['vyos_configdir'] env['vyos_conf_scripts_dir'] = '/usr/libexec/vyos/conf_mode' env['vyos_datadir'] = '/opt/vyatta/share' - env['vyos_datarootdir']= '/opt/vyatta/share' + env['vyos_datarootdir'] = '/opt/vyatta/share' env['vyos_libdir'] = '/opt/vyatta/lib' env['vyos_libexec_dir'] = '/usr/libexec/vyos' env['vyos_op_scripts_dir'] = '/usr/libexec/vyos/op_mode' @@ -102,6 +135,7 @@ class ConfigSession(object): """ The write API of VyOS. """ + def __init__(self, session_id, app=APP): """ Creates a new config session. @@ -116,7 +150,9 @@ class ConfigSession(object): and used the PID for the session identifier. """ - env_str = subprocess.check_output([CLI_SHELL_API, 'getSessionEnv', str(session_id)]) + env_str = subprocess.check_output( + [CLI_SHELL_API, 'getSessionEnv', str(session_id)] + ) self.__session_id = session_id # Extract actual variables from the chunk of shell it outputs @@ -129,20 +165,44 @@ class ConfigSession(object): session_env[k] = v self.__session_env = session_env - self.__session_env["COMMIT_VIA"] = app + self.__session_env['COMMIT_VIA'] = app self.__run_command([CLI_SHELL_API, 'setupSession']) + if vyconf_backend and boot_configuration_complete(): + self._vyconf_session = VyconfSession(on_error=ConfigSessionError) + else: + self._vyconf_session = None + def __del__(self): try: - output = subprocess.check_output([CLI_SHELL_API, 'teardownSession'], env=self.__session_env).decode().strip() + output = ( + subprocess.check_output( + [CLI_SHELL_API, 'teardownSession'], env=self.__session_env + ) + .decode() + .strip() + ) if output: - print("cli-shell-api teardownSession output for sesion {0}: {1}".format(self.__session_id, output), file=sys.stderr) + print( + 'cli-shell-api teardownSession output for sesion {0}: {1}'.format( + self.__session_id, output + ), + file=sys.stderr, + ) except Exception as e: - print("Could not tear down session {0}: {1}".format(self.__session_id, e), file=sys.stderr) + print( + 'Could not tear down session {0}: {1}'.format(self.__session_id, e), + file=sys.stderr, + ) def __run_command(self, cmd_list): - p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=self.__session_env) + p = subprocess.Popen( + cmd_list, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=self.__session_env, + ) (stdout_data, stderr_data) = p.communicate() output = stdout_data.decode() result = p.wait() @@ -158,7 +218,10 @@ class ConfigSession(object): 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: @@ -172,7 +235,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: @@ -204,34 +270,67 @@ class ConfigSession(object): def comment(self, path, value=None): if not value: - value = [""] + value = [''] else: value = [value] 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 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() if format == 'raw': return config_data def load_config(self, file_path): - out = self.__run_command(LOAD_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(LOAD_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.load_config(file=file_path) + return out + def load_explicit(self, file_path): + from vyos.load_config import load + from vyos.load_config import LoadConfigError + + try: + load(file_path, switch='explicit') + except LoadConfigError as e: + raise ConfigSessionError(e) from e + 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=file_path, migrate=True) + 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): @@ -285,3 +384,7 @@ class ConfigSession(object): def show_container_image(self): out = self.__run_command(SHOW + ['container', 'image']) return out + + def traceroute(self, host): + out = self.__run_command(TRACEROUTE + [host]) + return out diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index 59e5ac8a1..65cef5333 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -319,3 +319,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 bd77ab899..ff40fbad0 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-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # 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,35 +19,44 @@ 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): """Modify quoted strings containing backslashes not of escape sequences""" + def replace_method(match): result = match.group().replace(search, replace) return result + p = re.compile(r'("[^"]*[\\][^"]*"\n|\'[^\']*[\\][^\']*\'\n)') return p.sub(replace_method, s) + def escape_backslash(string: str) -> str: """Escape single backslashes in quoted strings""" result = replace_backslash(string, '\\', '\\\\') return result + def unescape_backslash(string: str) -> str: """Unescape backslashes in quoted strings""" result = replace_backslash(string, '\\\\', '\\') return result + def extract_version(s): - """ Extract the version string from the config string """ + """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): # Necessary type checking if not isinstance(path, list): - raise TypeError("Expected a list, got a {}".format(type(path))) + raise TypeError('Expected a list, got a {}'.format(type(path))) else: pass @@ -57,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) @@ -80,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 @@ -88,6 +109,10 @@ class ConfigTree(object): self.__to_json_ast.argtypes = [c_void_p] self.__to_json_ast.restype = c_char_p + self.__create_node = self.__lib.create_node + self.__create_node.argtypes = [c_void_p, c_char_p] + self.__create_node.restype = c_int + self.__set_add_value = self.__lib.set_add_value self.__set_add_value.argtypes = [c_void_p, c_char_p, c_char_p] self.__set_add_value.restype = c_int @@ -137,9 +162,17 @@ class ConfigTree(object): self.__is_tag.restype = c_int self.__set_tag = self.__lib.set_tag - self.__set_tag.argtypes = [c_void_p, c_char_p] + self.__set_tag.argtypes = [c_void_p, c_char_p, c_bool] self.__set_tag.restype = c_int + self.__is_leaf = self.__lib.is_leaf + self.__is_leaf.argtypes = [c_void_p, c_char_p] + self.__is_leaf.restype = c_bool + + self.__set_leaf = self.__lib.set_leaf + self.__set_leaf.argtypes = [c_void_p, c_char_p, c_bool] + self.__set_leaf.restype = c_int + self.__get_subtree = self.__lib.get_subtree self.__get_subtree.argtypes = [c_void_p, c_char_p] self.__get_subtree.restype = c_void_p @@ -147,19 +180,34 @@ 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 + 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()) if config is None: msg = self.__get_error().decode() - raise ValueError("Failed to parse config: {0}".format(msg)) + raise ValueError('Failed to parse config: {0}'.format(msg)) else: 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: @@ -169,6 +217,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() @@ -178,15 +231,18 @@ 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) + 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) if no_version: return config_string - config_string = "{0}\n{1}".format(config_string, self.__version) + config_string = '{0}\n{1}'.format(config_string, self.__version) return config_string - def to_commands(self, op="set"): + def to_commands(self, op='set'): commands = self.__to_commands(self.__config, op.encode()).decode() commands = unescape_backslash(commands) return commands @@ -197,6 +253,14 @@ class ConfigTree(object): def to_json_ast(self): return self.__to_json_ast(self.__config).decode() + def create_node(self, path): + check_path(path) + path_str = ' '.join(map(str, path)).encode() + + res = self.__create_node(self.__config, path_str) + if res != 0: + raise ConfigTreeError(f'Path already exists: {path}') + def set(self, path, value=None, replace=True): """Set new entry in VyOS configuration. path: configuration path e.g. 'system dns forwarding listen-address' @@ -207,7 +271,7 @@ class ConfigTree(object): """ check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() if value is None: self.__set_valueless(self.__config, path_str) @@ -218,25 +282,27 @@ class ConfigTree(object): self.__set_add_value(self.__config, path_str, str(value).encode()) if self.__migration: - self.migration_log.info(f"- op: set path: {path} value: {value} replace: {replace}") + self.migration_log.info( + f'- op: set path: {path} value: {value} replace: {replace}' + ) def delete(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__delete(self.__config, path_str) - if (res != 0): + if res != 0: raise ConfigTreeError(f"Path doesn't exist: {path}") if self.__migration: - self.migration_log.info(f"- op: delete path: {path}") + self.migration_log.info(f'- op: delete path: {path}') def delete_value(self, path, value): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__delete_value(self.__config, path_str, value.encode()) - if (res != 0): + if res != 0: if res == 1: raise ConfigTreeError(f"Path doesn't exist: {path}") elif res == 2: @@ -245,11 +311,11 @@ class ConfigTree(object): raise ConfigTreeError() if self.__migration: - self.migration_log.info(f"- op: delete_value path: {path} value: {value}") + self.migration_log.info(f'- op: delete_value path: {path} value: {value}') def rename(self, path, new_name): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() newname_str = new_name.encode() # Check if a node with intended new name already exists @@ -257,42 +323,46 @@ class ConfigTree(object): if self.exists(new_path): raise ConfigTreeError() res = self.__rename(self.__config, path_str, newname_str) - if (res != 0): + if res != 0: raise ConfigTreeError("Path [{}] doesn't exist".format(path)) if self.__migration: - self.migration_log.info(f"- op: rename old_path: {path} new_path: {new_path}") + self.migration_log.info( + f'- op: rename old_path: {path} new_path: {new_path}' + ) def copy(self, old_path, new_path): check_path(old_path) check_path(new_path) - oldpath_str = " ".join(map(str, old_path)).encode() - newpath_str = " ".join(map(str, new_path)).encode() + oldpath_str = ' '.join(map(str, old_path)).encode() + newpath_str = ' '.join(map(str, new_path)).encode() # Check if a node with intended new name already exists if self.exists(new_path): raise ConfigTreeError() res = self.__copy(self.__config, oldpath_str, newpath_str) - if (res != 0): + if res != 0: msg = self.__get_error().decode() raise ConfigTreeError(msg) if self.__migration: - self.migration_log.info(f"- op: copy old_path: {old_path} new_path: {new_path}") + self.migration_log.info( + f'- op: copy old_path: {old_path} new_path: {new_path}' + ) def exists(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__exists(self.__config, path_str) - if (res == 0): + if res == 0: return False else: return True def list_nodes(self, path, path_must_exist=True): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__list_nodes(self.__config, path_str).decode() res = json.loads(res_json) @@ -307,7 +377,7 @@ class ConfigTree(object): def return_value(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__return_value(self.__config, path_str).decode() res = json.loads(res_json) @@ -319,7 +389,7 @@ class ConfigTree(object): def return_values(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__return_values(self.__config, path_str).decode() res = json.loads(res_json) @@ -331,45 +401,62 @@ class ConfigTree(object): def is_tag(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__is_tag(self.__config, path_str) - if (res >= 1): + if res >= 1: return True else: return False - def set_tag(self, path): + def set_tag(self, path, value=True): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() - res = self.__set_tag(self.__config, path_str) - if (res == 0): + res = self.__set_tag(self.__config, path_str, value) + if res == 0: + return True + else: + raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) + + def is_leaf(self, path): + check_path(path) + path_str = ' '.join(map(str, path)).encode() + + return self.__is_leaf(self.__config, path_str) + + def set_leaf(self, path, value): + check_path(path) + path_str = ' '.join(map(str, path)).encode() + + res = self.__set_leaf(self.__config, path_str, value) + if res == 0: return True else: raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) def get_subtree(self, path, with_node=False): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__get_subtree(self.__config, path_str, with_node) subt = ConfigTree(address=res) return subt + def show_diff(left, right, path=[], commands=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") + raise TypeError('Arguments must be instances of ConfigTree') if path: if (not left.exists(path)) and (not right.exists(path)): raise ConfigTreeError(f"Path {path} doesn't exist") check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() __lib = cdll.LoadLibrary(libpath) __show_diff = __lib.show_diff @@ -381,20 +468,21 @@ def show_diff(left, right, path=[], commands=False, libpath=LIBPATH): res = __show_diff(commands, path_str, left._get_config(), right._get_config()) res = res.decode() - if res == "#1@": + if res == '#1@': msg = __get_error().decode() raise ConfigTreeError(msg) res = unescape_backslash(res) return res + def union(left, right, 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") + raise TypeError('Arguments must be instances of ConfigTree') __lib = cdll.LoadLibrary(libpath) __tree_union = __lib.tree_union @@ -404,14 +492,15 @@ def union(left, right, libpath=LIBPATH): __get_error.argtypes = [] __get_error.restype = c_char_p - res = __tree_union( left._get_config(), right._get_config()) + res = __tree_union(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") + raise TypeError('Arguments must be instances of ConfigTree') try: __lib = cdll.LoadLibrary(libpath) @@ -433,21 +522,75 @@ def mask_inclusive(left, right, libpath=LIBPATH): return tree -def reference_tree_to_json(from_dir, to_file, libpath=LIBPATH): + +def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH): try: __lib = cdll.LoadLibrary(libpath) __reference_tree_to_json = __lib.reference_tree_to_json - __reference_tree_to_json.argtypes = [c_char_p, c_char_p] + __reference_tree_to_json.argtypes = [c_char_p, c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __reference_tree_to_json( + internal_cache.encode(), from_dir.encode(), to_file.encode() + ) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + +def merge_reference_tree_cache(cache_dir, primary_name, result_name, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __merge_reference_tree_cache = __lib.merge_reference_tree_cache + __merge_reference_tree_cache.argtypes = [c_char_p, c_char_p, c_char_p] __get_error = __lib.get_error __get_error.argtypes = [] __get_error.restype = c_char_p - res = __reference_tree_to_json(from_dir.encode(), to_file.encode()) + res = __merge_reference_tree_cache( + cache_dir.encode(), primary_name.encode(), result_name.encode() + ) except Exception as e: raise ConfigTreeError(e) if res == 1: msg = __get_error().decode() raise ConfigTreeError(msg) + +def interface_definitions_to_cache(from_dir, cache_path, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __interface_definitions_to_cache = __lib.interface_definitions_to_cache + __interface_definitions_to_cache.argtypes = [c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __interface_definitions_to_cache(from_dir.encode(), cache_path.encode()) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + +def reference_tree_cache_to_json(cache_path, render_file, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __reference_tree_cache_to_json = __lib.reference_tree_cache_to_json + __reference_tree_cache_to_json.argtypes = [c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __reference_tree_cache_to_json(cache_path.encode(), render_file.encode()) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + class DiffTree: def __init__(self, left, right, path=[], libpath=LIBPATH): if left is None: @@ -455,7 +598,7 @@ class DiffTree: 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") + raise TypeError('Arguments must be instances of ConfigTree') if path: if not left.exists(path): raise ConfigTreeError(f"Path {path} doesn't exist in lhs tree") @@ -472,7 +615,7 @@ class DiffTree: self.__diff_tree.restype = c_void_p check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__diff_tree(path_str, left._get_config(), right._get_config()) @@ -488,11 +631,11 @@ class DiffTree: def to_commands(self): add = self.add.to_commands() - delete = self.delete.to_commands(op="delete") - return delete + "\n" + add + delete = self.delete.to_commands(op='delete') + return delete + '\n' + add + def deep_copy(config_tree: ConfigTree) -> ConfigTree: - """An inelegant, but reasonably fast, copy; replace with backend copy - """ + """An inelegant, but reasonably fast, copy; replace with backend copy""" D = DiffTree(None, config_tree) return D.add diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 92996f2ee..d5f443f15 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -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): @@ -420,7 +426,7 @@ def verify_common_route_maps(config): continue tmp = config[route_map] # Check if the specified route-map exists, if not error out - if dict_search(f'policy.route-map.{tmp}', config) == None: + if dict_search(f'policy.route_map.{tmp}', config) == None: raise ConfigError(f'Specified route-map "{tmp}" does not exist!') if 'redistribute' in config: @@ -434,7 +440,7 @@ def verify_route_map(route_map_name, config): recurring validation if a specified route-map exists! """ # Check if the specified route-map exists, if not error out - if dict_search(f'policy.route-map.{route_map_name}', config) == None: + if dict_search(f'policy.route_map.{route_map_name}', config) == None: raise ConfigError(f'Specified route-map "{route_map_name}" does not exist!') def verify_prefix_list(prefix_list, config, version=''): @@ -443,7 +449,7 @@ def verify_prefix_list(prefix_list, config, version=''): recurring validation if a specified prefix-list exists! """ # Check if the specified prefix-list exists, if not error out - if dict_search(f'policy.prefix-list{version}.{prefix_list}', config) == None: + if dict_search(f'policy.prefix_list{version}.{prefix_list}', config) == None: raise ConfigError(f'Specified prefix-list{version} "{prefix_list}" does not exist!') def verify_access_list(access_list, config, version=''): @@ -452,7 +458,7 @@ def verify_access_list(access_list, config, version=''): recurring validation if a specified prefix-list exists! """ # Check if the specified ACL exists, if not error out - if dict_search(f'policy.access-list{version}.{access_list}', config) == None: + if dict_search(f'policy.access_list{version}.{access_list}', config) == None: raise ConfigError(f'Specified access-list{version} "{access_list}" does not exist!') def verify_pki_certificate(config: dict, cert_name: str, no_password_protected: bool=False): @@ -537,3 +543,13 @@ def verify_eapol(config: dict): if 'ca_certificate' in config['eapol']: for ca_cert in config['eapol']['ca_certificate']: verify_pki_ca_certificate(config, ca_cert) + +def has_frr_protocol_in_dict(config_dict: dict, protocol: str) -> bool: + vrf = None + if config_dict and 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + if vrf and protocol in (dict_search(f'vrf.name.{vrf}.protocols', config_dict) or []): + return True + if config_dict and protocol in config_dict: + return True + return False diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index dec619d3e..c1e5ddc04 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 2018-2025 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,11 +36,25 @@ directories = { 'isc_dhclient_dir' : '/run/dhclient', 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', - 'completion_dir' : f'{base_dir}/completion' + 'completion_dir' : f'{base_dir}/completion', + 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', + 'ppp_nexthop_dir' : '/run/ppp_nexthop', + 'proto_path' : '/usr/share/vyos/vyconf' +} + +systemd_services = { + 'haproxy' : 'haproxy.service', + 'syslog' : 'syslog.service', + 'snmpd' : 'snmpd.service', +} + +internal_ports = { + 'certbot_haproxy' : 65080, # Certbot running behing haproxy } config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' +frr_debug_enable = '/tmp/vyos.frr.debug' cfg_group = 'vyattacfg' @@ -61,3 +75,5 @@ rt_symbolic_names = { rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] + +vyconfd_conf = '/etc/vyos/vyconfd.conf' diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py index 80bb56fa2..4710a5d40 100644 --- a/python/vyos/ethtool.py +++ b/python/vyos/ethtool.py @@ -23,7 +23,7 @@ from vyos.utils.process import popen # flow control settings _drivers_without_speed_duplex_flow = ['vmxnet3', 'virtio_net', 'xen_netfront', 'iavf', 'ice', 'i40e', 'hv_netvsc', 'veth', 'ixgbevf', - 'tun'] + 'tun', 'vif'] class Ethtool: """ @@ -56,11 +56,8 @@ class Ethtool: # '100' : {'full': '', 'half': ''}, # '1000': {'full': ''} # } - _speed_duplex = {'auto': {'auto': ''}} _ring_buffer = None _driver_name = None - _auto_negotiation = False - _auto_negotiation_supported = None _flow_control = None def __init__(self, ifname): @@ -74,56 +71,52 @@ class Ethtool: self._driver_name = driver.group(1) # Build a dictinary of supported link-speed and dupley settings. - out, _ = popen(f'ethtool {ifname}') - reading = False - pattern = re.compile(r'\d+base.*') - for line in out.splitlines()[1:]: - line = line.lstrip() - if 'Supported link modes:' in line: - reading = True - if 'Supported pause frame use:' in line: - reading = False - if reading: - for block in line.split(): - if pattern.search(block): - speed = block.split('base')[0] - duplex = block.split('/')[-1].lower() - if speed not in self._speed_duplex: - self._speed_duplex.update({ speed : {}}) - if duplex not in self._speed_duplex[speed]: - self._speed_duplex[speed].update({ duplex : ''}) - if 'Supports auto-negotiation:' in line: - # Split the following string: Auto-negotiation: off - # we are only interested in off or on - tmp = line.split()[-1] - self._auto_negotiation_supported = bool(tmp == 'Yes') - # Only read in if Auto-negotiation is supported - if self._auto_negotiation_supported and 'Auto-negotiation:' in line: - # Split the following string: Auto-negotiation: off - # we are only interested in off or on - tmp = line.split()[-1] - self._auto_negotiation = bool(tmp == 'on') + # [ { + # "ifname": "eth0", + # "supported-ports": [ "TP" ], + # "supported-link-modes": [ "10baseT/Half","10baseT/Full","100baseT/Half","100baseT/Full","1000baseT/Full" ], + # "supported-pause-frame-use": "Symmetric", + # "supports-auto-negotiation": true, + # "supported-fec-modes": [ ], + # "advertised-link-modes": [ "10baseT/Half","10baseT/Full","100baseT/Half","100baseT/Full","1000baseT/Full" ], + # "advertised-pause-frame-use": "Symmetric", + # "advertised-auto-negotiation": true, + # "advertised-fec-modes": [ ], + # "speed": 1000, + # "duplex": "Full", + # "auto-negotiation": false, + # "port": "Twisted Pair", + # "phyad": 1, + # "transceiver": "internal", + # "supports-wake-on": "pumbg", + # "wake-on": "g", + # "current-message-level": 7, + # "link-detected": true + # } ] + out, _ = popen(f'ethtool --json {ifname}') + self._base_settings = loads(out)[0] # Now populate driver features out, _ = popen(f'ethtool --json --show-features {ifname}') - self._features = loads(out) + self._features = loads(out)[0] # Get information about NIC ring buffers - out, _ = popen(f'ethtool --json --show-ring {ifname}') - self._ring_buffer = loads(out) + out, err = popen(f'ethtool --json --show-ring {ifname}') + if not bool(err): + self._ring_buffer = loads(out)[0] # Get current flow control settings, but this is not supported by # all NICs (e.g. vmxnet3 does not support is) out, err = popen(f'ethtool --json --show-pause {ifname}') if not bool(err): - self._flow_control = loads(out) + self._flow_control = loads(out)[0] def check_auto_negotiation_supported(self): """ Check if the NIC supports changing auto-negotiation """ - return self._auto_negotiation_supported + return self._base_settings['supports-auto-negotiation'] def get_auto_negotiation(self): - return self._auto_negotiation_supported and self._auto_negotiation + return self._base_settings['supports-auto-negotiation'] and self._base_settings['auto-negotiation'] def get_driver_name(self): return self._driver_name @@ -137,9 +130,9 @@ class Ethtool: """ active = False fixed = True - if feature in self._features[0]: - active = bool(self._features[0][feature]['active']) - fixed = bool(self._features[0][feature]['fixed']) + if feature in self._features: + active = bool(self._features[feature]['active']) + fixed = bool(self._features[feature]['fixed']) return active, fixed def get_generic_receive_offload(self): @@ -165,14 +158,14 @@ class Ethtool: # thus when it's impossible return None if rx_tx not in ['rx', 'tx']: ValueError('Ring-buffer type must be either "rx" or "tx"') - return str(self._ring_buffer[0].get(f'{rx_tx}-max', None)) + return str(self._ring_buffer.get(f'{rx_tx}-max', None)) def get_ring_buffer(self, rx_tx): # Configuration of RX/TX ring-buffers is not supported on every device, # thus when it's impossible return None if rx_tx not in ['rx', 'tx']: ValueError('Ring-buffer type must be either "rx" or "tx"') - return str(self._ring_buffer[0].get(rx_tx, None)) + return str(self._ring_buffer.get(rx_tx, None)) def check_speed_duplex(self, speed, duplex): """ Check if the passed speed and duplex combination is supported by @@ -184,12 +177,16 @@ class Ethtool: if duplex not in ['auto', 'full', 'half']: raise ValueError(f'Value "{duplex}" for duplex is invalid!') + if speed == 'auto' and duplex == 'auto': + return True + if self.get_driver_name() in _drivers_without_speed_duplex_flow: return False - if speed in self._speed_duplex: - if duplex in self._speed_duplex[speed]: - return True + # ['10baset/half', '10baset/full', '100baset/half', '100baset/full', '1000baset/full'] + tmp = [x.lower() for x in self._base_settings['supported-link-modes']] + if f'{speed}baset/{duplex}' in tmp: + return True return False def check_flow_control(self): @@ -201,4 +198,4 @@ class Ethtool: raise ValueError('Interface does not support changing '\ 'flow-control settings!') - return 'on' if bool(self._flow_control[0]['autonegotiate']) else 'off' + return 'on' if bool(self._flow_control['autonegotiate']) else 'off' diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 64fed8177..64022db84 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -53,25 +53,32 @@ def conntrack_required(conf): # Domain Resolver -def fqdn_config_parse(firewall): - firewall['ip_fqdn'] = {} - firewall['ip6_fqdn'] = {} - - for domain, path in dict_search_recursive(firewall, 'fqdn'): - hook_name = path[1] - priority = path[2] - - fw_name = path[2] - rule = path[4] - suffix = path[5][0] - set_name = f'{hook_name}_{priority}_{rule}_{suffix}' - - if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): - firewall['ip_fqdn'][set_name] = domain - elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): - if path[1] == 'name': - set_name = f'name6_{priority}_{rule}_{suffix}' - firewall['ip6_fqdn'][set_name] = domain +def fqdn_config_parse(config, node): + config['ip_fqdn'] = {} + config['ip6_fqdn'] = {} + + for domain, path in dict_search_recursive(config, 'fqdn'): + if node != 'nat': + hook_name = path[1] + priority = path[2] + + rule = path[4] + suffix = path[5][0] + set_name = f'{hook_name}_{priority}_{rule}_{suffix}' + + if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): + config['ip_fqdn'][set_name] = domain + elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): + if path[1] == 'name': + set_name = f'name6_{priority}_{rule}_{suffix}' + config['ip6_fqdn'][set_name] = domain + else: + # Parse FQDN for NAT + nat_direction = path[0] + nat_rule = path[2] + suffix = path[3][0] + set_name = f'{nat_direction}_{nat_rule}_{suffix}' + config['ip_fqdn'][set_name] = domain def fqdn_resolve(fqdn, ipv6=False): try: @@ -80,8 +87,6 @@ def fqdn_resolve(fqdn, ipv6=False): except: return None -# End Domain Resolver - def find_nftables_rule(table, chain, rule_matches=[]): # Find rule in table/chain that matches all criteria and return the handle results = cmd(f'sudo nft --handle list chain {table} {chain}').split("\n") @@ -228,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: @@ -305,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 = '' @@ -456,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}') @@ -578,6 +596,12 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if 'tcp_mss' in rule_conf['set']: mss = rule_conf['set']['tcp_mss'] output.append(f'tcp option maxseg size set {mss}') + if 'ttl' in rule_conf['set']: + ttl = rule_conf['set']['ttl'] + output.append(f'ip ttl set {ttl}') + if 'hop_limit' in rule_conf['set']: + hoplimit = rule_conf['set']['hop_limit'] + output.append(f'ip6 hoplimit set {hoplimit}') if 'action' in rule_conf: if rule_conf['action'] == 'offload': @@ -616,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, @@ -627,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] @@ -720,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): @@ -742,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: @@ -767,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/frr.py b/python/vyos/frr.py deleted file mode 100644 index 6fb81803f..000000000 --- a/python/vyos/frr.py +++ /dev/null @@ -1,551 +0,0 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this library. If not, see <http://www.gnu.org/licenses/>. - -r""" -A Library for interracting with the FRR daemon suite. -It supports simple configuration manipulation and loading using the official tools -supplied with FRR (vtysh and frr-reload) - -All configuration management and manipulation is done using strings and regex. - - -Example Usage -##### - -# Reading configuration from frr: -``` ->>> original_config = get_configuration() ->>> repr(original_config) -'!\nfrr version 7.3.1\nfrr defaults traditional\nhostname debian\n...... -``` - - -# Modify a configuration section: -``` ->>> new_bgp_section = 'router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n' ->>> modified_config = replace_section(original_config, new_bgp_section, replace_re=r'router bgp \d+') ->>> repr(modified_config) -'............router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n...........' -``` - -Remove a configuration section: -``` ->>> modified_config = remove_section(original_config, r'router ospf') -``` - -Test the new configuration: -``` ->>> try: ->>> mark_configuration(modified configuration) ->>> except ConfigurationNotValid as e: ->>> print('resulting configuration is not valid') ->>> sys.exit(1) -``` - -Apply the new configuration: -``` ->>> try: ->>> replace_configuration(modified_config) ->>> except CommitError as e: ->>> print('Exception while commiting the supplied configuration') ->>> print(e) ->>> exit(1) -``` -""" - -import tempfile -import re - -from vyos import ConfigError -from vyos.utils.process import cmd -from vyos.utils.process import popen -from vyos.utils.process import STDOUT - -import logging -from logging.handlers import SysLogHandler -import os -import sys - -LOG = logging.getLogger(__name__) -DEBUG = False - -ch = SysLogHandler(address='/dev/log') -ch2 = logging.StreamHandler(stream=sys.stdout) -LOG.addHandler(ch) -LOG.addHandler(ch2) - -_frr_daemons = ['zebra', 'staticd', 'bgpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', - 'isisd', 'pimd', 'pim6d', 'ldpd', 'eigrpd', 'babeld', 'bfdd', 'fabricd'] - -path_vtysh = '/usr/bin/vtysh' -path_frr_reload = '/usr/lib/frr/frr-reload.py' -path_config = '/run/frr' - -default_add_before = r'(ip prefix-list .*|route-map .*|line vty|end)' - - -class FrrError(Exception): - pass - - -class ConfigurationNotValid(FrrError): - """ - The configuratioin supplied to vtysh is not valid - """ - pass - - -class CommitError(FrrError): - """ - Commiting the supplied configuration failed to commit by a unknown reason - see commit error and/or run mark_configuration on the specified configuration - to se error generated - - used by: reload_configuration() - """ - pass - - -class ConfigSectionNotFound(FrrError): - """ - Removal of configuration failed because it is not existing in the supplied configuration - """ - pass - -def init_debugging(): - global DEBUG - - DEBUG = os.path.exists('/tmp/vyos.frr.debug') - if DEBUG: - LOG.setLevel(logging.DEBUG) - -def get_configuration(daemon=None, marked=False): - """ Get current running FRR configuration - daemon: Collect only configuration for the specified FRR daemon, - supplying daemon=None retrieves the complete configuration - marked: Mark the configuration with "end" tags - - return: string containing the running configuration from frr - - """ - if daemon and daemon not in _frr_daemons: - raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') - - cmd = f"{path_vtysh} -c 'show run'" - if daemon: - cmd += f' -d {daemon}' - - output, code = popen(cmd, stderr=STDOUT) - if code: - raise OSError(code, output) - - config = output.replace('\r', '') - # Remove first header lines from FRR config - config = config.split("\n", 3)[-1] - # Mark the configuration with end tags - if marked: - config = mark_configuration(config) - - return config - - -def mark_configuration(config): - """ Add end marks and Test the configuration for syntax faults - If the configuration is valid a marked version of the configuration is returned, - or else it failes with a ConfigurationNotValid Exception - - config: The configuration string to mark/test - return: The marked configuration from FRR - """ - output, code = popen(f"{path_vtysh} -m -f -", stderr=STDOUT, input=config) - - if code == 2: - raise ConfigurationNotValid(str(output)) - elif code: - raise OSError(code, output) - - config = output.replace('\r', '') - return config - - -def reload_configuration(config, daemon=None): - """ Execute frr-reload with the new configuration - This will try to reapply the supplied configuration inside FRR. - The configuration needs to be a complete configuration from the integrated config or - from a daemon. - - config: The configuration to apply - daemon: Apply the conigutaion to the specified FRR daemon, - supplying daemon=None applies to the integrated configuration - return: None - """ - if daemon and daemon not in _frr_daemons: - raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') - - f = tempfile.NamedTemporaryFile('w') - f.write(config) - f.flush() - - LOG.debug(f'reload_configuration: Reloading config using temporary file: {f.name}') - cmd = f'{path_frr_reload} --reload' - if daemon: - cmd += f' --daemon {daemon}' - - if DEBUG: - cmd += f' --debug --stdout' - - cmd += f' {f.name}' - - LOG.debug(f'reload_configuration: Executing command against frr-reload: "{cmd}"') - output, code = popen(cmd, stderr=STDOUT) - f.close() - - for i, e in enumerate(output.split('\n')): - LOG.debug(f'frr-reload output: {i:3} {e}') - - if code == 1: - raise ConfigError(output) - elif code: - raise OSError(code, output) - - return output - - -def save_configuration(): - """ T3217: Save FRR configuration to /run/frr/config/frr.conf """ - return cmd(f'{path_vtysh} -n -w') - - -def execute(command): - """ Run commands inside vtysh - command: str containing commands to execute inside a vtysh session - """ - if not isinstance(command, str): - raise ValueError(f'command needs to be a string: {repr(command)}') - - cmd = f"{path_vtysh} -c '{command}'" - - output, code = popen(cmd, stderr=STDOUT) - if code: - raise OSError(code, output) - - config = output.replace('\r', '') - return config - - -def configure(lines, daemon=False): - """ run commands inside config mode vtysh - lines: list or str conaining commands to execute inside a configure session - only one command executed on each configure() - Executing commands inside a subcontext uses the list to describe the context - ex: ['router bgp 6500', 'neighbor 192.0.2.1 remote-as 65000'] - return: None - """ - if isinstance(lines, str): - lines = [lines] - elif not isinstance(lines, list): - raise ValueError('lines needs to be string or list of commands') - - if daemon and daemon not in _frr_daemons: - raise ValueError(f'The specified daemon type is not supported {repr(daemon)}') - - cmd = f'{path_vtysh}' - if daemon: - cmd += f' -d {daemon}' - - cmd += " -c 'configure terminal'" - for x in lines: - cmd += f" -c '{x}'" - - output, code = popen(cmd, stderr=STDOUT) - if code == 1: - raise ConfigurationNotValid(f'Configuration FRR failed: {repr(output)}') - elif code: - raise OSError(code, output) - - config = output.replace('\r', '') - return config - - -def _replace_section(config, replacement, replace_re, before_re): - r"""Replace a section of FRR config - config: full original configuration - replacement: replacement configuration section - replace_re: The regex to replace - example: ^router bgp \d+$.?*^!$ - this will replace everything between ^router bgp X$ and ^!$ - before_re: When replace_re is not existant, the config will be added before this tag - example: ^line vty$ - - return: modified configuration as a text file - """ - # DEPRECATED, this is replaced by a new implementation - # Check if block is configured, remove the existing instance else add a new one - if re.findall(replace_re, config, flags=re.MULTILINE | re.DOTALL): - # Section is in the configration, replace it - return re.sub(replace_re, replacement, config, count=1, - flags=re.MULTILINE | re.DOTALL) - if before_re: - if not re.findall(before_re, config, flags=re.MULTILINE | re.DOTALL): - raise ConfigSectionNotFound(f"Config section {before_re} not found in config") - - # If no section is in the configuration, add it before the line vty line - return re.sub(before_re, rf'{replacement}\n\g<1>', config, count=1, - flags=re.MULTILINE | re.DOTALL) - - raise ConfigSectionNotFound(f"Config section {replacement} not found in config") - - -def replace_section(config, replacement, from_re, to_re=r'!', before_re=r'line vty'): - r"""Replace a section of FRR config - config: full original configuration - replacement: replacement configuration section - from_re: Regex for the start of section matching - example: 'router bgp \d+' - to_re: Regex for stop of section matching - default: '!' - example: '!' or 'end' - before_re: When from_re/to_re does not return a match, the config will - be added before this tag - default: ^line vty$ - - startline and endline tags will be automatically added to the resulting from_re/to_re and before_re regex'es - """ - # DEPRECATED, this is replaced by a new implementation - return _replace_section(config, replacement, replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=rf'^({before_re})$') - - -def remove_section(config, from_re, to_re='!'): - # DEPRECATED, this is replaced by a new implementation - return _replace_section(config, '', replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=None) - - -def _find_first_block(config, start_pattern, stop_pattern, start_at=0): - '''Find start and stop line numbers for a config block - config: (list) A list conaining the configuration that is searched - start_pattern: (raw-str) The pattern searched for a a start of block tag - stop_pattern: (raw-str) The pattern searched for to signify the end of the block - start_at: (int) The index to start searching at in the <config> - - Returns: - None: No complete block could be found - set(int, int): A complete block found between the line numbers returned in the set - - The object <config> is searched from the start for the regex <start_pattern> until the first match is found. - On a successful match it continues the search for the regex <stop_pattern> until it is found. - After a successful run a set is returned containing the start and stop line numbers. - ''' - LOG.debug(f'_find_first_block: find start={repr(start_pattern)} stop={repr(stop_pattern)} start_at={start_at}') - _start = None - for i, element in enumerate(config[start_at:], start=start_at): - # LOG.debug(f'_find_first_block: running line {i:3} "{element}"') - if not _start: - if not re.match(start_pattern, element): - LOG.debug(f'_find_first_block: no match {i:3} "{element}"') - continue - _start = i - LOG.debug(f'_find_first_block: Found start {i:3} "{element}"') - continue - - if not re.match(stop_pattern, element): - LOG.debug(f'_find_first_block: no match {i:3} "{element}"') - continue - - LOG.debug(f'_find_first_block: Found stop {i:3} "{element}"') - return (_start, i) - - LOG.debug('_find_first_block: exit start={repr(start_pattern)} stop={repr(stop_pattern)} start_at={start_at}') - return None - - -def _find_first_element(config, pattern, start_at=0): - '''Find the first element that matches the current pattern in config - config: (list) A list containing the configuration that is searched - start_pattern: (raw-str) The pattern searched for - start_at: (int) The index to start searching at in the <config> - - return: Line index of the line containing the searched pattern - - TODO: for now it returns -1 on a no-match because 0 also returns as False - TODO: that means that we can not use False matching to tell if its - ''' - LOG.debug(f'_find_first_element: find start="{pattern}" start_at={start_at}') - for i, element in enumerate(config[start_at:], start=0): - if re.match(pattern + '$', element): - LOG.debug(f'_find_first_element: Found stop {i:3} "{element}"') - return i - LOG.debug(f'_find_first_element: no match {i:3} "{element}"') - LOG.debug(f'_find_first_element: Did not find any match, exiting') - return -1 - - -def _find_elements(config, pattern, start_at=0): - '''Find all instances of pattern and return a list containing all element indexes - config: (list) A list containing the configuration that is searched - start_pattern: (raw-str) The pattern searched for - start_at: (int) The index to start searching at in the <config> - - return: A list of line indexes containing the searched pattern - TODO: refactor this to return a generator instead - ''' - return [i for i, element in enumerate(config[start_at:], start=0) if re.match(pattern + '$', element)] - - -class FRRConfig: - '''Main FRR Configuration manipulation object - Using this object the user could load, manipulate and commit the configuration to FRR - ''' - def __init__(self, config=[]): - self.imported_config = '' - - if isinstance(config, list): - self.config = config.copy() - self.original_config = config.copy() - elif isinstance(config, str): - self.config = config.split('\n') - self.original_config = self.config.copy() - else: - raise ValueError( - 'The config element needs to be a string or list type object') - - if config: - LOG.debug(f'__init__: frr library initiated with initial config') - for i, e in enumerate(self.config): - LOG.debug(f'__init__: initial {i:3} {e}') - - def load_configuration(self, daemon=None): - '''Load the running configuration from FRR into the config object - daemon: str with name of the FRR Daemon to load configuration from or - None to load the consolidated config - - Using this overwrites the current loaded config objects and replaces the original loaded config - ''' - init_debugging() - - self.imported_config = get_configuration(daemon=daemon) - if daemon: - LOG.debug(f'load_configuration: Configuration loaded from FRR daemon {daemon}') - else: - LOG.debug(f'load_configuration: Configuration loaded from FRR integrated config') - - self.original_config = self.imported_config.split('\n') - self.config = self.original_config.copy() - - for i, e in enumerate(self.imported_config.split('\n')): - LOG.debug(f'load_configuration: loaded {i:3} {e}') - return - - def test_configuration(self): - '''Test the current configuration against FRR - This will exception if FRR failes to load the current configuration object - ''' - LOG.debug('test_configation: Testing configuration') - mark_configuration('\n'.join(self.config)) - - def commit_configuration(self, daemon=None): - ''' - Commit the current configuration to FRR daemon: str with name of the - FRR daemon to commit to or None to use the consolidated config. - - Configuration is automatically saved after apply - ''' - LOG.debug('commit_configuration: Commiting configuration') - for i, e in enumerate(self.config): - LOG.debug(f'commit_configuration: new_config {i:3} {e}') - - # https://github.com/FRRouting/frr/issues/10132 - # https://github.com/FRRouting/frr/issues/10133 - count = 0 - count_max = 5 - emsg = '' - while count < count_max: - count += 1 - try: - reload_configuration('\n'.join(self.config), daemon=daemon) - break - except ConfigError as e: - emsg = str(e) - except: - # we just need to re-try the commit of the configuration - # for the listed FRR issues above - pass - if count >= count_max: - if emsg: - raise ConfigError(emsg) - raise ConfigurationNotValid(f'Config commit retry counter ({count_max}) exceeded for {daemon} daemon!') - - # Save configuration to /run/frr/config/frr.conf - save_configuration() - - - def modify_section(self, start_pattern, replacement='!', stop_pattern=r'\S+', remove_stop_mark=False, count=0): - if isinstance(replacement, str): - replacement = replacement.split('\n') - elif not isinstance(replacement, list): - return ValueError("The replacement element needs to be a string or list type object") - LOG.debug(f'modify_section: starting search for {repr(start_pattern)} until {repr(stop_pattern)}') - - _count = 0 - _next_start = 0 - while True: - if count and count <= _count: - # Break out of the loop after specified amount of matches - LOG.debug(f'modify_section: reached limit ({_count}), exiting loop at line {_next_start}') - break - # While searching, always assume that the user wants to search for the exact pattern he entered - # To be more specific the user needs a override, eg. a "pattern.*" - _w = _find_first_block( - self.config, start_pattern+'$', stop_pattern, start_at=_next_start) - if not _w: - # Reached the end, no more elements to remove - LOG.debug(f'modify_section: No more config sections found, exiting') - break - start_element, end_element = _w - LOG.debug(f'modify_section: found match between {start_element} and {end_element}') - for i, e in enumerate(self.config[start_element:end_element+1 if remove_stop_mark else end_element], - start=start_element): - LOG.debug(f'modify_section: remove {i:3} {e}') - del self.config[start_element:end_element + - 1 if remove_stop_mark else end_element] - if replacement: - # Append the replacement config at the current position - for i, e in enumerate(replacement, start=start_element): - LOG.debug(f'modify_section: add {i:3} {e}') - self.config[start_element:start_element] = replacement - _count += 1 - _next_start = start_element + len(replacement) - - return _count - - def add_before(self, before_pattern, addition): - '''Add config block before this element in the configuration''' - if isinstance(addition, str): - addition = addition.split('\n') - elif not isinstance(addition, list): - return ValueError("The replacement element needs to be a string or list type object") - - start = _find_first_element(self.config, before_pattern) - if start < 0: - return False - for i, e in enumerate(addition, start=start): - LOG.debug(f'add_before: add {i:3} {e}') - self.config[start:start] = addition - return True - - def __str__(self): - return '\n'.join(self.config) - - def __repr__(self): - return f'frr({repr(str(self))})' diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py new file mode 100644 index 000000000..73d6dd5f0 --- /dev/null +++ b/python/vyos/frrender.py @@ -0,0 +1,756 @@ +# Copyright 2024-2025 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/>. + +""" +Library used to interface with FRRs mgmtd introduced in version 10.0 +""" + +import os + +from time import sleep + +from vyos.defaults import frr_debug_enable +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.template import render_to_string +from vyos import ConfigError + +def debug(message): + if not os.path.exists(frr_debug_enable): + return + print(message) + +frr_protocols = ['babel', 'bfd', 'bgp', 'eigrp', 'isis', 'mpls', 'nhrp', + 'openfabric', 'ospf', 'ospfv3', 'pim', 'pim6', 'rip', + 'ripng', 'rpki', 'segment_routing', 'static'] + +babel_daemon = 'babeld' +bfd_daemon = 'bfdd' +bgp_daemon = 'bgpd' +isis_daemon = 'isisd' +ldpd_daemon = 'ldpd' +mgmt_daemon = 'mgmtd' +openfabric_daemon = 'fabricd' +ospf_daemon = 'ospfd' +ospf6_daemon = 'ospf6d' +pim_daemon = 'pimd' +pim6_daemon = 'pim6d' +rip_daemon = 'ripd' +ripng_daemon = 'ripngd' +zebra_daemon = 'zebra' +nhrp_daemon = 'nhrpd' + +def get_frrender_dict(conf, argv=None) -> dict: + from copy import deepcopy + from vyos.config import config_dict_merge + 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 = {} + + if argv and len(argv) > 1: + dict['vrf_context'] = argv[1] + + def dict_helper_ospf_defaults(ospf, path): + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(path, key_mangling=('-', '_'), + get_first_key=True, recursive=True) + + # We have to cleanup the default dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: default-information + # originate comes with a default metric-type of 2, which will enable the + # entire default-information originate tree, even when not set via CLI so we + # need to check this first and probably drop that key. + if dict_search('default_information.originate', ospf) is None: + del default_values['default_information'] + if 'mpls_te' not in ospf: + del default_values['mpls_te'] + if 'graceful_restart' not in ospf: + del default_values['graceful_restart'] + for area_num in default_values.get('area', []): + 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', 'nhrp', 'rip', 'static']: + if dict_search(f'redistribute.{protocol}', ospf) is None: + del default_values['redistribute'][protocol] + if not bool(default_values['redistribute']): + del default_values['redistribute'] + + for interface in ospf.get('interface', []): + # We need to reload the defaults on every pass b/c of + # hello-multiplier dependency on dead-interval + # If hello-multiplier is set, we need to remove the default from + # dead-interval. + if 'hello_multiplier' in ospf['interface'][interface]: + del default_values['interface'][interface]['dead_interval'] + + ospf = config_dict_merge(default_values, ospf) + return ospf + + def dict_helper_ospfv3_defaults(ospfv3, path): + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(path, key_mangling=('-', '_'), + get_first_key=True, recursive=True) + + # We have to cleanup the default dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: default-information + # originate comes with a default metric-type of 2, which will enable the + # entire default-information originate tree, even when not set via CLI so we + # need to check this first and probably drop that key. + if dict_search('default_information.originate', ospfv3) is None: + del default_values['default_information'] + if 'graceful_restart' not in ospfv3: + del default_values['graceful_restart'] + + for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'ripng', 'static']: + if dict_search(f'redistribute.{protocol}', ospfv3) is None: + del default_values['redistribute'][protocol] + if not bool(default_values['redistribute']): + del default_values['redistribute'] + + default_values.pop('interface', {}) + + # merge in remaining default values + ospfv3 = config_dict_merge(default_values, ospfv3) + return ospfv3 + + def dict_helper_pim_defaults(pim, path): + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(path, key_mangling=('-', '_'), + get_first_key=True, recursive=True) + + # We have to cleanup the default dict, as default values could enable features + # which are not explicitly enabled on the CLI. + for interface in pim.get('interface', []): + if 'igmp' not in pim['interface'][interface]: + del default_values['interface'][interface]['igmp'] + + pim = config_dict_merge(default_values, pim) + return pim + + def dict_helper_nhrp_defaults(nhrp): + # NFLOG group numbers which are used in netfilter firewall rules and + # in the global config in FRR. + # https://docs.frrouting.org/en/latest/nhrpd.html#hub-functionality + # https://docs.frrouting.org/en/latest/nhrpd.html#multicast-functionality + # Use nflog group number for NHRP redirects = 1 + # Use nflog group number from MULTICAST traffic = 2 + nflog_redirect = 1 + nflog_multicast = 2 + + nhrp = conf.merge_defaults(nhrp, recursive=True) + + nhrp_tunnel = conf.get_config_dict(['interfaces', 'tunnel'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + if nhrp_tunnel: nhrp.update({'if_tunnel': nhrp_tunnel}) + + for intf, intf_config in nhrp['tunnel'].items(): + if 'multicast' in intf_config: + nhrp['multicast'] = nflog_multicast + if 'redirect' in intf_config: + nhrp['redirect'] = nflog_redirect + + ##Add ipsec profile config to nhrp configuration to apply encryption + profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + for name, profile_conf in profile.items(): + if 'disable' in profile_conf: + continue + if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: + interfaces = profile_conf['bind']['tunnel'] + if isinstance(interfaces, str): + interfaces = [interfaces] + for interface in interfaces: + if dict_search(f'tunnel.{interface}', nhrp): + nhrp['tunnel'][interface][ + 'security_profile'] = name + return nhrp + + # Ethernet and bonding interfaces can participate in EVPN which is configured via FRR + tmp = {} + for if_type in ['ethernet', 'bonding']: + interface_path = ['interfaces', if_type] + if not conf.exists(interface_path): + continue + for interface in conf.list_nodes(interface_path): + evpn_path = interface_path + [interface, 'evpn'] + if not conf.exists(evpn_path): + continue + + evpn = conf.get_config_dict(evpn_path, key_mangling=('-', '_')) + tmp.update({interface : evpn}) + # At least one participating EVPN interface found, add to result dict + if tmp: dict['interfaces'] = tmp + + # Zebra prefix exchange for Kernel IP/IPv6 and routing protocols + for ip_version in ['ip', 'ipv6']: + ip_cli_path = ['system', ip_version] + ip_dict = conf.get_config_dict(ip_cli_path, key_mangling=('-', '_'), + get_first_key=True, with_recursive_defaults=True) + if ip_dict: + ip_dict['afi'] = ip_version + dict.update({ip_version : ip_dict}) + + # Enable SNMP agentx support + # SNMP AgentX support cannot be disabled once enabled + if conf.exists(['service', 'snmp']): + dict['snmp'] = {} + + # We will always need the policy key + dict['policy'] = conf.get_config_dict(['policy'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # We need to check the CLI if the BABEL node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + babel_cli_path = ['protocols', 'babel'] + if conf.exists(babel_cli_path): + babel = conf.get_config_dict(babel_cli_path, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + dict.update({'babel' : babel}) + + # We need to check the CLI if the BFD node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + bfd_cli_path = ['protocols', 'bfd'] + if conf.exists(bfd_cli_path): + bfd = conf.get_config_dict(bfd_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + dict.update({'bfd' : bfd}) + + # We need to check the CLI if the BGP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + bgp_cli_path = ['protocols', 'bgp'] + if conf.exists(bgp_cli_path): + bgp = conf.get_config_dict(bgp_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + bgp['dependent_vrfs'] = {} + dict.update({'bgp' : bgp}) + elif conf.exists_effective(bgp_cli_path): + dict.update({'bgp' : {'deleted' : '', 'dependent_vrfs' : {}}}) + + # We need to check the CLI if the EIGRP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + eigrp_cli_path = ['protocols', 'eigrp'] + if conf.exists(eigrp_cli_path): + eigrp = conf.get_config_dict(eigrp_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + dict.update({'eigrp' : eigrp}) + elif conf.exists_effective(eigrp_cli_path): + dict.update({'eigrp' : {'deleted' : ''}}) + + # We need to check the CLI if the ISIS node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + isis_cli_path = ['protocols', 'isis'] + if conf.exists(isis_cli_path): + isis = conf.get_config_dict(isis_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + dict.update({'isis' : isis}) + elif conf.exists_effective(isis_cli_path): + dict.update({'isis' : {'deleted' : ''}}) + + # We need to check the CLI if the MPLS node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + mpls_cli_path = ['protocols', 'mpls'] + if conf.exists(mpls_cli_path): + mpls = conf.get_config_dict(mpls_cli_path, key_mangling=('-', '_'), + get_first_key=True) + dict.update({'mpls' : mpls}) + elif conf.exists_effective(mpls_cli_path): + dict.update({'mpls' : {'deleted' : ''}}) + + # We need to check the CLI if the OPENFABRIC node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + openfabric_cli_path = ['protocols', 'openfabric'] + if conf.exists(openfabric_cli_path): + openfabric = conf.get_config_dict(openfabric_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + dict.update({'openfabric' : openfabric}) + elif conf.exists_effective(openfabric_cli_path): + dict.update({'openfabric' : {'deleted' : ''}}) + + # We need to check the CLI if the OSPF node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + ospf_cli_path = ['protocols', 'ospf'] + if conf.exists(ospf_cli_path): + ospf = conf.get_config_dict(ospf_cli_path, key_mangling=('-', '_'), + get_first_key=True) + ospf = dict_helper_ospf_defaults(ospf, ospf_cli_path) + dict.update({'ospf' : ospf}) + elif conf.exists_effective(ospf_cli_path): + dict.update({'ospf' : {'deleted' : ''}}) + + # We need to check the CLI if the OSPFv3 node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + ospfv3_cli_path = ['protocols', 'ospfv3'] + if conf.exists(ospfv3_cli_path): + ospfv3 = conf.get_config_dict(ospfv3_cli_path, key_mangling=('-', '_'), + get_first_key=True) + ospfv3 = dict_helper_ospfv3_defaults(ospfv3, ospfv3_cli_path) + dict.update({'ospfv3' : ospfv3}) + elif conf.exists_effective(ospfv3_cli_path): + dict.update({'ospfv3' : {'deleted' : ''}}) + + # We need to check the CLI if the PIM node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + pim_cli_path = ['protocols', 'pim'] + if conf.exists(pim_cli_path): + pim = conf.get_config_dict(pim_cli_path, key_mangling=('-', '_'), + get_first_key=True) + pim = dict_helper_pim_defaults(pim, pim_cli_path) + dict.update({'pim' : pim}) + elif conf.exists_effective(pim_cli_path): + dict.update({'pim' : {'deleted' : ''}}) + + # We need to check the CLI if the PIM6 node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + pim6_cli_path = ['protocols', 'pim6'] + if conf.exists(pim6_cli_path): + pim6 = conf.get_config_dict(pim6_cli_path, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + dict.update({'pim6' : pim6}) + elif conf.exists_effective(pim6_cli_path): + dict.update({'pim6' : {'deleted' : ''}}) + + # We need to check the CLI if the RIP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + rip_cli_path = ['protocols', 'rip'] + if conf.exists(rip_cli_path): + rip = conf.get_config_dict(rip_cli_path, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + dict.update({'rip' : rip}) + elif conf.exists_effective(rip_cli_path): + dict.update({'rip' : {'deleted' : ''}}) + + # We need to check the CLI if the RIPng node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + ripng_cli_path = ['protocols', 'ripng'] + if conf.exists(ripng_cli_path): + ripng = conf.get_config_dict(ripng_cli_path, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + dict.update({'ripng' : ripng}) + elif conf.exists_effective(ripng_cli_path): + dict.update({'ripng' : {'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_cli_path = ['protocols', 'rpki'] + if conf.exists(rpki_cli_path): + rpki = conf.get_config_dict(rpki_cli_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}' + dict.update({'rpki' : rpki}) + elif conf.exists_effective(rpki_cli_path): + dict.update({'rpki' : {'deleted' : ''}}) + + # We need to check the CLI if the Segment Routing node is present and thus load in + # all the default values present on the CLI - that's why we have if conf.exists() + sr_cli_path = ['protocols', 'segment-routing'] + if conf.exists(sr_cli_path): + sr = conf.get_config_dict(sr_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + dict.update({'segment_routing' : sr}) + elif conf.exists_effective(sr_cli_path): + dict.update({'segment_routing' : {'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_cli_path = ['protocols', 'static'] + if conf.exists(static_cli_path): + static = conf.get_config_dict(static_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + dict.update({'static' : static}) + elif conf.exists_effective(static_cli_path): + dict.update({'static' : {'deleted' : ''}}) + + # We need to check the CLI if the NHRP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + nhrp_cli_path = ['protocols', 'nhrp'] + if conf.exists(nhrp_cli_path): + nhrp = conf.get_config_dict(nhrp_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + nhrp = dict_helper_nhrp_defaults(nhrp) + dict.update({'nhrp' : nhrp}) + elif conf.exists_effective(nhrp_cli_path): + dict.update({'nhrp' : {'deleted' : ''}}) + + # T3680 - get a list of all interfaces currently configured to use DHCP + tmp = get_dhcp_interfaces(conf) + if tmp: + if 'static' in dict: + dict['static'].update({'dhcp' : tmp}) + else: + dict.update({'static' : {'dhcp' : tmp}}) + tmp = get_pppoe_interfaces(conf) + if tmp: + if 'static' in dict: + dict['static'].update({'pppoe' : tmp}) + else: + dict.update({'static' : {'pppoe' : tmp}}) + + # keep a re-usable list of dependent VRFs + dependent_vrfs_default = {} + if 'bgp' in dict: + dependent_vrfs_default = deepcopy(dict['bgp']) + # we do not need to nest the 'dependent_vrfs' key - simply remove it + if 'dependent_vrfs' in dependent_vrfs_default: + del dependent_vrfs_default['dependent_vrfs'] + + vrf_cli_path = ['vrf', 'name'] + if conf.exists(vrf_cli_path): + vrf = conf.get_config_dict(vrf_cli_path, key_mangling=('-', '_'), + get_first_key=False, + no_tag_node_value_mangle=True) + # We do not have any VRF related default values on the CLI. The defaults will only + # come into place under the protocols tree, thus we can safely merge them with the + # appropriate routing protocols + for vrf_name, vrf_config in vrf['name'].items(): + bgp_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'bgp'] + if 'bgp' in vrf_config.get('protocols', []): + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(bgp_vrf_path, key_mangling=('-', '_'), + get_first_key=True, recursive=True) + + # merge in remaining default values + vrf_config['protocols']['bgp'] = config_dict_merge(default_values, + vrf_config['protocols']['bgp']) + + # Add this BGP VRF instance as dependency into the default VRF + if 'bgp' in dict: + dict['bgp']['dependent_vrfs'].update({vrf_name : deepcopy(vrf_config)}) + + vrf_config['protocols']['bgp']['dependent_vrfs'] = conf.get_config_dict( + vrf_cli_path, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + # We can safely delete ourself from the dependent VRF list + if vrf_name in vrf_config['protocols']['bgp']['dependent_vrfs']: + del vrf_config['protocols']['bgp']['dependent_vrfs'][vrf_name] + + # Add dependency on possible existing default VRF to this VRF + if 'bgp' in dict: + vrf_config['protocols']['bgp']['dependent_vrfs'].update({'default': {'protocols': { + 'bgp': dependent_vrfs_default}}}) + elif conf.exists_effective(bgp_vrf_path): + # Add this BGP VRF instance as dependency into the default VRF + tmp = {'deleted' : '', 'dependent_vrfs': deepcopy(vrf['name'])} + # We can safely delete ourself from the dependent VRF list + if vrf_name in tmp['dependent_vrfs']: + del tmp['dependent_vrfs'][vrf_name] + + # Add dependency on possible existing default VRF to this VRF + if 'bgp' in dict: + tmp['dependent_vrfs'].update({'default': {'protocols': { + 'bgp': dependent_vrfs_default}}}) + + if 'bgp' in dict: + dict['bgp']['dependent_vrfs'].update({vrf_name : {'protocols': tmp} }) + + if 'protocols' not in vrf['name'][vrf_name]: + vrf['name'][vrf_name].update({'protocols': {'bgp' : tmp}}) + else: + vrf['name'][vrf_name]['protocols'].update({'bgp' : tmp}) + + # We need to check the CLI if the EIGRP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + eigrp_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'eigrp'] + if 'eigrp' in vrf_config.get('protocols', []): + eigrp = conf.get_config_dict(eigrp_vrf_path, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + vrf['name'][vrf_name]['protocols'].update({'eigrp' : isis}) + elif conf.exists_effective(eigrp_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'eigrp' : {'deleted' : ''}}) + + # We need to check the CLI if the ISIS node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + isis_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'isis'] + if 'isis' in vrf_config.get('protocols', []): + isis = conf.get_config_dict(isis_vrf_path, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True, with_recursive_defaults=True) + vrf['name'][vrf_name]['protocols'].update({'isis' : isis}) + elif conf.exists_effective(isis_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'isis' : {'deleted' : ''}}) + + # We need to check the CLI if the OSPF node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + ospf_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'ospf'] + if 'ospf' in vrf_config.get('protocols', []): + ospf = conf.get_config_dict(ospf_vrf_path, key_mangling=('-', '_'), get_first_key=True) + ospf = dict_helper_ospf_defaults(vrf_config['protocols']['ospf'], ospf_vrf_path) + vrf['name'][vrf_name]['protocols'].update({'ospf' : ospf}) + elif conf.exists_effective(ospf_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'ospf' : {'deleted' : ''}}) + + # We need to check the CLI if the OSPFv3 node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + ospfv3_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'ospfv3'] + if 'ospfv3' in vrf_config.get('protocols', []): + ospfv3 = conf.get_config_dict(ospfv3_vrf_path, key_mangling=('-', '_'), get_first_key=True) + ospfv3 = dict_helper_ospfv3_defaults(vrf_config['protocols']['ospfv3'], ospfv3_vrf_path) + vrf['name'][vrf_name]['protocols'].update({'ospfv3' : ospfv3}) + elif conf.exists_effective(ospfv3_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'ospfv3' : {'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'] + if 'static' in vrf_config.get('protocols', []): + static = conf.get_config_dict(static_vrf_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + # T3680 - get a list of all interfaces currently configured to use DHCP + tmp = get_dhcp_interfaces(conf, vrf_name) + if tmp: static.update({'dhcp' : tmp}) + tmp = get_pppoe_interfaces(conf, vrf_name) + if tmp: static.update({'pppoe' : tmp}) + + vrf['name'][vrf_name]['protocols'].update({'static': static}) + elif conf.exists_effective(static_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'static': {'deleted' : ''}}) + + vrf_vni_path = ['vrf', 'name', vrf_name, 'vni'] + if conf.exists(vrf_vni_path): + vrf_config.update({'vni': conf.return_value(vrf_vni_path)}) + + dict.update({'vrf' : vrf}) + elif conf.exists_effective(vrf_cli_path): + effective_vrf = conf.get_config_dict(vrf_cli_path, key_mangling=('-', '_'), + get_first_key=False, + no_tag_node_value_mangle=True, + effective=True) + vrf = {'name' : {}} + for vrf_name, vrf_config in effective_vrf.get('name', {}).items(): + vrf['name'].update({vrf_name : {}}) + for protocol in frr_protocols: + if protocol in vrf_config.get('protocols', []): + # Create initial protocols key if not present + if 'protocols' not in vrf['name'][vrf_name]: + vrf['name'][vrf_name].update({'protocols' : {}}) + # All routing protocols are deleted when we pass this point + tmp = {'deleted' : ''} + + # Special treatment for BGP routing protocol + if protocol == 'bgp': + tmp['dependent_vrfs'] = {} + if 'name' in vrf: + tmp['dependent_vrfs'] = conf.get_config_dict( + vrf_cli_path, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True, + effective=True) + # Add dependency on possible existing default VRF to this VRF + if 'bgp' in dict: + tmp['dependent_vrfs'].update({'default': {'protocols': { + 'bgp': dependent_vrfs_default}}}) + # We can safely delete ourself from the dependent VRF list + if vrf_name in tmp['dependent_vrfs']: + del tmp['dependent_vrfs'][vrf_name] + + # Update VRF related dict + vrf['name'][vrf_name]['protocols'].update({protocol : tmp}) + + 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 + +class FRRender: + cached_config_dict = {} + def __init__(self): + self._frr_conf = '/run/frr/config/vyos.frr.conf' + + def generate(self, config_dict) -> None: + """ + Generate FRR configuration file + Returns False if no changes to configuration were made, otherwise True + """ + if not isinstance(config_dict, dict): + tmp = type(config_dict) + raise ValueError(f'Config must be of type "dict" and not "{tmp}"!') + + + if self.cached_config_dict == config_dict: + debug('FRR: NO CHANGES DETECTED') + return False + self.cached_config_dict = config_dict + + def inline_helper(config_dict) -> str: + output = '!\n' + if 'babel' in config_dict and 'deleted' not in config_dict['babel']: + output += render_to_string('frr/babeld.frr.j2', config_dict['babel']) + output += '\n' + if 'bfd' in config_dict and 'deleted' not in config_dict['bfd']: + output += render_to_string('frr/bfdd.frr.j2', config_dict['bfd']) + output += '\n' + if 'bgp' in config_dict and 'deleted' not in config_dict['bgp']: + output += render_to_string('frr/bgpd.frr.j2', config_dict['bgp']) + output += '\n' + if 'eigrp' in config_dict and 'deleted' not in config_dict['eigrp']: + output += render_to_string('frr/eigrpd.frr.j2', config_dict['eigrp']) + output += '\n' + if 'isis' in config_dict and 'deleted' not in config_dict['isis']: + output += render_to_string('frr/isisd.frr.j2', config_dict['isis']) + output += '\n' + if 'mpls' in config_dict and 'deleted' not in config_dict['mpls']: + output += render_to_string('frr/ldpd.frr.j2', config_dict['mpls']) + output += '\n' + if 'openfabric' in config_dict and 'deleted' not in config_dict['openfabric']: + output += render_to_string('frr/fabricd.frr.j2', config_dict['openfabric']) + output += '\n' + if 'ospf' in config_dict and 'deleted' not in config_dict['ospf']: + output += render_to_string('frr/ospfd.frr.j2', config_dict['ospf']) + output += '\n' + if 'ospfv3' in config_dict and 'deleted' not in config_dict['ospfv3']: + output += render_to_string('frr/ospf6d.frr.j2', config_dict['ospfv3']) + output += '\n' + if 'pim' in config_dict and 'deleted' not in config_dict['pim']: + output += render_to_string('frr/pimd.frr.j2', config_dict['pim']) + output += '\n' + if 'pim6' in config_dict and 'deleted' not in config_dict['pim6']: + output += render_to_string('frr/pim6d.frr.j2', config_dict['pim6']) + output += '\n' + if 'policy' in config_dict and len(config_dict['policy']) > 0: + output += render_to_string('frr/policy.frr.j2', config_dict['policy']) + output += '\n' + if 'rip' in config_dict and 'deleted' not in config_dict['rip']: + output += render_to_string('frr/ripd.frr.j2', config_dict['rip']) + output += '\n' + if 'ripng' in config_dict and 'deleted' not in config_dict['ripng']: + 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 += '\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']) + output += '\n' + if 'static' in config_dict and 'deleted' not in config_dict['static']: + output += render_to_string('frr/staticd.frr.j2', config_dict['static']) + output += '\n' + if 'ip' in config_dict and 'deleted' not in config_dict['ip']: + output += render_to_string('frr/zebra.route-map.frr.j2', config_dict['ip']) + output += '\n' + if 'ipv6' in config_dict and 'deleted' not in config_dict['ipv6']: + output += render_to_string('frr/zebra.route-map.frr.j2', config_dict['ipv6']) + output += '\n' + if 'nhrp' in config_dict and 'deleted' not in config_dict['nhrp']: + output += render_to_string('frr/nhrpd.frr.j2', config_dict['nhrp']) + output += '\n' + return output + + 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: + output += 'agentx\n' + # Add routing protocols in global VRF + output += inline_helper(config_dict) + # Interface configuration for EVPN is not VRF related + if 'interfaces' in config_dict: + output += render_to_string('frr/evpn.mh.frr.j2', {'interfaces' : config_dict['interfaces']}) + output += '\n' + + if 'vrf' in config_dict and 'name' in config_dict['vrf']: + output += render_to_string('frr/zebra.vrf.route-map.frr.j2', config_dict['vrf']) + for vrf, vrf_config in config_dict['vrf']['name'].items(): + if 'protocols' not in vrf_config: + continue + for protocol in vrf_config['protocols']: + vrf_config['protocols'][protocol]['vrf'] = vrf + + output += inline_helper(vrf_config['protocols']) + + # remove any accidently added empty newline to not confuse FRR + output = os.linesep.join([s for s in output.splitlines() if s]) + + if '!!' in output: + raise ConfigError('FRR configuration contains "!!" which is not allowed') + + debug(output) + write_file(self._frr_conf, output) + debug('FRR: RENDERING CONFIG COMPLETE') + return True + + def apply(self, count_max=5): + count = 0 + emsg = '' + while count < count_max: + count += 1 + debug(f'FRR: reloading configuration - tries: {count} | Python class ID: {id(self)}') + cmdline = '/usr/lib/frr/frr-reload.py --reload' + if os.path.exists(frr_debug_enable): + cmdline += ' --debug --stdout' + rc, emsg = rc_cmd(f'{cmdline} {self._frr_conf}') + if rc != 0: + sleep(2) + continue + debug(emsg) + debug('FRR: configuration reload complete') + break + + if count >= count_max: + raise ConfigError(emsg) + + # T3217: Save FRR configuration to /run/frr/config/frr.conf + return cmd('/usr/bin/vtysh -n --writeconfig') diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py index b8ea90049..a659b9bd2 100644 --- a/python/vyos/ifconfig/bond.py +++ b/python/vyos/ifconfig/bond.py @@ -31,7 +31,6 @@ class BondIf(Interface): monitoring may be performed. """ - iftype = 'bond' definition = { **Interface.definition, ** { @@ -109,6 +108,9 @@ class BondIf(Interface): ] return options + def _create(self): + super()._create('bond') + def remove(self): """ Remove interface from operating system. Removing the interface @@ -504,3 +506,6 @@ class BondIf(Interface): # call base class first super().update(config) + + # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network) + self.set_eapol() diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index 917f962b7..f81026965 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -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 @@ -32,7 +32,6 @@ class BridgeIf(Interface): The Linux bridge code implements a subset of the ANSI/IEEE 802.1d standard. """ - iftype = 'bridge' definition = { **Interface.definition, **{ @@ -107,6 +106,9 @@ class BridgeIf(Interface): }, }} + def _create(self): + super()._create('bridge') + def get_vlan_filter(self): """ Get the status of the bridge VLAN filter @@ -378,7 +380,7 @@ class BridgeIf(Interface): 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'] @@ -387,14 +389,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..a886c1b9e 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -48,7 +48,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 +61,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 d45769931..29a1965a3 100644 --- a/python/vyos/ifconfig/dummy.py +++ b/python/vyos/ifconfig/dummy.py @@ -22,8 +22,6 @@ class DummyIf(Interface): interface. The purpose of a dummy interface is to provide a device to route packets through without actually transmitting them. """ - - iftype = 'dummy' definition = { **Interface.definition, **{ @@ -31,3 +29,6 @@ class DummyIf(Interface): 'prefixes': ['dum', ], }, } + + def _create(self): + super()._create('dummy') diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index 8d96c863f..93727bdf6 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -26,12 +26,13 @@ from vyos.utils.file import read_file from vyos.utils.process import run from vyos.utils.assertion import assert_list + @Interface.register class EthernetIf(Interface): """ Abstraction of a Linux Ethernet Interface """ - iftype = 'ethernet' + definition = { **Interface.definition, **{ @@ -41,7 +42,7 @@ class EthernetIf(Interface): 'broadcast': True, 'bridgeable': True, 'eternal': '(lan|eth|eno|ens|enp|enx)[0-9]+$', - } + }, } @staticmethod @@ -49,32 +50,35 @@ class EthernetIf(Interface): run(f'ethtool --features {ifname} {option} {value}') return False - _command_set = {**Interface._command_set, **{ - 'gro': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'gro', v), - }, - 'gso': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'gso', v), - }, - 'hw-tc-offload': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'hw-tc-offload', v), - }, - 'lro': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'lro', v), - }, - 'sg': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'sg', v), - }, - 'tso': { - 'validate': lambda v: assert_list(v, ['on', 'off']), - 'possible': lambda i, v: EthernetIf.feature(i, 'tso', v), + _command_set = { + **Interface._command_set, + **{ + 'gro': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'gro', v), + }, + 'gso': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'gso', v), + }, + 'hw-tc-offload': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'hw-tc-offload', v), + }, + 'lro': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'lro', v), + }, + 'sg': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'sg', v), + }, + 'tso': { + 'validate': lambda v: assert_list(v, ['on', 'off']), + 'possible': lambda i, v: EthernetIf.feature(i, 'tso', v), + }, }, - }} + } @staticmethod def get_bond_member_allowed_options() -> list: @@ -106,7 +110,7 @@ class EthernetIf(Interface): 'ring_buffer.rx', 'ring_buffer.tx', 'speed', - 'hw_id' + 'hw_id', ] return bond_allowed_sections @@ -114,6 +118,9 @@ class EthernetIf(Interface): super().__init__(ifname, **kargs) self.ethtool = Ethtool(ifname) + def _create(self): + pass + def remove(self): """ Remove interface from config. Removing the interface deconfigures all @@ -130,7 +137,11 @@ class EthernetIf(Interface): self.set_admin_state('down') # Remove all VLAN subinterfaces - filter with the VLAN dot - for vlan in [x for x in Section.interfaces(self.iftype) if x.startswith(f'{self.ifname}.')]: + for vlan in [ + x + for x in Section.interfaces('ethernet') + if x.startswith(f'{self.ifname}.') + ]: Interface(vlan).remove() super().remove() @@ -149,10 +160,12 @@ class EthernetIf(Interface): ifname = self.config['ifname'] if enable not in ['on', 'off']: - raise ValueError("Value out of range") + raise ValueError('Value out of range') if not self.ethtool.check_flow_control(): - self._debug_msg(f'NIC driver does not support changing flow control settings!') + self._debug_msg( + 'NIC driver does not support changing flow control settings!' + ) return False current = self.ethtool.get_flow_control() @@ -180,12 +193,24 @@ class EthernetIf(Interface): """ ifname = self.config['ifname'] - if speed not in ['auto', '10', '100', '1000', '2500', '5000', '10000', - '25000', '40000', '50000', '100000', '400000']: - raise ValueError("Value out of range (speed)") + if speed not in [ + 'auto', + '10', + '100', + '1000', + '2500', + '5000', + '10000', + '25000', + '40000', + '50000', + '100000', + '400000', + ]: + raise ValueError('Value out of range (speed)') if duplex not in ['auto', 'full', 'half']: - raise ValueError("Value out of range (duplex)") + raise ValueError('Value out of range (duplex)') if not self.ethtool.check_speed_duplex(speed, duplex): Warning(f'changing speed/duplex setting on "{ifname}" is unsupported!') @@ -224,7 +249,9 @@ class EthernetIf(Interface): # but they do not actually support it either. # In that case it's probably better to ignore the error # than end up with a broken config. - print('Warning: could not set speed/duplex settings: operation not permitted!') + print( + 'Warning: could not set speed/duplex settings: operation not permitted!' + ) def set_gro(self, state): """ @@ -243,7 +270,9 @@ class EthernetIf(Interface): if not fixed: return self.set_interface('gro', 'on' if state else 'off') else: - print('Adapter does not support changing generic-receive-offload settings!') + print( + 'Adapter does not support changing generic-receive-offload settings!' + ) return False def set_gso(self, state): @@ -262,7 +291,9 @@ class EthernetIf(Interface): if not fixed: return self.set_interface('gso', 'on' if state else 'off') else: - print('Adapter does not support changing generic-segmentation-offload settings!') + print( + 'Adapter does not support changing generic-segmentation-offload settings!' + ) return False def set_hw_tc_offload(self, state): @@ -300,7 +331,9 @@ class EthernetIf(Interface): if not fixed: return self.set_interface('lro', 'on' if state else 'off') else: - print('Adapter does not support changing large-receive-offload settings!') + print( + 'Adapter does not support changing large-receive-offload settings!' + ) return False def set_rps(self, state): @@ -310,13 +343,15 @@ class EthernetIf(Interface): rps_cpus = 0 queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*')) if state: + cpu_count = os.cpu_count() + # Enable RPS on all available CPUs except CPU0 which we will not # utilize so the system has one spare core when it's under high # preasure to server other means. Linux sysfs excepts a bitmask # representation of the CPUs which should participate on RPS, we # can enable more CPUs that are physically present on the system, # Linux will clip that internally! - rps_cpus = (1 << os.cpu_count()) -1 + rps_cpus = (1 << cpu_count) - 1 # XXX: we should probably reserve one core when the system is under # high preasure so we can still have a core left for housekeeping. @@ -324,8 +359,21 @@ class EthernetIf(Interface): # receive packet steering. rps_cpus &= ~1 - for i in range(0, queues): - self._write_sysfs(f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_cpus', f'{rps_cpus:x}') + # Convert the bitmask to hexadecimal chunks of 32 bits + # Split the bitmask into chunks of up to 32 bits each + hex_chunks = [] + for i in range(0, cpu_count, 32): + # Extract the next 32-bit chunk + chunk = (rps_cpus >> i) & 0xFFFFFFFF + hex_chunks.append(f'{chunk:08x}') + + # Join the chunks with commas + rps_cpus = ','.join(hex_chunks) + + for i in range(queues): + self._write_sysfs( + f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_cpus', rps_cpus + ) # send bitmask representation as hex string without leading '0x' return True @@ -335,10 +383,13 @@ class EthernetIf(Interface): queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*')) if state: global_rfs_flow = 32768 - rfs_flow = int(global_rfs_flow/queues) + rfs_flow = int(global_rfs_flow / queues) for i in range(0, queues): - self._write_sysfs(f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_flow_cnt', rfs_flow) + self._write_sysfs( + f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_flow_cnt', + rfs_flow, + ) return True @@ -379,7 +430,9 @@ class EthernetIf(Interface): if not fixed: return self.set_interface('tso', 'on' if state else 'off') else: - print('Adapter does not support changing tcp-segmentation-offload settings!') + print( + 'Adapter does not support changing tcp-segmentation-offload settings!' + ) return False def set_ring_buffer(self, rx_tx, size): @@ -404,39 +457,64 @@ class EthernetIf(Interface): print(f'could not set "{rx_tx}" ring-buffer for {ifname}') return output + def set_switchdev(self, enable): + ifname = self.config['ifname'] + addr, code = self._popen( + f"ethtool -i {ifname} | grep bus-info | awk '{{print $2}}'" + ) + if code != 0: + print(f'could not resolve PCIe address of {ifname}') + return + + enabled = False + state, code = self._popen( + f"/sbin/devlink dev eswitch show pci/{addr} | awk '{{print $3}}'" + ) + if code == 0 and state == 'switchdev': + enabled = True + + if enable and not enabled: + output, code = self._popen( + f'/sbin/devlink dev eswitch set pci/{addr} mode switchdev' + ) + if code != 0: + print(f'{ifname} does not support switchdev mode') + elif not enable and enabled: + self._cmd(f'/sbin/devlink dev eswitch set pci/{addr} mode legacy') + def update(self, config): - """ General helper function which works on a dictionary retrived by + """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. """ + on any interface.""" # disable ethernet flow control (pause frames) value = 'off' if 'disable_flow_control' in config else 'on' self.set_flow_control(value) # GRO (generic receive offload) - self.set_gro(dict_search('offload.gro', config) != None) + self.set_gro(dict_search('offload.gro', config) is not None) # GSO (generic segmentation offload) - self.set_gso(dict_search('offload.gso', config) != None) + self.set_gso(dict_search('offload.gso', config) is not None) # GSO (generic segmentation offload) - self.set_hw_tc_offload(dict_search('offload.hw_tc_offload', config) != None) + self.set_hw_tc_offload(dict_search('offload.hw_tc_offload', config) is not None) # LRO (large receive offload) - self.set_lro(dict_search('offload.lro', config) != None) + self.set_lro(dict_search('offload.lro', config) is not None) # RPS - Receive Packet Steering - self.set_rps(dict_search('offload.rps', config) != None) + self.set_rps(dict_search('offload.rps', config) is not None) # RFS - Receive Flow Steering - self.set_rfs(dict_search('offload.rfs', config) != None) + self.set_rfs(dict_search('offload.rfs', config) is not None) # scatter-gather option - self.set_sg(dict_search('offload.sg', config) != None) + self.set_sg(dict_search('offload.sg', config) is not None) # TSO (TCP segmentation offloading) - self.set_tso(dict_search('offload.tso', config) != None) + self.set_tso(dict_search('offload.tso', config) is not None) # Set physical interface speed and duplex if 'speed_duplex_changed' in config: @@ -450,5 +528,10 @@ class EthernetIf(Interface): for rx_tx, size in config['ring_buffer'].items(): self.set_ring_buffer(rx_tx, size) + self.set_switchdev('switchdev' in config) + # call base class last super().update(config) + + # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network) + self.set_eapol() diff --git a/python/vyos/ifconfig/geneve.py b/python/vyos/ifconfig/geneve.py index fbb261a35..f53ef4166 100644 --- a/python/vyos/ifconfig/geneve.py +++ b/python/vyos/ifconfig/geneve.py @@ -27,7 +27,6 @@ class GeneveIf(Interface): https://developers.redhat.com/blog/2019/05/17/an-introduction-to-linux-virtual-interfaces-tunnels/#geneve https://lwn.net/Articles/644938/ """ - iftype = 'geneve' definition = { **Interface.definition, **{ @@ -49,7 +48,7 @@ class GeneveIf(Interface): 'parameters.ipv6.flowlabel' : 'flowlabel', } - cmd = 'ip link add name {ifname} type {type} 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 3e5f5790d..201d3cacb 100644 --- a/python/vyos/ifconfig/input.py +++ b/python/vyos/ifconfig/input.py @@ -25,8 +25,6 @@ class InputIf(Interface): a single stack of qdiscs, classes and filters can be shared between multiple interfaces. """ - - iftype = 'ifb' definition = { **Interface.definition, **{ @@ -34,3 +32,6 @@ class InputIf(Interface): 'prefixes': ['ifb', ], }, } + + def _create(self): + super()._create('ifb') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 31fcf6ca6..003a273c0 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2025 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,12 +22,14 @@ from copy import deepcopy from glob import glob from ipaddress import IPv4Network +from ipaddress import IPv6Interface 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 netaddr import EUI +from netaddr import mac_unix_expanded -from vyos import ConfigError from vyos.configdict import list_diff from vyos.configdict import dict_merge from vyos.configdict import get_vlan_ids @@ -42,6 +44,7 @@ from vyos.template import render from vyos.utils.network import mac2eui64 from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config +from vyos.utils.network import get_interface_address from vyos.utils.network import get_interface_namespace from vyos.utils.network import get_vrf_tableid from vyos.utils.network import is_netns_interface @@ -62,9 +65,6 @@ from vyos.ifconfig.vrrp import VRRP from vyos.ifconfig.operational import Operational from vyos.ifconfig import Section -from netaddr import EUI -from netaddr import mac_unix_expanded - link_local_prefix = 'fe80::/64' class Interface(Control): @@ -74,7 +74,6 @@ class Interface(Control): OperationalClass = Operational options = ['debug', 'create'] - required = [] default = { 'debug': True, 'create': True, @@ -98,6 +97,10 @@ class Interface(Control): 'shellcmd': 'ip -json -detail link list dev {ifname}', 'format': lambda j: jmespath.search('[*].ifalias | [0]', json.loads(j)) or '', }, + 'ifindex': { + 'shellcmd': 'ip -json -detail link list dev {ifname}', + 'format': lambda j: jmespath.search('[*].ifindex | [0]', json.loads(j)) or '', + }, 'mac': { 'shellcmd': 'ip -json -detail link list dev {ifname}', 'format': lambda j: jmespath.search('[*].address | [0]', json.loads(j)), @@ -332,22 +335,10 @@ class Interface(Control): super().__init__(**kargs) if not self.exists(ifname): - # Any instance of Interface, such as Interface('eth0') can be used - # safely to access the generic function in this class as 'type' is - # unset, the class can not be created - if not self.iftype: - raise Exception(f'interface "{ifname}" not found') - self.config['type'] = self.iftype - # Should an Instance of a child class (EthernetIf, DummyIf, ..) # be required, then create should be set to False to not accidentally create it. # In case a subclass does not define it, we use get to set the default to True - if self.config.get('create',True): - for k in self.required: - if k not in kargs: - name = self.default['type'] - raise ConfigError(f'missing required option {k} for {name} {ifname} creation') - + if self.config.get('create', True): self._create() # If we can not connect to the interface then let the caller know # as the class could not be correctly initialised @@ -360,13 +351,14 @@ class Interface(Control): self.operational = self.OperationalClass(ifname) self.vrrp = VRRP(ifname) - def _create(self): + def _create(self, type: str=''): # Do not create interface that already exist or exists in netns netns = self.config.get('netns', None) if self.exists(f'{self.ifname}', netns=netns): return - cmd = 'ip link add dev {ifname} type {type}'.format(**self.config) + cmd = f'ip link add dev {self.ifname}' + if type: cmd += f' type {type}' if 'netns' in self.config: cmd = f'ip netns exec {netns} {cmd}' self._cmd(cmd) @@ -428,6 +420,17 @@ class Interface(Control): 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): + """ + Get interface index by name + + Example: + >>> from vyos.ifconfig import Interface + >>> Interface('eth0').get_ifindex() + '2' + """ + return int(self.get_interface('ifindex')) + def get_min_mtu(self): """ Get hardware minimum supported MTU @@ -593,12 +596,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 @@ -609,21 +616,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): """ @@ -891,7 +910,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): """ @@ -919,6 +942,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. @@ -1179,7 +1216,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 @@ -1212,15 +1249,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 @@ -1230,7 +1266,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 @@ -1293,6 +1329,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. @@ -1303,8 +1404,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) @@ -1320,7 +1419,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= [] @@ -1343,30 +1442,29 @@ 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. """ if enable not in [True, False]: raise ValueError() - ifname = self.ifname config_base = directories['isc_dhclient_dir'] + '/dhclient' - dhclient_config_file = f'{config_base}_{ifname}.conf' - dhclient_lease_file = f'{config_base}_{ifname}.leases' - systemd_override_file = f'/run/systemd/system/dhclient@{ifname}.service.d/10-override.conf' - systemd_service = f'dhclient@{ifname}.service' + dhclient_config_file = f'{config_base}_{self.ifname}.conf' + dhclient_lease_file = f'{config_base}_{self.ifname}.leases' + systemd_override_file = f'/run/systemd/system/dhclient@{self.ifname}.service.d/10-override.conf' + systemd_service = f'dhclient@{self.ifname}.service' # Rendered client configuration files require the apsolute config path self.config['isc_dhclient_dir'] = directories['isc_dhclient_dir'] @@ -1395,11 +1493,28 @@ 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): self._cmd(f'systemctl stop {systemd_service}') + + # Smoketests occationally fail if the lease is not removed from the Kernel fast enough: + # AssertionError: 2 unexpectedly found in {17: [{'addr': '52:54:00:00:00:00', + # 'broadcast': 'ff:ff:ff:ff:ff:ff'}], 2: [{'addr': '192.0.2.103', 'netmask': '255.255.255.0', + # + # We will force removal of any dynamic IPv4 address from the interface + tmp = get_interface_address(self.ifname) + if tmp and 'addr_info' in tmp: + for address_dict in tmp['addr_info']: + # Only remove dynamic assigned addresses + if address_dict['family'] == 'inet' and 'dynamic' in address_dict: + address = address_dict['local'] + prefixlen = address_dict['prefixlen'] + self.del_addr(f'{address}/{prefixlen}') + # cleanup old config files for file in [dhclient_config_file, systemd_override_file, dhclient_lease_file]: if os.path.isfile(file): @@ -1407,19 +1522,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) @@ -1436,7 +1550,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}') @@ -1653,30 +1770,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) @@ -1755,11 +1873,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 @@ -1813,9 +1946,6 @@ class Interface(Control): value = '1' if (tmp != None) else '0' self.set_per_client_thread(value) - # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network) - self.set_eapol() - # Enable/Disable of an interface must always be done at the end of the # derived class to make use of the ref-counting set_admin_state() # function. We will only enable the interface if 'up' was called as @@ -1926,8 +2056,6 @@ class Interface(Control): class VLANIf(Interface): """ Specific class which abstracts 802.1q and 802.1ad (Q-in-Q) VLAN interfaces """ - iftype = 'vlan' - def _create(self): # bail out early if interface already exists if self.exists(f'{self.ifname}'): diff --git a/python/vyos/ifconfig/l2tpv3.py b/python/vyos/ifconfig/l2tpv3.py index c1f2803ee..dfaa006aa 100644 --- a/python/vyos/ifconfig/l2tpv3.py +++ b/python/vyos/ifconfig/l2tpv3.py @@ -45,7 +45,6 @@ class L2TPv3If(Interface): either hot standby or load balancing services. Additionally, link integrity monitoring may be performed. """ - iftype = 'l2tp' definition = { **Interface.definition, **{ diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py index e1d041839..13e8a2c50 100644 --- a/python/vyos/ifconfig/loopback.py +++ b/python/vyos/ifconfig/loopback.py @@ -22,16 +22,20 @@ class LoopbackIf(Interface): uses to communicate with itself. """ _persistent_addresses = ['127.0.0.1/8', '::1/128'] - iftype = 'loopback' definition = { **Interface.definition, **{ 'section': 'loopback', 'prefixes': ['lo', ], 'bridgeable': True, + 'eternal': 'lo$', } } + def _create(self): + # we can not create this interface as it is managed by the Kernel + pass + def remove(self): """ Loopback interface can not be deleted from operating system. We can diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py index 383905814..3b4dc223f 100644 --- a/python/vyos/ifconfig/macsec.py +++ b/python/vyos/ifconfig/macsec.py @@ -27,7 +27,6 @@ class MACsecIf(Interface): other security solutions such as IPsec (layer 3) or TLS (layer 4), as all those solutions are used for their own specific use cases. """ - iftype = 'macsec' definition = { **Interface.definition, **{ @@ -43,7 +42,7 @@ class MACsecIf(Interface): """ # create tunnel interface - cmd = 'ip link add link {source_interface} {ifname} type {type}'.format(**self.config) + cmd = 'ip link add link {source_interface} {ifname} type macsec'.format(**self.config) cmd += f' cipher {self.config["security"]["cipher"]}' if 'encrypt' in self.config["security"]: diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index 2266879ec..fe948b920 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -20,7 +20,6 @@ class MACVLANIf(Interface): """ Abstraction of a Linux MACvlan interface """ - iftype = 'macvlan' definition = { **Interface.definition, **{ @@ -35,13 +34,12 @@ class MACVLANIf(Interface): down by default. """ # please do not change the order when assembling the command - cmd = 'ip link add {ifname} link {source_interface} type {type} mode {mode}' + cmd = 'ip link add {ifname} link {source_interface} type macvlan mode {mode}' self._cmd(cmd.format(**self.config)) # interface is always A/D down. It needs to be enabled explicitly self.set_admin_state('down') def set_mode(self, mode): - ifname = self.config['ifname'] - cmd = f'ip link set dev {ifname} type macvlan mode {mode}' + cmd = f'ip link set dev {self.ifname} type macvlan mode {mode}' return self._cmd(cmd) diff --git a/python/vyos/ifconfig/pppoe.py b/python/vyos/ifconfig/pppoe.py index febf1452d..85ca3877e 100644 --- a/python/vyos/ifconfig/pppoe.py +++ b/python/vyos/ifconfig/pppoe.py @@ -19,7 +19,6 @@ from vyos.utils.network import get_interface_config @Interface.register class PPPoEIf(Interface): - iftype = 'pppoe' definition = { **Interface.definition, **{ @@ -115,14 +114,6 @@ class PPPoEIf(Interface): # before this is done by the base class. self._config = config - # remove old routes from an e.g. old VRF assignment - if 'shutdown_required': - vrf = None - tmp = get_interface_config(self.ifname) - if 'master' in tmp: - vrf = tmp['master'] - self._remove_routes(vrf) - # DHCPv6 PD handling is a bit different on PPPoE interfaces, as we do # not require an 'address dhcpv6' CLI option as with other interfaces if 'dhcpv6_options' in config and 'pd' in config['dhcpv6_options']: diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py index 50fc6ee6b..d92ef23dc 100644 --- a/python/vyos/ifconfig/sstpc.py +++ b/python/vyos/ifconfig/sstpc.py @@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface @Interface.register class SSTPCIf(Interface): - iftype = 'sstpc' definition = { **Interface.definition, **{ diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py index 9ba7b31a6..df904f7d5 100644 --- a/python/vyos/ifconfig/tunnel.py +++ b/python/vyos/ifconfig/tunnel.py @@ -90,9 +90,8 @@ class TunnelIf(Interface): # T3357: we do not have the 'encapsulation' in kargs when calling this # class from op-mode like "show interfaces tunnel" if 'encapsulation' in kargs: - self.iftype = kargs['encapsulation'] # The gretap interface has the possibility to act as L2 bridge - if self.iftype in ['gretap', 'ip6gretap']: + if kargs['encapsulation'] in ['gretap', 'ip6gretap']: # no multicast, ttl or tos for gretap self.definition = { **TunnelIf.definition, @@ -110,10 +109,10 @@ class TunnelIf(Interface): mapping = { **self.mapping, **self.mapping_ipv4 } cmd = 'ip tunnel add {ifname} mode {encapsulation}' - if self.iftype in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']: + if self.config['encapsulation'] in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']: cmd = 'ip link add name {ifname} type {encapsulation}' # ERSPAN requires the serialisation of packets - if self.iftype in ['erspan', 'ip6erspan']: + if self.config['encapsulation'] in ['erspan', 'ip6erspan']: cmd += ' seq' for vyos_key, iproute2_key in mapping.items(): @@ -132,7 +131,7 @@ class TunnelIf(Interface): def _change_options(self): # gretap interfaces do not support changing any parameter - if self.iftype in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']: + if self.config['encapsulation'] in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']: return if self.config['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']: diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py index aafbf226a..2c8709d20 100644 --- a/python/vyos/ifconfig/veth.py +++ b/python/vyos/ifconfig/veth.py @@ -21,7 +21,6 @@ class VethIf(Interface): """ Abstraction of a Linux veth interface """ - iftype = 'veth' definition = { **Interface.definition, **{ @@ -46,7 +45,7 @@ class VethIf(Interface): return # create virtual-ethernet interface - cmd = 'ip link add {ifname} type {type}'.format(**self.config) + cmd = f'ip link add {self.ifname} type veth' cmd += f' peer name {self.config["peer_name"]}' self._cmd(cmd) diff --git a/python/vyos/ifconfig/vrrp.py b/python/vyos/ifconfig/vrrp.py index ee9336d1a..3ee22706c 100644 --- a/python/vyos/ifconfig/vrrp.py +++ b/python/vyos/ifconfig/vrrp.py @@ -35,25 +35,25 @@ class VRRPNoData(VRRPError): class VRRP(object): _vrrp_prefix = '00:00:5E:00:01:' location = { - 'pid': '/run/keepalived/keepalived.pid', - 'fifo': '/run/keepalived/keepalived_notify_fifo', - 'state': '/tmp/keepalived.data', - 'stats': '/tmp/keepalived.stats', - 'json': '/tmp/keepalived.json', - 'daemon': '/etc/default/keepalived', - 'config': '/run/keepalived/keepalived.conf', + 'pid': '/run/keepalived/keepalived.pid', + 'fifo': '/run/keepalived/keepalived_notify_fifo', + 'state': '/tmp/keepalived.data', + 'stats': '/tmp/keepalived.stats', + 'json': '/tmp/keepalived.json', + 'daemon': '/etc/default/keepalived', + 'config': '/run/keepalived/keepalived.conf', } _signal = { - 'state': signal.SIGUSR1, - 'stats': signal.SIGUSR2, - 'json': signal.SIGRTMIN + 2, + 'state': signal.SIGUSR1, + 'stats': signal.SIGUSR2, + 'json': signal.SIGRTMIN + 2, } _name = { 'state': 'information', 'stats': 'statistics', - 'json': 'data', + 'json': 'data', } state = { @@ -64,7 +64,7 @@ class VRRP(object): # UNKNOWN } - def __init__(self,ifname): + def __init__(self, ifname): self.ifname = ifname def enabled(self): @@ -79,7 +79,7 @@ class VRRP(object): @classmethod def decode_state(cls, code): - return cls.state.get(code,'UNKNOWN') + return cls.state.get(code, 'UNKNOWN') # used in conf mode @classmethod @@ -94,16 +94,20 @@ class VRRP(object): try: # send signal to generate the configuration file pid = read_file(cls.location['pid']) - wait_for_file_write_complete(fname, - pre_hook=(lambda: os.kill(int(pid), cls._signal[what])), - timeout=30) + wait_for_file_write_complete( + fname, + pre_hook=(lambda: os.kill(int(pid), cls._signal[what])), + timeout=30, + ) return read_file(fname) + except FileNotFoundError: + raise VRRPNoData( + 'VRRP data is not available (process not running or no active groups)' + ) except OSError: # raised by vyos.utils.file.read_file - raise VRRPNoData("VRRP data is not available (wait time exceeded)") - except FileNotFoundError: - raise VRRPNoData("VRRP data is not available (process not running or no active groups)") + raise VRRPNoData('VRRP data is not available (wait time exceeded)') except Exception: name = cls._name[what] raise VRRPError(f'VRRP {name} is not available') @@ -118,32 +122,41 @@ class VRRP(object): conf = ConfigTreeQuery() if conf.exists(base): # Read VRRP configuration directly from CLI - vrrp_config_dict = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) + vrrp_config_dict = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True + ) # add disabled groups to the list if 'group' in vrrp_config_dict: for group, group_config in vrrp_config_dict['group'].items(): if 'disable' not in group_config: continue - disabled.append([group, group_config['interface'], group_config['vrid'], 'DISABLED', '']) + disabled.append( + [ + group, + group_config['interface'], + group_config['vrid'], + 'DISABLED', + '', + ] + ) # return list with disabled instances return disabled @classmethod def format(cls, data): - headers = ["Name", "Interface", "VRID", "State", "Priority", "Last Transition"] + headers = ['Name', 'Interface', 'VRID', 'State', 'Priority', 'Last Transition'] groups = [] - data = json.loads(data) + data = json.loads(data) if isinstance(data, str) else data for group in data: data = group['data'] name = data['iname'] intf = data['ifp_ifname'] vrid = data['vrid'] - state = cls.decode_state(data["state"]) + state = cls.decode_state(data['state']) priority = data['effective_priority'] since = int(time() - float(data['last_transition'])) @@ -153,4 +166,4 @@ class VRRP(object): # add to the active list disabled instances groups.extend(cls.disabled()) - return(tabulate(groups, headers)) + return tabulate(groups, headers) diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 251cbeb36..78f5895f8 100644 --- a/python/vyos/ifconfig/vti.py +++ b/python/vyos/ifconfig/vti.py @@ -19,7 +19,6 @@ from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_re @Interface.register class VTIIf(Interface): - iftype = 'vti' definition = { **Interface.definition, **{ diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py index 6fb414e56..ee790f275 100644 --- a/python/vyos/ifconfig/vtun.py +++ b/python/vyos/ifconfig/vtun.py @@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface @Interface.register class VTunIf(Interface): - iftype = 'vtun' definition = { **Interface.definition, **{ diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py index 1023c58d1..58844885b 100644 --- a/python/vyos/ifconfig/vxlan.py +++ b/python/vyos/ifconfig/vxlan.py @@ -42,8 +42,6 @@ class VXLANIf(Interface): For more information please refer to: https://www.kernel.org/doc/Documentation/networking/vxlan.txt """ - - iftype = 'vxlan' definition = { **Interface.definition, **{ @@ -94,7 +92,7 @@ class VXLANIf(Interface): remote_list = self.config['remote'][1:] self.config['remote'] = self.config['remote'][0] - cmd = 'ip link add {ifname} type {type} dstport {port}' + cmd = 'ip link add {ifname} type vxlan 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/wireguard.py b/python/vyos/ifconfig/wireguard.py index 5b5f25323..f5217aecb 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 2019-2025 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,9 +22,11 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +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 class WireGuardOperational(Operational): def _dump(self): @@ -54,7 +56,17 @@ class WireGuardOperational(Operational): } else: # We are entering a peer - device, public_key, preshared_key, endpoint, allowed_ips, latest_handshake, transfer_rx, transfer_tx, persistent_keepalive = items + ( + device, + public_key, + preshared_key, + endpoint, + allowed_ips, + latest_handshake, + transfer_rx, + transfer_tx, + persistent_keepalive, + ) = items if allowed_ips == '(none)': allowed_ips = [] else: @@ -72,156 +84,259 @@ class WireGuardOperational(Operational): def show_interface(self): from vyos.config import Config + c = Config() wgdump = self._dump().get(self.config['ifname'], None) - c.set_level(["interfaces", "wireguard", self.config['ifname']]) - description = c.return_effective_value(["description"]) - ips = c.return_effective_values(["address"]) - - answer = "interface: {}\n".format(self.config['ifname']) - if (description): - answer += " description: {}\n".format(description) - if (ips): - answer += " address: {}\n".format(", ".join(ips)) - - answer += " public key: {}\n".format(wgdump['public_key']) - answer += " private key: (hidden)\n" - answer += " listening port: {}\n".format(wgdump['listen_port']) - answer += "\n" - - for peer in c.list_effective_nodes(["peer"]): + 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' + answer += ' listening port: {}\n'.format(wgdump['listen_port']) + answer += '\n' + + for peer in c.list_effective_nodes(['peer']): if wgdump['peers']: - pubkey = c.return_effective_value(["peer", peer, "public_key"]) + pubkey = c.return_effective_value(['peer', peer, 'public-key']) if pubkey in wgdump['peers']: wgpeer = wgdump['peers'][pubkey] - answer += " peer: {}\n".format(peer) - answer += " public key: {}\n".format(pubkey) + answer += ' peer: {}\n'.format(peer) + answer += ' public key: {}\n'.format(pubkey) """ figure out if the tunnel is recently active or not """ - status = "inactive" - if (wgpeer['latest_handshake'] is None): + status = 'inactive' + if wgpeer['latest_handshake'] is None: """ no handshake ever """ - status = "inactive" + status = 'inactive' else: if int(wgpeer['latest_handshake']) > 0: - delta = timedelta(seconds=int( - time.time() - wgpeer['latest_handshake'])) - answer += " latest handshake: {}\n".format(delta) - if (time.time() - int(wgpeer['latest_handshake']) < (60*5)): + delta = timedelta( + seconds=int(time.time() - wgpeer['latest_handshake']) + ) + answer += ' latest handshake: {}\n'.format(delta) + if time.time() - int(wgpeer['latest_handshake']) < (60 * 5): """ Five minutes and the tunnel is still active """ - status = "active" + status = 'active' else: """ it's been longer than 5 minutes """ - status = "inactive" + status = 'inactive' elif int(wgpeer['latest_handshake']) == 0: """ no handshake ever """ - status = "inactive" - answer += " status: {}\n".format(status) + status = 'inactive' + answer += ' status: {}\n'.format(status) if wgpeer['endpoint'] is not None: - answer += " endpoint: {}\n".format(wgpeer['endpoint']) + answer += ' endpoint: {}\n'.format(wgpeer['endpoint']) if wgpeer['allowed_ips'] is not None: - answer += " allowed ips: {}\n".format( - ",".join(wgpeer['allowed_ips']).replace(",", ", ")) + answer += ' allowed ips: {}\n'.format( + ','.join(wgpeer['allowed_ips']).replace(',', ', ') + ) if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0: - rx_size = size( - wgpeer['transfer_rx'], system=alternative) - tx_size = size( - wgpeer['transfer_tx'], system=alternative) - answer += " transfer: {} received, {} sent\n".format( - rx_size, tx_size) + rx_size = size(wgpeer['transfer_rx'], system=alternative) + tx_size = size(wgpeer['transfer_tx'], system=alternative) + answer += ' transfer: {} received, {} sent\n'.format( + rx_size, tx_size + ) if wgpeer['persistent_keepalive'] is not None: - answer += " persistent keepalive: every {} seconds\n".format( - wgpeer['persistent_keepalive']) + answer += ' persistent keepalive: every {} seconds\n'.format( + wgpeer['persistent_keepalive'] + ) answer += '\n' - return answer + super().formated_stats() + 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): OperationalClass = WireGuardOperational - iftype = 'wireguard' definition = { **Interface.definition, **{ 'section': 'wireguard', 'prefixes': ['wg', ], 'bridgeable': False, - } + }, } + def _create(self): + super()._create('wireguard') + def get_mac(self): - """ Get a synthetic MAC address. """ + """Get a synthetic MAC address.""" return self.get_mac_synthetic() def update(self, config): - """ General helper function which works on a dictionary retrived by + """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. """ - + 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}' + + 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) + + # 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' - base_cmd += f' private-key {tmp_file.name}' - base_cmd = base_cmd.format(**config) if 'peer' in config: 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 # the public key was already removed when entering this method! if 'disable' in peer_config: + # remove peer if disabled, no error report even if peer not exists + cmd = base_cmd + ' peer {public_key} remove' + self._cmd(cmd.format(**peer_config)) continue - # start of with a fresh 'wg' command - cmd = base_cmd + ' peer {public_key}' - - # 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}' - - # Persistent keepalive is optional - if 'persistent_keepalive'in peer_config: - cmd += ' persistent-keepalive {persistent_keepalive}' - - # Multiple allowed-ip ranges can be defined - ensure we are always - # dealing with a list - if isinstance(peer_config['allowed_ips'], str): - peer_config['allowed_ips'] = [peer_config['allowed_ips']] - cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - - # Endpoint configuration is optional - if {'address', 'port'} <= set(peer_config): - if is_ipv6(peer_config['address']): - cmd += ' endpoint [{address}]:{port}' - else: - cmd += ' endpoint {address}:{port}' - self._cmd(cmd.format(**peer_config)) - - # 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) + # start of with a fresh 'wg' command + peer_cmd = base_cmd + ' peer {public_key}' + + try: + cmd = peer_cmd + + 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}' + + # Persistent keepalive is optional + if 'persistent_keepalive' in peer_config: + cmd += ' persistent-keepalive {persistent_keepalive}' + + # Multiple allowed-ip ranges can be defined - ensure we are always + # dealing with a list + if isinstance(peer_config['allowed_ips'], str): + peer_config['allowed_ips'] = [peer_config['allowed_ips']] + cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) + + self._cmd(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}' + 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}' + + self._cmd(cmd.format(**peer_config), env={ + 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) + except: + # todo: logging + pass + finally: + # 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) # call base class super().update(config) diff --git a/python/vyos/ifconfig/wireless.py b/python/vyos/ifconfig/wireless.py index 88eaa772b..121f56bd5 100644 --- a/python/vyos/ifconfig/wireless.py +++ b/python/vyos/ifconfig/wireless.py @@ -20,7 +20,6 @@ class WiFiIf(Interface): """ Handle WIFI/WLAN interfaces. """ - iftype = 'wifi' definition = { **Interface.definition, **{ diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py index 845c9bef9..004a64b39 100644 --- a/python/vyos/ifconfig/wwan.py +++ b/python/vyos/ifconfig/wwan.py @@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface @Interface.register class WWANIf(Interface): - iftype = 'wwan' definition = { **Interface.definition, **{ diff --git a/python/vyos/include/__init__.py b/python/vyos/include/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 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..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 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..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/linux/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 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..72f0b18cb --- /dev/null +++ b/python/vyos/include/uapi/linux/fib_rules.py @@ -0,0 +1,20 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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..47e0c723c --- /dev/null +++ b/python/vyos/include/uapi/linux/icmpv6.py @@ -0,0 +1,18 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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..90cb66ebd --- /dev/null +++ b/python/vyos/include/uapi/linux/if_arp.py @@ -0,0 +1,176 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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..6797a762b --- /dev/null +++ b/python/vyos/include/uapi/linux/lwtunnel.py @@ -0,0 +1,38 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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..d5caf44b9 --- /dev/null +++ b/python/vyos/include/uapi/linux/neighbour.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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..e31272460 --- /dev/null +++ b/python/vyos/include/uapi/linux/rtnetlink.py @@ -0,0 +1,63 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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/kea.py b/python/vyos/kea.py index addfdba49..5eecbbaad 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023-2025 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,8 +17,11 @@ import json import os 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 @@ -40,7 +43,8 @@ kea4_options = { 'time_offset': 'time-offset', 'wpad_url': 'wpad-url', 'ipv6_only_preferred': 'v6-only-preferred', - 'captive_portal': 'v4-captive-portal' + 'captive_portal': 'v4-captive-portal', + 'capwap_controller': 'capwap-ac-v4', } kea6_options = { @@ -52,11 +56,36 @@ kea6_options = { 'nisplus_domain': 'nisp-domain-name', 'nisplus_server': 'nisp-servers', 'sntp_server': 'sntp-servers', - 'captive_portal': 'v6-captive-portal' + 'captive_portal': 'v6-captive-portal', + 'capwap_controller': 'capwap-ac-v6', } kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket' + +def _format_hex_string(in_str): + out_str = '' + # if input is divisible by 2, add : every 2 chars + if len(in_str) > 0 and len(in_str) % 2 == 0: + out_str = ':'.join(a + b for a, b in zip(in_str[::2], in_str[1::2])) + else: + out_str = in_str + + return out_str + + +def _find_list_of_dict_index(lst, key='ip', value=''): + """ + Find the index entry of list of dict matching the dict value + Exampe: + % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}] + % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2') + % 1 + """ + idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) + return idx + + def kea_parse_options(config): options = [] @@ -64,46 +93,62 @@ def kea_parse_options(config): if node not in config: continue - value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] + value = ( + ', '.join(config[node]) if isinstance(config[node], list) else config[node] + ) options.append({'name': option_name, 'data': value}) if 'client_prefix_length' in config: - options.append({'name': 'subnet-mask', 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length'])}) + options.append( + { + 'name': 'subnet-mask', + 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length']), + } + ) if 'ip_forwarding' in config: - options.append({'name': 'ip-forwarding', 'data': "true"}) + options.append({'name': 'ip-forwarding', 'data': 'true'}) if 'static_route' in config: default_route = '' if 'default_router' in config: - default_route = isc_static_route('0.0.0.0/0', config['default_router']) - - routes = [isc_static_route(route, route_options['next_hop']) for route, route_options in config['static_route'].items()] - - options.append({'name': 'rfc3442-static-route', 'data': ", ".join(routes if not default_route else routes + [default_route])}) - options.append({'name': 'windows-static-route', 'data': ", ".join(routes)}) + default_route = f'0.0.0.0/0 - {config["default_router"]}' + + routes = [ + f'{route} - {route_options["next_hop"]}' + for route, route_options in config['static_route'].items() + ] + + options.append( + { + 'name': 'classless-static-route', + 'data': ', '.join( + routes if not default_route else routes + [default_route] + ), + } + ) if 'time_zone' in config: - with open("/usr/share/zoneinfo/" + config['time_zone'], "rb") as f: - tz_string = f.read().split(b"\n")[-2].decode("utf-8") + with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f: + tz_string = f.read().split(b'\n')[-2].decode('utf-8') options.append({'name': 'pcode', 'data': tz_string}) options.append({'name': 'tcode', 'data': config['time_zone']}) - unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') + unifi_controller = dict_search_args( + config, 'vendor_option', 'ubiquiti', 'unifi_controller' + ) if unifi_controller: - options.append({ - 'name': 'unifi-controller', - 'data': unifi_controller, - 'space': 'ubnt' - }) + options.append( + {'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt'} + ) return options + def kea_parse_subnet(subnet, config): - out = {'subnet': subnet, 'id': int(config['subnet_id'])} - options = [] + out = {'subnet': subnet, 'id': int(config['subnet_id']), 'user-context': {}} if 'option' in config: out['option-data'] = kea_parse_options(config['option']) @@ -121,13 +166,14 @@ 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(): start, stop = range_config['start'], range_config['stop'] - pool = { - 'pool': f'{start} - {stop}' - } + pool = {'pool': f'{start} - {stop}'} if 'option' in range_config: pool['option-data'] = kea_parse_options(range_config['option']) @@ -164,16 +210,24 @@ def kea_parse_subnet(subnet, config): reservation['option-data'] = kea_parse_options(host_config['option']) if 'bootfile_name' in host_config['option']: - reservation['boot-file-name'] = host_config['option']['bootfile_name'] + reservation['boot-file-name'] = host_config['option'][ + 'bootfile_name' + ] if 'bootfile_server' in host_config['option']: - reservation['next-server'] = host_config['option']['bootfile_server'] + reservation['next-server'] = host_config['option'][ + 'bootfile_server' + ] reservations.append(reservation) out['reservations'] = reservations + if 'dynamic_dns_update' in config: + out.update(kea_parse_ddns_settings(config['dynamic_dns_update'])) + return out + def kea6_parse_options(config): options = [] @@ -181,7 +235,9 @@ def kea6_parse_options(config): if node not in config: continue - value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] + value = ( + ', '.join(config[node]) if isinstance(config[node], list) else config[node] + ) options.append({'name': option_name, 'data': value}) if 'sip_server' in config: @@ -197,17 +253,20 @@ def kea6_parse_options(config): hosts.append(server) if addrs: - options.append({'name': 'sip-server-addr', 'data': ", ".join(addrs)}) + options.append({'name': 'sip-server-addr', 'data': ', '.join(addrs)}) if hosts: - options.append({'name': 'sip-server-dns', 'data': ", ".join(hosts)}) + options.append({'name': 'sip-server-dns', 'data': ', '.join(hosts)}) cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server') if cisco_tftp: - options.append({'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp}) + options.append( + {'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp} + ) return options + def kea6_parse_subnet(subnet, config): out = {'subnet': subnet, 'id': int(config['subnet_id'])} @@ -245,12 +304,14 @@ def kea6_parse_subnet(subnet, config): pd_pool = { 'prefix': prefix, 'prefix-len': int(pd_conf['prefix_length']), - 'delegated-len': int(pd_conf['delegated_length']) + 'delegated-len': int(pd_conf['delegated_length']), } if 'excluded_prefix' in pd_conf: pd_pool['excluded-prefix'] = pd_conf['excluded_prefix'] - pd_pool['excluded-prefix-len'] = int(pd_conf['excluded_prefix_length']) + pd_pool['excluded-prefix-len'] = int( + pd_conf['excluded_prefix_length'] + ) pd_pools.append(pd_pool) @@ -270,9 +331,7 @@ def kea6_parse_subnet(subnet, config): if 'disable' in host_config: continue - reservation = { - 'hostname': host - } + reservation = {'hostname': host} if 'mac' in host_config: reservation['hw-address'] = host_config['mac'] @@ -281,10 +340,10 @@ def kea6_parse_subnet(subnet, config): reservation['duid'] = host_config['duid'] if 'ipv6_address' in host_config: - reservation['ip-addresses'] = [ host_config['ipv6_address'] ] + reservation['ip-addresses'] = [host_config['ipv6_address']] if 'ipv6_prefix' in host_config: - reservation['prefixes'] = [ host_config['ipv6_prefix'] ] + reservation['prefixes'] = [host_config['ipv6_prefix']] if 'option' in host_config: reservation['option-data'] = kea6_parse_options(host_config['option']) @@ -295,6 +354,55 @@ 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'] + + return data + def _ctrl_socket_command(inet, command, args=None): path = kea_ctrl_socket.format(inet=inet) @@ -321,6 +429,7 @@ 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') @@ -329,6 +438,42 @@ def kea_get_leases(inet): return leases['arguments']['leases'] + +def kea_add_lease( + inet, + ip_address, + host_name=None, + mac_address=None, + iaid=None, + duid=None, + subnet_id=None, +): + args = {'ip-address': ip_address} + + 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, f'lease{inet}-add', args) + + if result and 'result' in result: + return result['result'] == 0 + + return False + + def kea_delete_lease(inet, ip_address): args = {'ip-address': ip_address} @@ -339,6 +484,7 @@ def kea_delete_lease(inet, ip_address): return False + def kea_get_active_config(inet): config = _ctrl_socket_command(inet, 'config-get') @@ -347,8 +493,18 @@ def kea_get_active_config(inet): return config + +def kea_get_dhcp_pools(config, inet): + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + return [network['name'] for network in shared_networks] if shared_networks else [] + + def kea_get_pool_from_subnet_id(config, inet, subnet_id): - shared_networks = dict_search_args(config, 'arguments', f'Dhcp{inet}', 'shared-networks') + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) if not shared_networks: return None @@ -362,3 +518,146 @@ def kea_get_pool_from_subnet_id(config, inet, subnet_id): return network['name'] 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 + :return list + """ + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + + mappings = [] + + if shared_networks: + for network in shared_networks: + if f'subnet{inet}' not in network: + continue + + for p in pools: + if network['name'] == p: + for subnet in network[f'subnet{inet}']: + if 'reservations' in subnet: + for reservation in subnet['reservations']: + mapping = {'pool': p, 'subnet': subnet['subnet']} + mapping.update(reservation) + # rename 'ip(v6)-address' to 'ip', inet6 has 'ipv6-address' and inet has 'ip-address' + mapping['ip'] = mapping.pop( + 'ipv6-address', mapping.pop('ip-address', None) + ) + # rename 'hw-address' to 'mac' + mapping['mac'] = mapping.pop('hw-address', None) + mappings.append(mapping) + + return mappings + + +def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list: + """ + Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration + :return list + """ + leases = kea_get_leases(inet) + + data = [] + for lease in leases: + lifetime = lease['valid-lft'] + start = lease['cltt'] + expiry = start + lifetime + + lease['start_time'] = datetime.fromtimestamp(start, timezone.utc) + lease['expire_time'] = ( + datetime.fromtimestamp(expiry, timezone.utc) if expiry else None + ) + + data_lease = {} + data_lease['ip'] = lease['ip-address'] + lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'} + data_lease['state'] = lease_state_long[lease['state']] + data_lease['pool'] = ( + kea_get_pool_from_subnet_id(config, inet, lease['subnet-id']) + 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_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('.') or '-' + + if inet == '4': + data_lease['mac'] = lease['hw-address'] + data_lease['start'] = lease['start_time'].timestamp() + + if inet == '6': + data_lease['last_communication'] = lease['start_time'].timestamp() + data_lease['duid'] = _format_hex_string(lease['duid']) + data_lease['type'] = lease['type'] + + if lease['type'] == 'IA_PD': + prefix_len = lease['prefix-len'] + data_lease['ip'] += f'/{prefix_len}' + + data_lease['remaining'] = '' + + 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'] != '' + and data_lease['pool'] in pools + and data_lease['state'] != 'free' + and (not state or state == 'all' or data_lease['state'] in state) + ): + data.append(data_lease) + + # deduplicate + checked = [] + for entry in data: + addr = entry.get('ip') + if addr not in checked: + checked.append(addr) + else: + idx = _find_list_of_dict_index(data, key='ip', value=addr) + if idx is not None: + data.pop(idx) + + return data diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 5fab3c2a1..29f8e961b 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -242,6 +242,13 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): output.append(f'{proto} {prefix}port {operator} @P_{group_name}') + if 'fqdn' in side_conf: + fqdn = side_conf['fqdn'] + operator = '' + if fqdn[0] == '!': + operator = '!=' + output.append(f' ip {prefix}addr {operator} @FQDN_nat_{nat_type}_{rule_id}_{prefix}') + output.append('counter') if 'log' in rule_conf: diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 066c8058f..7b11d36dd 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -20,86 +20,110 @@ from humps import decamelize class Error(Exception): - """ Any error that makes requested operation impossible to complete - for reasons unrelated to the user input or script logic. + """Any error that makes requested operation impossible to complete + for reasons unrelated to the user input or script logic. - This is the base class, scripts should not use it directly - and should raise more specific errors instead, - whenever possible. + This is the base class, scripts should not use it directly + and should raise more specific errors instead, + whenever possible. """ + pass + class UnconfiguredSubsystem(Error): - """ Requested operation is valid, but cannot be completed - because corresponding subsystem is not configured - and thus is not running. + """Requested operation is valid, but cannot be completed + because corresponding subsystem is not configured + and thus is not running. """ + pass + class UnconfiguredObject(UnconfiguredSubsystem): - """ Requested operation is valid but cannot be completed - because its parameter refers to an object that does not exist - in the system configuration. + """Requested operation is valid but cannot be completed + because its parameter refers to an object that does not exist + in the system configuration. """ + pass + class DataUnavailable(Error): - """ Requested operation is valid, but cannot be completed - because data for it is not available. - This error MAY be treated as temporary because such issues - are often caused by transient events such as service restarts. + """Requested operation is valid, but cannot be completed + because data for it is not available. + This error MAY be treated as temporary because such issues + are often caused by transient events such as service restarts. """ + pass + class PermissionDenied(Error): - """ Requested operation is valid, but the caller has no permission - to perform it. + """Requested operation is valid, but the caller has no permission + to perform it. """ + pass + class InsufficientResources(Error): - """ Requested operation and its arguments are valid but the system - does not have enough resources (such as drive space or memory) - to complete it. + """Requested operation and its arguments are valid but the system + does not have enough resources (such as drive space or memory) + to complete it. """ + pass + class UnsupportedOperation(Error): - """ Requested operation is technically valid but is not implemented yet. """ + """Requested operation is technically valid but is not implemented yet.""" + pass + class IncorrectValue(Error): - """ Requested operation is valid, but an argument provided has an - incorrect value, preventing successful completion. + """Requested operation is valid, but an argument provided has an + incorrect value, preventing successful completion. """ + pass + class CommitInProgress(Error): - """ Requested operation is valid, but not possible at the time due + """Requested operation is valid, but not possible at the time due to a commit being in progress. """ + pass + class InternalError(Error): - """ Any situation when VyOS detects that it could not perform - an operation correctly due to logic errors in its own code - or errors in underlying software. + """Any situation when VyOS detects that it could not perform + an operation correctly due to logic errors in its own code + or errors in underlying software. """ + pass def _is_op_mode_function_name(name): - if re.match(r"^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute)", name): + if re.match( + r'^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute|import|mtr)', + name, + ): return True else: return False + def _capture_output(name): - if re.match(r"^(show|generate)", name): + if re.match(r'^(show|generate)', name): return True else: return False + def _get_op_mode_functions(module): from inspect import getmembers, isfunction @@ -110,32 +134,35 @@ def _get_op_mode_functions(module): funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs)) funcs_dict = {} - for (name, thunk) in funcs: + for name, thunk in funcs: funcs_dict[name] = thunk return funcs_dict + def _is_optional_type(t): # Optional[t] is internally an alias for Union[t, NoneType] # and there's no easy way to get union members it seems - if (type(t) == typing._UnionGenericAlias): - if (len(t.__args__) == 2): - if t.__args__[1] == type(None): + if type(t) is typing._UnionGenericAlias: + if len(t.__args__) == 2: + if t.__args__[1] is type(None): return True return False + def _get_arg_type(t): - """ Returns the type itself if it's a primitive type, - or the "real" type of typing.Optional + """Returns the type itself if it's a primitive type, + or the "real" type of typing.Optional - Doesn't work with anything else at the moment! + Doesn't work with anything else at the moment! """ if _is_optional_type(t): return t.__args__[0] else: return t + def _is_literal_type(t): if _is_optional_type(t): t = _get_arg_type(t) @@ -145,9 +172,9 @@ def _is_literal_type(t): return False + def _get_literal_values(t): - """ Returns the tuple of allowed values for a Literal type - """ + """Returns the tuple of allowed values for a Literal type""" if not _is_literal_type(t): return tuple() if _is_optional_type(t): @@ -155,6 +182,7 @@ def _get_literal_values(t): return typing.get_args(t) + def _normalize_field_name(name): # Convert the name to string if it is not # (in some cases they may be numbers) @@ -179,6 +207,7 @@ def _normalize_field_name(name): return name + def _normalize_dict_field_names(old_dict): new_dict = {} @@ -188,10 +217,11 @@ def _normalize_dict_field_names(old_dict): # Sanity check if len(old_dict) != len(new_dict): - raise InternalError("Dictionary fields do not allow unique normalization") + raise InternalError('Dictionary fields do not allow unique normalization') else: return new_dict + def _normalize_field_names(value): if isinstance(value, dict): return _normalize_dict_field_names(value) @@ -200,16 +230,19 @@ def _normalize_field_names(value): else: return value + def run(module): from argparse import ArgumentParser functions = _get_op_mode_functions(module) parser = ArgumentParser() - subparsers = parser.add_subparsers(dest="subcommand") + subparsers = parser.add_subparsers(dest='subcommand') for function_name in functions: - subparser = subparsers.add_parser(function_name, help=functions[function_name].__doc__) + subparser = subparsers.add_parser( + function_name, help=functions[function_name].__doc__ + ) type_hints = typing.get_type_hints(functions[function_name]) if 'return' in type_hints: @@ -222,62 +255,73 @@ def run(module): # Without this, we'd get options like "--foo_bar" opt = re.sub(r'_', '-', opt) - if _get_arg_type(th) == bool: - subparser.add_argument(f"--{opt}", action='store_true') + if _get_arg_type(th) is bool: + subparser.add_argument(f'--{opt}', action='store_true') else: if _is_optional_type(th): if _is_literal_type(th): - subparser.add_argument(f"--{opt}", - choices=list(_get_literal_values(th)), - default=None) + subparser.add_argument( + f'--{opt}', + choices=list(_get_literal_values(th)), + default=None, + ) else: - subparser.add_argument(f"--{opt}", - type=_get_arg_type(th), default=None) + subparser.add_argument( + f'--{opt}', + type=_get_arg_type(th), + default=None, + ) else: if _is_literal_type(th): - subparser.add_argument(f"--{opt}", - choices=list(_get_literal_values(th)), - required=True) + subparser.add_argument( + f'--{opt}', + choices=list(_get_literal_values(th)), + required=True, + ) else: - subparser.add_argument(f"--{opt}", - type=_get_arg_type(th), required=True) + subparser.add_argument( + f'--{opt}', type=_get_arg_type(th), required=True + ) # Get options as a dict rather than a namespace, # so that we can modify it and pack for passing to functions args = vars(parser.parse_args()) - if not args["subcommand"]: - print("Subcommand required!") + if not args['subcommand']: + print('Subcommand required!') parser.print_usage() sys.exit(1) - function_name = args["subcommand"] + function_name = args['subcommand'] func = functions[function_name] # Remove the subcommand from the arguments, # it would cause an extra argument error when we pass the dict to a function - del args["subcommand"] + del args['subcommand'] # Show and generate commands must always get the "raw" argument, # but other commands (clear/reset/restart/add/delete) should not, # because they produce no output and it makes no sense for them. - if ("raw" not in args) and _capture_output(function_name): - args["raw"] = False + if ('raw' not in args) and _capture_output(function_name): + args['raw'] = False if _capture_output(function_name): # Show and generate commands are slightly special: # they may return human-formatted output # or a raw dict that we need to serialize in JSON for printing res = func(**args) - if not args["raw"]: + if not args['raw']: return res else: if not isinstance(res, dict) and not isinstance(res, list): - raise InternalError(f"Bare literal is not an acceptable raw output, must be a list or an object.\ - The output was:{res}") + raise InternalError( + f'Bare literal is not an acceptable raw output, must be a list or an object.\ + The output was:{res}' + ) res = decamelize(res) res = _normalize_field_names(res) from json import dumps + return dumps(res, indent=4) else: # Other functions should not return anything, diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 5a0e2ddda..55dc02631 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -33,6 +33,8 @@ CERT_BEGIN='-----BEGIN CERTIFICATE-----\n' CERT_END='\n-----END CERTIFICATE-----' KEY_BEGIN='-----BEGIN PRIVATE KEY-----\n' KEY_END='\n-----END PRIVATE KEY-----' +KEY_EC_BEGIN='-----BEGIN EC PRIVATE KEY-----\n' +KEY_EC_END='\n-----END EC PRIVATE KEY-----' KEY_ENC_BEGIN='-----BEGIN ENCRYPTED PRIVATE KEY-----\n' KEY_ENC_END='\n-----END ENCRYPTED PRIVATE KEY-----' KEY_PUB_BEGIN='-----BEGIN PUBLIC KEY-----\n' @@ -228,8 +230,18 @@ def create_dh_parameters(bits=2048): def wrap_public_key(raw_data): return KEY_PUB_BEGIN + raw_data + KEY_PUB_END -def wrap_private_key(raw_data, passphrase=None): - return (KEY_ENC_BEGIN if passphrase else KEY_BEGIN) + raw_data + (KEY_ENC_END if passphrase else KEY_END) +def wrap_private_key(raw_data, passphrase=None, ec=False): + begin = KEY_BEGIN + end = KEY_END + + if passphrase: + begin = KEY_ENC_BEGIN + end = KEY_ENC_END + elif ec: + begin = KEY_EC_BEGIN + end = KEY_EC_END + + return begin + raw_data + end def wrap_openssh_public_key(raw_data, type): return f'{type} {raw_data}' @@ -262,17 +274,26 @@ def load_public_key(raw_data, wrap_tags=True): except ValueError: return False -def load_private_key(raw_data, passphrase=None, wrap_tags=True): - if wrap_tags: - raw_data = wrap_private_key(raw_data, passphrase) +def _load_private_key(raw_data, passphrase): + try: + return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase) + except (ValueError, TypeError): + return False +def load_private_key(raw_data, passphrase=None, wrap_tags=True): if passphrase is not None: passphrase = bytes(passphrase, 'utf-8') - try: - return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase) - except (ValueError, TypeError): + result = False + + if wrap_tags: + for ec_test in [False, True]: + wrapped_data = wrap_private_key(raw_data, passphrase, ec_test) + if result := _load_private_key(wrapped_data, passphrase): + return result return False + else: + return _load_private_key(raw_data, passphrase) def load_openssh_public_key(raw_data, type): try: 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..c6296c568 --- /dev/null +++ b/python/vyos/proto/generate_dataclass.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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/vyconf_client.py b/python/vyos/proto/vyconf_client.py new file mode 100644 index 000000000..b385f0951 --- /dev/null +++ b/python/vyos/proto/vyconf_client.py @@ -0,0 +1,89 @@ +# Copyright 2025 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/qos/base.py b/python/vyos/qos/base.py index 98e486e42..b477b5b5e 100644 --- a/python/vyos/qos/base.py +++ b/python/vyos/qos/base.py @@ -17,6 +17,7 @@ import os import jmespath from vyos.base import Warning +from vyos.ifconfig import Interface from vyos.utils.process import cmd from vyos.utils.dict import dict_search from vyos.utils.file import read_file @@ -88,7 +89,8 @@ class QoSBase: if value in self._dsfields: return self._dsfields[value] else: - return value + # left shift operation aligns the DSCP/TOS value with its bit position in the IP header. + return int(value) << 2 def _calc_random_detect_queue_params(self, avg_pkt, max_thr, limit=None, min_thr=None, mark_probability=None, precedence=0): @@ -163,11 +165,11 @@ class QoSBase: default_tc += f' red' qparams = self._calc_random_detect_queue_params( - avg_pkt=dict_search('average_packet', config), - max_thr=dict_search('maximum_threshold', config), + avg_pkt=dict_search('average_packet', config) or 1024, + max_thr=dict_search('maximum_threshold', config) or 18, limit=dict_search('queue_limit', config), min_thr=dict_search('minimum_threshold', config), - mark_probability=dict_search('mark_probability', config) + mark_probability=dict_search('mark_probability', config) or 10 ) default_tc += f' limit {qparams["limit"]} avpkt {qparams["avg_pkt"]}' @@ -244,28 +246,37 @@ class QoSBase: prio = cls_config['priority'] filter_cmd_base += f' prio {prio}' - filter_cmd_base += ' protocol all' - if 'match' in cls_config: has_filter = False + has_action_policy = any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config) + max_index = len(cls_config['match']) for index, (match, match_config) in enumerate(cls_config['match'].items(), start=1): filter_cmd = filter_cmd_base if not has_filter: - for key in ['mark', 'vif', 'ip', 'ipv6']: + for key in ['mark', 'vif', 'ip', 'ipv6', 'interface', 'ether']: if key in match_config: has_filter = True break - if self.qostype == 'shaper' and 'prio ' not in filter_cmd: + tmp = dict_search(f'ether.protocol', match_config) or 'all' + filter_cmd += f' protocol {tmp}' + + if self.qostype in ['shaper', 'shaper_hfsc'] and 'prio ' not in filter_cmd: filter_cmd += f' prio {index}' + if 'mark' in match_config: mark = match_config['mark'] filter_cmd += f' handle {mark} fw' + if 'vif' in match_config: vif = match_config['vif'] filter_cmd += f' basic match "meta(vlan mask 0xfff eq {vif})"' + elif 'interface' in match_config: + iif_name = match_config['interface'] + iif = Interface(iif_name).get_ifindex() + filter_cmd += f' basic match "meta(rt_iif eq {iif})"' - for af in ['ip', 'ipv6']: + for af in ['ip', 'ipv6', 'ether']: tc_af = af if af == 'ipv6': tc_af = 'ip6' @@ -273,77 +284,88 @@ class QoSBase: if af in match_config: filter_cmd += ' u32' - tmp = dict_search(f'{af}.source.address', match_config) - if tmp: filter_cmd += f' match {tc_af} src {tmp}' - - tmp = dict_search(f'{af}.source.port', match_config) - if tmp: filter_cmd += f' match {tc_af} sport {tmp} 0xffff' - - tmp = dict_search(f'{af}.destination.address', match_config) - if tmp: filter_cmd += f' match {tc_af} dst {tmp}' - - tmp = dict_search(f'{af}.destination.port', match_config) - if tmp: filter_cmd += f' match {tc_af} dport {tmp} 0xffff' - - tmp = dict_search(f'{af}.protocol', match_config) - if tmp: - tmp = get_protocol_by_name(tmp) - filter_cmd += f' match {tc_af} protocol {tmp} 0xff' - - tmp = dict_search(f'{af}.dscp', match_config) - if tmp: - tmp = self._get_dsfield(tmp) - if af == 'ip': - filter_cmd += f' match {tc_af} dsfield {tmp} 0xff' - elif af == 'ipv6': - filter_cmd += f' match u16 {tmp} 0x0ff0 at 0' - - # Will match against total length of an IPv4 packet and - # payload length of an IPv6 packet. - # - # IPv4 : match u16 0x0000 ~MAXLEN at 2 - # IPv6 : match u16 0x0000 ~MAXLEN at 4 - tmp = dict_search(f'{af}.max_length', match_config) - if tmp: - # We need the 16 bit two's complement of the maximum - # packet length - tmp = hex(0xffff & ~int(tmp)) - - if af == 'ip': - filter_cmd += f' match u16 0x0000 {tmp} at 2' - elif af == 'ipv6': - filter_cmd += f' match u16 0x0000 {tmp} at 4' - - # We match against specific TCP flags - we assume the IPv4 - # header length is 20 bytes and assume the IPv6 packet is - # not using extension headers (hence a ip header length of 40 bytes) - # TCP Flags are set on byte 13 of the TCP header. - # IPv4 : match u8 X X at 33 - # IPv6 : match u8 X X at 53 - # with X = 0x02 for SYN and X = 0x10 for ACK - tmp = dict_search(f'{af}.tcp', match_config) - if tmp: - mask = 0 - if 'ack' in tmp: - mask |= 0x10 - if 'syn' in tmp: - mask |= 0x02 - mask = hex(mask) - - if af == 'ip': - filter_cmd += f' match u8 {mask} {mask} at 33' - elif af == 'ipv6': - filter_cmd += f' match u8 {mask} {mask} at 53' - - cls = int(cls) - filter_cmd += f' flowid {self._parent:x}:{cls:x}' - self._cmd(filter_cmd) + if af == 'ether': + src = dict_search(f'{af}.source', match_config) + if src: filter_cmd += f' match {tc_af} src {src}' + + dst = dict_search(f'{af}.destination', match_config) + if dst: filter_cmd += f' match {tc_af} dst {dst}' + + if not src and not dst: + filter_cmd += f' match u32 0 0' + else: + tmp = dict_search(f'{af}.source.address', match_config) + if tmp: filter_cmd += f' match {tc_af} src {tmp}' + + tmp = dict_search(f'{af}.source.port', match_config) + if tmp: filter_cmd += f' match {tc_af} sport {tmp} 0xffff' + + tmp = dict_search(f'{af}.destination.address', match_config) + if tmp: filter_cmd += f' match {tc_af} dst {tmp}' + + tmp = dict_search(f'{af}.destination.port', match_config) + if tmp: filter_cmd += f' match {tc_af} dport {tmp} 0xffff' + ### + tmp = dict_search(f'{af}.protocol', match_config) + if tmp: + tmp = get_protocol_by_name(tmp) + filter_cmd += f' match {tc_af} protocol {tmp} 0xff' + + tmp = dict_search(f'{af}.dscp', match_config) + if tmp: + tmp = self._get_dsfield(tmp) + if af == 'ip': + filter_cmd += f' match {tc_af} dsfield {tmp} 0xff' + elif af == 'ipv6': + filter_cmd += f' match u16 {tmp} 0x0ff0 at 0' + + # Will match against total length of an IPv4 packet and + # payload length of an IPv6 packet. + # + # IPv4 : match u16 0x0000 ~MAXLEN at 2 + # IPv6 : match u16 0x0000 ~MAXLEN at 4 + tmp = dict_search(f'{af}.max_length', match_config) + if tmp: + # We need the 16 bit two's complement of the maximum + # packet length + tmp = hex(0xffff & ~int(tmp)) + + if af == 'ip': + filter_cmd += f' match u16 0x0000 {tmp} at 2' + elif af == 'ipv6': + filter_cmd += f' match u16 0x0000 {tmp} at 4' + + # We match against specific TCP flags - we assume the IPv4 + # header length is 20 bytes and assume the IPv6 packet is + # not using extension headers (hence a ip header length of 40 bytes) + # TCP Flags are set on byte 13 of the TCP header. + # IPv4 : match u8 X X at 33 + # IPv6 : match u8 X X at 53 + # with X = 0x02 for SYN and X = 0x10 for ACK + tmp = dict_search(f'{af}.tcp', match_config) + if tmp: + mask = 0 + if 'ack' in tmp: + mask |= 0x10 + if 'syn' in tmp: + mask |= 0x02 + mask = hex(mask) + + if af == 'ip': + filter_cmd += f' match u8 {mask} {mask} at 33' + elif af == 'ipv6': + filter_cmd += f' match u8 {mask} {mask} at 53' + + if index != max_index or not has_action_policy: + # avoid duplicate last match rule + cls = int(cls) + filter_cmd += f' flowid {self._parent:x}:{cls:x}' + self._cmd(filter_cmd) vlan_expression = "match.*.vif" match_vlan = jmespath.search(vlan_expression, cls_config) - if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config) \ - and has_filter: + if has_action_policy and has_filter: # For "vif" "basic match" is used instead of "action police" T5961 if not match_vlan: filter_cmd += f' action police' diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py index 1ee7d0fc3..ca5a26917 100644 --- a/python/vyos/qos/cake.py +++ b/python/vyos/qos/cake.py @@ -15,10 +15,25 @@ from vyos.qos.base import QoSBase + class CAKE(QoSBase): + """ + https://man7.org/linux/man-pages/man8/tc-cake.8.html + """ + _direction = ['egress'] - # https://man7.org/linux/man-pages/man8/tc-cake.8.html + flow_isolation_map = { + 'blind': 'flowblind', + 'src-host': 'srchost', + 'dst-host': 'dsthost', + 'dual-dst-host': 'dual-dsthost', + 'dual-src-host': 'dual-srchost', + 'triple-isolate': 'triple-isolate', + 'flow': 'flows', + 'host': 'hosts', + } + def update(self, config, direction): tmp = f'tc qdisc add dev {self._interface} root handle 1: cake {direction}' if 'bandwidth' in config: @@ -30,26 +45,16 @@ class CAKE(QoSBase): tmp += f' rtt {rtt}ms' if 'flow_isolation' in config: - if 'blind' in config['flow_isolation']: - tmp += f' flowblind' - if 'dst_host' in config['flow_isolation']: - tmp += f' dsthost' - if 'dual_dst_host' in config['flow_isolation']: - tmp += f' dual-dsthost' - if 'dual_src_host' in config['flow_isolation']: - tmp += f' dual-srchost' - if 'triple_isolate' in config['flow_isolation']: - tmp += f' triple-isolate' - if 'flow' in config['flow_isolation']: - tmp += f' flows' - if 'host' in config['flow_isolation']: - tmp += f' hosts' - if 'nat' in config['flow_isolation']: - tmp += f' nat' - if 'src_host' in config['flow_isolation']: - tmp += f' srchost ' - else: - tmp += f' nonat' + isolation_value = self.flow_isolation_map.get(config['flow_isolation']) + + if isolation_value is not None: + tmp += f' {isolation_value}' + else: + raise ValueError( + f'Invalid flow isolation parameter: {config["flow_isolation"]}' + ) + + tmp += ' nat' if 'flow_isolation_nat' in config else ' nonat' self._cmd(tmp) diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py index 7f0a67032..66d27a639 100644 --- a/python/vyos/qos/priority.py +++ b/python/vyos/qos/priority.py @@ -20,17 +20,18 @@ class Priority(QoSBase): # https://man7.org/linux/man-pages/man8/tc-prio.8.html def update(self, config, direction): - if 'class' in config: - class_id_max = self._get_class_max_id(config) - bands = int(class_id_max) +1 + class_id_max = self._get_class_max_id(config) + class_id_max = class_id_max if class_id_max else 1 + bands = int(class_id_max) + 1 - tmp = f'tc qdisc add dev {self._interface} root handle {self._parent:x}: prio bands {bands} priomap ' \ - f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ - f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ - f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ - f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' - self._cmd(tmp) + tmp = f'tc qdisc add dev {self._interface} root handle {self._parent:x}: prio bands {bands} priomap ' \ + f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ + f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ + f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \ + f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' + self._cmd(tmp) + if 'class' in config: for cls in config['class']: cls = int(cls) tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:{cls:x} pfifo' diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py index 80814ddfb..509c4069f 100644 --- a/python/vyos/qos/roundrobin.py +++ b/python/vyos/qos/roundrobin.py @@ -15,6 +15,7 @@ from vyos.qos.base import QoSBase + class RoundRobin(QoSBase): _parent = 1 @@ -34,11 +35,21 @@ class RoundRobin(QoSBase): if 'default' in config: class_id_max = self._get_class_max_id(config) - default_cls_id = int(class_id_max) +1 + default_cls_id = int(class_id_max) + 1 if class_id_max else 1 # class ID via CLI is in range 1-4095, thus 1000 hex = 4096 tmp = f'tc class replace dev {self._interface} parent 1:1 classid 1:{default_cls_id:x} drr' self._cmd(tmp) + # You need to add at least one filter to classify packets + # otherwise, all packets will be dropped. + filter_cmd = ( + f'tc filter replace dev {self._interface} ' + f'parent {self._parent:x}: prio {default_cls_id} protocol all ' + 'u32 match u32 0 0 ' + f'flowid {self._parent}:{default_cls_id}' + ) + self._cmd(filter_cmd) + # call base class super().update(config, direction, priority=True) diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py index 8b0333c21..9f92ccd8b 100644 --- a/python/vyos/qos/trafficshaper.py +++ b/python/vyos/qos/trafficshaper.py @@ -126,91 +126,71 @@ class TrafficShaper(QoSBase): # call base class super().update(config, direction) + class TrafficShaperHFSC(QoSBase): + """ + Traffic shaper using Hierarchical Fair Service Curve (HFSC). + Documentation: https://man7.org/linux/man-pages/man8/tc-hfsc.8.html + """ + _parent = 1 qostype = 'shaper_hfsc' - # https://man7.org/linux/man-pages/man8/tc-hfsc.8.html - def update(self, config, direction): - class_id_max = 0 - if 'class' in config: - tmp = list(config['class']) - tmp.sort() - class_id_max = tmp[-1] + criteria = ['linkshare', 'realtime', 'upperlimit'] + short_criterion = { + 'linkshare': 'ls', + 'realtime': 'rt', + 'upperlimit': 'ul', + } + + def _gen_class(self, cls: int, cls_config: dict): + """ + Generate HFSC class and add Stochastic Fair Queueing (SFQ) qdisc. + + Args: + cls (int): Class ID + cls_config (dict): Configuration for the class + """ + tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc' + + for crit in self.criteria: + param = cls_config.get(crit) + if param: + tmp += ( + f' {self.short_criterion[crit]}' + f' m1 {self._rate_convert(param["m1"]) if param.get("m1") else 0}' + f' d {param.get("d", 0)}ms' + f' m2 {self._rate_convert(param["m2"])}' + ) - r2q = 10 - # bandwidth is a mandatory CLI node - speed = self._rate_convert(config['bandwidth']) - speed_bps = int(speed) // 8 + self._cmd(tmp) - # need a bigger r2q if going fast than 16 mbits/sec - if (speed_bps // r2q) >= MAXQUANTUM: # integer division - r2q = ceil(speed_bps // MAXQUANTUM) - else: - # if there is a slow class then may need smaller value - if 'class' in config: - min_speed = speed_bps - for cls, cls_options in config['class'].items(): - # find class with the lowest bandwidth used - if 'bandwidth' in cls_options: - bw_bps = int(self._rate_convert(cls_options['bandwidth'])) // 8 # bandwidth in bytes per second - if bw_bps < min_speed: - min_speed = bw_bps + tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10' + self._cmd(tmp) - while (r2q > 1) and (min_speed // r2q) < MINQUANTUM: - tmp = r2q -1 - if (speed_bps // tmp) >= MAXQUANTUM: - break - r2q = tmp + def update(self, config, direction): + class_id_max = self._get_class_max_id(config) + default_cls_id = int(class_id_max) + 1 if class_id_max else 2 - default_minor_id = int(class_id_max) +1 - tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_minor_id:x}' # default is in hex + speed = self._rate_convert(config['bandwidth']) + + tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_cls_id:x}' # default is in hex self._cmd(tmp) tmp = f'tc class replace dev {self._interface} parent {self._parent:x}: classid {self._parent:x}:1 hfsc sc rate {speed} ul rate {speed}' self._cmd(tmp) + # tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:1 handle f1: sfq perturb 10' + # self._cmd(tmp) + if 'class' in config: for cls, cls_config in config['class'].items(): - # class id is used later on and passed as hex, thus this needs to be an int - cls = int(cls) - # ls m1 - if cls_config.get('linkshare', {}).get('m1').endswith('%'): - percent = cls_config['linkshare']['m1'].rstrip('%') - m_one_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100 - else: - m_one_rate = cls_config['linkshare']['m1'] - # ls m2 - if cls_config.get('linkshare', {}).get('m2').endswith('%'): - percent = cls_config['linkshare']['m2'].rstrip('%') - m_two_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100 - else: - m_two_rate = self._rate_convert(cls_config['linkshare']['m2']) - - tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} ' - self._cmd(tmp) - - tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10' - self._cmd(tmp) + self._gen_class(cls=int(cls), cls_config=cls_config) if 'default' in config: - # ls m1 - if config.get('default', {}).get('linkshare', {}).get('m1').endswith('%'): - percent = config['default']['linkshare']['m1'].rstrip('%') - m_one_rate = self._rate_convert(config['default']['linkshare']['m1']) * int(percent) // 100 - else: - m_one_rate = config['default']['linkshare']['m1'] - # ls m2 - if config.get('default', {}).get('linkshare', {}).get('m2').endswith('%'): - percent = config['default']['linkshare']['m2'].rstrip('%') - m_two_rate = self._rate_convert(config['default']['linkshare']['m2']) * int(percent) // 100 - else: - m_two_rate = self._rate_convert(config['default']['linkshare']['m2']) - tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{default_minor_id:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} ' - self._cmd(tmp) - - tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{default_minor_id:x} sfq perturb 10' - self._cmd(tmp) + self._gen_class( + cls=int(default_cls_id), cls_config=config.get('default', {}) + ) # call base class super().update(config, direction) diff --git a/python/vyos/remote.py b/python/vyos/remote.py index d87fd24f6..c54fb6031 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -363,6 +363,7 @@ class GitC: # environment vars for our git commands env = { + **os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email, diff --git a/python/vyos/system/grub_util.py b/python/vyos/system/grub_util.py index 4a3d8795e..ad95bb4f9 100644 --- a/python/vyos/system/grub_util.py +++ b/python/vyos/system/grub_util.py @@ -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/template.py b/python/vyos/template.py index be9f781a6..11e1cc50f 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -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): @@ -612,12 +614,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 +633,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) @@ -779,6 +786,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 +883,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 +964,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 +980,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 +1074,21 @@ 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 form 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] diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index a0b3e1cae..5d0e3464a 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -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/config.py b/python/vyos/utils/config.py index 33047010b..deda13c13 100644 --- a/python/vyos/utils/config.py +++ b/python/vyos/utils/config.py @@ -14,8 +14,14 @@ # License along with this library. If not, see <http://www.gnu.org/licenses/>. import os +from typing import TYPE_CHECKING + from vyos.defaults import directories +# https://peps.python.org/pep-0484/#forward-references +if TYPE_CHECKING: + from vyos.configtree import ConfigTree + config_file = os.path.join(directories['config'], 'config.boot') def read_saved_value(path: list): @@ -37,3 +43,63 @@ def read_saved_value(path: list): if len(res) == 1: return ' '.join(res) return res + +def flag(l: list) -> list: + res = [l[0:i] for i,_ in enumerate(l, start=1)] + return res + +def tag_node_of_path(p: list) -> list: + from vyos.xml_ref import is_tag + + fl = flag(p) + res = list(map(is_tag, fl)) + + return res + +def set_tags(ct: 'ConfigTree', path: list) -> None: + fl = flag(path) + if_tag = tag_node_of_path(path) + for condition, target in zip(if_tag, fl): + if condition: + ct.set_tag(target) + +def parse_commands(cmds: str) -> dict: + from re import split as re_split + from shlex import split as shlex_split + + from vyos.xml_ref import definition + from vyos.xml_ref.pkg_cache.vyos_1x_cache import reference + + ref_tree = definition.Xml() + ref_tree.define(reference) + + res = [] + + cmds = re_split(r'\n+', cmds) + for c in cmds: + cmd_parts = shlex_split(c) + + if not cmd_parts: + # Ignore empty lines + continue + + path = cmd_parts[1:] + op = cmd_parts[0] + + try: + path, value = ref_tree.split_path(path) + except ValueError as e: + raise ValueError(f'Incorrect command: {e}') + + entry = {} + entry["op"] = op + entry["path"] = path + entry["value"] = value + + entry["is_multi"] = ref_tree.is_multi(path) + entry["is_leaf"] = ref_tree.is_leaf(path) + entry["is_tag"] = ref_tree.is_tag(path) + + res.append(entry) + + return res diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py index dd4266f57..2f587405d 100644 --- a/python/vyos/utils/convert.py +++ b/python/vyos/utils/convert.py @@ -235,3 +235,29 @@ def convert_data(data) -> dict | list | tuple | str | int | float | bool | None: # which cannot be converted to JSON # for example: complex | range | memoryview return + + +def encode_to_base64(input_string): + """ + Encodes a given string to its base64 representation. + + Args: + input_string (str): The string to be encoded. + + Returns: + str: The base64-encoded version of the input string. + + Example: + input_string = "Hello, World!" + encoded_string = encode_to_base64(input_string) + print(encoded_string) # Output: SGVsbG8sIFdvcmxkIQ== + """ + import base64 + # Convert the string to bytes + byte_string = input_string.encode('utf-8') + + # Encode the byte string to base64 + encoded_string = base64.b64encode(byte_string) + + # Decode the base64 bytes back to a string + return encoded_string.decode('utf-8') diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py index 3bea5ac12..8ace77d15 100644 --- a/python/vyos/utils/cpu.py +++ b/python/vyos/utils/cpu.py @@ -99,3 +99,18 @@ 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'] diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 847f80108..05eac8a6a 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -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/network.py b/python/vyos/utils/network.py index 8fce08de0..20b6a3c9e 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -69,7 +69,9 @@ def get_vrf_members(vrf: str) -> list: answer = json.loads(output) for data in answer: if 'ifname' in data: - interfaces.append(data.get('ifname')) + # Skip PIM interfaces which appears in VRF + if 'pim' not in data.get('ifname'): + interfaces.append(data.get('ifname')) except: pass return interfaces @@ -254,40 +256,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 @@ -597,3 +619,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/process.py b/python/vyos/utils/process.py index ce880f4a4..21335e6b3 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -14,16 +14,27 @@ # 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 from subprocess import STDOUT from subprocess import DEVNULL + +def get_wrapper(vrf, netns): + wrapper = None + if vrf: + wrapper = ['ip', 'vrf', 'exec', vrf] + elif netns: + 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'): + stdout=PIPE, stderr=PIPE, decode='utf-8', vrf=None, netns=None): """ - popen is a wrapper helper aound subprocess.Popen + popen is a wrapper helper around subprocess.Popen with it default setting it will return a tuple (out, err) out: the output of the program run err: the error code returned by the program @@ -45,6 +56,8 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, - DEVNULL, discard the output decode: specify the expected text encoding (utf-8, ascii, ...) the default is explicitely utf-8 which is python's own default + vrf: run command in a VRF context + netns: run command in the named network namespace usage: get both stdout and stderr: popen('command', stdout=PIPE, stderr=STDOUT) @@ -60,9 +73,6 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, if not debug.enabled(flag): flag = 'command' - cmd_msg = f"cmd '{command}'" - debug.message(cmd_msg, flag) - use_shell = shell stdin = None if shell is None: @@ -72,6 +82,24 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, 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' + ) + + 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}'" if use_shell else f"cmd '{shlex.join(command)}'" + debug.message(cmd_msg, flag) + if input: stdin = PIPE input = input.encode() if type(input) is str else input @@ -111,7 +139,7 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, def run(command, flag='', shell=None, input=None, timeout=None, env=None, - stdout=DEVNULL, stderr=PIPE, decode='utf-8'): + stdout=DEVNULL, stderr=PIPE, decode='utf-8', vrf=None, netns=None): """ A wrapper around popen, which discard the stdout and will return the error code of a command @@ -122,13 +150,15 @@ def run(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, + vrf=vrf, + netns=netns, ) return code def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, stdout=PIPE, stderr=PIPE, decode='utf-8', raising=None, message='', - expect=[0]): + expect=[0], vrf=None, netns=None): """ A wrapper around popen, which returns the stdout and will raise the error code of a command @@ -144,8 +174,12 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, + vrf=vrf, + netns=netns, ) if code not in expect: + wrapper = get_wrapper(vrf, netns) + command = f'{wrapper} {command}' feedback = message + '\n' if message else '' feedback += f'failed to run command: {command}\n' feedback += f'returned: {decoded}\n' @@ -159,7 +193,7 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None, - stdout=PIPE, stderr=STDOUT, decode='utf-8'): + stdout=PIPE, stderr=STDOUT, decode='utf-8', vrf=None, netns=None): """ A wrapper around popen, which returns the return code of a command and stdout @@ -175,11 +209,14 @@ def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, + vrf=vrf, + netns=netns, ) return code, out + def call(command, flag='', shell=None, input=None, timeout=None, env=None, - stdout=None, stderr=None, decode='utf-8'): + stdout=None, stderr=None, decode='utf-8', vrf=None, netns=None): """ A wrapper around popen, which print the stdout and will return the error code of a command @@ -190,11 +227,14 @@ def call(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, + vrf=vrf, + netns=netns, ) if out: print(out) return code + def process_running(pid_file): """ Checks if a process with PID in pid_file is running """ from psutil import pid_exists diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py index 7b12efb14..6c112334b 100644 --- a/python/vyos/utils/system.py +++ b/python/vyos/utils/system.py @@ -145,5 +145,5 @@ def get_secure_boot_state() -> bool: from vyos.utils.boot import is_uefi_system if not is_uefi_system(): return False - tmp = cmd('mokutil --sb-state') + tmp = cmd('mokutil --sb-state', expect=[0, 255]) return bool('enabled' in tmp) diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py new file mode 100644 index 000000000..506095625 --- /dev/null +++ b/python/vyos/vyconf_session.py @@ -0,0 +1,123 @@ +# Copyright 2025 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 tempfile +import shutil +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 + + +def output(o): + out = '' + for res in (o.output, o.error, o.warning): + if res is not None: + out = out + res + return out + + +class VyconfSession: + def __init__(self, token: str = None, on_error: Type[Exception] = None): + if token is None: + out = vyconf_client.send_request('setup_session') + self.__token = out.output + else: + self.__token = token + + self.on_error = on_error + + @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 + + @raise_exception + def set(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('set', token=self.__token, path=path) + return output(out), out.status + + @raise_exception + def delete(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('delete', token=self.__token, path=path) + return output(out), out.status + + @raise_exception + def commit(self) -> tuple[str, int]: + out = vyconf_client.send_request('commit', token=self.__token) + return output(out), out.status + + @raise_exception + def discard(self) -> tuple[str, int]: + out = vyconf_client.send_request('discard', token=self.__token) + return output(out), out.status + + def session_changed(self) -> bool: + out = vyconf_client.send_request('session_changed', token=self.__token) + return not bool(out.status) + + @raise_exception + def load_config(self, file: str, migrate: bool = False) -> tuple[str, int]: + # pylint: disable=consider-using-with + if migrate: + tmp = tempfile.NamedTemporaryFile() + shutil.copy2(file, tmp.name) + config_migrate = ConfigMigrate(tmp.name) + try: + config_migrate.run() + except ConfigMigrateError as e: + tmp.close() + return repr(e), 1 + file = tmp.name + else: + tmp = '' + + out = vyconf_client.send_request('load', token=self.__token, location=file) + if tmp: + tmp.close() + + return output(out), out.status + + @raise_exception + def save_config(self, file: str, append_version: bool = False) -> tuple[str, int]: + out = vyconf_client.send_request('save', token=self.__token, location=file) + if append_version: + append_system_version(file) + return 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 output(out), out.status + + def __del__(self): + out = vyconf_client.send_request('teardown', token=self.__token) + if out.status: + print(f'Could not tear down session {self.__token}: {output(out)}') diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py new file mode 100644 index 000000000..62e109f21 --- /dev/null +++ b/python/vyos/wanloadbalance.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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/definition.py b/python/vyos/xml_ref/definition.py index 5ff28daed..4e755ab72 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -13,7 +13,7 @@ # 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 Optional, Union, Any, TYPE_CHECKING +from typing import Tuple, Optional, Union, Any, TYPE_CHECKING # https://peps.python.org/pep-0484/#forward-references # for type 'ConfigDict' @@ -90,6 +90,32 @@ class Xml: res = self._get_ref_node_data(node, 'node_type') return res == 'tag' + def exists(self, path: list) -> bool: + try: + _ = self._get_ref_path(path) + return True + except ValueError: + return False + + def split_path(self, path: list) -> Tuple[list, Optional[str]]: + """ Splits a list into config path and value components """ + + # First, check if the complete path is valid by itself + if self.exists(path): + if self.is_valueless(path) or not self.is_leaf(path): + # It's a complete path for a valueless node + # or a path to an empy non-leaf node + return (path, None) + else: + raise ValueError(f'Path "{path}" needs a value or children') + else: + # If the complete path doesn't exist, it's probably a path with a value + if self.exists(path[0:-1]): + return (path[0:-1], path[-1]) + else: + # Or not a valid path at all + raise ValueError(f'Path "{path}" is incorrect') + def is_tag(self, path: list) -> bool: ref_path = path.copy() d = self.ref diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py index 5f3f84dee..093697993 100755 --- a/python/vyos/xml_ref/generate_cache.py +++ b/python/vyos/xml_ref/generate_cache.py @@ -55,6 +55,8 @@ def main(): parser = ArgumentParser(description='generate and save dict from xml defintions') parser.add_argument('--xml-dir', type=str, required=True, help='transcluded xml interface-definition directory') + parser.add_argument('--internal-cache', type=str, required=True, + help='cache as unrendered json data for loading by vyconfd') parser.add_argument('--package-name', type=non_trivial, default='vyos-1x', help='name of current package') parser.add_argument('--output-path', help='path to generated cache') @@ -66,9 +68,11 @@ def main(): out_path = args['output_path'] path = out_path if out_path is not None else pkg_cache xml_cache = abspath(join(path, cache_name)) + internal_cache = args['internal_cache'] try: - reference_tree_to_json(xml_dir, xml_tmp) + reference_tree_to_json(xml_dir, xml_tmp, + internal_cache=internal_cache) except ConfigTreeError as e: print(e) sys.exit(1) diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index cd2ac890e..95779d066 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 (C) 2024-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -33,9 +33,9 @@ _here = dirname(__file__) sys.path.append(join(_here, '..')) from defaults import directories -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')) @@ -74,7 +74,7 @@ def translate_op_script(s: str) -> str: return s -def insert_node(n: Element, l: list[PathData], path = None) -> None: +def insert_node(n: Element, l: list[PathData], path=None) -> None: # pylint: disable=too-many-locals,too-many-branches prop: OptElement = n.find('properties') children: OptElement = n.find('children') @@ -95,65 +95,67 @@ def insert_node(n: Element, l: list[PathData], path = None) -> None: if command_text is not None: command_text = translate_command(command_text, path) - comp_help = 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) - - inner_l = d.setdefault(name, []) - - 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_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) + + comp_paths = [] + for i in comp_path_els: + comp_paths.append(i.text) + + 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 + + cur_node_dict = {} + cur_node_dict['name'] = name + cur_node_dict['type'] = node_type + cur_node_dict['comp_help'] = comp_help + cur_node_dict['help'] = help_text + cur_node_dict['command'] = command_text + cur_node_dict['path'] = path + cur_node_dict['children'] = [] + l.append(cur_node_dict) 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, cur_node_dict['children'], inner_path) def parse_file(file_path, l): tree = ET.parse(file_path) root = tree.getroot() - for n in root.iterfind("*"): + for n in root.iterfind('*'): insert_node(n, l) def main(): 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', + ) args = vars(parser.parse_args()) @@ -170,5 +172,6 @@ def main(): with open(op_ref_cache, 'w') as f: f.write(f'op_reference = {str(l)}') + if __name__ == '__main__': main() |