diff options
Diffstat (limited to 'python')
42 files changed, 2366 insertions, 186 deletions
| diff --git a/python/setup.py b/python/setup.py index e2d28bd6b..2d614e724 100644 --- a/python/setup.py +++ b/python/setup.py @@ -24,4 +24,9 @@ setup(          "Topic :: Utilities",          "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)",      ], +    entry_points={ +        "console_scripts": [ +            "config-mgmt = vyos.config_mgmt:run", +        ], +    },  ) diff --git a/python/vyos/accel_ppp.py b/python/vyos/accel_ppp.py new file mode 100644 index 000000000..0af311e57 --- /dev/null +++ b/python/vyos/accel_ppp.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 sys + +import vyos.opmode +from vyos.util import rc_cmd + + +def get_server_statistics(accel_statistics, pattern, sep=':') -> dict: +    import re + +    stat_dict = {'sessions': {}} + +    cpu = re.search(r'cpu(.*)', accel_statistics).group(0) +    # Find all lines with pattern, for example 'sstp:' +    data = re.search(rf'{pattern}(.*)', accel_statistics, re.DOTALL).group(0) +    session_starting = re.search(r'starting(.*)', data).group(0) +    session_active = re.search(r'active(.*)', data).group(0) + +    for entry in {cpu, session_starting, session_active}: +        if sep in entry: +            key, value = entry.split(sep) +            if key in ['starting', 'active', 'finishing']: +                stat_dict['sessions'][key] = value.strip() +                continue +            if key == 'cpu': +                stat_dict['cpu_load_percentage'] = int(re.sub(r'%', '', value.strip())) +                continue +            stat_dict[key] = value.strip() +    return stat_dict + + +def accel_cmd(port: int, command: str) -> str: +    _, output = rc_cmd(f'/usr/bin/accel-cmd -p{port} {command}') +    return output + + +def accel_out_parse(accel_output: list[str]) -> list[dict[str, str]]: +    """ Parse accel-cmd show sessions output """ +    data_list: list[dict[str, str]] = list() +    field_names: list[str] = list() + +    field_names_unstripped: list[str] = accel_output.pop(0).split('|') +    for field_name in field_names_unstripped: +        field_names.append(field_name.strip()) + +    while accel_output: +        if '|' not in accel_output[0]: +            accel_output.pop(0) +            continue + +        current_item: list[str] = accel_output.pop(0).split('|') +        item_dict: dict[str, str] = {} + +        for field_index in range(len(current_item)): +            field_name: str = field_names[field_index] +            field_value: str = current_item[field_index].strip() +            item_dict[field_name] = field_value + +        data_list.append(item_dict) + +    return data_list diff --git a/python/vyos/base.py b/python/vyos/base.py index 78067d5b2..9b93cb2f2 100644 --- a/python/vyos/base.py +++ b/python/vyos/base.py @@ -15,17 +15,47 @@  from textwrap import fill + +class BaseWarning: +    def __init__(self, header, message, **kwargs): +        self.message = message +        self.kwargs = kwargs +        if 'width' not in kwargs: +            self.width = 72 +        if 'initial_indent' in kwargs: +            del self.kwargs['initial_indent'] +        if 'subsequent_indent' in kwargs: +            del self.kwargs['subsequent_indent'] +        self.textinitindent = header +        self.standardindent = '' + +    def print(self): +        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) +            if isfirstmessage: +                isfirstmessage = False +                initial_indent = self.standardindent +            print(f'{mes}') +        print('') + +  class Warning(): -    def __init__(self, message): -        # Reformat the message and trim it to 72 characters in length -        message = fill(message, width=72) -        print(f'\nWARNING: {message}') +    def __init__(self, message, **kwargs): +        self.BaseWarn = BaseWarning('WARNING: ', message, **kwargs) +        self.BaseWarn.print() +  class DeprecationWarning(): -    def __init__(self, message): +    def __init__(self, message, **kwargs):          # Reformat the message and trim it to 72 characters in length -        message = fill(message, width=72) -        print(f'\nDEPRECATION WARNING: {message}\n') +        self.BaseWarn = BaseWarning('DEPRECATION WARNING: ', message, **kwargs) +        self.BaseWarn.print() +  class ConfigError(Exception):      def __init__(self, message): diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py new file mode 100644 index 000000000..fade3081c --- /dev/null +++ b/python/vyos/config_mgmt.py @@ -0,0 +1,669 @@ +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import sys +import gzip +import logging +from typing import Optional, Tuple, Union +from filecmp import cmp +from datetime import datetime +from tabulate import tabulate + +from vyos.config import Config +from vyos.configtree import ConfigTree, ConfigTreeError, show_diff +from vyos.defaults import directories +from vyos.util import is_systemd_service_active, ask_yes_no, rc_cmd + +SAVE_CONFIG = '/opt/vyatta/sbin/vyatta-save-config.pl' + +# 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'} + +DEFAULT_TIME_MINUTES = 10 +timer_name = 'commit-confirm' + +config_file = os.path.join(directories['config'], 'config.boot') +archive_dir = os.path.join(directories['config'], 'archive') +archive_config_file = os.path.join(archive_dir, 'config.boot') +commit_log_file = os.path.join(archive_dir, 'commits') +logrotate_conf = os.path.join(archive_dir, 'lr.conf') +logrotate_state = os.path.join(archive_dir, 'lr.state') +rollback_config = os.path.join(archive_dir, 'config.boot-rollback') +prerollback_config = os.path.join(archive_dir, 'config.boot-prerollback') +tmp_log_entry = '/tmp/commit-rev-entry' + +logger = logging.getLogger('config_mgmt') +logger.setLevel(logging.INFO) +ch = logging.StreamHandler() +formatter = logging.Formatter('%(funcName)s: %(levelname)s:%(message)s') +ch.setFormatter(formatter) +logger.addHandler(ch) + +class ConfigMgmtError(Exception): +    pass + +class ConfigMgmt: +    def __init__(self, session_env=None, config=None): +        if session_env: +            self._session_env = session_env +        else: +            self._session_env = None + +        if config is None: +            config = Config() + +        d = config.get_config_dict(['system', 'config-management'], +                                   key_mangling=('-', '_'), +                                   get_first_key=True) + +        self.max_revisions = int(d.get('commit_revisions', 0)) +        self.locations = d.get('commit_archive', {}).get('location', []) +        self.source_address = d.get('commit_archive', +                                    {}).get('source_address', '') +        if config.exists(['system', 'host-name']): +            self.hostname = config.return_value(['system', 'host-name']) +        else: +            self.hostname = 'vyos' + +        # upload only on existence of effective values, notably, on boot. +        # one still needs session self.locations (above) for setting +        # post-commit hook in conf_mode script +        path = ['system', 'config-management', 'commit-archive', 'location'] +        if config.exists_effective(path): +            self.effective_locations = config.return_effective_values(path) +        else: +            self.effective_locations = [] + +        # 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.active_config = config._running_config +        self.working_config = config._session_config + +    @staticmethod +    def save_config(target): +        cmd = f'{SAVE_CONFIG} {target}' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            logger.critical(f'save config failed: {out}') + +    def _unsaved_commits(self) -> bool: +        tmp_save = '/tmp/config.boot.check-save' +        self.save_config(tmp_save) +        ret = not cmp(tmp_save, config_file, shallow=False) +        os.unlink(tmp_save) +        return ret + +    # 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 +        'confirm' call is not issued. +        """ +        if is_systemd_service_active(f'{timer_name}.timer'): +            msg = 'Another confirm is pending' +            return msg, 1 + +        if self._unsaved_commits(): +            W = '\nYou should save previous commits before commit-confirm !\n' +        else: +            W = '' + +        prompt_str = f''' +commit-confirm will automatically reboot in {minutes} minutes unless changes +are confirmed.\n +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"' +        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}' +        os.system(cmd) + +        msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' +        return msg, 0 + +    def confirm(self) -> Tuple[str,int]: +        """Do not reboot to saved config following 'commit-confirm'. +        Update commit log and archive. +        """ +        if not is_systemd_service_active(f'{timer_name}.timer'): +            msg = 'No confirm pending' +            return msg, 0 + +        cmd = f'sudo systemctl stop --quiet {timer_name}.timer' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            raise ConfigMgmtError(out) + +        # kill notify +        cmd = 'sudo pkill -f commit-confirm-notify.py' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            raise ConfigMgmtError(out) + +        entry = self._read_tmp_log_entry() +        self._add_log_entry(**entry) + +        if self._archive_active_config(): +            self._update_archive() + +        msg = 'Reboot timer stopped' +        return msg, 0 + +    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 +        rc, out = rc_cmd('sudo systemctl reboot') +        if rc != 0: +            raise ConfigMgmtError(out) + +        return '', 0 + +    def rollback(self, rev: int, no_prompt: bool=False) -> Tuple[str,int]: +        """Reboot to config revision 'rev'. +        """ +        from shutil import copy + +        msg = '' + +        if not self._check_revision_number(rev): +            msg = f'Invalid revision number {rev}: must be 0 < rev < {maxrev}' +            return msg, 1 + +        prompt_str = 'Proceed with reboot ?' +        if not no_prompt and not ask_yes_no(prompt_str, default=True): +            msg = 'Canceling rollback' +            return msg, 0 + +        rc, out = rc_cmd(f'sudo cp {archive_config_file} {prerollback_config}') +        if rc != 0: +            raise ConfigMgmtError(out) + +        path = os.path.join(archive_dir, f'config.boot.{rev}.gz') +        with gzip.open(path) as f: +            config = f.read() +        try: +            with open(rollback_config, 'wb') as f: +                f.write(config) +            copy(rollback_config, config_file) +        except OSError as e: +            raise ConfigMgmtError from e + +        rc, out = rc_cmd('sudo systemctl reboot') +        if rc != 0: +            raise ConfigMgmtError(out) + +        return msg, 0 + +    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. +        """ +        ct1 = self.active_config +        ct2 = self.working_config +        msg = 'No changes between working and active configurations.\n' +        if saved: +            ct1 = self._get_saved_config_tree() +            ct2 = self.working_config +            msg = 'No changes between working and saved configurations.\n' +        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) +            ct2 = self.working_config +            msg = f'No changes between working and revision {rev1} configurations.\n' +        if rev2 is not None: +            if not self._check_revision_number(rev2): +                return f'Invalid revision number {rev2}', 1 +            # compare older to newer +            ct2 = ct1 +            ct1 = self._get_config_tree_revision(rev2) +            msg = f'No changes between revisions {rev2} and {rev1} configurations.\n' + +        out = '' +        path = [] if commands else self.edit_path +        try: +            if commands: +                out = show_diff(ct1, ct2, path=path, commands=True) +            else: +                out = show_diff(ct1, ct2, path=path) +        except ConfigTreeError as e: +            return e, 1 + +        if out: +            msg = out + +        return msg, 0 + +    def wrap_compare(self, options) -> Tuple[str,int]: +        """Interface to vyatta-cfg-run: args collected as 'options' to parse +        for compare. +        """ +        cmnds = False +        r1 = None +        r2 = None +        if 'commands' in options: +            cmnds=True +            options.remove('commands') +        for i in options: +            if not i.isnumeric(): +                options.remove(i) +        if len(options) > 0: +            r1 = int(options[0]) +        if len(options) > 1: +            r2 = int(options[1]) + +        return self.compare(commands=cmnds, rev1=r1, rev2=r2) + +    # Initialization and post-commit hooks for conf-mode +    # +    def initialize_revision(self): +        """Initialize config archive, logrotate conf, and commit log. +        """ +        mask = os.umask(0o002) +        os.makedirs(archive_dir, exist_ok=True) + +        self._add_logrotate_conf() + +        if (not os.path.exists(commit_log_file) or +            self._get_number_of_revisions() == 0): +            user = self._get_user() +            via = 'init' +            comment = '' +            self._add_log_entry(user, via, comment) +            # add empty init config before boot-config load for revision +            # and diff consistency +            if self._archive_active_config(): +                self._update_archive() + +        os.umask(mask) + +    def commit_revision(self): +        """Update commit log and rotate archived config.boot. + +        commit_revision is called in post-commit-hooks, if +        ['commit-archive', 'commit-revisions'] is configured. +        """ +        if os.getenv('IN_COMMIT_CONFIRM', ''): +            self._new_log_entry(tmp_file=tmp_log_entry) +            return + +        self._add_log_entry() + +        if self._archive_active_config(): +            self._update_archive() + +    def commit_archive(self): +        """Upload config to remote archive. +        """ +        from vyos.remote import upload + +        hostname = self.hostname +        t = datetime.now() +        timestamp = t.strftime('%Y%m%d_%H%M%S') +        remote_file = f'config.boot-{hostname}.{timestamp}' +        source_address = self.source_address + +        for location in self.effective_locations: +            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] +        """ +        log = self._get_log_entries() +        res_l = [] +        for line in log: +            d = self._get_log_entry(line) +            res_l.append(d) + +        return res_l + +    @staticmethod +    def format_log_data(data: list) -> 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") + +            res_l.append([l_no, time_str, +                          f"by {l['user']}", f"via {l['commit_via']}"]) + +            if l['commit_comment'] != 'commit': # default comment +                res_l.append([None, l['commit_comment']]) + +        ret = tabulate(res_l, tablefmt="plain") +        return ret + +    @staticmethod +    def format_log_data_brief(data: list) -> str: +        """Return 'brief' form of log data as str. + +        Slightly compacted format used in completion help for +        '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") + +            res_l.append(['\t', l_no, time_str, +                          f"{l['user']}", f"by {l['commit_via']}"]) + +        ret = tabulate(res_l, tablefmt="plain") +        return ret + +    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)) +            return out + +        out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2) +        return out + +    def show_commit_file(self, rev: int) -> str: +        return self._get_file_revision(rev) + +    # utility functions +    # +    @staticmethod +    def _strip_version(s): +        return re.split(r'(^//)', s, maxsplit=1, flags=re.MULTILINE)[0] + +    def _get_saved_config_tree(self): +        with open(config_file) as f: +            c = self._strip_version(f.read()) +        return ConfigTree(c) + +    def _get_file_revision(self, rev: int): +        if rev not in range(0, self._get_number_of_revisions()): +            raise ConfigMgmtError('revision not available') +        revision = os.path.join(archive_dir, f'config.boot.{rev}.gz') +        with gzip.open(revision) as f: +            r = f.read().decode() +        return r + +    def _get_config_tree_revision(self, rev: int): +        c = self._strip_version(self._get_file_revision(rev)) +        return ConfigTree(c) + +    def _add_logrotate_conf(self): +        conf = f"""{archive_config_file} {{ +    su root vyattacfg +    rotate {self.max_revisions} +    start 0 +    compress +    copy +}}""" +        mask = os.umask(0o133) + +        with open(logrotate_conf, 'w') as f: +            f.write(conf) + +        os.umask(mask) + +    def _archive_active_config(self) -> bool: +        mask = os.umask(0o113) + +        ext = os.getpid() +        tmp_save = f'/tmp/config.boot.{ext}' +        self.save_config(tmp_save) + +        try: +            if cmp(tmp_save, archive_config_file, shallow=False): +                # this will be the case on boot, as well as certain +                # re-initialiation instances after delete/set +                os.unlink(tmp_save) +                return False +        except FileNotFoundError: +            pass + +        rc, out = rc_cmd(f'sudo mv {tmp_save} {archive_config_file}') +        os.umask(mask) + +        if rc != 0: +            logger.critical(f'mv file to archive failed: {out}') +            return False + +        return True + +    @staticmethod +    def _update_archive(): +        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 +        """ +        entries = [] +        if os.path.exists(commit_log_file): +            with open(commit_log_file) as f: +                entries = f.readlines() + +        return entries + +    def _get_number_of_revisions(self) -> int: +        l = self._get_log_entries() +        return len(l) + +    def _check_revision_number(self, rev: int) -> bool: +        # exclude init revision: +        maxrev = self._get_number_of_revisions() +        if not 0 <= rev < maxrev - 1: +            return False +        return True + +    @staticmethod +    def _get_user() -> str: +        import pwd + +        try: +            user = os.getlogin() +        except OSError: +            try: +                user = pwd.getpwuid(os.geteuid())[0] +            except KeyError: +                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]: +        # Format log entry and return str or write to file. +        # +        # Usage is within a post-commit hook, using env values. In case of +        # commit-confirm, it can be written to a temporary file for +        # inclusion on 'confirm'. +        from time import time + +        if timestamp is None: +            timestamp = int(time()) + +        if not user: +            user = self._get_user() +        if not commit_via: +            commit_via = os.getenv('COMMIT_VIA', 'other') +        if not commit_comment: +            commit_comment = os.getenv('COMMIT_COMMENT', 'commit') + +        # the commit log reserves '|' as field demarcation, so replace in +        # comment if present; undo this in _get_log_entry, below +        if re.search(r'\|', commit_comment): +            commit_comment = commit_comment.replace('|', '%%') + +        entry = f'|{timestamp}|{user}|{commit_via}|{commit_comment}|\n' + +        mask = os.umask(0o113) +        if tmp_file is not None: +            try: +                with open(tmp_file, 'w') as f: +                    f.write(entry) +            except OSError as e: +                logger.critical(f'write to {tmp_file} failed: {e}') +            os.umask(mask) +            return None + +        os.umask(mask) +        return entry + +    @staticmethod +    def _get_log_entry(line: str) -> dict: +        log_fmt = re.compile(r'\|.*\|\n?$') +        keys = ['user', 'commit_via', 'commit_comment', 'timestamp'] +        if not log_fmt.match(line): +            logger.critical(f'Invalid log format {line}') +            return {} + +        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])) + +        return d + +    def _read_tmp_log_entry(self) -> dict: +        try: +            with open(tmp_log_entry) as f: +                entry = f.read() +            os.unlink(tmp_log_entry) +        except OSError as e: +            logger.critical(f'error on file {tmp_log_entry}: {e}') + +        return self._get_log_entry(entry) + +    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) + +        log_entries = self._get_log_entries() +        log_entries.insert(0, entry) +        if len(log_entries) > self.max_revisions: +            log_entries = log_entries[:-1] + +        try: +            with open(commit_log_file, 'w') as f: +                f.writelines(log_entries) +        except OSError as e: +            logger.critical(e) + +        os.umask(mask) + +# entry_point for console script +# +def run(): +    from argparse import ArgumentParser, REMAINDER + +    config_mgmt = ConfigMgmt() + +    for s in list(commit_hooks): +        if sys.argv[0].replace('-', '_').endswith(s): +            func = getattr(config_mgmt, s) +            try: +                func() +            except Exception as e: +                print(f'{s}: {e}') +            sys.exit(0) + +    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") + +    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()) + +    func = getattr(config_mgmt, args['subcommand']) +    del args['subcommand'] + +    res = '' +    try: +        res, rc = func(**args) +    except ConfigMgmtError as e: +        print(e) +        sys.exit(1) +    if res: +        print(res) +    sys.exit(rc) diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py new file mode 100644 index 000000000..d4b2cc78f --- /dev/null +++ b/python/vyos/configdep.py @@ -0,0 +1,95 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os +import json +import typing +from inspect import stack + +from vyos.util import load_as_module +from vyos.defaults import directories +from vyos.configsource import VyOSError +from vyos import ConfigError + +# https://peps.python.org/pep-0484/#forward-references +# for type 'Config' +if typing.TYPE_CHECKING: +    from vyos.config import Config + +dependent_func: dict[str, list[typing.Callable]] = {} + +def canon_name(name: str) -> str: +    return os.path.splitext(name)[0].replace('-', '_') + +def canon_name_of_path(path: str) -> str: +    script = os.path.basename(path) +    return canon_name(script) + +def caller_name() -> str: +    return stack()[-1].filename + +def read_dependency_dict() -> dict: +    path = os.path.join(directories['data'], +                        'config-mode-dependencies.json') +    with open(path) as f: +        d = json.load(f) +    return d + +def get_dependency_dict(config: 'Config') -> dict: +    if hasattr(config, 'cached_dependency_dict'): +        d = getattr(config, 'cached_dependency_dict') +    else: +        d = read_dependency_dict() +        setattr(config, 'cached_dependency_dict', d) +    return d + +def run_config_mode_script(script: str, config: 'Config'): +    path = os.path.join(directories['conf_mode'], script) +    name = canon_name(script) +    mod = load_as_module(name, path) + +    config.set_level([]) +    try: +        c = mod.get_config(config) +        mod.verify(c) +        mod.generate(c) +        mod.apply(c) +    except (VyOSError, ConfigError) as e: +        raise ConfigError(repr(e)) + +def def_closure(target: str, config: 'Config', +                tagnode: typing.Optional[str] = None) -> typing.Callable: +    script = target + '.py' +    def func_impl(): +        if tagnode: +            os.environ['VYOS_TAGNODE_VALUE'] = tagnode +        run_config_mode_script(script, config) +    return func_impl + +def set_dependents(case: str, config: 'Config', +                   tagnode: typing.Optional[str] = None): +    d = get_dependency_dict(config) +    k = canon_name_of_path(caller_name()) +    l = dependent_func.setdefault(k, []) +    for target in d[k][case]: +        func = def_closure(target, config, tagnode) +        l.append(func) + +def call_dependents(): +    k = canon_name_of_path(caller_name()) +    l = dependent_func.get(k, []) +    while l: +        f = l.pop(0) +        f() diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py index 9185575df..ac86af09c 100644 --- a/python/vyos/configdiff.py +++ b/python/vyos/configdiff.py @@ -78,23 +78,34 @@ def get_config_diff(config, key_mangling=None):              isinstance(key_mangling[1], str)):          raise ValueError("key_mangling must be a tuple of two strings") -    diff_t = DiffTree(config._running_config, config._session_config) +    if hasattr(config, 'cached_diff_tree'): +        diff_t = getattr(config, 'cached_diff_tree') +    else: +        diff_t = DiffTree(config._running_config, config._session_config) +        setattr(config, 'cached_diff_tree', diff_t) -    return ConfigDiff(config, key_mangling, diff_tree=diff_t) +    if hasattr(config, 'cached_diff_dict'): +        diff_d = getattr(config, 'cached_diff_dict') +    else: +        diff_d = diff_t.dict +        setattr(config, 'cached_diff_dict', diff_d) + +    return ConfigDiff(config, key_mangling, diff_tree=diff_t, +                                            diff_dict=diff_d)  class ConfigDiff(object):      """      The class of config changes as represented by comparison between the      session config dict and the effective config dict.      """ -    def __init__(self, config, key_mangling=None, diff_tree=None): +    def __init__(self, config, key_mangling=None, diff_tree=None, diff_dict=None):          self._level = config.get_level()          self._session_config_dict = config.get_cached_root_dict(effective=False)          self._effective_config_dict = config.get_cached_root_dict(effective=True)          self._key_mangling = key_mangling          self._diff_tree = diff_tree -        self._diff_dict = diff_tree.dict if diff_tree else {} +        self._diff_dict = diff_dict      # mirrored from Config; allow path arguments relative to level      def _make_path(self, path): @@ -209,9 +220,9 @@ class ConfigDiff(object):              if self._diff_tree is None:                  raise NotImplementedError("diff_tree class not available")              else: -                add = get_sub_dict(self._diff_tree.dict, ['add'], get_first_key=True) -                sub = get_sub_dict(self._diff_tree.dict, ['sub'], get_first_key=True) -                inter = get_sub_dict(self._diff_tree.dict, ['inter'], get_first_key=True) +                add = get_sub_dict(self._diff_dict, ['add'], get_first_key=True) +                sub = get_sub_dict(self._diff_dict, ['sub'], get_first_key=True) +                inter = get_sub_dict(self._diff_dict, ['inter'], get_first_key=True)                  ret = {}                  ret[enum_to_key(Diff.MERGE)] = session_dict                  ret[enum_to_key(Diff.DELETE)] = get_sub_dict(sub, self._make_path(path), @@ -284,9 +295,9 @@ class ConfigDiff(object):              if self._diff_tree is None:                  raise NotImplementedError("diff_tree class not available")              else: -                add = get_sub_dict(self._diff_tree.dict, ['add'], get_first_key=True) -                sub = get_sub_dict(self._diff_tree.dict, ['sub'], get_first_key=True) -                inter = get_sub_dict(self._diff_tree.dict, ['inter'], get_first_key=True) +                add = get_sub_dict(self._diff_dict, ['add'], get_first_key=True) +                sub = get_sub_dict(self._diff_dict, ['sub'], get_first_key=True) +                inter = get_sub_dict(self._diff_dict, ['inter'], get_first_key=True)                  ret = {}                  ret[enum_to_key(Diff.MERGE)] = session_dict                  ret[enum_to_key(Diff.DELETE)] = get_sub_dict(sub, self._make_path(path)) diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 3a60f6d92..df44fd8d6 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -34,6 +34,8 @@ REMOVE_IMAGE = ['/opt/vyatta/bin/vyatta-boot-image.pl', '--del']  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'] +OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add'] +OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete']  # Default "commit via" string  APP = "vyos-http-api" @@ -204,3 +206,15 @@ class ConfigSession(object):      def reset(self, path):          out = self.__run_command(RESET + path)          return out + +    def add_container_image(self, name): +        out = self.__run_command(OP_CMD_ADD + ['container', 'image'] + [name]) +        return out + +    def delete_container_image(self, name): +        out = self.__run_command(OP_CMD_DELETE + ['container', 'image'] + [name]) +        return out + +    def show_container_image(self): +        out = self.__run_command(SHOW + ['container', 'image']) +        return out diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index e9cdb69e4..c0b3ebd78 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 VyOS maintainers and contributors +# Copyright (C) 2018-2022 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; @@ -12,10 +12,11 @@  # 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 os  import re  import json -from ctypes import cdll, c_char_p, c_void_p, c_int +from ctypes import cdll, c_char_p, c_void_p, c_int, c_bool  LIBPATH = '/usr/lib/libvyosconfig.so.0' @@ -147,6 +148,8 @@ class ConfigTree(object):              self.__config = address              self.__version = '' +        self.__migration = os.environ.get('VYOS_MIGRATION') +      def __del__(self):          if self.__config is not None:              self.__destroy(self.__config) @@ -191,18 +194,27 @@ class ConfigTree(object):              else:                  self.__set_add_value(self.__config, path_str, str(value).encode()) +        if self.__migration: +            print(f"- op: set path: {path} value: {value} replace: {replace}") +      def delete(self, path):          check_path(path)          path_str = " ".join(map(str, path)).encode()          self.__delete(self.__config, path_str) +        if self.__migration: +            print(f"- op: delete path: {path}") +      def delete_value(self, path, value):          check_path(path)          path_str = " ".join(map(str, path)).encode()          self.__delete_value(self.__config, path_str, value.encode()) +        if self.__migration: +            print(f"- op: delete_value path: {path} value: {value}") +      def rename(self, path, new_name):          check_path(path)          path_str = " ".join(map(str, path)).encode() @@ -216,6 +228,9 @@ class ConfigTree(object):          if (res != 0):              raise ConfigTreeError("Path [{}] doesn't exist".format(path)) +        if self.__migration: +            print(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) @@ -227,7 +242,11 @@ class ConfigTree(object):              raise ConfigTreeError()          res = self.__copy(self.__config, oldpath_str, newpath_str)          if (res != 0): -            raise ConfigTreeError("Path [{}] doesn't exist".format(old_path)) +            msg = self.__get_error().decode() +            raise ConfigTreeError(msg) + +        if self.__migration: +            print(f"- op: copy old_path: {old_path} new_path: {new_path}")      def exists(self, path):          check_path(path) @@ -303,6 +322,36 @@ class ConfigTree(object):          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") +    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() + +    __lib = cdll.LoadLibrary(libpath) +    __show_diff = __lib.show_diff +    __show_diff.argtypes = [c_bool, c_char_p, c_void_p, c_void_p] +    __show_diff.restype = c_char_p +    __get_error = __lib.get_error +    __get_error.argtypes = [] +    __get_error.restype = c_char_p + +    res = __show_diff(commands, path_str, left._get_config(), right._get_config()) +    res = res.decode() +    if res == "#1@": +        msg = __get_error().decode() +        raise ConfigTreeError(msg) + +    return res +  class DiffTree:      def __init__(self, left, right, path=[], libpath=LIBPATH):          if left is None: diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index afa0c5b33..8fddd91d0 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -1,4 +1,4 @@ -# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2020-2023 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 @@ -23,6 +23,7 @@  from vyos import ConfigError  from vyos.util import dict_search +from vyos.util import dict_search_recursive  def verify_mtu(config):      """ @@ -35,8 +36,14 @@ def verify_mtu(config):          mtu = int(config['mtu'])          tmp = Interface(config['ifname']) -        min_mtu = tmp.get_min_mtu() -        max_mtu = tmp.get_max_mtu() +        # Not all interfaces support min/max MTU +        # https://vyos.dev/T5011 +        try: +            min_mtu = tmp.get_min_mtu() +            max_mtu = tmp.get_max_mtu() +        except: # Fallback to defaults +            min_mtu = 68 +            max_mtu = 9000          if mtu < min_mtu:              raise ConfigError(f'Interface MTU too low, ' \ @@ -232,7 +239,7 @@ def verify_authentication(config):      """      if 'authentication' not in config:          return -    if not {'user', 'password'} <= set(config['authentication']): +    if not {'username', 'password'} <= set(config['authentication']):          raise ConfigError('Authentication requires both username and ' \                            'password to be set!') @@ -388,8 +395,10 @@ def verify_accel_ppp_base_service(config, local_users=True):      """      # vertify auth settings      if local_users and dict_search('authentication.mode', config) == 'local': -        if dict_search(f'authentication.local_users', config) == None: -            raise ConfigError('Authentication mode local requires local users to be configured!') +        if (dict_search(f'authentication.local_users', config) is None or +                dict_search(f'authentication.local_users', config) == {}): +            raise ConfigError( +                'Authentication mode local requires local users to be configured!')          for user in dict_search('authentication.local_users.username', config):              user_config = config['authentication']['local_users']['username'][user] @@ -412,7 +421,18 @@ def verify_accel_ppp_base_service(config, local_users=True):              if 'key' not in radius_config:                  raise ConfigError(f'Missing RADIUS secret key for server "{server}"') -    if 'gateway_address' not in config: +    # Check global gateway or gateway in named pool +    gateway = False +    if 'gateway_address' in config: +        gateway = True +    else: +        if 'client_ip_pool' in config: +            if dict_search_recursive(config, 'gateway_address', ['client_ip_pool', 'name']): +                for _, v in config['client_ip_pool']['name'].items(): +                    if 'gateway_address' in v: +                        gateway = True +                        break +    if not gateway:          raise ConfigError('Server requires gateway-address to be configured!')      if 'name_server_ipv4' in config: diff --git a/python/vyos/cpu.py b/python/vyos/cpu.py index 488ae79fb..d2e5f6504 100644 --- a/python/vyos/cpu.py +++ b/python/vyos/cpu.py @@ -73,7 +73,7 @@ def _find_physical_cpus():              # On other architectures, e.g. on ARM, there's no such field.              # We just assume they are different CPUs,              # whether single core ones or cores of physical CPUs. -            phys_cpus[num] = cpu[num] +            phys_cpus[num] = cpus[num]      return phys_cpus diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 7de458960..db0def8ed 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -1,4 +1,4 @@ -# Copyright 2018 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2018-2023 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,19 +15,22 @@  import os +base_dir = '/usr/libexec/vyos/' +  directories = { -  "data": "/usr/share/vyos/", -  "conf_mode": "/usr/libexec/vyos/conf_mode", -  "op_mode": "/usr/libexec/vyos/op_mode", -  "config": "/opt/vyatta/etc/config", -  "current": "/opt/vyatta/etc/config-migrate/current", -  "migrate": "/opt/vyatta/etc/config-migrate/migrate", -  "log": "/var/log/vyatta", -  "templates": "/usr/share/vyos/templates/", -  "certbot": "/config/auth/letsencrypt", -  "api_schema": "/usr/libexec/vyos/services/api/graphql/graphql/schema/", -  "api_templates": "/usr/libexec/vyos/services/api/graphql/session/templates/", -  "vyos_udev_dir": "/run/udev/vyos" +  'base' : base_dir, +  'data' : '/usr/share/vyos/', +  'conf_mode' : f'{base_dir}/conf_mode', +  'op_mode' : f'{base_dir}/op_mode', +  'config' : '/opt/vyatta/etc/config', +  'current' : '/opt/vyatta/etc/config-migrate/current', +  'migrate' : '/opt/vyatta/etc/config-migrate/migrate', +  'log' : '/var/log/vyatta', +  'templates' : '/usr/share/vyos/templates/', +  'certbot' : '/config/auth/letsencrypt', +  'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/', +  'api_templates': f'{base_dir}/services/api/graphql/session/templates/', +  'vyos_udev_dir' : '/run/udev/vyos'  }  config_status = '/tmp/vyos-config-status' @@ -50,12 +53,12 @@ api_data = {      'socket' : False,      'strict' : False,      'debug' : False, -    'api_keys' : [ {"id": "testapp", "key": "qwerty"} ] +    'api_keys' : [ {'id' : 'testapp', 'key' : 'qwerty'} ]  }  vyos_cert_data = { -    "conf": "/etc/nginx/snippets/vyos-cert.conf", -    "crt": "/etc/ssl/certs/vyos-selfsigned.crt", -    "key": "/etc/ssl/private/vyos-selfsign", -    "lifetime": "365", +    'conf' : '/etc/nginx/snippets/vyos-cert.conf', +    'crt' : '/etc/ssl/certs/vyos-selfsigned.crt', +    'key' : '/etc/ssl/private/vyos-selfsign', +    'lifetime' : '365',  } diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py index 2b6012a73..abf8de688 100644 --- a/python/vyos/ethtool.py +++ b/python/vyos/ethtool.py @@ -56,10 +56,10 @@ class Ethtool:      def __init__(self, ifname):          # Get driver used for interface -        sysfs_file = f'/sys/class/net/{ifname}/device/driver/module' -        if os.path.exists(sysfs_file): -            link = os.readlink(sysfs_file) -            self._driver_name = os.path.basename(link) +        out, err = popen(f'ethtool --driver {ifname}') +        driver = re.search(r'driver:\s(\w+)', out) +        if driver: +            self._driver_name = driver.group(1)          # Build a dictinary of supported link-speed and dupley settings.          out, err = popen(f'ethtool {ifname}') diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 4075e55b0..b4b9e67bb 100644 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -20,6 +20,9 @@ import os  import re  from pathlib import Path +from socket import AF_INET +from socket import AF_INET6 +from socket import getaddrinfo  from time import strftime  from vyos.remote import download @@ -31,65 +34,31 @@ from vyos.util import dict_search_args  from vyos.util import dict_search_recursive  from vyos.util import run +# Domain Resolver -# Functions for firewall group domain-groups -def get_ips_domains_dict(list_domains): -    """ -    Get list of IPv4 addresses by list of domains -    Ex: get_ips_domains_dict(['ex1.com', 'ex2.com']) -        {'ex1.com': ['192.0.2.1'], 'ex2.com': ['192.0.2.2', '192.0.2.3']} -    """ -    from socket import gethostbyname_ex -    from socket import gaierror - -    ip_dict = {} -    for domain in list_domains: -        try: -            _, _, ips = gethostbyname_ex(domain) -            ip_dict[domain] = ips -        except gaierror: -            pass - -    return ip_dict - -def nft_init_set(group_name, table="vyos_filter", family="ip"): -    """ -    table ip vyos_filter { -        set GROUP_NAME -            type ipv4_addr -           flags interval -        } -    """ -    return call(f'nft add set ip {table} {group_name} {{ type ipv4_addr\\; flags interval\\; }}') - - -def nft_add_set_elements(group_name, elements, table="vyos_filter", family="ip"): -    """ -    table ip vyos_filter { -        set GROUP_NAME { -            type ipv4_addr -            flags interval -            elements = { 192.0.2.1, 192.0.2.2 } -        } -    """ -    elements = ", ".join(elements) -    return call(f'nft add element {family} {table} {group_name} {{ {elements} }} ') - -def nft_flush_set(group_name, table="vyos_filter", family="ip"): -    """ -    Flush elements of nft set -    """ -    return call(f'nft flush set {family} {table} {group_name}') - -def nft_update_set_elements(group_name, elements, table="vyos_filter", family="ip"): -    """ -    Update elements of nft set -    """ -    flush_set = nft_flush_set(group_name, table="vyos_filter", family="ip") -    nft_add_set = nft_add_set_elements(group_name, elements, table="vyos_filter", family="ip") -    return flush_set, nft_add_set - -# END firewall group domain-group (sets) +def fqdn_config_parse(firewall): +    firewall['ip_fqdn'] = {} +    firewall['ip6_fqdn'] = {} + +    for domain, path in dict_search_recursive(firewall, 'fqdn'): +        fw_name = path[1] # name/ipv6-name +        rule = path[3] # rule id +        suffix = path[4][0] # source/destination (1 char) +        set_name = f'{fw_name}_{rule}_{suffix}' +             +        if path[0] == 'name': +            firewall['ip_fqdn'][set_name] = domain +        elif path[0] == 'ipv6_name': +            firewall['ip6_fqdn'][set_name] = domain + +def fqdn_resolve(fqdn, ipv6=False): +    try: +        res = getaddrinfo(fqdn, None, AF_INET6 if ipv6 else AF_INET) +        return set(item[4][0] for item in res) +    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 @@ -144,12 +113,26 @@ def parse_rule(rule_conf, fw_name, rule_id, ip_name):          if side in rule_conf:              prefix = side[0]              side_conf = rule_conf[side] +            address_mask = side_conf.get('address_mask', None)              if 'address' in side_conf:                  suffix = side_conf['address'] -                if suffix[0] == '!': -                    suffix = f'!= {suffix[1:]}' -                output.append(f'{ip_name} {prefix}addr {suffix}') +                operator = '' +                exclude = suffix[0] == '!' +                if exclude: +                    operator = '!= ' +                    suffix = suffix[1:] +                if address_mask: +                    operator = '!=' if exclude else '==' +                    operator = f'& {address_mask} {operator} ' +                output.append(f'{ip_name} {prefix}addr {operator}{suffix}') + +            if 'fqdn' in side_conf: +                fqdn = side_conf['fqdn'] +                operator = '' +                if fqdn[0] == '!': +                    operator = '!=' +                output.append(f'{ip_name} {prefix}addr {operator} @FQDN_{fw_name}_{rule_id}_{prefix}')              if dict_search_args(side_conf, 'geoip', 'country_code'):                  operator = '' @@ -192,9 +175,13 @@ def parse_rule(rule_conf, fw_name, rule_id, ip_name):                  if 'address_group' in group:                      group_name = group['address_group']                      operator = '' -                    if group_name[0] == '!': +                    exclude = group_name[0] == "!" +                    if exclude:                          operator = '!='                          group_name = group_name[1:] +                    if address_mask: +                        operator = '!=' if exclude else '==' +                        operator = f'& {address_mask} {operator}'                      output.append(f'{ip_name} {prefix}addr {operator} @A{def_suffix}_{group_name}')                  # Generate firewall group domain-group                  elif 'domain_group' in group: @@ -249,12 +236,20 @@ def parse_rule(rule_conf, fw_name, rule_id, ip_name):                  output.append(f'ip6 hoplimit {operator} {value}')      if 'inbound_interface' in rule_conf: -        iiface = rule_conf['inbound_interface'] -        output.append(f'iifname {iiface}') +        if 'interface_name' in rule_conf['inbound_interface']: +            iiface = rule_conf['inbound_interface']['interface_name'] +            output.append(f'iifname {{{iiface}}}') +        else: +            iiface = rule_conf['inbound_interface']['interface_group'] +            output.append(f'iifname @I_{iiface}')      if 'outbound_interface' in rule_conf: -        oiface = rule_conf['outbound_interface'] -        output.append(f'oifname {oiface}') +        if 'interface_name' in rule_conf['outbound_interface']: +            oiface = rule_conf['outbound_interface']['interface_name'] +            output.append(f'oifname {{{oiface}}}') +        else: +            oiface = rule_conf['outbound_interface']['interface_group'] +            output.append(f'oifname @I_{oiface}')      if 'ttl' in rule_conf:          operators = {'eq': '==', 'gt': '>', 'lt': '<'} @@ -327,6 +322,10 @@ def parse_rule(rule_conf, fw_name, rule_id, ip_name):      if tcp_mss:          output.append(f'tcp option maxseg size {tcp_mss}') +    if 'connection_mark' in rule_conf: +        conn_mark_str = ','.join(rule_conf['connection_mark']) +        output.append(f'ct mark {{{conn_mark_str}}}') +      output.append('counter')      if 'set' in rule_conf: @@ -373,6 +372,9 @@ def parse_time(time):  def parse_policy_set(set_conf, def_suffix):      out = [] +    if 'connection_mark' in set_conf: +        conn_mark = set_conf['connection_mark'] +        out.append(f'ct mark set {conn_mark}')      if 'dscp' in set_conf:          dscp = set_conf['dscp']          out.append(f'ip{def_suffix} dscp set {dscp}') diff --git a/python/vyos/frr.py b/python/vyos/frr.py index 0ffd5cba9..ccb132dd5 100644 --- a/python/vyos/frr.py +++ b/python/vyos/frr.py @@ -477,7 +477,7 @@ class FRRConfig:                  # for the listed FRR issues above                  pass          if count >= count_max: -            raise ConfigurationNotValid(f'Config commit retry counter ({count_max}) exceeded') +            raise ConfigurationNotValid(f'Config commit retry counter ({count_max}) exceeded for {daemon} dameon!')          # Save configuration to /run/frr/config/frr.conf          save_configuration() diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py index a37615c8f..206b2bba1 100644 --- a/python/vyos/ifconfig/__init__.py +++ b/python/vyos/ifconfig/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2022 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,4 +36,6 @@ from vyos.ifconfig.tunnel import TunnelIf  from vyos.ifconfig.wireless import WiFiIf  from vyos.ifconfig.l2tpv3 import L2TPv3If  from vyos.ifconfig.macsec import MACsecIf +from vyos.ifconfig.veth import VethIf  from vyos.ifconfig.wwan import WWANIf +from vyos.ifconfig.sstpc import SSTPCIf diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index 519cfc58c..5080144ff 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -239,7 +239,7 @@ class EthernetIf(Interface):          if not isinstance(state, bool):              raise ValueError('Value out of range') -        rps_cpus = '0' +        rps_cpus = 0          queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*'))          if state:              # Enable RPS on all available CPUs except CPU0 which we will not @@ -248,10 +248,16 @@ class EthernetIf(Interface):              # 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 = 'ffffffff,ffffffff,ffffffff,fffffffe' +            rps_cpus = (1 << os.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. +            # This is done by masking out the lowst bit so CPU0 is spared from +            # 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', rps_cpus) +            self._write_sysfs(f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_cpus', f'{rps_cpus:x}')          # send bitmask representation as hex string without leading '0x'          return True diff --git a/python/vyos/ifconfig/input.py b/python/vyos/ifconfig/input.py index db7d2b6b4..3e5f5790d 100644 --- a/python/vyos/ifconfig/input.py +++ b/python/vyos/ifconfig/input.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023 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,6 +17,16 @@ from vyos.ifconfig.interface import Interface  @Interface.register  class InputIf(Interface): +    """ +    The Intermediate Functional Block (ifb) pseudo network interface acts as a +    QoS concentrator for multiple different sources of traffic. Packets from +    or to other interfaces have to be redirected to it using the mirred action +    in order to be handled, regularly routed traffic will be dropped. This way, +    a single stack of qdiscs, classes and filters can be shared between +    multiple interfaces. +    """ + +    iftype = 'ifb'      definition = {          **Interface.definition,          **{ diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index c50ead89f..fc33430eb 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -751,8 +751,8 @@ class Interface(Control):              elif all_rp_filter == 2: global_setting = 'loose'              from vyos.base import Warning -            Warning(f'Global source-validation is set to "{global_setting} '\ -                    f'this overrides per interface setting!') +            Warning(f'Global source-validation is set to "{global_setting}", this '\ +                    f'overrides per interface setting on "{self.ifname}"!')          tmp = self.get_interface('rp_filter')          if int(tmp) == value: @@ -1365,7 +1365,7 @@ class Interface(Control):          if not isinstance(state, bool):              raise ValueError("Value out of range") -        # https://phabricator.vyos.net/T3448 - there is (yet) no RPI support for XDP +        # https://vyos.dev/T3448 - there is (yet) no RPI support for XDP          if not os.path.exists('/usr/sbin/xdp_loader'):              return diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py index b3babfadc..e1d041839 100644 --- a/python/vyos/ifconfig/loopback.py +++ b/python/vyos/ifconfig/loopback.py @@ -46,7 +46,7 @@ class LoopbackIf(Interface):              if addr in self._persistent_addresses:                  # Do not allow deletion of the default loopback addresses as                  # this will cause weird system behavior like snmp/ssh no longer -                # operating as expected, see https://phabricator.vyos.net/T2034. +                # operating as expected, see https://vyos.dev/T2034.                  continue              self.del_addr(addr) diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index 776014bc3..2266879ec 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2022 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 @@ -30,10 +30,17 @@ class MACVLANIf(Interface):      }      def _create(self): +        """ +        Create MACvlan interface in OS kernel. Interface is administrative +        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}'          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}' diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py new file mode 100644 index 000000000..50fc6ee6b --- /dev/null +++ b/python/vyos/ifconfig/sstpc.py @@ -0,0 +1,40 @@ +# Copyright 2022 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/>. + +from vyos.ifconfig.interface import Interface + +@Interface.register +class SSTPCIf(Interface): +    iftype = 'sstpc' +    definition = { +        **Interface.definition, +        **{ +            'section': 'sstpc', +            'prefixes': ['sstpc', ], +            'eternal': 'sstpc[0-9]+$', +        }, +    } + +    def _create(self): +        # we can not create this interface as it is managed outside +        pass + +    def _delete(self): +        # we can not create this interface as it is managed outside +        pass + +    def get_mac(self): +        """ Get a synthetic MAC address. """ +        return self.get_mac_synthetic() diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py index 5258a2cb1..b7bf7d982 100644 --- a/python/vyos/ifconfig/tunnel.py +++ b/python/vyos/ifconfig/tunnel.py @@ -83,11 +83,6 @@ class TunnelIf(Interface):                  'convert': enable_to_on,                  'shellcmd': 'ip link set dev {ifname} multicast {value}',              }, -            'allmulticast': { -                'validate': lambda v: assert_list(v, ['enable', 'disable']), -                'convert': enable_to_on, -                'shellcmd': 'ip link set dev {ifname} allmulticast {value}', -            },          }      } @@ -162,6 +157,10 @@ class TunnelIf(Interface):          """ Get a synthetic MAC address. """          return self.get_mac_synthetic() +    def set_multicast(self, enable): +        """ Change the MULTICAST flag on the device """ +        return self.set_interface('multicast', enable) +      def update(self, config):          """ General helper function which works on a dictionary retrived by          get_config_dict(). It's main intention is to consolidate the scattered @@ -170,5 +169,10 @@ class TunnelIf(Interface):          # Adjust iproute2 tunnel parameters if necessary          self._change_options() +        # IP Multicast +        tmp = dict_search('enable_multicast', config) +        value = 'enable' if (tmp != None) else 'disable' +        self.set_multicast(value) +          # call base class first          super().update(config) diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py new file mode 100644 index 000000000..aafbf226a --- /dev/null +++ b/python/vyos/ifconfig/veth.py @@ -0,0 +1,54 @@ +# Copyright 2022 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/>. + +from vyos.ifconfig.interface import Interface + + +@Interface.register +class VethIf(Interface): +    """ +    Abstraction of a Linux veth interface +    """ +    iftype = 'veth' +    definition = { +        **Interface.definition, +        **{ +            'section': 'virtual-ethernet', +            'prefixes': ['veth', ], +            'bridgeable': True, +        }, +    } + +    def _create(self): +        """ +        Create veth interface in OS kernel. Interface is administrative +        down by default. +        """ +        # check before create, as we have 2 veth interfaces in our CLI +        # interface virtual-ethernet veth0 peer-name 'veth1' +        # interface virtual-ethernet veth1 peer-name 'veth0' +        # +        # but iproute2 creates the pair with one command: +        # ip link add vet0 type veth peer name veth1 +        if self.exists(self.config['peer_name']): +            return + +        # create virtual-ethernet interface +        cmd = 'ip link add {ifname} type {type}'.format(**self.config) +        cmd += f' peer name {self.config["peer_name"]}' +        self._cmd(cmd) + +        # interface is always A/D down. It needs to be enabled explicitly +        self.set_admin_state('down') diff --git a/python/vyos/ipsec.py b/python/vyos/ipsec.py new file mode 100644 index 000000000..cb7c39ff6 --- /dev/null +++ b/python/vyos/ipsec.py @@ -0,0 +1,141 @@ +# Copyright 2020-2023 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/>. + +#Package to communicate with Strongswan VICI + +class ViciInitiateError(Exception): +    """ +        VICI can't initiate a session. +    """ +    pass +class ViciCommandError(Exception): +    """ +        VICI can't execute a command by any reason. +    """ +    pass + +def get_vici_sas(): +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise ViciInitiateError("IPsec not initialized") +    sas = list(session.list_sas()) +    return sas + + +def get_vici_connections(): +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise ViciInitiateError("IPsec not initialized") +    connections = list(session.list_conns()) +    return connections + + +def get_vici_sas_by_name(ike_name: str, tunnel: str) -> list: +    """ +    Find sas by IKE_SA name and/or CHILD_SA name +    and return list of OrdinaryDicts with SASs info +    If tunnel is not None return value is list of OrdenaryDicts contained only +    CHILD_SAs wich names equal tunnel value. +    :param ike_name: IKE SA name +    :type ike_name: str +    :param tunnel: CHILD SA name +    :type tunnel: str +    :return: list of Ordinary Dicts with SASs +    :rtype: list +    """ +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise ViciInitiateError("IPsec not initialized") +    vici_dict = {} +    if ike_name: +        vici_dict['ike'] = ike_name +    if tunnel: +        vici_dict['child'] = tunnel +    try: +        sas = list(session.list_sas(vici_dict)) +        return sas +    except Exception: +        raise ViciCommandError(f'Failed to get SAs') + + +def terminate_vici_ikeid_list(ike_id_list: list) -> None: +    """ +    Terminate IKE SAs by their id that contained in the list +    :param ike_id_list: list of IKE SA id +    :type ike_id_list: list +    """ +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise ViciInitiateError("IPsec not initialized") +    try: +        for ikeid in ike_id_list: +            session_generator = session.terminate( +                {'ike-id': ikeid, 'timeout': '-1'}) +            # a dummy `for` loop is required because of requirements +            # from vici. Without a full iteration on the output, the +            # command to vici may not be executed completely +            for _ in session_generator: +                pass +    except Exception: +        raise ViciCommandError( +            f'Failed to terminate SA for IKE ids {ike_id_list}') + + +def terminate_vici_by_name(ike_name: str, child_name: str) -> None: +    """ +    Terminate IKE SAs by name if CHILD SA name is None. +    Terminate CHILD SAs by name if CHILD SA name is specified +    :param ike_name: IKE SA name +    :type ike_name: str +    :param child_name: CHILD SA name +    :type child_name: str +    """ +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise ViciInitiateError("IPsec not initialized") +    try: +        vici_dict: dict= {} +        if ike_name: +            vici_dict['ike'] = ike_name +        if child_name: +            vici_dict['child'] = child_name +        session_generator = session.terminate(vici_dict) +        # a dummy `for` loop is required because of requirements +        # from vici. Without a full iteration on the output, the +        # command to vici may not be executed completely +        for _ in session_generator: +            pass +    except Exception: +        if child_name: +            raise ViciCommandError( +                f'Failed to terminate SA for IPSEC {child_name}') +        else: +            raise ViciCommandError( +                f'Failed to terminate SA for IKE {ike_name}') diff --git a/python/vyos/migrator.py b/python/vyos/migrator.py index 45ea8b0eb..87c74e1ea 100644 --- a/python/vyos/migrator.py +++ b/python/vyos/migrator.py @@ -16,9 +16,13 @@  import sys  import os  import json -import subprocess +import logging +  import vyos.defaults  import vyos.component_version as component_version +from vyos.util import cmd + +log_file = os.path.join(vyos.defaults.directories['config'], 'vyos-migrate.log')  class MigratorError(Exception):      pass @@ -29,9 +33,21 @@ class Migrator(object):          self._force = force          self._set_vintage = set_vintage          self._config_file_vintage = None -        self._log_file = None          self._changed = False +    def init_logger(self): +        self.logger = logging.getLogger(__name__) +        self.logger.setLevel(logging.DEBUG) + +        # on adding the file handler, allow write permission for cfg_group; +        # restore original umask on exit +        mask = os.umask(0o113) +        fh = logging.FileHandler(log_file) +        formatter = logging.Formatter('%(message)s') +        fh.setFormatter(formatter) +        self.logger.addHandler(fh) +        os.umask(mask) +      def read_config_file_versions(self):          """          Get component versions from config file footer and set vintage; @@ -68,34 +84,15 @@ class Migrator(object):          else:              return True -    def open_log_file(self): -        """ -        Open log file for migration, catching any error. -        Note that, on boot, migration takes place before the canonical log -        directory is created, hence write to the config file directory. -        """ -        self._log_file = os.path.join(vyos.defaults.directories['config'], -                                      'vyos-migrate.log') -        # on creation, allow write permission for cfg_group; -        # restore original umask on exit -        mask = os.umask(0o113) -        try: -            log = open('{0}'.format(self._log_file), 'w') -            log.write("List of executed migration scripts:\n") -        except Exception as e: -            os.umask(mask) -            print("Logging error: {0}".format(e)) -            return None - -        os.umask(mask) -        return log -      def run_migration_scripts(self, config_file_versions, system_versions):          """          Run migration scripts iteratively, until config file version equals          system component version.          """ -        log = self.open_log_file() +        os.environ['VYOS_MIGRATION'] = '1' +        self.init_logger() + +        self.logger.info("List of executed migration scripts:")          cfg_versions = config_file_versions          sys_versions = system_versions @@ -127,8 +124,9 @@ class Migrator(object):                          '{}-to-{}'.format(cfg_ver, next_ver))                  try: -                    subprocess.check_call([migrate_script, -                        self._config_file]) +                    out = cmd([migrate_script, self._config_file]) +                    self.logger.info(f'{migrate_script}') +                    if out: self.logger.info(out)                  except FileNotFoundError:                      pass                  except Exception as err: @@ -136,19 +134,10 @@ class Migrator(object):                            "".format(migrate_script, err))                      sys.exit(1) -                if log: -                    try: -                        log.write('{0}\n'.format(migrate_script)) -                    except Exception as e: -                        print("Error writing log: {0}".format(e)) -                  cfg_ver = next_ver -              rev_versions[key] = cfg_ver -        if log: -            log.close() - +        del os.environ['VYOS_MIGRATION']          return rev_versions      def write_config_file_versions(self, cfg_versions): diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 31bbdc386..8a311045a 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -16,6 +16,8 @@  from vyos.template import is_ip_network  from vyos.util import dict_search_args +from vyos.template import bracketize_ipv6 +  def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):      output = [] @@ -69,6 +71,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):          else:              translation_output.append('to')              if addr: +                addr = bracketize_ipv6(addr)                  translation_output.append(addr)          options = [] @@ -85,8 +88,13 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):              translation_str += f' {",".join(options)}'      for target in ['source', 'destination']: +        if target not in rule_conf: +            continue + +        side_conf = rule_conf[target]          prefix = target[:1] -        addr = dict_search_args(rule_conf, target, 'address') + +        addr = dict_search_args(side_conf, 'address')          if addr and not (ignore_type_addr and target == nat_type):              operator = ''              if addr[:1] == '!': @@ -94,7 +102,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):                  addr = addr[1:]              output.append(f'{ip_prefix} {prefix}addr {operator} {addr}') -        addr_prefix = dict_search_args(rule_conf, target, 'prefix') +        addr_prefix = dict_search_args(side_conf, 'prefix')          if addr_prefix and ipv6:              operator = ''              if addr_prefix[:1] == '!': @@ -102,7 +110,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):                  addr_prefix = addr[1:]              output.append(f'ip6 {prefix}addr {operator} {addr_prefix}') -        port = dict_search_args(rule_conf, target, 'port') +        port = dict_search_args(side_conf, 'port')          if port:              protocol = rule_conf['protocol']              if protocol == 'tcp_udp': @@ -113,6 +121,51 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):                  port = port[1:]              output.append(f'{protocol} {prefix}port {operator} {{ {port} }}') +        if 'group' in side_conf: +            group = side_conf['group'] +            if 'address_group' in group and not (ignore_type_addr and target == nat_type): +                group_name = group['address_group'] +                operator = '' +                if group_name[0] == '!': +                    operator = '!=' +                    group_name = group_name[1:] +                output.append(f'{ip_prefix} {prefix}addr {operator} @A_{group_name}') +            # Generate firewall group domain-group +            elif 'domain_group' in group and not (ignore_type_addr and target == nat_type): +                group_name = group['domain_group'] +                operator = '' +                if group_name[0] == '!': +                    operator = '!=' +                    group_name = group_name[1:] +                output.append(f'{ip_prefix} {prefix}addr {operator} @D_{group_name}') +            elif 'network_group' in group and not (ignore_type_addr and target == nat_type): +                group_name = group['network_group'] +                operator = '' +                if group_name[0] == '!': +                    operator = '!=' +                    group_name = group_name[1:] +                output.append(f'{ip_prefix} {prefix}addr {operator} @N_{group_name}') +            if 'mac_group' in group: +                group_name = group['mac_group'] +                operator = '' +                if group_name[0] == '!': +                    operator = '!=' +                    group_name = group_name[1:] +                output.append(f'ether {prefix}addr {operator} @M_{group_name}') +            if 'port_group' in group: +                proto = rule_conf['protocol'] +                group_name = group['port_group'] + +                if proto == 'tcp_udp': +                    proto = 'th' + +                operator = '' +                if group_name[0] == '!': +                    operator = '!=' +                    group_name = group_name[1:] + +                output.append(f'{proto} {prefix}port {operator} @P_{group_name}') +      output.append('counter')      if 'log' in rule_conf: diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 2e896c8e6..d02ad4de6 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2022-2023 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -22,6 +22,10 @@ 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. + +        This is the base class, scripts should not use it directly +        and should raise more specific errors instead, +        whenever possible.      """      pass @@ -45,6 +49,29 @@ class PermissionDenied(Error):      """      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. +    """ +    pass + +class UnsupportedOperation(Error): +    """ 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. +    """ +    pass + +class CommitInProgress(Error): +    """ 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 @@ -54,13 +81,13 @@ class InternalError(Error):  def _is_op_mode_function_name(name): -    if re.match(r"^(show|clear|reset|restart)", name): +    if re.match(r"^(show|clear|reset|restart|add|delete|generate|set)", name):          return True      else:          return False -def _is_show(name): -    if re.match(r"^show", name): +def _capture_output(name): +    if re.match(r"^(show|generate)", name):          return True      else:          return False @@ -187,20 +214,23 @@ def run(module):      # it would cause an extra argument error when we pass the dict to a function      del args["subcommand"] -    # Show commands must always get the "raw" argument, -    # but other commands (clear/reset/restart) should not, +    # 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 _is_show(function_name): +    if ("raw" not in args) and _capture_output(function_name):          args["raw"] = False -    if re.match(r"^show", function_name): -        # Show commands are slightly special: +    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"]:              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}")              res = decamelize(res)              res = _normalize_field_names(res)              from json import dumps diff --git a/python/vyos/qos/__init__.py b/python/vyos/qos/__init__.py new file mode 100644 index 000000000..a2980ccde --- /dev/null +++ b/python/vyos/qos/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase +from vyos.qos.cake import CAKE +from vyos.qos.droptail import DropTail +from vyos.qos.fairqueue import FairQueue +from vyos.qos.fqcodel import FQCodel +from vyos.qos.limiter import Limiter +from vyos.qos.netem import NetEm +from vyos.qos.priority import Priority +from vyos.qos.randomdetect import RandomDetect +from vyos.qos.ratelimiter import RateLimiter +from vyos.qos.roundrobin import RoundRobin +from vyos.qos.trafficshaper import TrafficShaper +from vyos.qos.trafficshaper import TrafficShaperHFSC diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py new file mode 100644 index 000000000..28635b5e7 --- /dev/null +++ b/python/vyos/qos/base.py @@ -0,0 +1,282 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os + +from vyos.base import Warning +from vyos.util import cmd +from vyos.util import dict_search +from vyos.util import read_file + +class QoSBase: +    _debug = False +    _direction = ['egress'] +    _parent = 0xffff + +    def __init__(self, interface): +        if os.path.exists('/tmp/vyos.qos.debug'): +            self._debug = True +        self._interface = interface + +    def _cmd(self, command): +        if self._debug: +            print(f'DEBUG/QoS: {command}') +        return cmd(command) + +    def get_direction(self) -> list: +        return self._direction + +    def _get_class_max_id(self, config) -> int: +        if 'class' in config: +            tmp = list(config['class'].keys()) +            tmp.sort(key=lambda ii: int(ii)) +            return tmp[-1] +        return None + +    def _build_base_qdisc(self, config : dict, cls_id : int): +        """ +        Add/replace qdisc for every class (also default is a class). This is +        a genetic method which need an implementation "per" queue-type. + +        This matches the old mapping as defined in Perl here: +        https://github.com/vyos/vyatta-cfg-qos/blob/equuleus/lib/Vyatta/Qos/ShaperClass.pm#L223-L229 +        """ +        queue_type = dict_search('queue_type', config) +        default_tc = f'tc qdisc replace dev {self._interface} parent {self._parent}:{cls_id:x}' + +        if queue_type == 'priority': +            handle = 0x4000 + cls_id +            default_tc += f' handle {handle:x}: prio' +            self._cmd(default_tc) + +            queue_limit = dict_search('queue_limit', config) +            for ii in range(1, 4): +                tmp = f'tc qdisc replace dev {self._interface} parent {handle:x}:{ii:x} pfifo limit {queue_limit}' +                self._cmd(tmp) + +        elif queue_type == 'fair-queue': +            default_tc += f' sfq' + +            tmp = dict_search('queue_limit', config) +            if tmp: default_tc += f' limit {tmp}' + +            self._cmd(default_tc) + +        elif queue_type == 'fq-codel': +            default_tc += f' fq_codel' +            tmp = dict_search('codel_quantum', config) +            if tmp: default_tc += f' quantum {tmp}' + +            tmp = dict_search('flows', config) +            if tmp: default_tc += f' flows {tmp}' + +            tmp = dict_search('interval', config) +            if tmp: default_tc += f' interval {tmp}' + +            tmp = dict_search('interval', config) +            if tmp: default_tc += f' interval {tmp}' + +            tmp = dict_search('queue_limit', config) +            if tmp: default_tc += f' limit {tmp}' + +            tmp = dict_search('target', config) +            if tmp: default_tc += f' target {tmp}' + +            default_tc += f' noecn' + +            self._cmd(default_tc) + +        elif queue_type == 'random-detect': +            default_tc += f' red' + +            self._cmd(default_tc) + +        elif queue_type == 'drop-tail': +            default_tc += f' pfifo' + +            tmp = dict_search('queue_limit', config) +            if tmp: default_tc += f' limit {tmp}' + +            self._cmd(default_tc) + +    def _rate_convert(self, rate) -> int: +        rates = { +            'bit'   : 1, +            'kbit'  : 1000, +            'mbit'  : 1000000, +            'gbit'  : 1000000000, +            'tbit'  : 1000000000000, +        } + +        if rate == 'auto' or rate.endswith('%'): +            speed = read_file(f'/sys/class/net/{self._interface}/speed') +            if not speed.isnumeric(): +                Warning('Interface speed cannot be determined (assuming 10 Mbit/s)') +                speed = 10 +            if rate.endswith('%'): +                percent = rate.rstrip('%') +                speed = int(speed) * int(percent) // 100 +            return int(speed) *1000000 # convert to MBit/s + +        rate_numeric = int(''.join([n for n in rate if n.isdigit()])) +        rate_scale   = ''.join([n for n in rate if not n.isdigit()]) + +        if int(rate_numeric) <= 0: +            raise ValueError(f'{rate_numeric} is not a valid bandwidth <= 0') + +        if rate_scale: +            return int(rate_numeric * rates[rate_scale]) +        else: +            # No suffix implies Kbps just as Cisco IOS +            return int(rate_numeric * 1000) + +    def update(self, config, direction, priority=None): +        """ method must be called from derived class after it has completed qdisc setup """ + +        if 'class' in config: +            for cls, cls_config in config['class'].items(): +                self._build_base_qdisc(cls_config, int(cls)) + +                if 'match' in cls_config: +                    for match, match_config in cls_config['match'].items(): +                        for af in ['ip', 'ipv6']: +                            # every match criteria has it's tc instance +                            filter_cmd = f'tc filter replace dev {self._interface} parent {self._parent:x}:' + +                            if priority: +                                filter_cmd += f' prio {cls}' +                            elif 'priority' in cls_config: +                                prio = cls_config['priority'] +                                filter_cmd += f' prio {prio}' + +                            filter_cmd += ' protocol all u32' + +                            tc_af = af +                            if af == 'ipv6': +                                tc_af = 'ip6' + +                            if af in match_config: +                                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: filter_cmd += f' match {tc_af} protocol {tmp} 0xff' + +                                # 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' + +                                # The police block allows limiting of the byte or packet rate of +                                # traffic matched by the filter it is attached to. +                                # https://man7.org/linux/man-pages/man8/tc-police.8.html +                                if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config): +                                    filter_cmd += f' action police' + +                                if 'exceed' in cls_config: +                                    action = cls_config['exceed'] +                                    filter_cmd += f' conform-exceed {action}' +                                    if 'not_exceed' in cls_config: +                                        action = cls_config['not_exceed'] +                                        filter_cmd += f'/{action}' + +                                if 'bandwidth' in cls_config: +                                    rate = self._rate_convert(cls_config['bandwidth']) +                                    filter_cmd += f' rate {rate}' + +                                if 'burst' in cls_config: +                                    burst = cls_config['burst'] +                                    filter_cmd += f' burst {burst}' + +                                cls = int(cls) +                                filter_cmd += f' flowid {self._parent:x}:{cls:x}' +                                self._cmd(filter_cmd) + +        if 'default' in config: +            if 'class' in config: +                class_id_max = self._get_class_max_id(config) +                default_cls_id = int(class_id_max) +1 +                self._build_base_qdisc(config['default'], default_cls_id) + +            filter_cmd = f'tc filter replace dev {self._interface} parent {self._parent:x}: ' +            filter_cmd += 'prio 255 protocol all basic' + +            # The police block allows limiting of the byte or packet rate of +            # traffic matched by the filter it is attached to. +            # https://man7.org/linux/man-pages/man8/tc-police.8.html +            if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in config['default']): +                filter_cmd += f' action police' + +            if 'exceed' in config['default']: +                action = config['default']['exceed'] +                filter_cmd += f' conform-exceed {action}' +                if 'not_exceed' in config['default']: +                    action = config['default']['not_exceed'] +                    filter_cmd += f'/{action}' + +            if 'bandwidth' in config['default']: +                rate = self._rate_convert(config['default']['bandwidth']) +                filter_cmd += f' rate {rate}' + +            if 'burst' in config['default']: +                burst = config['default']['burst'] +                filter_cmd += f' burst {burst}' + +            if 'class' in config: +                filter_cmd += f' flowid {self._parent:x}:{default_cls_id:x}' + +            self._cmd(filter_cmd) + diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py new file mode 100644 index 000000000..a89b1de1e --- /dev/null +++ b/python/vyos/qos/cake.py @@ -0,0 +1,55 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class CAKE(QoSBase): +    _direction = ['egress'] + +    # https://man7.org/linux/man-pages/man8/tc-cake.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root handle 1: cake {direction}' +        if 'bandwidth' in config: +            bandwidth = self._rate_convert(config['bandwidth']) +            tmp += f' bandwidth {bandwidth}' + +        if 'rtt' in config: +            rtt = config['rtt'] +            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 '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' + +        self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/droptail.py b/python/vyos/qos/droptail.py new file mode 100644 index 000000000..427d43d19 --- /dev/null +++ b/python/vyos/qos/droptail.py @@ -0,0 +1,28 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class DropTail(QoSBase): +    # https://man7.org/linux/man-pages/man8/tc-pfifo.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root pfifo' +        if 'queue_limit' in config: +            limit = config["queue_limit"] +            tmp += f' limit {limit}' +        self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/fairqueue.py b/python/vyos/qos/fairqueue.py new file mode 100644 index 000000000..f41d098fb --- /dev/null +++ b/python/vyos/qos/fairqueue.py @@ -0,0 +1,31 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class FairQueue(QoSBase): +    # https://man7.org/linux/man-pages/man8/tc-sfq.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root sfq' + +        if 'hash_interval' in config: +            tmp += f' perturb {config["hash_interval"]}' +        if 'queue_limit' in config: +            tmp += f' limit {config["queue_limit"]}' + +        self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/fqcodel.py b/python/vyos/qos/fqcodel.py new file mode 100644 index 000000000..cd2340aa2 --- /dev/null +++ b/python/vyos/qos/fqcodel.py @@ -0,0 +1,40 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class FQCodel(QoSBase): +    # https://man7.org/linux/man-pages/man8/tc-fq_codel.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root fq_codel' + +        if 'codel_quantum' in config: +            tmp += f' quantum {config["codel_quantum"]}' +        if 'flows' in config: +            tmp += f' flows {config["flows"]}' +        if 'interval' in config: +            interval = int(config['interval']) * 1000 +            tmp += f' interval {interval}' +        if 'queue_limit' in config: +            tmp += f' limit {config["queue_limit"]}' +        if 'target' in config: +            target = int(config['target']) * 1000 +            tmp += f' target {target}' + +        tmp += f' noecn' +        self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/limiter.py b/python/vyos/qos/limiter.py new file mode 100644 index 000000000..ace0c0b6c --- /dev/null +++ b/python/vyos/qos/limiter.py @@ -0,0 +1,27 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class Limiter(QoSBase): +    _direction = ['ingress'] + +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} handle {self._parent:x}: {direction}' +        self._cmd(tmp) + +        # base class must be called last +        super().update(config, direction) + diff --git a/python/vyos/qos/netem.py b/python/vyos/qos/netem.py new file mode 100644 index 000000000..8bdef300b --- /dev/null +++ b/python/vyos/qos/netem.py @@ -0,0 +1,53 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class NetEm(QoSBase): +    # https://man7.org/linux/man-pages/man8/tc-netem.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root netem' +        if 'bandwidth' in config: +            rate = self._rate_convert(config["bandwidth"]) +            tmp += f' rate {rate}' + +        if 'queue_limit' in config: +            limit = config["queue_limit"] +            tmp += f' limit {limit}' + +        if 'delay' in config: +            delay = config["delay"] +            tmp += f' delay {delay}ms' + +        if 'loss' in config: +            drop  = config["loss"] +            tmp += f' drop {drop}%' + +        if 'corruption' in config: +            corrupt = config["corruption"] +            tmp += f' corrupt {corrupt}%' + +        if 'reordering' in config: +            reorder = config["reordering"] +            tmp += f' reorder {reorder}%' + +        if 'duplicate' in config: +            duplicate = config["duplicate"] +            tmp += f' duplicate {duplicate}%' + +        self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py new file mode 100644 index 000000000..6d4a60a43 --- /dev/null +++ b/python/vyos/qos/priority.py @@ -0,0 +1,41 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase +from vyos.util import dict_search + +class Priority(QoSBase): +    _parent = 1 + +    # 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 + +            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) + +            for cls in config['class']: +                cls = int(cls) +                tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:{cls:x} pfifo' +                self._cmd(tmp) + +        # base class must be called last +        super().update(config, direction, priority=True) diff --git a/python/vyos/qos/randomdetect.py b/python/vyos/qos/randomdetect.py new file mode 100644 index 000000000..d7d84260f --- /dev/null +++ b/python/vyos/qos/randomdetect.py @@ -0,0 +1,54 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class RandomDetect(QoSBase): +    _parent = 1 + +    # https://man7.org/linux/man-pages/man8/tc.8.html +    def update(self, config, direction): + +        tmp = f'tc qdisc add dev {self._interface} root handle {self._parent}:0 dsmark indices 8 set_tc_index' +        self._cmd(tmp) + +        tmp = f'tc filter add dev {self._interface} parent {self._parent}:0 protocol ip prio 1 tcindex mask 0xe0 shift 5' +        self._cmd(tmp) + +        # Generalized Random Early Detection +        handle = self._parent +1 +        tmp = f'tc qdisc add dev {self._interface} parent {self._parent}:0 handle {handle}:0 gred setup DPs 8 default 0 grio' +        self._cmd(tmp) + +        bandwidth = self._rate_convert(config['bandwidth']) + +        # set VQ (virtual queue) parameters +        for precedence, precedence_config in config['precedence'].items(): +            precedence = int(precedence) +            avg_pkt = int(precedence_config['average_packet']) +            limit = int(precedence_config['queue_limit']) * avg_pkt +            min_val = int(precedence_config['minimum_threshold']) * avg_pkt +            max_val = int(precedence_config['maximum_threshold']) * avg_pkt + +            tmp  = f'tc qdisc change dev {self._interface} handle {handle}:0 gred limit {limit} min {min_val} max {max_val} avpkt {avg_pkt} ' + +            burst = (2 * int(precedence_config['minimum_threshold']) + int(precedence_config['maximum_threshold'])) // 3 +            probability = 1 / int(precedence_config['mark_probability']) +            tmp += f'burst {burst} bandwidth {bandwidth} probability {probability} DP {precedence} prio {8 - precedence:x}' + +            self._cmd(tmp) + +        # call base class +        super().update(config, direction) diff --git a/python/vyos/qos/ratelimiter.py b/python/vyos/qos/ratelimiter.py new file mode 100644 index 000000000..a4f80a1be --- /dev/null +++ b/python/vyos/qos/ratelimiter.py @@ -0,0 +1,37 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class RateLimiter(QoSBase): +    # https://man7.org/linux/man-pages/man8/tc-tbf.8.html +    def update(self, config, direction): +        # call base class +        super().update(config, direction) + +        tmp = f'tc qdisc add dev {self._interface} root tbf' +        if 'bandwidth' in config: +            rate = self._rate_convert(config['bandwidth']) +            tmp += f' rate {rate}' + +        if 'burst' in config: +            burst = config['burst'] +            tmp += f' burst {burst}' + +        if 'latency' in config: +            latency = config['latency'] +            tmp += f' latency {latency}ms' + +        self._cmd(tmp) diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py new file mode 100644 index 000000000..80814ddfb --- /dev/null +++ b/python/vyos/qos/roundrobin.py @@ -0,0 +1,44 @@ +# Copyright 2022 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/>. + +from vyos.qos.base import QoSBase + +class RoundRobin(QoSBase): +    _parent = 1 + +    # https://man7.org/linux/man-pages/man8/tc-drr.8.html +    def update(self, config, direction): +        tmp = f'tc qdisc add dev {self._interface} root handle 1: drr' +        self._cmd(tmp) + +        if 'class' in config: +            for cls in config['class']: +                cls = int(cls) +                tmp = f'tc class replace dev {self._interface} parent 1:1 classid 1:{cls:x} drr' +                self._cmd(tmp) + +                tmp = f'tc qdisc replace dev {self._interface} parent 1:{cls:x} pfifo' +                self._cmd(tmp) + +        if 'default' in config: +            class_id_max = self._get_class_max_id(config) +            default_cls_id = int(class_id_max) +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) + +        # call base class +        super().update(config, direction, priority=True) diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py new file mode 100644 index 000000000..f42f4d022 --- /dev/null +++ b/python/vyos/qos/trafficshaper.py @@ -0,0 +1,106 @@ +# Copyright 2022 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/>. + +from math import ceil +from vyos.qos.base import QoSBase + +# Kernel limits on quantum (bytes) +MAXQUANTUM = 200000 +MINQUANTUM = 1000 + +class TrafficShaper(QoSBase): +    _parent = 1 + +    # https://man7.org/linux/man-pages/man8/tc-htb.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] + +        r2q = 10 +        # bandwidth is a mandatory CLI node +        speed = self._rate_convert(config['bandwidth']) +        speed_bps = int(speed) // 8 + +        # 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 + +                while (r2q > 1) and (min_speed // r2q) < MINQUANTUM: +                    tmp = r2q -1 +                    if (speed_bps // tmp) >= MAXQUANTUM: +                        break +                    r2q = tmp + + +        default_minor_id = int(class_id_max) +1 +        tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: htb r2q {r2q} default {default_minor_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 htb rate {speed}' +        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) + +                # bandwidth is a mandatory CLI node +                rate = self._rate_convert(cls_config['bandwidth']) +                burst = cls_config['burst'] +                quantum = cls_config['codel_quantum'] + +                tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} htb rate {rate} burst {burst} quantum {quantum}' +                if 'priority' in cls_config: +                    priority = cls_config['priority'] +                    tmp += f' prio {priority}' +                self._cmd(tmp) + +                tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq' +                self._cmd(tmp) + +        if 'default' in config: +                rate = self._rate_convert(config['default']['bandwidth']) +                burst = config['default']['burst'] +                quantum = config['default']['codel_quantum'] +                tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{default_minor_id:x} htb rate {rate} burst {burst} quantum {quantum}' +                if 'priority' in config['default']: +                    priority = config['default']['priority'] +                    tmp += f' prio {priority}' +                self._cmd(tmp) + +                tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{default_minor_id:x} sfq' +                self._cmd(tmp) + +        # call base class +        super().update(config, direction) + +class TrafficShaperHFSC(TrafficShaper): +    def update(self, config, direction): +        # call base class +        super().update(config, direction) + diff --git a/python/vyos/template.py b/python/vyos/template.py index 2a4135f9e..6367f51e5 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2023 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 @@ -158,6 +158,24 @@ def force_to_list(value):      else:          return [value] +@register_filter('seconds_to_human') +def seconds_to_human(seconds, separator=""): +    """ Convert seconds to human-readable values like 1d6h15m23s """ +    from vyos.util import seconds_to_human +    return seconds_to_human(seconds, separator=separator) + +@register_filter('bytes_to_human') +def bytes_to_human(bytes, initial_exponent=0, precision=2): +    """ Convert bytes to human-readable values like 1.44M """ +    from vyos.util import bytes_to_human +    return bytes_to_human(bytes, initial_exponent=initial_exponent, precision=precision) + +@register_filter('human_to_bytes') +def human_to_bytes(value): +    """ Convert a data amount with a unit suffix to bytes, like 2K to 2048 """ +    from vyos.util import human_to_bytes +    return human_to_bytes(value) +  @register_filter('ip_from_cidr')  def ip_from_cidr(prefix):      """ Take an IPv4/IPv6 CIDR host and strip cidr mask. @@ -193,6 +211,16 @@ def dot_colon_to_dash(text):      text = text.replace(".", "-")      return text +@register_filter('generate_uuid4') +def generate_uuid4(text): +    """ Generate random unique ID +    Example: +      % uuid4() +      UUID('958ddf6a-ef14-4e81-8cfb-afb12456d1c5') +    """ +    from uuid import uuid4 +    return uuid4() +  @register_filter('netmask_from_cidr')  def netmask_from_cidr(prefix):      """ Take CIDR prefix and convert the prefix length to a "subnet mask". @@ -476,6 +504,8 @@ def get_esp_ike_cipher(group_config, ike_group=None):                  continue              tmp = '{encryption}-{hash}'.format(**proposal) +            if 'prf' in proposal: +                tmp += '-' + proposal['prf']              if 'dh_group' in proposal:                  tmp += '-' + pfs_lut[ 'dh-group' +  proposal['dh_group'] ]              elif 'pfs' in group_config and group_config['pfs'] != 'disable': diff --git a/python/vyos/util.py b/python/vyos/util.py index a80584c5a..66ded464d 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -348,9 +348,11 @@ def colon_separated_to_dict(data_string, uniquekeys=False):          l = l.strip()          if l:              match = re.match(key_value_re, l) -            if match: +            if match and (len(match.groups()) == 2):                  key = match.groups()[0].strip()                  value = match.groups()[1].strip() +            else: +                raise ValueError(f"""Line "{l}" could not be parsed a colon-separated pair """, l)              if key in data.keys():                  if uniquekeys:                      raise ValueError("Data string has duplicate keys: {0}".format(key)) @@ -486,7 +488,7 @@ def is_listen_port_bind_service(port: int, service: str) -> bool:      Example:          % is_listen_port_bind_service(443, 'nginx')          True -        % is_listen_port_bind_service(443, 'ocservr-main') +        % is_listen_port_bind_service(443, 'ocserv-main')          False      """      from psutil import net_connections as connections @@ -539,13 +541,16 @@ def seconds_to_human(s, separator=""):      return result -def bytes_to_human(bytes, initial_exponent=0): +def bytes_to_human(bytes, initial_exponent=0, precision=2):      """ Converts a value in bytes to a human-readable size string like 640 KB      The initial_exponent parameter is the exponent of 2,      e.g. 10 (1024) for kilobytes, 20 (1024 * 1024) for megabytes.      """ +    if bytes == 0: +        return "0 B" +      from math import log2      bytes = bytes * (2**initial_exponent) @@ -571,7 +576,7 @@ def bytes_to_human(bytes, initial_exponent=0):      # Add a new case when the first machine with petabyte RAM      # hits the market. -    size_string = "{0:.2f} {1}".format(value, suffix) +    size_string = "{0:.{1}f} {2}".format(value, precision, suffix)      return size_string  def human_to_bytes(value): @@ -1143,3 +1148,11 @@ def camel_to_snake_case(name: str) -> str:      pattern = r'\d+|[A-Z]?[a-z]+|\W|[A-Z]{2,}(?=[A-Z][a-z]|\d|\W|$)'      words = re.findall(pattern, name)      return '_'.join(map(str.lower, words)) + +def load_as_module(name: str, path: str): +    import importlib.util + +    spec = importlib.util.spec_from_file_location(name, path) +    mod = importlib.util.module_from_spec(spec) +    spec.loader.exec_module(mod) +    return mod | 
