diff options
Diffstat (limited to 'python')
41 files changed, 2430 insertions, 383 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..bfc8ee5a9 --- /dev/null +++ b/python/vyos/accel_ppp.py @@ -0,0 +1,74 @@ +#!/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 + 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/component_version.py b/python/vyos/component_version.py new file mode 100644 index 000000000..a4e318d08 --- /dev/null +++ b/python/vyos/component_version.py @@ -0,0 +1,192 @@ +# 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/>. + +""" +Functions for reading/writing component versions. + +The config file version string has the following form: + +VyOS 1.3/1.4: + +// Warning: Do not remove the following line. +// vyos-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack@3:conntrack-sync@2:dhcp-relay@2:dhcp-server@6:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@22:ipoe-server@1:ipsec@5:isis@1:l2tp@3:lldp@1:mdns@1:nat@5:ntp@1:pppoe-server@5:pptp@2:qos@1:quagga@8:rpki@1:salt@1:snmp@2:ssh@2:sstp@3:system@21:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webproxy@2:zone-policy@1" +// Release version: 1.3.0 + +VyOS 1.2: + +/* Warning: Do not remove the following line. */ +/* === vyatta-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack-sync@1:conntrack@1:dhcp-relay@2:dhcp-server@5:dns-forwarding@1:firewall@5:ipsec@5:l2tp@1:mdns@1:nat@4:ntp@1:pppoe-server@2:pptp@1:qos@1:quagga@7:snmp@1:ssh@1:system@10:vrrp@2:wanloadbalance@3:webgui@1:webproxy@2:zone-policy@1" === */ +/* Release version: 1.2.8 */ + +""" + +import os +import re +import sys +import fileinput + +from vyos.xml import component_version +from vyos.version import get_version +from vyos.defaults import directories + +DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') + +def from_string(string_line, vintage='vyos'): + """ + Get component version dictionary from string. + Return empty dictionary if string contains no config information + or raise error if component version string malformed. + """ + version_dict = {} + + if vintage == 'vyos': + if re.match(r'// vyos-config-version:.+', string_line): + if not re.match(r'// vyos-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s*', string_line): + raise ValueError(f"malformed configuration string: {string_line}") + + for pair in re.findall(r'([\w,-]+)@(\d+)', string_line): + version_dict[pair[0]] = int(pair[1]) + + elif vintage == 'vyatta': + if re.match(r'/\* === vyatta-config-version:.+=== \*/$', string_line): + if not re.match(r'/\* === vyatta-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s+=== \*/$', string_line): + raise ValueError(f"malformed configuration string: {string_line}") + + for pair in re.findall(r'([\w,-]+)@(\d+)', string_line): + version_dict[pair[0]] = int(pair[1]) + else: + raise ValueError("Unknown config string vintage") + + return version_dict + +def from_file(config_file_name=DEFAULT_CONFIG_PATH, vintage='vyos'): + """ + Get component version dictionary parsing config file line by line + """ + with open(config_file_name, 'r') as f: + for line_in_config in f: + version_dict = from_string(line_in_config, vintage=vintage) + if version_dict: + return version_dict + + # no version information + return {} + +def from_system(): + """ + Get system component version dict. + """ + return component_version() + +def legacy_from_system(): + """ + Get system component version dict from legacy location. + This is for a transitional sanity check; the directory will eventually + be removed. + """ + system_versions = {} + legacy_dir = directories['current'] + + # To be removed: + if not os.path.isdir(legacy_dir): + return system_versions + + try: + version_info = os.listdir(legacy_dir) + except OSError as err: + sys.exit(repr(err)) + + for info in version_info: + if re.match(r'[\w,-]+@\d+', info): + pair = info.split('@') + system_versions[pair[0]] = int(pair[1]) + + return system_versions + +def format_string(ver: dict) -> str: + """ + Version dict to string. + """ + keys = list(ver) + keys.sort() + l = [] + for k in keys: + v = ver[k] + l.append(f'{k}@{v}') + sep = ':' + return sep.join(l) + +def version_footer(ver: dict, vintage='vyos') -> str: + """ + Version footer as string. + """ + ver_str = format_string(ver) + release = get_version() + if vintage == 'vyos': + ret_str = (f'// Warning: Do not remove the following line.\n' + + f'// vyos-config-version: "{ver_str}"\n' + + f'// Release version: {release}\n') + elif vintage == 'vyatta': + ret_str = (f'/* Warning: Do not remove the following line. */\n' + + f'/* === vyatta-config-version: "{ver_str}" === */\n' + + f'/* Release version: {release} */\n') + else: + raise ValueError("Unknown config string vintage") + + return ret_str + +def system_footer(vintage='vyos') -> str: + """ + System version footer as string. + """ + ver_d = from_system() + return version_footer(ver_d, vintage=vintage) + +def write_version_footer(ver: dict, file_name, vintage='vyos'): + """ + Write version footer to file. + """ + footer = version_footer(ver=ver, vintage=vintage) + if file_name: + with open(file_name, 'a') as f: + f.write(footer) + else: + sys.stdout.write(footer) + +def write_system_footer(file_name, vintage='vyos'): + """ + Write system version footer to file. + """ + ver_d = from_system() + return write_version_footer(ver_d, file_name=file_name, vintage=vintage) + +def remove_footer(file_name): + """ + Remove old version footer. + """ + for line in fileinput.input(file_name, inplace=True): + if re.match(r'/\* Warning:.+ \*/$', line): + continue + if re.match(r'/\* === vyatta-config-version:.+=== \*/$', line): + continue + if re.match(r'/\* Release version:.+ \*/$', line): + continue + if re.match('// vyos-config-version:.+', line): + continue + if re.match('// Warning:.+', line): + continue + if re.match('// Release version:.+', line): + continue + sys.stdout.write(line) diff --git a/python/vyos/component_versions.py b/python/vyos/component_versions.py deleted file mode 100644 index 90b458aae..000000000 --- a/python/vyos/component_versions.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2017 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/>. - -""" -The version data looks like: - -/* Warning: Do not remove the following line. */ -/* === vyatta-config-version: -"cluster@1:config-management@1:conntrack-sync@1:conntrack@1:dhcp-relay@1:dhcp-server@4:firewall@5:ipsec@4:nat@4:qos@1:quagga@2:system@8:vrrp@1:wanloadbalance@3:webgui@1:webproxy@1:zone-policy@1" -=== */ -/* Release version: 1.2.0-rolling+201806131737 */ -""" - -import re - -def get_component_version(string_line): - """ - Get component version dictionary from string - return empty dictionary if string contains no config information - or raise error if component version string malformed - """ - return_value = {} - if re.match(r'/\* === vyatta-config-version:.+=== \*/$', string_line): - - if not re.match(r'/\* === vyatta-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s+=== \*/$', string_line): - raise ValueError("malformed configuration string: " + str(string_line)) - - for pair in re.findall(r'([\w,-]+)@(\d+)', string_line): - if pair[0] in return_value.keys(): - raise ValueError("duplicate unit name: \"" + str(pair[0]) + "\" in string: \"" + string_line + "\"") - return_value[pair[0]] = int(pair[1]) - - return return_value - - -def get_component_versions_from_file(config_file_name='/opt/vyatta/etc/config/config.boot'): - """ - Get component version dictionary parsing config file line by line - """ - f = open(config_file_name, 'r') - for line_in_config in f: - component_version = get_component_version(line_in_config) - if component_version: - return component_version - raise ValueError("no config string in file:", config_file_name) diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py new file mode 100644 index 000000000..22a49ff50 --- /dev/null +++ b/python/vyos/config_mgmt.py @@ -0,0 +1,686 @@ +# 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 +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', '') + edit_path = [l for l in edit_level.split('/') if l] + if edit_path: + eff_conf = config.show_config(edit_path, effective=True) + self.edit_level_active_config = ConfigTree(eff_conf) + conf = config.show_config(edit_path) + self.edit_level_working_config = ConfigTree(conf) + else: + self.edit_level_active_config = None + self.edit_level_working_config = None + + 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. + """ + from difflib import unified_diff + + ct1 = self.edit_level_active_config + if ct1 is None: + ct1 = self.active_config + ct2 = self.edit_level_working_config + if ct2 is None: + 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' + + if commands: + lines1 = ct1.to_commands().splitlines(keepends=True) + lines2 = ct2.to_commands().splitlines(keepends=True) + else: + lines1 = ct1.to_string().splitlines(keepends=True) + lines2 = ct2.to_string().splitlines(keepends=True) + + out = '' + comp = unified_diff(lines1, lines2) + for line in comp: + if re.match(r'(\-\-)|(\+\+)|(@@)', line): + continue + out += line + 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..f2358ee4f 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,6 +12,7 @@ # 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 @@ -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) diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index afa0c5b33..8e0ce701e 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -388,8 +388,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] 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/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/formatversions.py b/python/vyos/formatversions.py deleted file mode 100644 index 29117a5d3..000000000 --- a/python/vyos/formatversions.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright 2019 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 sys -import os -import re -import fileinput - -def read_vyatta_versions(config_file): - config_file_versions = {} - - with open(config_file, 'r') as config_file_handle: - for config_line in config_file_handle: - if re.match(r'/\* === vyatta-config-version:.+=== \*/$', config_line): - if not re.match(r'/\* === vyatta-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s+=== \*/$', config_line): - raise ValueError("malformed configuration string: " - "{}".format(config_line)) - - for pair in re.findall(r'([\w,-]+)@(\d+)', config_line): - config_file_versions[pair[0]] = int(pair[1]) - - - return config_file_versions - -def read_vyos_versions(config_file): - config_file_versions = {} - - with open(config_file, 'r') as config_file_handle: - for config_line in config_file_handle: - if re.match(r'// vyos-config-version:.+', config_line): - if not re.match(r'// vyos-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s*', config_line): - raise ValueError("malformed configuration string: " - "{}".format(config_line)) - - for pair in re.findall(r'([\w,-]+)@(\d+)', config_line): - config_file_versions[pair[0]] = int(pair[1]) - - return config_file_versions - -def remove_versions(config_file): - """ - Remove old version string. - """ - for line in fileinput.input(config_file, inplace=True): - if re.match(r'/\* Warning:.+ \*/$', line): - continue - if re.match(r'/\* === vyatta-config-version:.+=== \*/$', line): - continue - if re.match(r'/\* Release version:.+ \*/$', line): - continue - if re.match('// vyos-config-version:.+', line): - continue - if re.match('// Warning:.+', line): - continue - if re.match('// Release version:.+', line): - continue - sys.stdout.write(line) - -def format_versions_string(config_versions): - cfg_keys = list(config_versions.keys()) - cfg_keys.sort() - - component_version_strings = [] - - for key in cfg_keys: - cfg_vers = config_versions[key] - component_version_strings.append('{}@{}'.format(key, cfg_vers)) - - separator = ":" - component_version_string = separator.join(component_version_strings) - - return component_version_string - -def write_vyatta_versions_foot(config_file, component_version_string, - os_version_string): - if config_file: - with open(config_file, 'a') as config_file_handle: - config_file_handle.write('/* Warning: Do not remove the following line. */\n') - config_file_handle.write('/* === vyatta-config-version: "{}" === */\n'.format(component_version_string)) - config_file_handle.write('/* Release version: {} */\n'.format(os_version_string)) - else: - sys.stdout.write('/* Warning: Do not remove the following line. */\n') - sys.stdout.write('/* === vyatta-config-version: "{}" === */\n'.format(component_version_string)) - sys.stdout.write('/* Release version: {} */\n'.format(os_version_string)) - -def write_vyos_versions_foot(config_file, component_version_string, - os_version_string): - if config_file: - with open(config_file, 'a') as config_file_handle: - config_file_handle.write('// Warning: Do not remove the following line.\n') - config_file_handle.write('// vyos-config-version: "{}"\n'.format(component_version_string)) - config_file_handle.write('// Release version: {}\n'.format(os_version_string)) - else: - sys.stdout.write('// Warning: Do not remove the following line.\n') - sys.stdout.write('// vyos-config-version: "{}"\n'.format(component_version_string)) - sys.stdout.write('// Release version: {}\n'.format(os_version_string)) - 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/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/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/migrator.py b/python/vyos/migrator.py index c6e3435ca..87c74e1ea 100644 --- a/python/vyos/migrator.py +++ b/python/vyos/migrator.py @@ -1,4 +1,4 @@ -# Copyright 2019 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 @@ -16,11 +16,13 @@ import sys import os import json -import subprocess -import vyos.version +import logging + import vyos.defaults -import vyos.systemversions as systemversions -import vyos.formatversions as formatversions +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 @@ -31,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; @@ -42,13 +56,13 @@ class Migrator(object): cfg_file = self._config_file component_versions = {} - cfg_versions = formatversions.read_vyatta_versions(cfg_file) + cfg_versions = component_version.from_file(cfg_file, vintage='vyatta') if cfg_versions: self._config_file_vintage = 'vyatta' component_versions = cfg_versions - cfg_versions = formatversions.read_vyos_versions(cfg_file) + cfg_versions = component_version.from_file(cfg_file, vintage='vyos') if cfg_versions: self._config_file_vintage = 'vyos' @@ -70,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 @@ -129,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: @@ -138,38 +134,25 @@ 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): """ Write new versions string. """ - versions_string = formatversions.format_versions_string(cfg_versions) - - os_version_string = vyos.version.get_version() - if self._config_file_vintage == 'vyatta': - formatversions.write_vyatta_versions_foot(self._config_file, - versions_string, - os_version_string) + component_version.write_version_footer(cfg_versions, + self._config_file, + vintage='vyatta') if self._config_file_vintage == 'vyos': - formatversions.write_vyos_versions_foot(self._config_file, - versions_string, - os_version_string) + component_version.write_version_footer(cfg_versions, + self._config_file, + vintage='vyos') def save_json_record(self, component_versions: dict): """ @@ -200,7 +183,7 @@ class Migrator(object): # This will force calling all migration scripts: cfg_versions = {} - sys_versions = systemversions.get_system_component_version() + sys_versions = component_version.from_system() # save system component versions in json file for easy reference self.save_json_record(sys_versions) @@ -216,7 +199,7 @@ class Migrator(object): if not self._changed: return - formatversions.remove_versions(cfg_file) + component_version.remove_footer(cfg_file) self.write_config_file_versions(rev_versions) @@ -237,7 +220,7 @@ class VirtualMigrator(Migrator): if not self._changed: return - formatversions.remove_versions(cfg_file) + component_version.remove_footer(cfg_file) self.write_config_file_versions(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 7e3545c87..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 @@ -16,11 +16,16 @@ import re import sys import typing +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 @@ -44,15 +49,45 @@ 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 + or errors in underlying software. + """ + pass + 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 @@ -93,6 +128,51 @@ def _get_arg_type(t): else: return t +def _normalize_field_name(name): + # Convert the name to string if it is not + # (in some cases they may be numbers) + name = str(name) + + # Replace all separators with underscores + name = re.sub(r'(\s|[\(\)\[\]\{\}\-\.\,:\"\'\`])+', '_', name) + + # Replace specific characters with textual descriptions + name = re.sub(r'@', '_at_', name) + name = re.sub(r'%', '_percentage_', name) + name = re.sub(r'~', '_tilde_', name) + + # Force all letters to lowercase + name = name.lower() + + # Remove leading and trailing underscores, if any + name = re.sub(r'(^(_+)(?=[^_])|_+$)', '', name) + + # Ensure there are only single underscores + name = re.sub(r'_+', '_', name) + + return name + +def _normalize_dict_field_names(old_dict): + new_dict = {} + + for key in old_dict: + new_key = _normalize_field_name(key) + new_dict[new_key] = _normalize_field_names(old_dict[key]) + + # Sanity check + if len(old_dict) != len(new_dict): + raise InternalError("Dictionary fields do not allow unique normalization") + else: + return new_dict + +def _normalize_field_names(value): + if isinstance(value, dict): + return _normalize_dict_field_names(value) + elif isinstance(value, list): + return list(map(lambda v: _normalize_field_names(v), value)) + else: + return value + def run(module): from argparse import ArgumentParser @@ -134,20 +214,25 @@ 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 return dumps(res, indent=4) else: 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/systemversions.py b/python/vyos/systemversions.py deleted file mode 100644 index f2da76d4f..000000000 --- a/python/vyos/systemversions.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2019 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 vyos.defaults -from vyos.xml import component_version - -# legacy version, reading from the file names in -# /opt/vyatta/etc/config-migrate/current -def get_system_versions(): - """ - Get component versions from running system; critical failure if - unable to read migration directory. - """ - system_versions = {} - - try: - version_info = os.listdir(vyos.defaults.directories['current']) - except OSError as err: - print("OS error: {}".format(err)) - sys.exit(1) - - for info in version_info: - if re.match(r'[\w,-]+@\d+', info): - pair = info.split('@') - system_versions[pair[0]] = int(pair[1]) - - return system_versions - -# read from xml cache -def get_system_component_version(): - return component_version() diff --git a/python/vyos/template.py b/python/vyos/template.py index 2a4135f9e..15240f815 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -193,6 +193,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 +486,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 461df9a6e..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,9 +576,40 @@ 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): + """ Converts a data amount with a unit suffix to bytes, like 2K to 2048 """ + + from re import match as re_match + + res = re_match(r'^\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*$', value) + + if not res: + raise ValueError(f"'{value}' is not a valid data amount") + else: + amount = float(res.group(1)) + unit = res.group(2).lower() + + if unit == 'b': + res = amount + elif (unit == 'k') or (unit == 'kb'): + res = amount * 1024 + elif (unit == 'm') or (unit == 'mb'): + res = amount * 1024**2 + elif (unit == 'g') or (unit == 'gb'): + res = amount * 1024**3 + elif (unit == 't') or (unit == 'tb'): + res = amount * 1024**4 + else: + raise ValueError(f"Unsupported data unit '{unit}'") + + # There cannot be fractional bytes, so we convert them to integer. + # However, truncating causes problems with conversion back to human unit, + # so we round instead -- that seems to work well enough. + return round(res) + def get_cfg_group_id(): from grp import getgrnam from vyos.defaults import cfg_group @@ -1105,3 +1141,18 @@ def sysctl_write(name, value): call(f'sysctl -wq {name}={value}') return True return False + +# approach follows a discussion in: +# https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case +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 |