diff options
Diffstat (limited to 'src/op_mode')
120 files changed, 4250 insertions, 787 deletions
diff --git a/src/op_mode/accelppp.py b/src/op_mode/accelppp.py index 67ce786d0..6f6fd4858 100755 --- a/src/op_mode/accelppp.py +++ b/src/op_mode/accelppp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/activation.py b/src/op_mode/activation.py new file mode 100644 index 000000000..16192f3eb --- /dev/null +++ b/src/op_mode/activation.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License 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 re +import typing +import tabulate + +import vyos.opmode +from vyos.utils.activate import get_activation_scripts +from vyos.utils.activate import set_activation as util_activate +from vyos.utils.activate import get_activation +from vyos.utils.activate import ActiveOpt +from vyos.utils.io import ask_yes_no +from vyos.base import Warning as Warn + + +def _get_raw_data() -> dict: + return get_activation_scripts() + + +def _split_name(name: str) -> tuple[str, str]: + # script names are guaranteed to have this format by construction: + # cf. scripts/generate-activation-scripts-json.py + match = re.match(r'(\d+)\-(.+)', name) + if match is None: + return '0', '_' + prio, base_name = match.groups() + return prio, base_name + + +def _find_full_name(name: str) -> typing.Optional[str]: + script_names = list(_get_raw_data()) + result = list(filter(lambda s: s.endswith(name), script_names)) + + return result[0] if result else None + + +def show_list(raw: bool) -> typing.Optional[list]: + scripts = _get_raw_data() + data = [] + for key in scripts.keys(): + _, name = _split_name(key) + data.append(name) + + if raw: + return data + + print(*data) + return None + + +def show_opts(raw: bool) -> typing.Optional[list]: + opts = list(typing.get_args(ActiveOpt)) + + if raw: + return opts + + print(*opts) + return None + + +def _format_scripts(scripts: dict): + headers = ['name', 'activate on reboot', 'priority'] + data = [] + for key in scripts.keys(): + prio, name = _split_name(key) + value = scripts[key] + data.append([name, value, prio]) + + print('Activation units:') + print(tabulate.tabulate(data, headers)) + + +def show(raw: bool): + activation_dict = _get_raw_data() + if raw: + return activation_dict + return _format_scripts(activation_dict) + + +def set_active(name: str, value: ActiveOpt, no_prompt: bool = False): + PROMPT_ENABLED = f'This will set {name} active on subsequent reboots. Proceed ?' + PROMPT_ONCE = f'This will set {name} active only for the next reboot. Proceed ?' + PROMPT_OFF = f'This will set {name} inactive. Proceed ?' + UNCHANGED = f'{name} is already set to {value}' + UNKNOWN = 'None such' + + full_name = _find_full_name(name) + if not full_name: + Warn(f'No activation unit {name}') + return + + state = get_activation(full_name) + + if state == 'never': + Warn(f'{name} has been set to \'never\' and should not be reset') + return + + if value == state: + print(UNCHANGED) + return + + if value not in list(typing.get_args(ActiveOpt)): + Warn(f'No such value {value}') + return + + match value: + case 'enabled': + message = PROMPT_ENABLED + case 'once': + message = PROMPT_ONCE + case 'off': + message = PROMPT_OFF + case _: + # not reached + message = UNKNOWN + + if no_prompt or ask_yes_no(message, default=True): + util_activate(full_name, value) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/bgp.py b/src/op_mode/bgp.py index 096113cb4..7f0815433 100755 --- a/src/op_mode/bgp.py +++ b/src/op_mode/bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -80,6 +80,29 @@ show bgp ArgFamily = typing.Literal['inet', 'inet6', 'l2vpn'] ArgFamilyModifier = typing.Literal['unicast', 'labeled_unicast', 'multicast', 'vpn', 'flowspec'] +def reset(command: str): + from vyos.utils.process import cmd + + tokens = command.split() + + # reset -> clear (only if it's the first token) + if tokens and tokens[0] == "reset": + tokens[0] = "clear" + + # peer-group and vrf may have 'all' in their names; don't replace 'all' with '*' + skip_indexes = [] + for index, word in enumerate(tokens[:-1]): + if word in ("peer-group", "vrf"): + skip_indexes.append(index + 1) + + # replace standalone "all" with "*" unless it's in the skip list + for index, word in enumerate(tokens): + if word == "all" and index not in skip_indexes: + tokens[index] = "*" + + command = " ".join(tokens) + cmd(f'vtysh -c "{command}"') + def show_summary(raw: bool): from vyos.utils.process import cmd diff --git a/src/op_mode/bonding.py b/src/op_mode/bonding.py index 07bccbd4b..0ceb65cff 100755 --- a/src/op_mode/bonding.py +++ b/src/op_mode/bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py index c4293a77c..9056e16d4 100755 --- a/src/op_mode/bridge.py +++ b/src/op_mode/bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -83,7 +83,7 @@ def _get_raw_data_fdb(bridge): def _get_raw_data_mdb(bridge): - """Get MAC-address multicast gorup for the bridge brX + """Get MAC-address multicast group for the bridge brX :return list """ json_data = cmd(f'bridge --json mdb show br {bridge}') diff --git a/src/op_mode/cgnat.py b/src/op_mode/cgnat.py index 9ad8f92f9..d53f6158b 100755 --- a/src/op_mode/cgnat.py +++ b/src/op_mode/cgnat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/clear_conntrack.py b/src/op_mode/clear_conntrack.py index fec7cf144..2a4f19607 100755 --- a/src/op_mode/clear_conntrack.py +++ b/src/op_mode/clear_conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/config_mgmt.py b/src/op_mode/config_mgmt.py index 66de26d1f..fa2abec0e 100755 --- a/src/op_mode/config_mgmt.py +++ b/src/op_mode/config_mgmt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/config_sync.py b/src/op_mode/config_sync.py new file mode 100644 index 000000000..d6eff7cd2 --- /dev/null +++ b/src/op_mode/config_sync.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License 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 typing +from pathlib import Path + +from vyos import opmode +from vyos import http_api_client as http +from vyos.utils.file import read_json +from vyos.utils.dict import dict_to_paths +from vyos.utils.list import list_contains_sublist +from vyos.configtree import ConfigTree +from vyos.configtree import ConfigTreeError +from vyos.config_mgmt import ConfigMgmt +from vyos.config_mgmt import ConfigMgmtError + +CONFIG_FILE = Path('/run/config_sync_conf.conf') + + +def _normalize_section(section: typing.Optional[str]) -> list: + """Convert optional CLI section argument to config tree path list""" + + if not section: + return [] + + # Section can be passed as a single string token ('interfaces ethernet') + return list(section.split()) + + +def _read_json_config() -> dict: + """Read config-sync service runtime JSON file""" + + if not CONFIG_FILE.exists(): + raise opmode.UnconfiguredObject('Config-sync service is not configured') + + return read_json(CONFIG_FILE, defaultonfailure={}) + + +def _load_config_sync_sections() -> list: + """Load sections from config-sync service runtime JSON file""" + + cfg = _read_json_config() + sections = cfg.get('section', {}) + + return list(dict_to_paths(sections)) if sections else [] + + +def _load_config_sync_settings() -> dict: + """Load remote API settings from config-sync service runtime JSON file""" + + cfg = _read_json_config() + secondary = cfg.get('secondary', {}) + address = secondary.get('address') + key = secondary.get('key') + port = int(secondary.get('port', 443)) + timeout = int(secondary.get('timeout')) if secondary.get('timeout') else None + + if not address or not key: + raise opmode.UnconfiguredObject( + 'Config-sync is not fully configured: missing secondary address/key' + ) + + return dict(host=address, key=key, port=port, timeout=timeout) + + +class ConfigSyncDiffManager: + def __init__(self): + api_settings = _load_config_sync_settings() + self._client = http.ApiClient(http.ApiClientConfig(**api_settings)) + + self._config_mgmt = ConfigMgmt() + + def _get_remote_config_tree(self, section_path: list = None) -> ConfigTree: + """ + Retrieve remote config (or subtree) as ConfigTree via HTTPS API. + + Note: Endpoint name is expected to be available on remote VyOS instance. + """ + + payload = { + 'configFormat': 'raw', + 'op': 'showConfig', + 'path': section_path or [], + } + + try: + resp_data = self._client.post('retrieve', payload, raise_on_error=False) + except http.ApiError as e: + raise opmode.InternalError(f'Remote API failed: {e}') from e + + error = (resp_data.get('error') or resp_data.get('detail') or '').strip() + if error: + ignored_errors = ('configuration under specified path is empty',) + if error.lower() not in ignored_errors: + raise opmode.InternalError( + f'Remote API responded with an error: {error}' + ) + + config_raw = resp_data.get('data') or '' + try: + return ConfigTree(config_raw) + except ConfigTreeError as e: + raise opmode.InternalError(f'Unable to build remote ConfigTree: {e}') from e + + def get_sync_diff( + self, + source: str, + sections: list, + commands: typing.Optional[bool] = False, + ) -> str: + """Returns differences between local config and remote config for a given sections""" + + results = [] + remote_tree = self._get_remote_config_tree() + for section_path in sections: + try: + result = self._config_mgmt.remote_compare( + source, + remote_tree, + path=section_path, + commands=commands, + ) + except ConfigMgmtError as e: + raise opmode.InternalError(str(e)) from e + + result = result.strip() + if result: + results.append(result) + + return '\n'.join(results) + + +def show_sync_diff( + raw: bool, + source: typing.Optional[str], + section: typing.Optional[str], + commands: typing.Optional[bool], +) -> str: + """Show differences between local config and remote config for a given section. + + Args: + raw: unused (op-mode convention); output is always text. + source: local source config: running/candidate/saved. + section: optional top-level section to diff (e.g. "nat", "system time-zone"). + commands: flag which indicates format of output. + + Returns: + Diff output (string). Empty diff is rendered as "no changes". + """ + _ = raw # op-mode framework passes it; keep signature consistent + + source = source or 'running' + selected_section = _normalize_section(section) + + configured_sections = _load_config_sync_sections() + if selected_section: + if not list_contains_sublist(configured_sections, selected_section): + raise opmode.UnconfiguredObject( + f"Config-sync is not configured for '{section}' section. " + f"Use 'set service config-sync section {section}' for this." + ) + sections = [selected_section] + else: + sections = configured_sections + + manager = ConfigSyncDiffManager() + output = manager.get_sync_diff(source, sections, commands=commands) + + return output if output else 'No changes between local and remote configuration' + + +if __name__ == '__main__': + try: + res = opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 8903f916a..d5db85d25 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,7 @@ import os import argparse from psutil import process_iter +from time import sleep from vyos.configquery import ConfigTreeQuery from vyos.utils.process import call @@ -68,7 +69,7 @@ def connect(interface): if ( count % 60 == 0 ): print(f'Commit still in progress after {count}s - waiting') count += 1 - time.sleep(1) + sleep(1) call('/usr/libexec/vyos/conf_mode/qos.py') def disconnect(interface): @@ -97,19 +98,23 @@ def main(): group = parser.add_mutually_exclusive_group() group.add_argument("--connect", help="Bring up a connection-oriented network interface", action="store_true") group.add_argument("--disconnect", help="Take down connection-oriented network interface", action="store_true") + group.add_argument("--reconnect", help="Reconnect connection-oriented network interface", action="store_true") parser.add_argument("--interface", help="Interface name", action="store", required=True) args = parser.parse_args() - if args.connect or args.disconnect: - if args.disconnect: - disconnect(args.interface) - - if args.connect: - if commit_in_progress(): - print('Cannot connect while a commit is in progress') - exit(1) - connect(args.interface) - + # Disallow connecting interfaces while their configuration might be changing + if args.connect or args.reconnect: + if commit_in_progress(): + print('Cannot connect while a commit is in progress') + exit(1) + + if args.connect: + connect(args.interface) + elif args.disconnect: + disconnect(args.interface) + elif args.reconnect: + disconnect(args.interface) + connect(args.interface) else: parser.print_help() diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py index c379c3e60..f39012b2b 100755 --- a/src/op_mode/conntrack.py +++ b/src/op_mode/conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -127,7 +127,6 @@ def get_formatted_output(dict_data): reply_dport = meta['layer4']['dport'] proto = meta['layer4']['protoname'] if direction == 'independent': - conn_id = meta['id'] # T6138 flowtable offload conntrack entries without 'timeout' timeout = meta.get('timeout', 'n/a') orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src @@ -137,10 +136,29 @@ def get_formatted_output(dict_data): state = meta['state'] if 'state' in meta else '' mark = meta['mark'] if 'mark' in meta else '' zone = meta['zone'] if 'zone' in meta else '' - data_entries.append( - [conn_id, orig_src, orig_dst, reply_src, reply_dst, proto, state, timeout, mark, zone]) - headers = ["Id", "Original src", "Original dst", "Reply src", "Reply dst", "Protocol", "State", "Timeout", "Mark", - "Zone"] + data_entry = [ + orig_src, + orig_dst, + reply_src, + reply_dst, + proto, + state, + timeout, + mark, + zone, + ] + data_entries.append(data_entry) + headers = [ + "Original src", + "Original dst", + "Reply src", + "Reply dst", + "Protocol", + "State", + "Timeout", + "Mark", + "Zone", + ] output = tabulate(data_entries, headers, numalign="left") return output diff --git a/src/op_mode/conntrack_sync.py b/src/op_mode/conntrack_sync.py index f3b09b452..0da5b3b0b 100755 --- a/src/op_mode/conntrack_sync.py +++ b/src/op_mode/conntrack_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/container.py b/src/op_mode/container.py index 05f65df1f..e0753f1be 100755 --- a/src/op_mode/container.py +++ b/src/op_mode/container.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,13 +14,53 @@ # 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 typing import json +import shutil import sys +import subprocess +from pathlib import Path +from vyos.defaults import directories from vyos.utils.process import cmd from vyos.utils.process import rc_cmd +from vyos.utils.process import run import vyos.opmode +def clean_layer(name: str) -> int: + def layer_id_from_containers(name: str) -> str | None: + if containers.is_file(): + try: + index = json.loads(containers.read_text()) + except Exception: + return None + for item in index: + if name in item.get("names", []): + return item.get("layer") + return None + + def purge_layer_by_id(layer_id: str): + layer_dir = overlay_root / layer_id + + # Remove the overlay ID directory + shutil.rmtree(layer_dir, ignore_errors=True) + storage_dir = Path(directories['podman_storage']) + overlay_root = storage_dir / "overlay" + containers = storage_dir / "overlay-containers/containers.json" + unit = f"vyos-container-{name}.service" + layer_id = layer_id_from_containers(name) + if not layer_id: + # No mapping found; nothing to do + return 2 + + purge_layer_by_id(layer_id) + + # Reinitiate the container's overlay layer + cmd(f"rm -f /run/{unit}.cid /run/{unit}.pid") + cmd(f"systemctl reset-failed {unit}") + result = run(f"systemctl start {unit}") + return result + def _get_json_data(command: str) -> list: """ Get container command format JSON @@ -34,7 +74,7 @@ def _get_raw_data(command: str) -> list: def add_image(name: str): """ Pull image from container registry. If registry authentication - is defined within VyOS CLI, credentials are used to login befroe pull """ + is defined within VyOS CLI, credentials are used to login before pull """ from vyos.configquery import ConfigTreeQuery conf = ConfigTreeQuery() @@ -54,14 +94,14 @@ def add_image(name: str): rc, out = rc_cmd(cmd) if rc != 0: raise vyos.opmode.InternalError(out) - rc, output = rc_cmd(f'podman image pull {name}') + rc, output = rc_cmd(f'podman image pull {name}', buffered=False) if rc != 0: raise vyos.opmode.InternalError(output) if do_logout: rc_cmd('podman logout --all') -def delete_image(name: str): +def delete_image(name: str, force: typing.Optional[bool] = False): from vyos.utils.process import rc_cmd if name == 'all': @@ -71,9 +111,33 @@ def delete_image(name: str): if not name: return # replace newline with whitespace name = name.replace('\n', ' ') - rc, output = rc_cmd(f'podman image rm {name}') - if rc != 0: - raise vyos.opmode.InternalError(output) + # convert to list + name = name.split() + else: + # convert str -> list for further processing down the line + name = [name] + + for image in name: + # convert the truncated image ID to a full image ID + rc, ancestor = rc_cmd(f'podman inspect {image} --format "{{{{.Id}}}}"', stderr=None) + if rc != 0: + raise vyos.opmode.InternalError(ancestor) + # check if the image ID is an ancestor of any running container + rc, in_use = rc_cmd(f'podman ps --filter ancestor={ancestor} -q', stderr=None) + if rc != 0: + raise vyos.opmode.InternalError(in_use) + + if bool(in_use): + error = f'Cannot delete image "{image}" because it is currently '\ + f'being used by container "{in_use}"!' + raise vyos.opmode.InternalError(error) + + tmp = f'podman image rm {image}' + if force: tmp += ' --force' + + rc, output = rc_cmd(tmp) + if rc != 0: + raise vyos.opmode.InternalError(output) def show_container(raw: bool): command = 'podman ps --all' @@ -101,14 +165,66 @@ def show_network(raw: bool): def restart(name: str): from vyos.utils.process import rc_cmd + from vyos.config import Config + from vyos.container import restart_network rc, output = rc_cmd(f'systemctl restart vyos-container-{name}.service') if rc != 0: - print(output) - return None + rc2 = clean_layer(name) + if rc2 != 0: + print(output) + return None + if rc == 0: + conf = Config() + container = conf.get_config_dict(['container'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + restart_network(container) print(f'Container "{name}" restarted!') return output +def show_log(name: str, follow: bool = False, raw: bool = False): + """ + Show or monitor logs for a specific container. + Use --follow to continuously stream logs. + """ + from vyos.configquery import ConfigTreeQuery + conf = ConfigTreeQuery() + container = conf.get_config_dict(['container', 'name', name], get_first_key=True, with_recursive_defaults=True) + log_type = container.get('log-driver') + if log_type == 'k8s-file': + if follow: + log_command_list = ['sudo', 'podman', 'logs', '--follow', '--names', name] + else: + log_command_list = ['sudo', 'podman', 'logs', '--names', name] + elif log_type == 'journald': + if follow: + log_command_list = ['journalctl', '--follow', '--unit', f'vyos-container-{name}.service'] + else: + log_command_list = ['journalctl', '-e', '--no-pager', '--unit', f'vyos-container-{name}.service'] + elif log_type == 'none': + print(f'Container "{name}" has disabled logs.') + return None + else: + raise vyos.opmode.InternalError(f'Unknown log type "{log_type}" for container "{name}".') + + process = None + try: + process = subprocess.Popen(log_command_list, + stdout=sys.stdout, + stderr=sys.stderr) + process.wait() + except KeyboardInterrupt: + if process: + process.terminate() + process.wait() + return None + except Exception as e: + raise vyos.opmode.InternalError(f"Error starting logging command: {e} ") + return None + + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/cpu.py b/src/op_mode/cpu.py index 1a0f7392f..07cb90187 100755 --- a/src/op_mode/cpu.py +++ b/src/op_mode/cpu.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -47,7 +47,7 @@ def _format_cpus(cpu_data): def _get_summary_data(): count = get_core_count() cpu_data = get_cpus() - models = [c['model name'] for c in cpu_data] + models = [c.get('model name', 'unknown') for c in cpu_data] env = {'count': count, "models": models} return env diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 725bfc75b..9cb5a84ae 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -83,12 +83,12 @@ ArgOrigin = typing.Literal['local', 'remote'] def _get_raw_server_leases( - config, family='inet', pool=None, sorted=None, state=[], origin=None + config, family='inet', vrf='', pool=None, sorted=None, state=[], origin=None ) -> list: inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) - mappings = kea_get_server_leases(config, inet_suffix, pools, state, origin) + mappings = kea_get_server_leases(config, inet_suffix, vrf, pools, state, origin) if sorted: if sorted == 'ip': @@ -134,6 +134,7 @@ def _get_formatted_server_leases(raw_data, family='inet'): if family == 'inet6': for lease in raw_data: ipaddr = lease.get('ip') + hw_addr = lease.get('mac') state = lease.get('state') start = datetime.fromtimestamp( lease.get('last_communication'), timezone.utc @@ -146,19 +147,22 @@ def _get_formatted_server_leases(raw_data, family='inet'): remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') + hostname = lease.get('hostname') host_identifier = lease.get('duid') data_entries.append( - [ipaddr, state, start, end, remain, lease_type, pool, host_identifier] + [ipaddr, hw_addr, state, start, end, remain, pool, hostname, lease_type, host_identifier] ) headers = [ 'IPv6 address', + 'MAC address', 'State', 'Last communication', 'Lease expiration', 'Remaining', - 'Type', 'Pool', + 'Hostname', + 'Type', 'DUID', ] @@ -166,9 +170,13 @@ def _get_formatted_server_leases(raw_data, family='inet'): return output -def _get_pool_size(pool, family='inet'): +def _get_pool_size(pool, family='inet', vrf=''): v = 'v6' if family == 'inet6' else '' - base = f'service dhcp{v}-server shared-network-name {pool}' + # if vrf is set get the correct base for config + if vrf: + base = f'vrf name {vrf} service dhcp{v}-server shared-network-name {pool}' + else: + base = f'service dhcp{v}-server shared-network-name {pool}' size = 0 subnets = config.list_nodes(f'{base} subnet') for subnet in subnets: @@ -185,14 +193,14 @@ def _get_pool_size(pool, family='inet'): return size -def _get_raw_server_pool_statistics(config, family='inet', pool=None): +def _get_raw_server_pool_statistics(config, family='inet', vrf='', pool=None): inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) stats = [] for p in pools: - size = _get_pool_size(family=family, pool=p) - leases = len(_get_raw_server_leases(config, family=family, pool=p)) + size = _get_pool_size(family=family, vrf=vrf, pool=p) + leases = len(_get_raw_server_leases(config, family=family, vrf=vrf, pool=p)) use_percentage = round(leases / size * 100) if size != 0 else 0 pool_stats = { 'pool': p, @@ -269,11 +277,20 @@ def _verify_server(func): def _wrapper(*args, **kwargs): config = ConfigTreeQuery() family = kwargs.get('family') + vrf = kwargs.get('vrf') v = 'v6' if family == 'inet6' else '' - unconf_message = f'DHCP{v} server is not configured' + # Check if config does not exist - if not config.exists(f'service dhcp{v}-server'): - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + if vrf: + unconf_message = f'DHCP{v} server is not configured for VRF {vrf}' + if not config.exists(f'vrf name {vrf} service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + else: + unconf_message = f'DHCP{v} server is not configured' + if not config.exists(f'service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + + # return return func(*args, **kwargs) return _wrapper @@ -291,11 +308,31 @@ def _verify_client(func): v = 'v6' if family == 'inet6' else '' interface = kwargs.get('interface') interface_path = Section.get_config_path(interface) + path_elems = interface_path.split() + base_path = ['interfaces'] + path_elems + unconf_message = f'DHCP{v} client not configured on interface {interface}!' - # Check if config does not exist - if not config.exists(f'interfaces {interface_path} address dhcp{v}'): + iface_conf = config.get_config_dict( + base_path, key_mangling=('-', '_'), get_first_key=True + ) + + if family == 'inet6': + addrs = iface_conf.get('address', []) + has_dhcpv6_addr = 'dhcpv6' in addrs + + dhcpv6_opts = iface_conf.get('dhcpv6_options', {}) + has_parameters_only = 'parameters_only' in dhcpv6_opts + has_pd = 'pd' in dhcpv6_opts + + config_exists = has_dhcpv6_addr or has_parameters_only or has_pd + else: + addrs = iface_conf.get('address', []) + config_exists = 'dhcp' in addrs + + if not config_exists: raise vyos.opmode.UnconfiguredObject(unconf_message) + return func(*args, **kwargs) return _wrapper @@ -303,25 +340,42 @@ def _verify_client(func): @_verify_server def show_server_pool_statistics( - raw: bool, family: ArgFamily, pool: typing.Optional[str] + raw: bool, family: ArgFamily, vrf: typing.Optional[str], pool: typing.Optional[str] ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') - pool_data = _get_raw_server_pool_statistics(active_config, family=family, pool=pool) + pool_data = _get_raw_server_pool_statistics( + active_config, family=family, vrf=vrf, pool=pool + ) if raw: return pool_data else: @@ -332,6 +386,7 @@ def show_server_pool_statistics( def show_server_leases( raw: bool, family: ArgFamily, + vrf: typing.Optional[str], pool: typing.Optional[str], sorted: typing.Optional[str], state: typing.Optional[ArgState], @@ -340,18 +395,33 @@ def show_server_leases( v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet if sorted and sorted not in sort_valid: @@ -363,6 +433,7 @@ def show_server_leases( lease_data = _get_raw_server_leases( config=active_config, family=family, + vrf=vrf, pool=pool, sorted=sorted, state=state, @@ -378,24 +449,40 @@ def show_server_leases( def show_server_static_mappings( raw: bool, family: ArgFamily, + vrf: typing.Optional[str], pool: typing.Optional[str], sorted: typing.Optional[str], ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') if sorted and sorted not in mapping_sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') @@ -409,21 +496,21 @@ def show_server_static_mappings( return _get_formatted_server_static_mappings(static_mappings) -def _lease_valid(inet, address): - leases = kea_get_leases(inet) +def _lease_valid(inet, vrf, address): + leases = kea_get_leases(inet, vrf) return any(lease['ip-address'] == address for lease in leases) @_verify_server -def clear_dhcp_server_lease(family: ArgFamily, address: str): +def clear_dhcp_server_lease(family: ArgFamily, address: str, vrf: typing.Optional[str]): v = 'v6' if family == 'inet6' else '' inet = '6' if family == 'inet6' else '4' - if not _lease_valid(inet, address): + if not _lease_valid(inet, vrf, address): print(f'Lease not found on DHCP{v} server') return None - if not kea_delete_lease(inet, address): + if not kea_delete_lease(inet, vrf, address): print(f'Failed to clear lease for "{address}"') return None @@ -509,7 +596,7 @@ def _get_formatted_client_leases(lease_data): if 'new_dhcp_server_identifier' in lease: data_entries.append(['DHCP Server', lease['new_dhcp_server_identifier']]) if 'new_dhcp_lease_time' in lease: - data_entries.append(['DHCP Server', lease['new_dhcp_lease_time']]) + data_entries.append(['Lease Time', lease['new_dhcp_lease_time']]) if 'vrf' in lease: data_entries.append(['VRF', lease['vrf']]) if 'last_update' in lease: diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py index 16c462f23..7c9f769f1 100755 --- a/src/op_mode/dns.py +++ b/src/op_mode/dns.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/evpn.py b/src/op_mode/evpn.py index cae4ab9f5..a6dee0b34 100644 --- a/src/op_mode/evpn.py +++ b/src/op_mode/evpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/execute_bandwidth_test.sh b/src/op_mode/execute_bandwidth_test.sh index a6ad0b42c..a7c7484d2 100755 --- a/src/op_mode/execute_bandwidth_test.sh +++ b/src/op_mode/execute_bandwidth_test.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/execute_port-scan.py b/src/op_mode/execute_port-scan.py index bf17d0379..47cd2f7c4 100644 --- a/src/op_mode/execute_port-scan.py +++ b/src/op_mode/execute_port-scan.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/file.py b/src/op_mode/file.py index bf13bed6f..8420c5355 100755 --- a/src/op_mode/file.py +++ b/src/op_mode/file.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -133,9 +133,6 @@ def print_file_data(path: str) -> None: with open(path, 'r') as f: for line in f: print(line, end='') - # tcpdump files go to TShark. - elif 'pcap' in file_type or os.path.splitext(path)[1] == '.pcap': - print(cmd(['sudo', 'tshark', '-r', path])) # All other binaries get hexdumped. else: print(cmd(['hexdump', '-C', path])) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index 7a3ab921d..d5cd088e6 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,7 @@ import argparse import ipaddress import json import re +from signal import signal, SIGPIPE, SIG_DFL import tabulate import textwrap @@ -25,6 +26,9 @@ from vyos.config import Config from vyos.utils.process import cmd from vyos.utils.dict import dict_search_args +signal(SIGPIPE, SIG_DFL) + + def get_config_node(conf, node=None, family=None, hook=None, priority=None): if node == 'nat': if family == 'ipv6': @@ -148,6 +152,38 @@ def get_nftables_group_members(family, table, name): return out +def get_nftables_remote_group_members(family, table, name): + prefix = 'ip6' if family == 'ipv6' else 'ip' + out = [] + + try: + results_str = cmd(f'nft -j list set {prefix} {table} {name}') + results = json.loads(results_str) + except: + return out + + if 'nftables' not in results: + return out + + for obj in results['nftables']: + if 'set' not in obj: + continue + + set_obj = obj['set'] + if 'elem' in set_obj: + for elem in set_obj['elem']: + # search for single IP elements + if isinstance(elem, str): + out.append(elem) + # search for prefix elements + elif isinstance(elem, dict) and 'prefix' in elem: + out.append(f"{elem['prefix']['addr']}/{elem['prefix']['len']}") + # search for IP range elements + elif isinstance(elem, dict) and 'range' in elem: + out.append(f"{elem['range'][0]}-{elem['range'][1]}") + + return out + def output_firewall_vertical(rules, headers, adjust=True): for rule in rules: adjusted_rule = rule + [""] * (len(headers) - len(rule)) if adjust else rule # account for different header length, like default-action @@ -178,7 +214,7 @@ def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=N row.append(rule_details['conditions']) rows.append(row) - if hook in ['input', 'forward', 'output']: + if hook in ['input', 'forward', 'output', 'prerouting']: def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'accept' else: def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'drop' @@ -316,7 +352,7 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule rows.append(row) - if hook in ['input', 'forward', 'output']: + if hook in ['input', 'forward', 'output', 'prerouting']: row = ['default', ''] rule_details = details['default-action'] row.append(rule_details.get('packets', 0)) @@ -556,32 +592,8 @@ def show_firewall_group(name=None): header_tail = [] for group_type, group_type_conf in firewall['group'].items(): - ## - if group_type != 'dynamic_group': - - for group_name, group_conf in group_type_conf.items(): - if name and name != group_name: - continue - - references = find_references(group_type, group_name) - row = [group_name, textwrap.fill(group_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] - if 'address' in group_conf: - row.append("\n".join(sorted(group_conf['address']))) - elif 'network' in group_conf: - row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) - elif 'mac_address' in group_conf: - row.append("\n".join(sorted(group_conf['mac_address']))) - elif 'port' in group_conf: - row.append("\n".join(sorted(group_conf['port']))) - elif 'interface' in group_conf: - row.append("\n".join(sorted(group_conf['interface']))) - elif 'url' in group_conf: - row.append(group_conf['url']) - else: - row.append('N/D') - rows.append(row) - - else: + # iterate over dynamic-groups + if group_type == 'dynamic_group': if not args.detail: header_tail = ['Timeout', 'Expires'] @@ -590,6 +602,9 @@ def show_firewall_group(name=None): prefix = 'DA_' if dynamic_type == 'address_group' else 'DA6_' if dynamic_type in firewall['group']['dynamic_group']: for dynamic_name, dynamic_conf in firewall['group']['dynamic_group'][dynamic_type].items(): + if name and name != dynamic_name: + continue + references = find_references(dynamic_type, dynamic_name) row = [dynamic_name, textwrap.fill(dynamic_conf.get('description') or '', 50), dynamic_type + '(dynamic)', '\n'.join(references) or 'N/D'] @@ -628,6 +643,68 @@ def show_firewall_group(name=None): header_tail += [""] * (len(members) - 1) rows.append(row) + # iterate over remote-groups + elif group_type == 'remote_group': + for remote_name, remote_conf in group_type_conf.items(): + if name and name != remote_name: + continue + + references = find_references(group_type, remote_name) + row = [remote_name, textwrap.fill(remote_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] + members = get_nftables_remote_group_members("ipv4", 'vyos_filter', f'R_{remote_name}') + members6 = get_nftables_remote_group_members("ipv6", 'vyos_filter', f'R6_{remote_name}') + + if 'url' in remote_conf: + # display only the url if no members are found for both views + if not members and not members6: + if args.detail: + header_tail = ['IPv6 Members', 'Remote URL'] + row.append('N/D') + row.append('N/D') + row.append(remote_conf['url']) + else: + row.append(remote_conf['url']) + rows.append(row) + else: + # display all table elements in detail view + if args.detail: + header_tail = ['IPv6 Members', 'Remote URL'] + if members: + row.append(' '.join(members)) + else: + row.append('N/D') + if members6: + row.append(' '.join(members6)) + else: + row.append('N/D') + row.append(remote_conf['url']) + rows.append(row) + else: + row.append(remote_conf['url']) + rows.append(row) + + # catch the rest of the group types + else: + for group_name, group_conf in group_type_conf.items(): + if name and name != group_name: + continue + + references = find_references(group_type, group_name) + row = [group_name, textwrap.fill(group_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] + if 'address' in group_conf: + row.append("\n".join(sorted(group_conf['address']))) + elif 'network' in group_conf: + row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) + elif 'mac_address' in group_conf: + row.append("\n".join(sorted(group_conf['mac_address']))) + elif 'port' in group_conf: + row.append("\n".join(sorted(group_conf['port']))) + elif 'interface' in group_conf: + row.append("\n".join(sorted(group_conf['interface']))) + else: + row.append('N/D') + rows.append(row) + if rows: print('Firewall Groups\n') if args.detail: diff --git a/src/op_mode/flow_accounting_op.py b/src/op_mode/flow_accounting_op.py index 497ccafdf..078634610 100755 --- a/src/op_mode/flow_accounting_op.py +++ b/src/op_mode/flow_accounting_op.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,18 +18,16 @@ import sys import argparse import re import ipaddress -import os.path from tabulate import tabulate -from json import loads -from vyos.utils.commit import commit_in_progress +from vyos.utils.kernel import is_module_loaded from vyos.utils.process import cmd -from vyos.utils.process import run from vyos.logger import syslog +from vyos.configquery import ConfigTreeQuery +from vyos import ipt_netflow # some default values -uacctd_pidfile = '/var/run/uacctd.pid' -uacctd_pipefile = '/tmp/uacctd.pipe' +flows_dump_path = '/proc/net/stat/ipt_netflow_flows' def parse_port(port): try: @@ -45,7 +43,7 @@ def parse_ports(arg): if re.match(r'^\d+$', arg): # Single port port = parse_port(arg) - return {"type": "single", "value": port} + return {"type": "single", "values": (port,)} elif re.match(r'^\d+\-\d+$', arg): # Port range ports = arg.split("-") @@ -53,12 +51,12 @@ def parse_ports(arg): if ports[0] > ports[1]: raise ValueError("Malformed port range \'{0}\': lower end is greater than the higher".format(arg)) else: - return {"type": "range", "value": (ports[0], ports[1])} + return {"type": "range", "values": range(ports[0], ports[1] + 1)} elif re.match(r'^\d+,.*\d$', arg): # Port list - ports = re.split(r',+', arg) # This allows duplicate commad like '1,,2,3,4' + ports = re.split(r',+', arg) # This allows duplicate commas like '1,,2,3,4' ports = list(map(parse_port, ports)) - return {"type": "list", "value": ports} + return {"type": "list", "values": ports} else: raise ValueError("Malformed port spec \'{0}\'".format(arg)) @@ -69,9 +67,8 @@ def check_host(host): raise ValueError("Invalid host \'{}\', must be a valid IP or IPv6 address".format(host)) # check if flow-accounting running -def _uacctd_running(): - command = 'systemctl status uacctd.service > /dev/null' - return run(command) == 0 +def _netflow_running(): + return is_module_loaded(ipt_netflow.module_name) # get list of interfaces @@ -89,26 +86,62 @@ def _get_ifaces_dict(): if regex_filter.search(iface_line): ifaces_dict[int(regex_filter.search(iface_line).group('iface_index'))] = regex_filter.search(iface_line).group('iface_name') - # return dictioanry + # return dictionary return ifaces_dict # get list of flows def _get_flows_list(): - # run command to get flows list - out = cmd(f'/usr/bin/pmacct -s -O json -T flows -p {uacctd_pipefile}', - message='Failed to get flows list') + # File format: + # When MAC disabled: + # # hash a dev:i,o proto src:ip,port dst:ip,port nexthop tos,tcpflags,options,tcpoptions packets bytes ts:first,last + # 1 c06c 0 4,-1 1 10.2.0.7,0 10.1.0.5,0 0.0.0.0 0,0,0,0 186 15624 92261,131 + # 2 1e3ca 0 3,-1 1 10.1.0.5,0 10.2.0.7,2048 0.0.0.0 0,0,0,0 186 15624 92261,132 + + # When MAC enabled + VLAN fix: + # hash a dev:i,o mac:src,dst vlan type proto src:ip,port dst:ip,port nexthop tos,tcpflags,options,tcpoptions packets bytes ts:first,last + # 1 11a41 0 4,-1 0c:27:1f:55:00:00,0c:e8:b1:71:00:02 - 0800 1 10.2.0.7,0 10.1.0.5,0 0.0.0.0 0,0,0,0 1182 99288 591502,529 + # 2 13bc5 0 4,-1 0c:27:1f:55:00:00,0c:e8:b1:71:00:02 - 0800 1 10.2.0.7,0 10.2.0.1,2048 0.0.0.0 0,0,0,0 577 48468 590831,1006 + # 3 166dd 0 3,-1 0c:f1:0a:d5:00:00,0c:e8:b1:71:00:01 - 0800 1 10.1.0.5,0 10.2.0.7,2048 0.0.0.0 0,0,0,0 1182 99288 591502,529 - # read output - flows_out = out.splitlines() - # make a list with flows flows_list = [] - for flow_line in flows_out: - try: - flows_list.append(loads(flow_line)) - except Exception as err: - syslog.error('Unable to read flow info: {}'.format(err)) + with open(flows_dump_path) as f: + headers = f.readline() + headers = headers.split() + for i, h in enumerate(headers): + + if ',' in h and ':' not in h: + h = 'extra:' + h + + if ':' in h: + key, subkeys = h.split(':', 1) + headers[i] = {'key': key, 'subkeys': subkeys.split(',')} + + linenum = 1 + for flow_line in f: + linenum += 1 + flow_dict = {} + flow_line = flow_line.split() + if len(flow_line) != len(headers): + syslog.error( + f'Unexpected number of elements in {flows_dump_path}, line {linenum}' + ) + continue + for i, val in enumerate(flow_line): + if isinstance(headers[i], str): + flow_dict[headers[i]] = val + elif isinstance(headers[i], dict): + val = val.split(',') + if len(val) != len(headers[i]['subkeys']): + syslog.error( + f"Unexpected number of elements in {flows_dump_path} in column {headers[i]['key']} in line {linenum}" + ) + continue + flow_dict[headers[i]['key']] = dict(zip(headers[i]['subkeys'], val)) + else: + assert False, "Unexpected type of header" + flows_list.append(flow_dict) # return list of flows return flows_list @@ -119,12 +152,15 @@ def _flows_filter(flows, ifaces): # predefine filtered flows list flows_filtered = [] + def _iface_to_str(iface): + if int(iface) in ifaces: + return ifaces[int(iface)] + return 'unknown' + # add interface names to flows for flow in flows: - if flow['iface_in'] in ifaces: - flow['iface_in_name'] = ifaces[flow['iface_in']] - else: - flow['iface_in_name'] = 'unknown' + flow['iface_in_name'] = _iface_to_str(flow['dev']['i']) + flow['iface_out_name'] = _iface_to_str(flow['dev']['o']) # iterate through flows list for flow in flows: @@ -134,16 +170,19 @@ def _flows_filter(flows, ifaces): continue # filter by host if cmd_args.host: - if flow['ip_src'] != cmd_args.host and flow['ip_dst'] != cmd_args.host: + if ( + flow['src']['ip'] != cmd_args.host + and flow['dst']['ip'] != cmd_args.host + ): continue # filter by ports if cmd_args.ports: - if cmd_args.ports['type'] == 'single': - if flow['port_src'] != cmd_args.ports['value'] and flow['port_dst'] != cmd_args.ports['value']: - continue - else: - if flow['port_src'] not in cmd_args.ports['value'] and flow['port_dst'] not in cmd_args.ports['value']: - continue + # for 'single' it is a tuple with one value, for 'list' - list of ports, for range - range of ports + if ( + int(flow['src']['port']) not in cmd_args.ports['values'] + and int(flow['dst']['port']) not in cmd_args.ports['values'] + ): + continue # add filtered flows to new list flows_filtered.append(flow) @@ -159,23 +198,36 @@ def _flows_filter(flows, ifaces): # print flow table def _flows_table_print(flows): # define headers and body - table_headers = ['IN_IFACE', 'SRC_MAC', 'DST_MAC', 'SRC_IP', 'DST_IP', 'SRC_PORT', 'DST_PORT', 'PROTOCOL', 'TOS', 'PACKETS', 'FLOWS', 'BYTES'] + table_headers = [ + 'IN_IFACE', + 'SRC_MAC', + 'DST_MAC', + 'SRC_IP', + 'DST_IP', + 'SRC_PORT', + 'DST_PORT', + 'PROTOCOL', + 'TOS', + 'PACKETS', + # 'FLOWS', # What was here in pmacct? + 'BYTES', + ] table_body = [] # convert flows to list for flow in flows: table_line = [ flow.get('iface_in_name'), - flow.get('mac_src'), - flow.get('mac_dst'), - flow.get('ip_src'), - flow.get('ip_dst'), - flow.get('port_src'), - flow.get('port_dst'), - flow.get('ip_proto'), - flow.get('tos'), + flow.get('mac', {}).get('src'), + flow.get('mac', {}).get('dst'), + flow.get('src', {}).get('ip'), + flow.get('dst', {}).get('ip'), + flow.get('src', {}).get('port'), + flow.get('dst', {}).get('port'), + flow.get('proto'), + flow.get('extra', {}).get('tos'), flow.get('packets'), - flow.get('flows'), - flow.get('bytes') + # flow.get('flows'), + flow.get('bytes'), ] table_body.append(table_line) # configure and fill table @@ -190,21 +242,37 @@ def _flows_table_print(flows): sys.exit(0) -# check if in-memory table is active -def _check_imt(): - if not os.path.exists(uacctd_pipefile): - print("In-memory table is not available") - sys.exit(1) - - # define program arguments cmd_args_parser = argparse.ArgumentParser(description='show flow-accounting') -cmd_args_parser.add_argument('--action', choices=['show', 'clear', 'restart'], required=True, help='command to flow-accounting daemon') -cmd_args_parser.add_argument('--filter', choices=['interface', 'host', 'ports', 'top'], required=False, nargs='*', help='filter flows to display') -cmd_args_parser.add_argument('--interface', required=False, help='interface name for output filtration') -cmd_args_parser.add_argument('--host', type=str, required=False, help='host address for output filtering') -cmd_args_parser.add_argument('--ports', type=str, required=False, help='port number, range or list for output filtering') -cmd_args_parser.add_argument('--top', type=int, required=False, help='top records for output filtering') +# 'clear' and 'restart' are not implemented +cmd_args_parser.add_argument( + '--action', + choices=['show', 'restart'], + default='show', + help='show stat or restart module', +) +cmd_args_parser.add_argument( + '--filter', + choices=['interface', 'host', 'ports', 'top'], + required=False, + nargs='*', + help='filter flows to display', +) +cmd_args_parser.add_argument( + '--interface', required=False, help='interface name for output filtration' +) +cmd_args_parser.add_argument( + '--host', type=str, required=False, help='host address for output filtering' +) +cmd_args_parser.add_argument( + '--ports', + type=str, + required=False, + help='port number, range or list for output filtering', +) +cmd_args_parser.add_argument( + '--top', type=int, required=False, help='top records for output filtering' +) # parse arguments cmd_args = cmd_args_parser.parse_args() @@ -219,30 +287,13 @@ except ValueError as e: sys.exit(1) # main logic -# do nothing if uacctd daemon is not running -if not _uacctd_running(): +# do nothing if ipt_NETFLOW is not active +if not _netflow_running(): print("flow-accounting is not active") sys.exit(1) -# restart pmacct daemon -if cmd_args.action == 'restart': - if commit_in_progress(): - print('Cannot restart flow-accounting while a commit is in progress') - exit(1) - # run command to restart flow-accounting - cmd('systemctl restart uacctd.service', - message='Failed to restart flow-accounting') - -# clear in-memory collected flows -if cmd_args.action == 'clear': - _check_imt() - # run command to clear flows - cmd(f'/usr/bin/pmacct -e -p {uacctd_pipefile}', - message='Failed to clear flows') - # show table with flows if cmd_args.action == 'show': - _check_imt() # get interfaces index and names ifaces_dict = _get_ifaces_dict() # get flows @@ -254,4 +305,22 @@ if cmd_args.action == 'show': # print flows _flows_table_print(tabledata) +if cmd_args.action == 'restart': + ipt_netflow.stop() + + # get needed interfaces + conf = ConfigTreeQuery() + config_path = ['system', 'flow-accounting'] + if not conf.exists(config_path + ['netflow', 'interface']): + print("Flow accounting not configured, exiting") + sys.exit(1) + + ingress_interfaces = conf.values(config_path + ['netflow', 'interface']) + if conf.exists(config_path + ['enable-egress']): + egress_interfaces = ingress_interfaces + else: + egress_interfaces = [] + + ipt_netflow.start(ingress_interfaces, egress_interfaces) + sys.exit(0) diff --git a/src/op_mode/force_mtu_host.sh b/src/op_mode/force_mtu_host.sh index c72fc243f..e3e24b57b 100755 --- a/src/op_mode/force_mtu_host.sh +++ b/src/op_mode/force_mtu_host.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/force_root-partition-auto-resize.sh b/src/op_mode/force_root-partition-auto-resize.sh index b39e87560..e17f63a88 100755 --- a/src/op_mode/force_root-partition-auto-resize.sh +++ b/src/op_mode/force_root-partition-auto-resize.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/format_disk.py b/src/op_mode/format_disk.py index dc3c96322..56cb51a02 100755 --- a/src/op_mode/format_disk.py +++ b/src/op_mode/format_disk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -123,10 +123,10 @@ if __name__ == '__main__': f'\ndata on {target_disk}.\n') if not ask_yes_no('Do you wish to proceed?'): - print(f'Disk drive {target_disk} will not be re-formated') + print(f'Disk drive {target_disk} will not be re-formatted') exit(0) - print(f'Re-formating disk drive {target_disk}...') + print(f'Re-formatting disk drive {target_disk}...') print('Making backup copy of partitions...') backup_partitions(target_disk) diff --git a/src/op_mode/generate_interfaces_debug_archive.py b/src/op_mode/generate_interfaces_debug_archive.py index 3059aad23..cf1f23c42 100755 --- a/src/op_mode/generate_interfaces_debug_archive.py +++ b/src/op_mode/generate_interfaces_debug_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -54,7 +54,7 @@ CMD_INTERFACES_LIST: list[str] = [ "ethtool --phy-statistics " ] -# get intefaces info +# get interfaces info interfaces_list = os.popen('ls /sys/class/net/').read().split() # modify CMD_INTERFACES_LIST for all interfaces diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py index ca2eeb511..de0f25bfd 100755 --- a/src/op_mode/generate_ipsec_debug_archive.py +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_openconnect_otp_key.py b/src/op_mode/generate_openconnect_otp_key.py index 99b67d261..5cb1c6edf 100755 --- a/src/op_mode/generate_openconnect_otp_key.py +++ b/src/op_mode/generate_openconnect_otp_key.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py index 1d2f1067a..a55f2e3c5 100755 --- a/src/op_mode/generate_ovpn_client_file.py +++ b/src/op_mode/generate_ovpn_client_file.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -108,13 +108,13 @@ if __name__ == '__main__': required=True, ) parser.add_argument( - "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True + "-a", "--ca", type=str, help='OpenVPN CA certificate', required=True ) parser.add_argument( - "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True + "-c", "--cert", type=str, help='OpenVPN client certificate', required=True ) parser.add_argument( - "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store" + "-k", "--key", type=str, help='OpenVPN client certificate key', action="store" ) args = parser.parse_args() diff --git a/src/op_mode/generate_psk.py b/src/op_mode/generate_psk.py index d51293712..816150dbf 100644 --- a/src/op_mode/generate_psk.py +++ b/src/op_mode/generate_psk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_public_key_command.py b/src/op_mode/generate_public_key_command.py index 8ba55c901..1c1246742 100755 --- a/src/op_mode/generate_public_key_command.py +++ b/src/op_mode/generate_public_key_command.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_service_rule-resequence.py b/src/op_mode/generate_service_rule-resequence.py index 9333d6353..fd6354110 100755 --- a/src/op_mode/generate_service_rule-resequence.py +++ b/src/op_mode/generate_service_rule-resequence.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_ssh_server_key.py b/src/op_mode/generate_ssh_server_key.py index d6063c43c..459157afd 100755 --- a/src/op_mode/generate_ssh_server_key.py +++ b/src/op_mode/generate_ssh_server_key.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,6 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit + +from vyos.defaults import directories from vyos.utils.io import ask_yes_no from vyos.utils.process import cmd from vyos.utils.commit import commit_in_progress @@ -26,6 +28,8 @@ if commit_in_progress(): print('Cannot restart SSH while a commit is in progress') exit(1) +conf_mode_dir = directories['conf_mode'] + cmd('rm -v /etc/ssh/ssh_host_*') cmd('dpkg-reconfigure openssh-server') -cmd('systemctl restart ssh.service') +cmd(f'{conf_mode_dir}/service_ssh.py') diff --git a/src/op_mode/generate_system_login_user.py b/src/op_mode/generate_system_login_user.py index 1b328eae0..c0cb69708 100755 --- a/src/op_mode/generate_system_login_user.py +++ b/src/op_mode/generate_system_login_user.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/generate_tech-support_archive.py b/src/op_mode/generate_tech-support_archive.py index 41b53cd15..d005d78ee 100755 --- a/src/op_mode/generate_tech-support_archive.py +++ b/src/op_mode/generate_tech-support_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,34 +13,32 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. + import os import argparse import glob from datetime import datetime from pathlib import Path from shutil import rmtree - from socket import gethostname from sys import exit from tarfile import open as tar_open -from vyos.utils.process import rc_cmd + +from vyos.defaults import directories +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.file import get_name_from_path from vyos.remote import upload -def op(cmd: str) -> str: - """Returns a command with the VyOS operational mode wrapper.""" - return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' - -def save_stdout(command: str, file: Path) -> None: - rc, stdout = rc_cmd(command) - body: str = f'''### {command} ### -Command: {command} -Exit code: {rc} -Stdout: -{stdout} - -''' - with file.open(mode='a') as f: - f.write(body) + +# Example: bdbdd9a4807f_tech-support-archive_2026-02-02T13-53-18 +ARCHIVE_PATTERN = '_tech-support-archive_' +# Example: drops-debug_2026-02-02T13-53-18 +ARCHIVE_TMP_DIR_PATTERN = 'drops-debug_' +DEFAULT_TMP_DIR = '/tmp' +EXCLUDED_ARCHIVE_EXT = ('.iso', '.gz', '.tar', '.zip') + + def __rotate_logs(path: str, log_pattern:str): files_list = glob.glob(f'{path}/{log_pattern}') if len(files_list) > 5: @@ -48,12 +46,59 @@ def __rotate_logs(path: str, log_pattern:str): os.remove(oldest_file) +def __save_show_report_files(reports_dir: Path): + """ + Save result of execution `show tech-support report` command + :param reports_dir: path to the result directory + :type reports_dir: pathlib.Path + """ + + vyos_op_scripts_dir = directories['op_mode'] + script_path = f'{vyos_op_scripts_dir}/show_techsupport_report.py' + arguments = [ + '--launched-from-generate-archive', + '--outdir', + str(reports_dir), + ] + output = cmd([script_path] + arguments) + + if output.strip(): + print(output) + + def __generate_archived_files(location_path: str) -> None: """ - Generate arhives of main directories + Generate archives of main directories :param location_path: path to temporary directory :type location_path: str """ + + # sync/flush journald before archiving /var/log/journal + cmd(['journalctl', '--sync']) + cmd(['journalctl', '--flush']) + + def __tar_filter(tarinfo): + # path inside tar, because we set arcname=... below + name = tarinfo.name + basename = os.path.basename(name) + + # /var/log: exclude /var/log/messages and /var/log/messages.* + if name.startswith('var/log/messages'): + if basename == 'messages' or basename.startswith('messages.'): + return None + + # /tmp, /home: exclude previous tech-support archives and temporary archive directories + if name.startswith(('tmp/', 'home/')): + if ARCHIVE_PATTERN in name or basename.startswith(ARCHIVE_TMP_DIR_PATTERN): + return None + + # /home, /opt/vyatta/etc/config, /tmp: exclude general archives + if name.startswith(('home/', 'opt/vyatta/etc/config/', 'tmp/')): + if basename.lower().endswith(EXCLUDED_ARCHIVE_EXT): + return None + + return tarinfo + # Dictionary arhive_name:directory_to_arhive archive_dict = { 'etc': '/etc', @@ -62,87 +107,149 @@ def __generate_archived_files(location_path: str) -> None: 'root': '/root', 'tmp': '/tmp', 'core-dump': '/var/core', - 'config': '/opt/vyatta/etc/config' - } - # Dictionary arhive_name:excluding pattern - archive_excludes = { - # Old location of archives - 'config': 'tech-support-archive', - # New locations of arhives - 'tmp': 'tech-support-archive' + 'config': '/opt/vyatta/etc/config', + 'run': '/run', } + for archive_name, path in archive_dict.items(): - archive_file: str = f'{location_path}/{archive_name}.tar.gz' + if not os.path.exists(path): + continue + + arcname = str(path).lstrip('/') # e.g. /etc -> 'etc' + + archive_file = f'{location_path}/{archive_name}.tar.gz' with tar_open(name=archive_file, mode='x:gz') as tar_file: - if archive_name in archive_excludes: - tar_file.add(path, filter=lambda x: None if str(archive_excludes[archive_name]) in str(x.name) else x) - else: - tar_file.add(path) + try: + tar_file.add(path, arcname=arcname, filter=__tar_filter) + except (PermissionError, OSError) as e: + print(f'Unable to read `{path}` to archive files:', e) + continue # skip paths we can't read def __generate_main_archive_file(archive_file: str, tmp_dir_path: str) -> None: """ - Generate main arhive file - :param archive_file: name of arhive file + Generate main archive file + :param archive_file: name of archive file :type archive_file: str - :param tmp_dir_path: path to arhive memeber + :param tmp_dir_path: path to archive member :type tmp_dir_path: str """ + + arcname = get_name_from_path(archive_file) with tar_open(name=archive_file, mode='x:gz') as tar_file: - tar_file.add(tmp_dir_path, arcname=os.path.basename(tmp_dir_path)) + tar_file.add(tmp_dir_path, arcname=arcname) + +def __generate_topology_snapshots(output_dir: Path) -> None: + """ + Generates physical and logical topology PNG files using `lstopo` + + :param output_dir: directory where topology PNGs will be stored + """ + + physical_topo = output_dir / 'topology.png' + logical_topo = output_dir / 'topology-logical.png' + + # Capture physical topology + call(['lstopo', '--output-format', 'png', str(physical_topo)]) + + # Capture logical topology + call(['lstopo', '--logical', '--output-format', 'png', str(logical_topo)]) + + +def __resolve_main_archive_path(input_path: str, default_archive_name: str) -> Path: + """ + Normalize path for saving a .tar.gz file based on rules: + + Rules: + - file -> file.tar.gz + - file.tar -> file.tar.gz + - file.tgz -> file.tgz + - dir/ -> dir/{default_archive_name} + - ../dir/file.tar.gz -> (unchanged) + - file.zip -> file.tar.gz + + :param input_path: user's provided path to the archive + :param default_archive_name: name of archive if user didn't provide it + """ + + path = Path(input_path) + + # Case 1: default temporary directory -> extend by default name of file + if input_path == DEFAULT_TMP_DIR: + return path / default_archive_name + + # Case 2: already .tar.gz -> return unchanged + if path.name.endswith(('.tar.gz', '.tgz')): + return path + + # Case 3: already .tar -> .tar.gz + if path.name.endswith('.tar'): + return path.with_suffix('.tar.gz') + + # Case 4: directory (explicit trailing slash OR existing directory) + if input_path.endswith(('/', '\\')) or path.is_dir(): + dir_path = path + return dir_path / default_archive_name + + # Default behavior for any other extension + return path.with_suffix('.tar.gz') if __name__ == '__main__': - defualt_tmp_dir = '/tmp' parser = argparse.ArgumentParser() - parser.add_argument("path", nargs='?', default=defualt_tmp_dir) + parser.add_argument('path', nargs='?', default=DEFAULT_TMP_DIR) args = parser.parse_args() - location_path = args.path[:-1] if args.path[-1] == '/' else args.path hostname: str = gethostname() - time_now: str = datetime.now().isoformat(timespec='seconds').replace(":", "-") - - remote = False - tmp_path = '' - tmp_dir_path = '' - if 'ftp://' in args.path or 'scp://' in args.path: - remote = True - tmp_path = defualt_tmp_dir + time_now: str = datetime.now().isoformat(timespec='seconds').replace(':', '-') + default_archive_inner_dir = f'{hostname}{ARCHIVE_PATTERN}{time_now}' + default_archive_name = f'{default_archive_inner_dir}.tar.gz' + + is_remote = args.path.startswith(('ftp://', 'scp://')) + if is_remote: + base_tmp_path = DEFAULT_TMP_DIR + archive_dest_path = Path(f'{base_tmp_path}/{default_archive_name}') else: - tmp_path = location_path - archive_pattern = f'_tech-support-archive_' - archive_file_name = f'{hostname}{archive_pattern}{time_now}.tar.gz' + # Define destination path to the main archive file based on a rules + archive_dest_path = __resolve_main_archive_path(args.path, default_archive_name) + base_tmp_path = str(archive_dest_path.parent) + default_archive_name = archive_dest_path.name + default_archive_inner_dir = get_name_from_path(archive_dest_path.name) # Log rotation in tmp directory - if tmp_path == defualt_tmp_dir: - __rotate_logs(tmp_path, f'*{archive_pattern}*') + if base_tmp_path == DEFAULT_TMP_DIR: + __rotate_logs(base_tmp_path, f'*{ARCHIVE_PATTERN}*') # Temporary directory creation - tmp_dir_path = f'{tmp_path}/drops-debug_{time_now}' - tmp_dir: Path = Path(tmp_dir_path) + tmp_dir: Path = Path(f'{base_tmp_path}/{ARCHIVE_TMP_DIR_PATTERN}{time_now}') tmp_dir.mkdir(parents=True) - report_file: Path = Path(f'{tmp_dir_path}/show_tech-support_report.txt') - report_file.touch() + # Directory which contains list of 'tech-support' reports + reports_dir: Path = Path(f'{tmp_dir}/show_tech-support_report') + + # Call the topology snapshot function here + __generate_topology_snapshots(tmp_dir) + try: + # Generate files using `show tech-support report` command + __save_show_report_files(reports_dir) - save_stdout(op('show tech-support report'), report_file) # Generate included archives - __generate_archived_files(tmp_dir_path) + __generate_archived_files(tmp_dir) # Generate main archive - __generate_main_archive_file(f'{tmp_path}/{archive_file_name}', tmp_dir_path) - # Delete temporary directory - rmtree(tmp_dir) + __generate_main_archive_file(archive_dest_path, tmp_dir) + # Upload to remote site if it is scpecified - if remote: - upload(f'{tmp_path}/{archive_file_name}', args.path) - print(f'Debug file is generated and located in {location_path}/{archive_file_name}') + if is_remote: + upload_uri = args.path + upload(str(archive_dest_path), upload_uri) except Exception as err: print(f'Error during generating a debug file: {err}') - # cleanup - if tmp_dir.exists(): - rmtree(tmp_dir) + else: + print(f'Debug file is generated and located in {archive_dest_path}') finally: - # cleanup + # Delete temporary directory + if tmp_dir.exists(): + rmtree(tmp_dir, ignore_errors=True) exit() diff --git a/src/op_mode/igmp-proxy.py b/src/op_mode/igmp-proxy.py index 709e25915..d197e11f6 100755 --- a/src/op_mode/igmp-proxy.py +++ b/src/op_mode/igmp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index cf2bc6d5c..0db9ef545 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py index 56aefcd6e..119960a6f 100755 --- a/src/op_mode/image_info.py +++ b/src/op_mode/image_info.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -72,6 +72,14 @@ def _format_show_images_details( return tabulated +def show_images_current(raw: bool) -> Union[image.BootDetails, str]: + + images_summary = show_images_summary(raw=True) + if raw: + return {'image_running' : images_summary['image_running']} + else: + return images_summary['image_running'] + def show_images_summary(raw: bool) -> Union[image.BootDetails, str]: images_available: list[str] = grub.version_list() diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index 9c17d0229..07f69ccd5 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -17,14 +17,23 @@ # You should have received a copy of the GNU General Public License along with # VyOS. If not, see <https://www.gnu.org/licenses/>. -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace from pathlib import Path -from shutil import copy, chown, rmtree, copytree +from shutil import copy +from shutil import chown +from shutil import rmtree +from shutil import copytree +from shutil import disk_usage from glob import glob from sys import exit from os import environ from os import readlink -from os import getpid, getppid +from os import getpid +from os import getppid +from os import sync +from json import loads +from json import dumps from typing import Union from urllib.parse import urlparse from passlib.hosts import linux_context @@ -34,22 +43,42 @@ from psutil import disk_partitions from vyos.base import Warning from vyos.configtree import ConfigTree +from vyos.config_mgmt import unsaved_commits +from vyos.defaults import base_dir +from vyos.defaults import directories +from vyos.defaults import activation_hint +from vyos.flavor import get_image_serial_console from vyos.remote import download -from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER +from vyos.system import disk +from vyos.system import grub +from vyos.system import image +from vyos.system import compat +from vyos.system import raid +from vyos.system import SYSTEM_CFG_VER +from vyos.system import grub_util from vyos.template import render -from vyos.utils.auth import ( - DEFAULT_PASSWORD, - EPasswdStrength, - evaluate_strength -) -from vyos.utils.io import ask_input, ask_yes_no, select_entry +from vyos.utils.auth import DEFAULT_PASSWORD +from vyos.utils.auth import EPasswdStrength +from vyos.utils.auth import evaluate_strength +from vyos.utils.auth import get_local_users +from vyos.utils.auth import get_user_home_dir +from vyos.utils.dict import dict_search +from vyos.utils.io import ask_input +from vyos.utils.io import ask_yes_no +from vyos.utils.io import select_entry from vyos.utils.file import chmod_2775 -from vyos.utils.process import cmd, run, rc_cmd +from vyos.utils.file import read_file +from vyos.utils.file import write_file +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.utils.process import rc_cmd from vyos.version import get_version_data # define text messages MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.' MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.' +MSG_ERR_NOT_ENOUGH_SPACE: str = 'Image upgrade requires at least 2GB of free drive space.' +MSG_ERR_UNSAVED_COMMITS: str = 'There are unsaved changes to the configuration. Either save or revert before upgrade.' MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.' MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.' MSG_ERR_INCOMPATIBLE_IMAGE: str = 'Image compatibility check failed, aborting installation.' @@ -73,7 +102,9 @@ MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...' MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?' MSG_INPUT_CONFIG_CHOICE: str = 'The following config files are available for boot:' MSG_INPUT_CONFIG_CHOOSE: str = 'Which file would you like as boot config?' +MSG_INPUT_UNSAVED_COMMITS: str = 'There are unsaved changes to the configuration. They will not be copied to the new image. Continue without saving?' MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?' +MSG_INPUT_IMAGE_NAME_TAKEN: str = 'There is already an installed image by that name; please choose again' MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?' MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user:' MSG_INPUT_PASSWORD_CONFIRM: str = 'Please confirm password for the "vyos" user:' @@ -102,6 +133,8 @@ CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2 # define directories and paths +DIR_CONFIG: str = directories['config'] +DIR_DATA: str = directories['data'] DIR_INSTALLATION: str = '/mnt/installation' DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src' DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst' @@ -111,19 +144,20 @@ DIR_KERNEL_SRC: str = '/boot/' FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs' ISO_DOWNLOAD_PATH: str = '' -external_download_script = '/usr/libexec/vyos/simple-download.py' -external_latest_image_url_script = '/usr/libexec/vyos/latest-image-url.py' +external_download_script: str = f'{base_dir}/simple-download.py' +external_latest_image_url_script: str = f'{base_dir}/latest-image-url.py' + +(flavor_sercon_type, flavor_sercon_num, flavor_sercon_speed) = get_image_serial_console() # default boot variables DEFAULT_BOOT_VARS: dict[str, str] = { 'timeout': '5', 'console_type': 'tty', - 'console_num': '0', - 'console_speed': '115200', + 'console_num': flavor_sercon_num, + 'console_speed': flavor_sercon_speed, 'bootmode': 'normal' } - def bytes_to_gb(size: int) -> float: """Convert Bytes to GBytes, rounded to 1 decimal number @@ -249,12 +283,18 @@ def search_previous_installation(disks: list[str]) -> None: print('Searching for data from previous installations') image_data = [] encrypted_configs = [] + legacy_bind_mount = False for disk_name in disks: for partition in disk.partition_list(disk_name): if disk.partition_mount(partition, mnt_tmp): if Path(mnt_tmp + '/boot').exists(): for path in Path(mnt_tmp + '/boot').iterdir(): if path.joinpath('rw/config/.vyatta_config').exists(): + legacy_bind_mount = True + image_data.append((path.name, partition)) + elif path.joinpath( + 'rw/opt/vyatta/etc/config/.vyatta_config' + ).exists(): image_data.append((path.name, partition)) if Path(mnt_tmp + '/luks').exists(): for path in Path(mnt_tmp + '/luks').iterdir(): @@ -307,7 +347,12 @@ def search_previous_installation(disks: list[str]) -> None: disk.partition_mount(image_drive, mnt_tmp) if not encrypted: - copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + if legacy_bind_mount: + copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + else: + copytree( + f'{mnt_tmp}/boot/{image_name}/rw/opt/vyatta/etc/config', mnt_config + ) else: copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config) @@ -330,7 +375,7 @@ def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True): def copy_previous_installation_data(target_dir: str) -> None: if Path('/mnt/config').exists(): - copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config', + copytree('/mnt/config', f'{target_dir}{DIR_CONFIG}', dirs_exist_ok=True) if Path('/mnt/ssh').exists(): copytree('/mnt/ssh', f'{target_dir}/etc/ssh', @@ -476,6 +521,77 @@ def setup_grub(root_dir: str) -> None: render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) +def get_cli_kernel_options(config_file: str) -> list: + config = ConfigTree(read_file(config_file)) + config_dict = loads(config.to_json()) + cmdline_options = [] + kernel_options = dict_search('system.option.kernel', config_dict) + if kernel_options is None: + return cmdline_options + + k_cpu_opts = kernel_options.get('cpu', {}) + k_memory_opts = kernel_options.get('memory', {}) + + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in system_options.py:generate(). This occurrence is used + # for having the appropriate options passed to GRUB after an image upgrade! + if 'disable-mitigations' in kernel_options: + cmdline_options.append('mitigations=off') + if 'disable-power-saving' in kernel_options: + cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1') + if 'amd-pstate-driver' in kernel_options: + mode = kernel_options['amd-pstate-driver'] + cmdline_options.append( + f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}') + if 'quiet' in kernel_options: + cmdline_options.append('quiet') + + # Early reboot on kernel panic via kernel cmdline (must match system_option.py) + if dict_search('system.option.reboot-on-panic', config_dict) is not None: + cmdline_options.append('panic=60') + + if 'disable-hpet' in kernel_options: + cmdline_options.append('hpet=disable') + + if 'disable-mce' in kernel_options: + cmdline_options.append('mce=off') + + if 'disable-softlockup' in kernel_options: + cmdline_options.append('nosoftlockup') + + # CPU options + isol_cpus = k_cpu_opts.get('isolate-cpus') + if isol_cpus: + cmdline_options.append(f'isolcpus={isol_cpus}') + + nohz_full = k_cpu_opts.get('nohz-full') + if nohz_full: + cmdline_options.append(f'nohz_full={nohz_full}') + + rcu_nocbs = k_cpu_opts.get('rcu-no-cbs') + if rcu_nocbs: + cmdline_options.append(f'rcu_nocbs={rcu_nocbs}') + + if 'disable-nmi-watchdog' in k_cpu_opts: + cmdline_options.append('nmi_watchdog=0') + + # Memory options + if 'disable-numa-balancing' in k_memory_opts: + cmdline_options.append('numa_balancing=disable') + + default_hp_size = k_memory_opts.get('default-hugepage-size') + if default_hp_size: + cmdline_options.append(f'default_hugepagesz={default_hp_size}') + + hp_sizes = k_memory_opts.get('hugepage-size') + if hp_sizes: + for size, settings in hp_sizes.items(): + cmdline_options.append(f'hugepagesz={size}') + count = settings.get('hugepage-count') + if count: + cmdline_options.append(f'hugepages={count}') + + return cmdline_options def configure_authentication(config_file: str, password: str) -> None: """Write encrypted password to config file @@ -490,10 +606,7 @@ def configure_authentication(config_file: str, password: str) -> None: plaintext exposed """ encrypted_password = linux_context.hash(password) - - with open(config_file) as f: - config_string = f.read() - + config_string = read_file(config_file) config = ConfigTree(config_string) config.set([ 'system', 'login', 'user', 'vyos', 'authentication', @@ -506,6 +619,49 @@ def configure_authentication(config_file: str, password: str) -> None: with open(config_file, 'w') as f: f.write(config.to_string()) +def configure_serial_console(config_file: str, console_type: str) -> None: + """Apply serial console settings to config.boot from kernel cmdline. + + This overlaps with 05-serial_console.py activation logic, but that script + only runs during live boot. During installation, the user may pick a + different source config, so serial console settings must be written to + the final target config explicitly. + + Behavior: + - Reads the kernel serial console device/speed from the current boot cmdline. + - If the detected device is a valid tty, writes: + system console device <TTY> speed <rate> + - If "console_type == 'S'", also writes: + system console device <TTY> kernel + + Args: + config_file (str): path of target config file + console_type (str): 'K' (KVM/tty) or 'S' (serial) + """ + from vyos.utils.serial import is_tty + from vyos.utils.kernel import get_kernel_serial_console + + # Parse current kernel cmdline and continue only for valid serial console + # data. Prevent writing incomplete/invalid console settings to config.boot. + k_console_type, k_console_num, k_console_speed = get_kernel_serial_console() + device = f'{k_console_type}{k_console_num}' + if not is_tty(device) or not k_console_speed: + return + + base = ['system', 'console', 'device'] + config_string = read_file(config_file) + config = ConfigTree(config_string) + config.set(base + [device, 'speed'], value=k_console_speed) + config.set_tag(base) + + # Only mark this device as kernel boot console when console_type 'S' for + # serial was defined by user. + if console_type == 'S': + config.set(base + [device, 'kernel']) + + with open(config_file, 'w') as f: + f.write(config.to_string()) + def validate_signature(file_path: str, sign_type: str) -> None: """Validate a file by signature and delete a signature file @@ -534,21 +690,18 @@ def validate_signature(file_path: str, sign_type: str) -> None: print('Signature is valid') def download_file(local_file: str, remote_path: str, vrf: str, - username: str, password: str, progressbar: bool = False, check_space: bool = False): - environ['REMOTE_USERNAME'] = username - environ['REMOTE_PASSWORD'] = password + # Server credentials are implicitly passed in environment variables + # that are set by add_image if vrf is None: download(local_file, remote_path, progressbar=progressbar, check_space=check_space, raise_error=True) else: - remote_auth = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password}' vrf_cmd = f'ip vrf exec {vrf} {external_download_script} \ --local-file {local_file} --remote-path {remote_path}' - cmd(vrf_cmd, auth=remote_auth) + cmd(vrf_cmd, env=environ) def image_fetch(image_path: str, vrf: str = None, - username: str = '', password: str = '', no_prompt: bool = False) -> Path: """Fetch an ISO image @@ -567,9 +720,8 @@ def image_fetch(image_path: str, vrf: str = None, if image_path == 'latest': command = external_latest_image_url_script if vrf: - command = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \ - ip vrf exec {vrf} ' + command - code, output = rc_cmd(command) + command = f'ip vrf exec {vrf} {command}' + code, output = rc_cmd(command, env=environ) if code: print(output) exit(MSG_INFO_INSTALL_EXIT) @@ -581,7 +733,6 @@ def image_fetch(image_path: str, vrf: str = None, # Download the image file ISO_DOWNLOAD_PATH = os.path.join(os.path.expanduser("~"), '{0}.iso'.format(uuid4())) download_file(ISO_DOWNLOAD_PATH, image_path, vrf, - username, password, progressbar=True, check_space=True) # Download the image signature @@ -592,8 +743,7 @@ def image_fetch(image_path: str, vrf: str = None, for sign_type in ['minisig']: try: download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}', - f'{image_path}.{sign_type}', vrf, - username, password) + f'{image_path}.{sign_type}', vrf) sign_file = (True, sign_type) break except Exception: @@ -625,7 +775,7 @@ def migrate_config() -> bool: Returns: bool: user's decision """ - active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot') + active_config_path: Path = Path(f'{DIR_CONFIG}/config.boot') if active_config_path.exists(): if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True): return True @@ -643,6 +793,20 @@ def copy_ssh_host_keys() -> bool: return False +def copy_ssh_known_hosts() -> bool: + """Ask user to copy SSH `known_hosts` files + + Returns: + bool: user's decision + """ + known_hosts_files = get_known_hosts_files() + msg = ( + 'Would you like to save the SSH known hosts (fingerprints) ' + 'from your current configuration?' + ) + return known_hosts_files and ask_yes_no(msg, default=True) + + def console_hint() -> str: pid = getppid() if 'SUDO_USER' in environ else getpid() try: @@ -651,12 +815,11 @@ def console_hint() -> str: path = '/dev/tty' name = Path(path).name - if name == 'ttyS0': + if name.startswith(('ttyS', 'ttyAMA')): return 'S' else: return 'K' - def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: """Clean up after installation @@ -805,12 +968,12 @@ def install_image() -> None: print(MSG_WARN_PASSWORD_CONFIRM) # ask for default console + console_dict: dict[str, str] = {'K': 'tty', 'S': flavor_sercon_type} console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE, default=console_hint(), - valid_responses=['K', 'S']) - console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS'} + valid_responses=console_dict.keys()) - config_boot_list = ['/opt/vyatta/etc/config/config.boot', + config_boot_list = [f'{DIR_CONFIG}/config.boot', '/opt/vyatta/etc/config.boot.default'] default_config = config_boot_list[0] @@ -843,10 +1006,10 @@ def install_image() -> None: Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') - # a config dir. It is the deepest one, so the comand will + # a config dir. It is the deepest one, so the command will # create all the rest in a single step print('Creating a configuration file') - target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/' + target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw{DIR_CONFIG}/' Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') chmod_2775(target_config_dir) @@ -854,6 +1017,8 @@ def install_image() -> None: copy(default_config, f'{target_config_dir}/config.boot') configure_authentication(f'{target_config_dir}/config.boot', user_password) + configure_serial_console(f'{target_config_dir}/config.boot', + console_type) Path(f'{target_config_dir}/.vyatta_config').touch() # create a persistence.conf @@ -878,6 +1043,14 @@ def install_image() -> None: write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw' raid.update_default(write_dir) + # set activation hint + target_data_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw{DIR_DATA}/' + data_path = Path(target_data_dir) + data_path.mkdir(parents=True) + data_path.chmod(0o755) + init_hint = data_path.joinpath(Path(activation_hint).name) + init_hint.touch() + setup_grub(DIR_DST_ROOT) # add information about version grub.create_structure() @@ -897,8 +1070,7 @@ def install_image() -> None: for disk_target in l: disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/', - f'{DIR_DST_ROOT}/boot/efi', - id=f'VyOS (RAID disk {l.index(disk_target) + 1})') + f'{DIR_DST_ROOT}/boot/efi') disk.partition_umount(disk_target.partition['efi']) else: print('Installing GRUB to the drive') @@ -924,7 +1096,7 @@ def install_image() -> None: except Exception as err: print(f'Unable to install VyOS: {err}') - # unmount filesystems and clenup + # unmount filesystems and cleanup try: if install_target is not None: if is_raid_install(install_target): @@ -939,6 +1111,55 @@ def install_image() -> None: exit(1) +def get_known_hosts_files(for_root=True, for_users=True) -> list: + """Collect all existing `known_hosts` files for root and/or users under /home""" + + files = [] + + if for_root: + base_files = ('/root/.ssh/known_hosts', '/etc/ssh/ssh_known_hosts') + for file_path in base_files: + root_known_hosts = Path(file_path) + if root_known_hosts.exists(): + files.append(root_known_hosts) + + if for_users: # for each non-system user + for user in get_local_users(): + home_dir = Path(get_user_home_dir(user)) + if home_dir.exists(): + known_hosts = home_dir / '.ssh' / 'known_hosts' + if known_hosts.exists(): + files.append(known_hosts) + + return files + + +def migrate_known_hosts(target_dir: str): + """Copy `known_hosts` for root and all users to the new image directory""" + + def _mkdir_and_copy_file(known_hosts_file, target_known_hosts): + target_known_hosts.parent.mkdir(parents=True, exist_ok=True) + copy(known_hosts_file, target_known_hosts) + + # Copy root only files using default path + known_hosts_files = get_known_hosts_files(for_root=True, for_users=False) + for known_hosts_file in known_hosts_files: + target_known_hosts = Path(f'{target_dir}{known_hosts_file}') + _mkdir_and_copy_file(known_hosts_file, target_known_hosts) + + # During image installation, backup critical user-specific files (e.g., known_hosts) + # from each user's home directory into /var/.users_backups/{user}. This ensures that their + # SSH configuration and trust relationships are preserved across system re-installations + # or provisioning. + # More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265 + known_hosts_files = get_known_hosts_files(for_root=False, for_users=True) + for known_hosts_file in known_hosts_files: + username = known_hosts_file.parent.parent.name + base_dir = Path(f'{target_dir}/var/.users_backups/{username}') + target_known_hosts = base_dir / '.ssh' / 'known_hosts' + _mkdir_and_copy_file(known_hosts_file, target_known_hosts) + + @compat.grub_cfg_update def add_image(image_path: str, vrf: str = None, username: str = '', password: str = '', no_prompt: bool = False, force: bool = False) -> None: @@ -950,8 +1171,26 @@ def add_image(image_path: str, vrf: str = None, username: str = '', if image.is_live_boot(): exit(MSG_ERR_LIVE) + # Trying to upgrade with insufficient space can break the system. + # It's better to be on the safe side: + # our images are a bit below 1G, + # so one gigabyte to download the image plus one more to install it + # sounds like a sensible estimate. + if disk_usage('/').free < (2 * 1024**3): + exit(MSG_ERR_NOT_ENOUGH_SPACE) + + if unsaved_commits(): + if not no_prompt: + if not ask_yes_no(MSG_INPUT_UNSAVED_COMMITS, default=False): + exit() + else: + exit(MSG_ERR_UNSAVED_COMMITS) + + environ['REMOTE_USERNAME'] = username + environ['REMOTE_PASSWORD'] = password + # fetch an image - iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt) + iso_path: Path = image_fetch(image_path, vrf, no_prompt) try: # mount an ISO Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True) @@ -984,8 +1223,12 @@ def add_image(image_path: str, vrf: str = None, username: str = '', f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed') if not no_prompt: + versions = grub.version_list() while True: image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) + if image_name in versions: + print(MSG_INPUT_IMAGE_NAME_TAKEN) + continue if image.validate_name(image_name): break print(MSG_WARN_IMAGE_NAME_WRONG) @@ -997,18 +1240,45 @@ def add_image(image_path: str, vrf: str = None, username: str = '', # find target directory root_dir: str = disk.find_persistence() - # a config dir. It is the deepest one, so the comand will + cmdline_options = [] + + # a config dir. It is the deepest one, so the command will # create all the rest in a single step - target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/' + target_config_dir: str = f'{root_dir}/boot/{image_name}/rw{DIR_CONFIG}/' # copy config if no_prompt or migrate_config(): - print('Copying configuration directory') - # copytree preserves perms but not ownership: - Path(target_config_dir).mkdir(parents=True) - chown(target_config_dir, group='vyattacfg') - chmod_2775(target_config_dir) - copytree('/opt/vyatta/etc/config/', target_config_dir, symlinks=True, - copy_function=copy_preserve_owner, dirs_exist_ok=True) + if Path('/dev/mapper/vyos_config').exists(): + print('Copying encrypted configuration volume') + + # Record information from which image we upgraded to the new one. + # This can be used for a future automatic rollback into the old image. + # + # For encrypted config, we need to copy, sync filesystems and remove from current image + tmp = {'previous_image' : image.get_running_image()} + write_file('/opt/vyatta/etc/config/first_boot', dumps(tmp)) + sync() + + # Copy encrypted volumes + current_name = image.get_running_image() + current_config_path = f'{root_dir}/luks/{current_name}' + target_config_path = f'{root_dir}/luks/{image_name}' + copy(current_config_path, target_config_path) + + # Now remove from current image + Path('/opt/vyatta/etc/config/first_boot').unlink() + else: + print('Copying configuration directory') + # copytree preserves perms but not ownership: + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + copytree(f'{DIR_CONFIG}/', target_config_dir, symlinks=True, + copy_function=copy_preserve_owner, dirs_exist_ok=True) + + # Record information from which image we upgraded to the new one. + # This can be used for a future automatic rollback into the old image. + tmp = {'previous_image' : image.get_running_image()} + write_file(f'{target_config_dir}/first_boot', dumps(tmp)) else: Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') @@ -1023,6 +1293,11 @@ def add_image(image_path: str, vrf: str = None, username: str = '', for host_key in host_keys: copy(host_key, target_ssh_dir) + target_ssh_known_hosts_dir: str = f'{root_dir}/boot/{image_name}/rw' + if no_prompt or copy_ssh_known_hosts(): + print('Copying SSH known_hosts files') + migrate_known_hosts(target_ssh_known_hosts_dir) + # copy system image and kernel files print('Copying system image files') for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir(): @@ -1040,6 +1315,13 @@ def add_image(image_path: str, vrf: str = None, username: str = '', if set_as_default: grub.set_default(image_name, root_dir) + if Path(f'{target_config_dir}/config.boot').exists(): + cmdline_options = get_cli_kernel_options( + f'{target_config_dir}/config.boot') + grub_util.update_kernel_cmdline_options(' '.join(cmdline_options), + root_dir=root_dir, + version=image_name) + except OSError as e: # if no space error, remove image dir and cleanup if e.errno == ENOSPC: diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index fb4286dbc..985db96dd 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -33,7 +33,7 @@ DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:' MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first' MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' -ConsoleType: TypeAlias = Literal['tty', 'ttyS'] +ConsoleType: TypeAlias = Literal['tty', 'ttyS', 'ttyAMA'] def annotate_list(images_list: list[str]) -> list[str]: """Annotate list of images with additional info diff --git a/src/op_mode/install_mok.sh b/src/op_mode/install_mok.sh new file mode 100755 index 000000000..29f78cd1f --- /dev/null +++ b/src/op_mode/install_mok.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if test -f /var/lib/shim-signed/mok/vyos-dev-2025-shim.der; then + mokutil --ignore-keyring --import /var/lib/shim-signed/mok/vyos-dev-2025-shim.der; +else + echo "Secure Boot Machine Owner Key not found"; +fi diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index e7afc4caa..ecc4e73d2 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,14 +13,13 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# -import os import re import sys import glob import json import typing +import textwrap from datetime import datetime from tabulate import tabulate @@ -28,18 +27,14 @@ import vyos.opmode from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.ifconfig import VRRP +from vyos.utils.dict import dict_set_nested +from vyos.utils.io import catch_broken_pipe +from vyos.utils.network import get_interface_vrf +from vyos.utils.network import interface_exists from vyos.utils.process import cmd from vyos.utils.process import rc_cmd from vyos.utils.process import call - -def catch_broken_pipe(func): - def wrapped(*args, **kwargs): - try: - func(*args, **kwargs) - except (BrokenPipeError, KeyboardInterrupt): - # Flush output to /dev/null and bail out. - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) - return wrapped +from vyos.configquery import op_mode_config_dict # The original implementation of filtered_interfaces has signature: # (ifnames: list, iftypes: typing.Union[str, list], vif: bool, vrrp: bool) -> intf: Interface: @@ -84,6 +79,18 @@ def filtered_interfaces(ifnames: typing.Union[str, list], yield interface +def is_interface_has_mac(interface_name): + interface_no_mac = ('tun', 'wg') + return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) + +def detailed_output(dataset, headers): + for data in dataset: + adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action + transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char + + print(tabulate(transformed_rule, tablefmt="presto")) + print() + def _split_text(text, used=0): """ take a string and attempt to split it to fit with the width of the screen @@ -109,6 +116,7 @@ def _split_text(text, used=0): continue if line: yield line[1:] + line = f' {word}' else: line = f'{line} {word}' @@ -236,10 +244,6 @@ def _get_summary_data(ifname: typing.Optional[str], iftype = '' ret = [] - def is_interface_has_mac(interface_name): - interface_no_mac = ('tun', 'wg') - return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) - for interface in filtered_interfaces(ifname, iftype, vif, vrrp): res_intf = {} @@ -296,6 +300,140 @@ def _get_counter_data(ifname: typing.Optional[str], return ret +def _get_kernel_data(raw, ifname = None, detail = False, + statistics = False): + if ifname: + # Check if the interface exists + if not interface_exists(ifname): + raise vyos.opmode.IncorrectValue(f"{ifname} does not exist!") + int_name = f'dev {ifname}' + else: + int_name = '' + + kernel_interface = json.loads(cmd(f'ip -j -d -s address show {int_name}')) + + # Return early if raw + if raw: + return kernel_interface, None + + # Format the kernel data + kernel_interface_out = _format_kernel_data(kernel_interface, detail, statistics) + + return kernel_interface, kernel_interface_out + +def _format_kernel_data(data, detail, statistics): + output_list = [] + podman_vrf = {} + tmpInfo = {} + + # Sort interfaces by name + for interface in sorted(data, key=lambda x: x.get('ifname', '')): + interface_name = interface.get('ifname', '') + + # Skip VRF interfaces + if interface.get('linkinfo', {}).get('info_kind') == 'vrf': + continue + # Skip spawned interfaces + elif interface_name.startswith(('tunl', 'gre', 'erspan', 'pim6reg')): + continue + + master = interface.get('master', 'default') + vrf = get_interface_vrf(interface) + + # Get the device model; ex. Intel Corporation Ethernet Controller I225-V + dev_model = interface.get('parentdev', '') + if 'parentdev' in interface: + parentdev = interface['parentdev'] + if re.match(r'^[0-9a-fA-F]{4}:', parentdev): + dev_model = cmd(f'lspci -nn -s {parentdev}').split(']:')[1].strip() + + # Get the IP addresses on interface + ip_list = [] + has_global = False + + for ip in interface['addr_info']: + if ip.get('scope') in ('global', 'host'): + has_global = True + local = ip.get('local', '-') + prefixlen = ip.get('prefixlen', '') + ip_list.append(f"{local}/{prefixlen}") + + # If no global IP address, add '-'; indicates no IP address on interface + if not has_global: + ip_list.append('-') + + # Generate a mapping of podman interfaces to their VRF + if interface_name.startswith('pod-'): + dict_set_nested(f'{interface_name}.vrf', master, podman_vrf) + + # If the veth interface's master is a podman interface, the VRF is the VRF of the podman interface + if master.startswith('pod-'): + vrf = podman_vrf.get(master).get('vrf', 'default') + + rx_stats = interface.get('stats64', {}).get('rx') + tx_stats = interface.get('stats64', {}).get('tx') + + sl_status = ('A' if not 'UP' in interface['flags'] else 'u') + '/' + ('D' if interface['operstate'] == 'DOWN' else 'u') + + # Generate temporary dict to hold data + tmpInfo['ifname'] = interface_name + tmpInfo['ip'] = ip_list + tmpInfo['mac'] = interface.get('address', 'n/a') if is_interface_has_mac(interface_name) else 'n/a' + tmpInfo['mtu'] = interface.get('mtu', '') + tmpInfo['vrf'] = vrf + tmpInfo['status'] = sl_status + tmpInfo['description'] = "\n".join(textwrap.wrap(interface.get('ifalias', ''), width=50)) + tmpInfo['device'] = dev_model + tmpInfo['alternate_names'] = interface.get('altnames', '') + tmpInfo['minimum_mtu'] = interface.get('min_mtu', '') + tmpInfo['maximum_mtu'] = interface.get('max_mtu', '') + tmpInfo['rx_packets'] = rx_stats.get('packets', "") + tmpInfo['rx_bytes'] = rx_stats.get('bytes', "") + tmpInfo['rx_errors'] = rx_stats.get('errors', "") + tmpInfo['rx_dropped'] = rx_stats.get('dropped', "") + tmpInfo['rx_over_errors'] = rx_stats.get('over_errors', '') + tmpInfo['multicast'] = rx_stats.get('multicast', "") + tmpInfo['tx_packets'] = tx_stats.get('packets', "") + tmpInfo['tx_bytes'] = tx_stats.get('bytes', "") + tmpInfo['tx_errors'] = tx_stats.get('errors', "") + tmpInfo['tx_dropped'] = tx_stats.get('dropped', "") + tmpInfo['tx_carrier_errors'] = tx_stats.get('carrier_errors', "") + tmpInfo['tx_collisions'] = tx_stats.get('collisions', "") + + # Order the stats based on 'detail' or 'statistics' + if detail: + stat_keys = [ + "rx_packets", "rx_bytes", "rx_errors", "rx_dropped", + "rx_over_errors", "multicast", + "tx_packets", "tx_bytes", "tx_errors", "tx_dropped", + "tx_carrier_errors", "tx_collisions", + ] + elif statistics: + stat_keys = [ + "rx_packets", "rx_bytes", "tx_packets", "tx_bytes", + "rx_dropped", "tx_dropped", "rx_errors", "tx_errors", + ] + else: + stat_keys = [] + + stat_list = [tmpInfo.get(k, "") for k in stat_keys] + + # Generate output list; detail adds more fields + output_list.append([tmpInfo['ifname'], + *(['\n'.join(tmpInfo['ip'])] if not statistics else []), + *([tmpInfo['mac']] if not statistics else []), + *([tmpInfo['vrf']] if not statistics else []), + *([tmpInfo['mtu']] if not statistics else []), + *([tmpInfo['status']] if not statistics else []), + *([tmpInfo['description']] if not statistics else []), + *([tmpInfo['device']] if detail else []), + *(['\n'.join(tmpInfo['alternate_names'])] if detail else []), + *([tmpInfo['minimum_mtu']] if detail else []), + *([tmpInfo['maximum_mtu']] if detail else []), + *(stat_list if any([detail, statistics]) else [])]) + + return output_list + @catch_broken_pipe def _format_show_data(data: list): unhandled = [] @@ -445,6 +583,34 @@ def _format_show_counters(data: list): print (output) return output +def show_kernel(raw: bool, intf_name: typing.Optional[str], + detail: bool, statistics: bool): + raw_data, data = _get_kernel_data(raw, intf_name, detail, statistics) + + # Return early if raw + if raw: + return raw_data + + if detail: + # Detail headers; ex. show interfaces kernel detail; show interfaces kernel eth0 detail + detail_header = ['Interface', 'IP Address', 'MAC', 'VRF', 'MTU', 'S/L', 'Description', + 'Device', 'Alternate Names','Minimum MTU', 'Maximum MTU', 'RX_Packets', + 'RX_Bytes', 'RX_Errors', 'RX_Dropped', 'Receive Overrun Errors', 'Received Multicast', + 'TX_Packets', 'TX_Bytes', 'TX_Errors', 'TX_Dropped', 'Transmit Carrier Errors', + 'Transmit Collisions'] + elif statistics: + # Statistics headers; ex. show interfaces kernel statistics; show interfaces kernel eth0 statistics + headers = ['Interface', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes', 'Rx Dropped', 'Tx Dropped', 'Rx Errors', 'Tx Errors'] + else: + # Normal headers; ex. show interfaces kernel; show interfaces kernel eth0 + print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down') + headers = ['Interface', 'IP Address', 'MAC', 'VRF', 'MTU', 'S/L', 'Description'] + + + if detail: + detailed_output(data, detail_header) + else: + print(tabulate(data, headers)) def _show_raw(data: list, intf_name: str): if intf_name is not None and len(data) <= 1: @@ -489,6 +655,86 @@ def show_counters(raw: bool, intf_name: typing.Optional[str], return _show_raw(data, intf_name) return _format_show_counters(data) +def show_vlan_to_vni(raw: bool, intf_name: typing.Optional[str], + vid: typing.Optional[str], detail: bool, + statistics: bool): + if not interface_exists(intf_name): + raise vyos.opmode.UnconfiguredObject(f"Interface {intf_name} does not exist\n") + + if not vid: + vid = "all" + + tunnel_data = json.loads(cmd(f"bridge -j vlan tunnelshow dev {intf_name} vid {vid}")) + + if not tunnel_data: + if vid == "all": + raise vyos.opmode.UnconfiguredObject(f"No VLAN-to-VNI mapping found for interface {intf_name}\n") + else: + raise vyos.opmode.UnconfiguredObject(f"No VLAN-to-VNI mapping found for VLAN {vid}\n") + + statistics_data = json.loads(cmd(f"bridge -j -s vlan tunnelshow dev {intf_name} vid {vid}"))[0] + + mapping_config = op_mode_config_dict(['interfaces', 'vxlan', intf_name, 'vlan-to-vni'], + get_first_key=True) + + raw_data = {intf_name: {}} + output_list = [] + + for tunnel in tunnel_data[0].get("tunnels", []): + tunnel_id = tunnel.get("tunid") + tunnel_dict = raw_data[intf_name][tunnel_id] = {} + + for vlan in statistics_data.get("vlans", []): + if vlan.get("vid") == tunnel.get("vlan"): + vlan_id = str(vlan.get("vid")) + description = mapping_config.get(vlan_id, {}).get("description", "") + + # detail allows for longer descriptions; each output wraps to 80 characters + if detail: + description = "\n".join(textwrap.wrap(description, width=65)) + elif raw: + pass + else: + description = "\n".join(textwrap.wrap(description, width=48)) + + if raw: + tunnel_dict["vlan"] = vlan_id + tunnel_dict["rx_bytes"] = vlan.get("rx_bytes") + tunnel_dict["tx_bytes"] = vlan.get("tx_bytes") + tunnel_dict["rx_packets"] = vlan.get("rx_packets") + tunnel_dict["tx_packets"] = vlan.get("tx_packets") + tunnel_dict["description"] = description + else: + #Generate output list; detail adds more fields + output_list.append([ + *([intf_name] if not detail else []), + vlan_id, + tunnel_id, + *([description] if not statistics else []), + *([vlan.get("rx_packets")] if any([detail, statistics]) else []), + *([vlan.get("rx_bytes")] if any([detail, statistics]) else []), + *([vlan.get("tx_packets")] if any([detail, statistics]) else []), + *([vlan.get("tx_bytes")] if any([detail, statistics]) else []) + ]) + + if raw: + return raw_data + + if detail: + # Detail headers; ex. show interfaces vxlan vxlan1 vlan-to-vni detail + detail_header = ['VLAN', 'VNI', 'Description', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes'] + print('-' * 35) + print(f"Interface: {intf_name}\n") + detailed_output(output_list, detail_header) + elif statistics: + # Statistics headers; ex. show interfaces vxlan vxlan1 vlan-to-vni statistics + headers = ['Interface', 'VLAN', 'VNI', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes'] + print(tabulate(output_list, headers)) + else: + # Normal headers; ex. show interfaces vxlan vxlan1 vlan-to-vni + headers = ['Interface', 'VLAN', 'VNI', 'Description'] + print(tabulate(output_list, headers)) + def clear_counters(intf_name: typing.Optional[str], intf_type: typing.Optional[str], vif: bool, vrrp: bool): diff --git a/src/op_mode/interfaces_wireguard.py b/src/op_mode/interfaces_wireguard.py index 627af0579..b600bd3a4 100644 --- a/src/op_mode/interfaces_wireguard.py +++ b/src/op_mode/interfaces_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,31 +18,13 @@ import sys import vyos.opmode from vyos.ifconfig import WireGuardIf -from vyos.configquery import ConfigTreeQuery - -def _verify(func): - """Decorator checks if WireGuard interface config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - interface = kwargs.get('intf_name') - if not config.exists(['interfaces', 'wireguard', interface]): - unconf_message = f'WireGuard interface {interface} is not configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - - return _wrapper - - -@_verify +@vyos.opmode.verify_cli_exists(['interfaces', 'wireguard'], + 'WireGuard interface {interface} is not configured!') def show_summary(raw: bool, intf_name: str): intf = WireGuardIf(intf_name, create=False, debug=False) return intf.operational.show_interface() - if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/interfaces_wireless.py b/src/op_mode/interfaces_wireless.py index bf6e462f3..d1070e95f 100755 --- a/src/op_mode/interfaces_wireless.py +++ b/src/op_mode/interfaces_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,18 +23,9 @@ from tabulate import tabulate from vyos.utils.process import popen from vyos.configquery import ConfigTreeQuery -def _verify(func): - """Decorator checks if Wireless LAN config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - if not config.exists(['interfaces', 'wireless']): - unconf_message = 'No Wireless interfaces configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - return _wrapper +verify_path = ['interfaces', 'wireless'] +verify_error = 'Wireless/WiFi subsystem unconfigured!' +verify_interface_error = 'Wireless interface {interface} is not configured!' def _get_raw_info_data(): output_data = [] @@ -93,7 +84,7 @@ def _get_raw_scan_data(intf_name): ssid['ssid'] = line.lstrip().split(':')[-1].lstrip() elif line.lstrip().startswith('signal: '): - # Siganl can be " signal: -67.00 dBm", thus strip all leading whitespaces + # Signal can be " signal: -67.00 dBm", thus strip all leading whitespaces ssid['signal'] = line.lstrip().split(':')[-1].split()[0] elif line.lstrip().startswith('DS Parameter set: channel'): @@ -156,7 +147,7 @@ def _format_station_data(raw_data): headers = ["Station", "Signal", "RX bytes", "RX packets", "TX bytes", "TX packets"] return tabulate(output, headers, numalign="left") -@_verify +@vyos.opmode.verify_cli_exists(verify_path, verify_error) def show_info(raw: bool): info_data = _get_raw_info_data() if raw: @@ -169,7 +160,7 @@ def show_scan(raw: bool, intf_name: str): return data return _format_scan_data(data) -@_verify +@vyos.opmode.verify_cli_exists(verify_path, verify_interface_error) def show_stations(raw: bool, intf_name: str): data = _get_raw_station_data(intf_name) if raw: diff --git a/src/op_mode/ipoe-control.py b/src/op_mode/ipoe-control.py index b7d6a0c43..632cc93cc 100755 --- a/src/op_mode/ipoe-control.py +++ b/src/op_mode/ipoe-control.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -27,7 +27,7 @@ cmd_dict = { 'actions' : { 'show_sessions' : 'show sessions', 'show_stat' : 'show stat', - 'terminate' : 'teminate' + 'terminate' : 'terminate' } } diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 1ab50b105..3061089ee 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -229,18 +229,48 @@ def _get_parent_sa_state(connection_name: str, data: list) -> str: ike_state = 'up' return ike_state +def _get_parent_ppk_state(connection_name: str, data: list) -> str: + """Get paren PPK state by connection name -def _get_child_sa_state(connection_name: str, tunnel_name: str, data: list) -> str: + Args: + connection_name (str): Connection name + data (list): List of current SAs from vici + + Returns: + Parent PPK state + """ + ppk_state = 'no' + if not data: + return ppk_state + for sa in data: + # check if parent PPK exists + for connection, connection_conf in sa.items(): + if connection_name != connection: + continue + if 'ppk' in connection_conf and connection_conf['ppk'].lower() == 'yes': + ppk_state = 'yes' + return ppk_state + + +def _get_child_sa_state( + connection_name: str, tunnel_name: str, data: list, mode: str +) -> str: """Get child SA state by connection and tunnel name Args: connection_name (str): Connection name tunnel_name (str): Tunnel name data (list): List of current SAs from vici + mode (str): Mode of child from vici list_connections Returns: - str: `up` if child SA state is 'installed' otherwise `down` + str: `up` if child SA state is 'installed' or child is passthrough + otherwise `down` """ + # passthrough child (trap mode) has 'PASS' mode and is always up, + # but has no sa, so is not present in list_sas (data) + if mode == 'PASS': + return 'up' child_sa = 'down' if not data: return child_sa @@ -327,10 +357,22 @@ def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: base_list['local_id'] = conn_conf.get('local-1', '').get('id') base_list['remote_id'] = conn_conf.get('remote-1', '').get('id') base_list['version'] = conn_conf.get('version', 'IKE') + if conn_conf.get('ppk_id'): + if conn_conf.get('ppk_required') == 'yes': + base_list['ppk'] = 'req/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'opt/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'none/' + _get_parent_ppk_state(connection, list_sas) base_list['children'] = [] children = conn_conf['children'] for tunnel, tun_options in children.items(): - state = _get_child_sa_state(connection, tunnel, list_sas) + mode = tun_options.get('mode') + state = _get_child_sa_state(connection, tunnel, list_sas, mode) local_ts = tun_options.get('local-ts') remote_ts = tun_options.get('remote-ts') dpd_action = tun_options.get('dpd_action') @@ -391,6 +433,8 @@ def _get_formatted_output_conections(data): f'{entry["ike_proposal"]["hash"]}/' f'{entry["ike_proposal"]["dh"]}' ) + ppk = entry['ppk'] + connections.append( [ ike_name, @@ -402,6 +446,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) for tun in entry['children']: @@ -419,6 +464,7 @@ def _get_formatted_output_conections(data): f'{tun["esp_proposal"]["hash"]}/' f'{tun["esp_proposal"]["dh"]}' ) + ppk = '-' connections.append( [ tun_name, @@ -430,6 +476,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) connection_headers = [ @@ -442,8 +489,12 @@ def _get_formatted_output_conections(data): 'Local id', 'Remote id', 'Proposal', + 'PPK', ] - output = tabulate(connections, connection_headers, numalign='left') + output = ( + 'PPK Codes: none - Not Configured, opt - PPK is Optional, req - PPK is required, no - PPK not negotiated, yes - PPK negotiated\n' + + tabulate(connections, connection_headers, numalign='left') + ) return output @@ -453,7 +504,7 @@ def _get_formatted_output_conections(data): def _get_childsa_id_list(ike_sas: list) -> list: """ Generate list of CHILD SA ids based on list of OrderingDict - wich is returned by vici + which is returned by vici :param ike_sas: list of IKE SAs generated by vici :type ike_sas: list :return: list of IKE SAs ids @@ -472,7 +523,7 @@ def _get_con_childsa_name_list( ) -> list: """ Generate list of CHILD SA ids based on list of OrderingDict - wich is returned by vici + which is returned by vici :param ike_sas: list of IKE SAs connections generated by vici :type ike_sas: list :param filter_dict: dict of filter options @@ -739,7 +790,7 @@ def show_sa(raw: bool): def _get_output_sas_detail(ra_output_list: list) -> str: """ - Formate all IKE SAs detail output + Format all IKE SAs detail output :param ra_output_list: IKE SAs list :type ra_output_list: list :return: formatted RA IKE SAs detail output @@ -870,7 +921,7 @@ def _get_formatted_ipsec_proposal(sa: dict) -> str: def _get_output_ra_sas_detail(ra_output_list: list) -> str: """ - Formate RA IKE SAs detail output + Format RA IKE SAs detail output :param ra_output_list: IKE SAs list :type ra_output_list: list :return: formatted RA IKE SAs detail output diff --git a/src/op_mode/kernel_modules.py b/src/op_mode/kernel_modules.py index e381a1df7..5475158aa 100755 --- a/src/op_mode/kernel_modules.py +++ b/src/op_mode/kernel_modules.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/lldp.py b/src/op_mode/lldp.py index fac622b81..6d77db5bc 100755 --- a/src/op_mode/lldp.py +++ b/src/op_mode/lldp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/load-balancing_haproxy.py b/src/op_mode/load-balancing_haproxy.py index ae6734e16..3ea016677 100755 --- a/src/op_mode/load-balancing_haproxy.py +++ b/src/op_mode/load-balancing_haproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py index 9fa473802..6f1d00dcd 100755 --- a/src/op_mode/load-balancing_wan.py +++ b/src/op_mode/load-balancing_wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -56,13 +56,15 @@ def _get_raw_data(): return data def _get_formatted_output(raw_data): + from time import time + for ifname, if_data in raw_data.items(): latest_change = if_data['last_success'] if if_data['last_success'] > if_data['last_failure'] else if_data['last_failure'] change_dt = datetime.fromtimestamp(latest_change) if latest_change > 0 else None success_dt = datetime.fromtimestamp(if_data['last_success']) if if_data['last_success'] > 0 else None failure_dt = datetime.fromtimestamp(if_data['last_failure']) if if_data['last_failure'] > 0 else None - now = datetime.utcnow() + now = datetime.fromtimestamp(time()) fmt_data = { 'ifname': ifname, diff --git a/src/op_mode/log.py b/src/op_mode/log.py index 797ba5a88..0bf44cf6a 100755 --- a/src/op_mode/log.py +++ b/src/op_mode/log.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/maya_date.py b/src/op_mode/maya_date.py index 847b543e0..2d5a13ab9 100755 --- a/src/op_mode/maya_date.py +++ b/src/op_mode/maya_date.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (c) 2013, 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -168,7 +168,7 @@ class MayaDate(object): """ The start date is not the beginning of both cycles, it's 4 Ajaw. So we need to add 4 to the 13 days cycle day, - and substract 1 from the 20 day cycle to get correct result. + and subtract 1 from the 20 day cycle to get correct result. """ tzolkin_13 = (days + 4) % 13 tzolkin_20 = (days - 1) % 20 @@ -181,7 +181,7 @@ class MayaDate(object): """ Returns haab date string. The time start on 8 Kumk'u rather than 0 Pop, which is - 17 days before the new haab, so we need to substract 17 + 17 days before the new haab, so we need to subtract 17 from the current date to get correct result. """ days = self.days diff --git a/src/op_mode/memory.py b/src/op_mode/memory.py index eb530035b..20d937243 100755 --- a/src/op_mode/memory.py +++ b/src/op_mode/memory.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py index 522cbe008..646d95e7b 100644 --- a/src/op_mode/mtr.py +++ b/src/op_mode/mtr.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/mtr_execute.py b/src/op_mode/mtr_execute.py index 2585a7ee4..b97e46a1f 100644 --- a/src/op_mode/mtr_execute.py +++ b/src/op_mode/mtr_execute.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/multicast.py b/src/op_mode/multicast.py index 0666f8af3..096d01665 100755 --- a/src/op_mode/multicast.py +++ b/src/op_mode/multicast.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py index c6cf4770a..f97d7dc0f 100755 --- a/src/op_mode/nat.py +++ b/src/op_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -144,9 +144,11 @@ def _get_formatted_output_rules(data, direction, family): if 'expr' in rule['rule']: interface = rule.get('rule').get('expr')[0].get('match').get('right') \ if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any' + if interface[0] == '@': + interface = interface[3:] for index, match in enumerate(jmespath.search('rule.expr[*].match', rule)): if 'payload' in match['left']: - # Handle NAT rule containing comma-seperated list of ports + # Handle NAT rule containing comma-separated list of ports if (isinstance(match['right'], dict) and ('prefix' in match['right'] or 'set' in match['right'] or 'range' in match['right'])): @@ -154,7 +156,10 @@ def _get_formatted_output_rules(data, direction, family): my_dict = {**match['left']['payload'], **match['right']} my_dict['op'] = match['op'] op = '!' if my_dict.get('op') == '!=' else '' - proto = my_dict.get('protocol').upper() + if my_dict['field'] in ['sport', 'dport']: + proto = my_dict.get('protocol').upper() + if proto == 'TH': + proto = 'TCP, UDP' if my_dict['field'] == 'saddr': saddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' elif my_dict['field'] == 'daddr': @@ -166,6 +171,10 @@ def _get_formatted_output_rules(data, direction, family): # Handle NAT rule containing a single port else: field = jmespath.search('left.payload.field', match) + if field in ['sport', 'dport']: + proto = jmespath.search('left.payload.protocol', match).upper() + if proto == 'TH': + proto = 'TCP, UDP' if field == 'saddr': saddr = match.get('right') elif field == 'daddr': @@ -186,8 +195,12 @@ sport {sport}''' destination = f'''{daddr} dport {dport}''' - if jmespath.search('left.payload.field', match) == 'protocol': - field_proto = match.get('right').upper() + if jmespath.search('left.meta.key', match) == 'l4proto': + right = match.get('right') + if isinstance(right, dict) and 'set' in right: + proto = ', '.join(right['set']) + elif isinstance(right, str): + proto = right.upper() for expr in rule.get('rule').get('expr'): if 'snat' in expr: diff --git a/src/op_mode/neighbor.py b/src/op_mode/neighbor.py index 8b3c45c7c..9bbad94e1 100755 --- a/src/op_mode/neighbor.py +++ b/src/op_mode/neighbor.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -109,7 +109,7 @@ def reset(family: ArgFamily, interface: typing.Optional[str], address: typing.Op run(f"""ip --family {family} neighbor flush dev {interface}""") else: # Flush an entire neighbor table - run(f"""ip --family {family} neighbor flush""") + run(f"""ip --family {family} neighbor flush all""") if __name__ == '__main__': try: @@ -119,4 +119,3 @@ if __name__ == '__main__': except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1) - diff --git a/src/op_mode/ntp.py b/src/op_mode/ntp.py index 6ec0fedcb..42d0c1b00 100644 --- a/src/op_mode/ntp.py +++ b/src/op_mode/ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/openconnect-control.py b/src/op_mode/openconnect-control.py index b70d4fa16..dec5b9482 100755 --- a/src/op_mode/openconnect-control.py +++ b/src/op_mode/openconnect-control.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/openconnect.py b/src/op_mode/openconnect.py index 62c683ebb..35df25856 100755 --- a/src/op_mode/openconnect.py +++ b/src/op_mode/openconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/openvpn.py b/src/op_mode/openvpn.py index 092873909..7347ac757 100755 --- a/src/op_mode/openvpn.py +++ b/src/op_mode/openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/otp.py b/src/op_mode/otp.py index a4ab9b22b..aceb75660 100755 --- a/src/op_mode/otp.py +++ b/src/op_mode/otp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 - -# Copyright 2017, 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/op_mode/ping.py b/src/op_mode/ping.py index 583d8792c..f52dfa7de 100755 --- a/src/op_mode/ping.py +++ b/src/op_mode/ping.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -97,7 +97,7 @@ options = { 'no-loopback': { 'ping': '{command} -L', 'type': 'noarg', - 'help': 'Supress loopback of multicast pings' + 'help': 'Suppress loopback of multicast pings' }, 'pattern': { 'ping': '{command} -p {value}', diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 49a461e9e..bf8bba657 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,6 +26,7 @@ from cryptography.x509.oid import ExtendedKeyUsageOID import vyos.opmode +from vyos.base import Warning from vyos.config import Config from vyos.config import config_dict_mangle_acme from vyos.pki import encode_certificate @@ -417,7 +418,7 @@ def parse_san_string(san_string): output.append(ipaddress.IPv6Address(value)) elif tag == 'dns' or tag == 'rfc822': output.append(value) - return + return output def generate_certificate_request( @@ -1251,6 +1252,7 @@ def show_certificate_authority( def show_certificate( raw: bool, name: typing.Optional[str] = None, + private: typing.Optional[bool] = False, pem: typing.Optional[bool] = False, fingerprint: typing.Optional[ArgsFingerprint] = None, ): @@ -1281,12 +1283,31 @@ def show_certificate( if not cert: continue - if name and pem: + if name and pem and not (private or fingerprint): print(encode_certificate(cert)) return - elif name and fingerprint: + elif name and fingerprint and not private: print(get_certificate_fingerprint(cert, fingerprint)) return + elif name and private: + if 'private' in cert_dict and 'key' in cert_dict['private']: + protected = 'password_protected' in cert_dict['private'] + private_key = load_private_key( + cert_dict['private']['key'], + passphrase=None, + wrap_tags=True, + ) + if private_key: + print(encode_private_key(private_key, passphrase=None)) + else: + if protected: + print(f'Private key for certificate "{cert_name}" is ' + 'password-protected and cannot be displayed') + else: + print(f'Failed to load private key for certificate "{cert_name}"') + else: + print(f'No private key found for certificate "{cert_name}"') + return ca_name = get_certificate_ca(cert, ca_certs) cert_subject_cn = cert.subject.rfc4514_string().split(',')[0] @@ -1373,6 +1394,27 @@ def show_all(raw: bool): print('\n') show_crl(raw) +def renew_certbot(raw: bool, force: typing.Optional[bool] = False): + from vyos.defaults import directories + + certbot_config = directories['certbot'] + vyos_conf_scripts_dir = directories['conf_mode'] + + if force and not os.path.isdir(f'{certbot_config}'): + # Assume someone deleted the certbot_config folder, renew alone will not + # work as there are no configuration files left to know what to renew. + # Re-run CLI PKI helper to initially request certificates via ACME + # again. This should never be the case - but sometimes the universe has + # a bad time + Warning(f'Directory "{certbot_config}" missing. Reinitializing PKI ' \ + 'subsystem...\n\n') + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py"') + elif force: + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py certbot_renew_force"') + else: + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py certbot_renew"') + + print(out) if __name__ == '__main__': try: diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py index d12465008..966dc80f9 100755 --- a/src/op_mode/policy_route.py +++ b/src/op_mode/policy_route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index c32a2be7d..a60c33dc6 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/ppp-server-ctrl.py b/src/op_mode/ppp-server-ctrl.py index 2bae5b32a..96b497549 100755 --- a/src/op_mode/ppp-server-ctrl.py +++ b/src/op_mode/ppp-server-ctrl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/qos.py b/src/op_mode/qos.py index 464b552ee..47a7a3d59 100755 --- a/src/op_mode/qos.py +++ b/src/op_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/raid.py b/src/op_mode/raid.py index fed8ae2c3..985ef730f 100755 --- a/src/op_mode/raid.py +++ b/src/op_mode/raid.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/reset_openvpn.py b/src/op_mode/reset_openvpn.py index cef5299da..e3345435a 100755 --- a/src/op_mode/reset_openvpn.py +++ b/src/op_mode/reset_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/reset_vpn.py b/src/op_mode/reset_vpn.py index 61d7c8c81..4ff1740d2 100755 --- a/src/op_mode/reset_vpn.py +++ b/src/op_mode/reset_vpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py index 1fcfb31b5..ad8ea0346 100755 --- a/src/op_mode/reset_wireguard.py +++ b/src/op_mode/reset_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,35 +16,16 @@ import sys import typing - import vyos.opmode from vyos.ifconfig import WireGuardIf -from vyos.configquery import ConfigTreeQuery - - -def _verify(func): - """Decorator checks if WireGuard interface config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - interface = kwargs.get('interface') - if not config.exists(['interfaces', 'wireguard', interface]): - unconf_message = f'WireGuard interface {interface} is not configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - return _wrapper - - -@_verify +@vyos.opmode.verify_cli_exists(['interfaces', 'wireguard'], + 'WireGuard interface {interface} is not configured!') def reset_peer(interface: str, peer: typing.Optional[str] = None): intf = WireGuardIf(interface, create=False, debug=False) return intf.operational.reset_peer(peer) - if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/restart.py b/src/op_mode/restart.py index efa835485..4f05d6eb8 100755 --- a/src/op_mode/restart.py +++ b/src/op_mode/restart.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -26,11 +26,11 @@ config = ConfigTreeQuery() service_map = { 'dhcp': { - 'systemd_service': 'kea-dhcp4-server', + 'systemd_service': 'isc-kea-dhcp4-server', 'path': ['service', 'dhcp-server'], }, 'dhcpv6': { - 'systemd_service': 'kea-dhcp6-server', + 'systemd_service': 'isc-kea-dhcp6-server', 'path': ['service', 'dhcpv6-server'], }, 'dns_dynamic': { diff --git a/src/op_mode/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py index 42626cac4..99c2c20b6 100755 --- a/src/op_mode/restart_dhcp_relay.py +++ b/src/op_mode/restart_dhcp_relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py index 83146f5ec..188d99037 100755 --- a/src/op_mode/restart_frr.py +++ b/src/op_mode/restart_frr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/route.py b/src/op_mode/route.py index 4aa57dbf4..b11b0ccc2 100755 --- a/src/op_mode/route.py +++ b/src/op_mode/route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/secure_boot.py b/src/op_mode/secure_boot.py index 5f6390a15..a2d4c9e72 100755 --- a/src/op_mode/secure_boot.py +++ b/src/op_mode/secure_boot.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/serial.py b/src/op_mode/serial.py index a5864872b..e9f9fc121 100644 --- a/src/op_mode/serial.py +++ b/src/op_mode/serial.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/sflow.py b/src/op_mode/sflow.py index 0f3feb35a..668d0b7b3 100755 --- a/src/op_mode/sflow.py +++ b/src/op_mode/sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/show-bond.py b/src/op_mode/show-bond.py index f676e0841..19abc440a 100755 --- a/src/op_mode/show-bond.py +++ b/src/op_mode/show-bond.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -60,7 +60,7 @@ elif args.slaves: cfg_dict['mode'] = tmp.get_mode() cfg_dict['admin_state'] = tmp.get_admin_state() cfg_dict['oper_state'] = tmp.operational.get_state() - cfg_dict['members'] = tmp.get_slaves() + cfg_dict['members'] = tmp.get_members() data.append(cfg_dict) elif args.interface: @@ -74,7 +74,7 @@ elif args.interface: # each bond member interface has its own statistics data['members'] = [] - for member in BondIf(args.interface).get_slaves(): + for member in BondIf(args.interface).get_members(): tmp = {} tmp['ifname'] = member tmp['rx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/rx_bytes') diff --git a/src/op_mode/show_acceleration.py b/src/op_mode/show_acceleration.py index 1c4831f1d..05c591356 100755 --- a/src/op_mode/show_acceleration.py +++ b/src/op_mode/show_acceleration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,7 +26,7 @@ from vyos.utils.process import popen def detect_qat_dev(): output, err = popen('lspci -nn', decode='utf-8') if not err: - data = re.findall('(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)', output) + data = re.findall('(8086:19e2)|(8086:37c[8-9])|(8086:0435)|(8086:6f54)', output) # QAT devices found if data: return @@ -71,7 +71,7 @@ def get_qat_proc_path(qat_dev): q_bsf = q_list[1] return "/sys/kernel/debug/qat_"+q_type+"_"+q_bsf+"/" -# Check if QAT service confgured +# Check if QAT service configured def check_qat_if_conf(): if not Config().exists_effective('system acceleration qat'): print("\t system acceleration qat is not configured") @@ -92,8 +92,8 @@ args = parser.parse_args() if args.hw: detect_qat_dev() - # Show availible Intel QAT devices - call('lspci -nn | egrep -e \'8086:37c8|8086:19e2|8086:0435|8086:6f54\'') + # Show available Intel QAT devices + call('lspci -nn | egrep -e \'8086:37c[8-9]|8086:19e2|8086:0435|8086:6f54\'') elif args.flow and args.dev: check_qat_if_conf() call('cat '+get_qat_proc_path(args.dev)+"fw_counters") diff --git a/src/op_mode/show_bonding_detail.sh b/src/op_mode/show_bonding_detail.sh new file mode 100755 index 000000000..62265daa2 --- /dev/null +++ b/src/op_mode/show_bonding_detail.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ -f "/proc/net/bonding/$1" ]; then + cat "/proc/net/bonding/$1"; +else + echo "Interface $1 does not exist!"; +fi diff --git a/src/op_mode/show_configuration_json.py b/src/op_mode/show_configuration_json.py index fdece533b..4e4b4d386 100755 --- a/src/op_mode/show_configuration_json.py +++ b/src/op_mode/show_configuration_json.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/show_openconnect_otp.py b/src/op_mode/show_openconnect_otp.py index 3771fb385..61e52d01e 100755 --- a/src/op_mode/show_openconnect_otp.py +++ b/src/op_mode/show_openconnect_otp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -97,7 +97,7 @@ def display_otp_ocserv(username, params, info): if __name__ == '__main__': parser = argparse.ArgumentParser(add_help=False, description='Show OTP authentication information for selected user') parser.add_argument('--user', action="store", type=str, default='', help='Username') - parser.add_argument('--info', action="store", type=str, default='full', help='Wich information to display') + parser.add_argument('--info', action="store", type=str, default='full', help='Which information to display') args = parser.parse_args() if check_uname_otp(args.user): diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py index 6abafc8b6..9f2708b69 100755 --- a/src/op_mode/show_openvpn.py +++ b/src/op_mode/show_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/show_openvpn_mfa.py b/src/op_mode/show_openvpn_mfa.py index 100c42154..a08fd33ae 100755 --- a/src/op_mode/show_openvpn_mfa.py +++ b/src/op_mode/show_openvpn_mfa.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/op_mode/show_ppp_stats.sh b/src/op_mode/show_ppp_stats.sh new file mode 100755 index 000000000..d9c17f966 --- /dev/null +++ b/src/op_mode/show_ppp_stats.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -d "/sys/class/net/$1" ]; then + /usr/sbin/pppstats "$1"; +fi diff --git a/src/op_mode/show_sensors.py b/src/op_mode/show_sensors.py index 5e3084fe9..b7ff05178 100755 --- a/src/op_mode/show_sensors.py +++ b/src/op_mode/show_sensors.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -17,25 +17,26 @@ import re import sys + from vyos.utils.process import popen from vyos.utils.process import DEVNULL -output,retcode = popen("sensors --no-adapter", stderr=DEVNULL) +output, retcode = popen("sensors --no-adapter", stderr=DEVNULL) if retcode == 0: print (output) sys.exit(0) else: - output,retcode = popen("sensors-detect --auto",stderr=DEVNULL) - match = re.search(r'#----cut here----(.*)#----cut here----',output, re.DOTALL) + output, retcode = popen("sensors-detect --auto", stderr=DEVNULL) + match = re.search(r'#----cut here----(.*)#----cut here----', output, + re.DOTALL) if match: for module in match.group(0).split('\n'): if not module.startswith("#"): popen("modprobe {}".format(module.strip())) - output,retcode = popen("sensors --no-adapter", stderr=DEVNULL) + output, retcode = popen("sensors --no-adapter", stderr=DEVNULL) if retcode == 0: - print (output) + print(output) sys.exit(0) - -print ("No sensors found") +print("No sensors found") sys.exit(1) diff --git a/src/op_mode/show_techsupport_report.py b/src/op_mode/show_techsupport_report.py index 32cf67778..2434da29a 100644 --- a/src/op_mode/show_techsupport_report.py +++ b/src/op_mode/show_techsupport_report.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,235 +16,321 @@ import os import sys -from typing import List +import argparse +from pathlib import Path +from dataclasses import dataclass +from dataclasses import field +from typing import Callable +from typing import Optional +from typing import Sequence + from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.utils.process import rc_cmd +from vyos.utils.process import wrap_op as op -def print_header(command: str) -> None: - """Prints a command with headers '-'. - - Example: +@dataclass(frozen=True) +class BaseSpec: + # Available only for 'show tech-support report' (not 'generate tech-support archive') + report_only: Optional[bool] = field(kw_only=True, default=False) - % print_header('Example command') - --------------- - Example command - --------------- - """ - header_length = len(command) * '-' - print(f"\n{header_length}\n{command}\n{header_length}") +@dataclass(frozen=True) +class CommandSpec(BaseSpec): + header: str # Display header for a command section + command: str # Shell command to execute -def execute_command(command: str, header_text: str) -> None: - """Executes a command and prints the output with a header. +@dataclass(frozen=True) +class FuncSpec(BaseSpec): + name: str # Display name for a function section + fn: Callable[ + ['Runner'], None + ] # Callable that receives a Runner and writes output via it - Example: - % execute_command('uptime', "Uptime of the system") - -------------------- - Uptime of the system - -------------------- - 20:21:57 up 9:04, 5 users, load average: 0.00, 0.00, 0.0 +class OutputSink: + """Writes output to stdout and/or a file, depending on parameters""" - """ - print_header(header_text) - try: - rc, output = rc_cmd(command) - # Enable unbuffered print param to improve responsiveness of printed - # output to end user - print(output, flush=True) - # Exit gracefully when user interrupts program output - # Flush standard streams; redirect remaining output to devnull - # Resolves T5633: Bug #1 and 3 - except (BrokenPipeError, KeyboardInterrupt): - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) - sys.exit(1) - except Exception as e: - print(f"Error executing command: {command}") - print(f"Error message: {e}") + def __init__(self, *, file_path: Optional[Path]): + # Target file path; None means stdout + self._file_path = file_path + self._fh = None + def __enter__(self) -> 'OutputSink': + # Open output file if not writing to stdout + if not self.is_stdout: + self._file_path.parent.mkdir(parents=True, exist_ok=True) + # Use text mode, overwrite per run + self._fh = self._file_path.open('w', encoding='utf-8', errors='replace') + return self -def op(cmd: str) -> str: - """Returns a command with the VyOS operational mode wrapper.""" - return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' + def __exit__(self, exc_type, exc, tb): + if self._fh is not None: + self._fh.close() + self._fh = None + @property + def is_stdout(self): + # True when output is directed to stdout + return self._file_path is None -def get_ethernet_interfaces() -> List[Interface]: + def write(self, text: str): + # Enable unbuffered print param to improve responsiveness of printed + # output to end user + if self.is_stdout: + print(text, end='', flush=True) + else: + if self._fh is not None: + self._fh.write(text) + self._fh.flush() + + +class Runner: + """Executes commands and writes output to the provided sink""" + + def __init__(self, sink: OutputSink): + self.sink = sink + + def exec(self, command: str, header: str = None): + texts = [] + + if header: + texts.append(header) + texts.append(f'Command: {command}') + + try: + # Start a new section for this command + self.section('\n'.join(texts)) + + rc, output = rc_cmd(command) + # Ensure output ends with newline when present + if output and not output.endswith('\n'): + output += '\n' + + self.sink.write(output) + if rc not in (0, None): + self.sink.write( + f'Command `{command}` returned non-zero ({rc}) exit status\n' + ) + except (BrokenPipeError, KeyboardInterrupt): + raise + except Exception as e: + self.sink.write(f'Error executing command: {command}\n') + self.sink.write(f'Error message: {e}\n') + + def section(self, title: str): + """Just print a section header without running a command""" + self.sink.write(header_block(title)) + + +def header_block(title: str, delimiter='-') -> str: + """Create an underline/overline header block for multiline text.""" + lines = title.splitlines() + max_len = max(len(line) for line in lines) + line = delimiter * max_len + title_block = '\n'.join(lines) + return f'\n{line}\n{title_block}\n{line}\n' + + +def select_reports(args: argparse.Namespace) -> dict[str, tuple[BaseSpec]]: + reports = REPORTS.copy() + requested = args.reports + + if requested: + missing = [r for r in requested if r not in reports] + if missing: + known = ', '.join(sorted(reports.keys())) + raise SystemExit(f'Unknown report(s): {", ".join(missing)}. Known: {known}') + + reports = {name: reports[name] for name in requested} + + if args.launched_from_generate_archive: + for key in reports.keys(): + items = reports[key] + reports[key] = {item for item in items if not item.report_only} + + return reports + + +def execute_item(item: BaseSpec, runner: Runner) -> None: + if isinstance(item, CommandSpec): + runner.exec(item.command, item.header) + elif isinstance(item, FuncSpec): + runner.section(item.name) + item.fn(runner) + else: + assert False, f'Unsupported report type: {type(item)}' + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser(description='VyOS tech-support command collector') + p.add_argument( + '--outdir', + type=Path, + default=None, + help=( + 'Directory to write report files into (one file per report group). ' + 'If this option is omitted, files are written to stdout.' + ), + ) + p.add_argument( + '--reports', + nargs='*', + default=[], + help=( + 'Which report groups to run (default: all). ' + 'Example: --reports vyos-main-info' + ), + ) + p.add_argument( + '--launched-from-generate-archive', + action='store_true', + default=False, + help=( + 'A boolean flag indicates that command executed for generating ' + 'tech-support archive (`generate tech-support archive`). ' + 'In this case some sections will be ignored because already available in archive.' + ), + ) + return p.parse_args(argv) + + +def main(argv: Sequence[str]): + args = parse_args(argv) + + # Select which reports to execute + chosen = select_reports(args) + + # Execute each report and write to its own sink + for report_name, commands in chosen.items(): + # Use a per-report output file when `outdir` is provided + file_path = (args.outdir / report_name) if args.outdir else None + + with OutputSink(file_path=file_path) as sink: + runner = Runner(sink) + try: + # Write top-level report header + sink.write(header_block(report_name, delimiter='=')) + + # Execute each item in the report + for item in commands: + execute_item(item, runner) + except (BrokenPipeError, KeyboardInterrupt): + if sink.is_stdout: + # Exit gracefully when user interrupts program output + # Flush standard streams; redirect remaining output to devnull + # Resolves T5633: Bug #1 and 3 + os.dup2( + os.open(os.devnull, os.O_WRONLY), + sys.stdout.fileno(), # pylint: disable = no-member + ) + sys.exit(1) + + +def get_ethernet_interfaces() -> list[Interface]: """Returns a list of Ethernet interfaces.""" return Section.interfaces('ethernet') -def show_version() -> None: - """Prints the VyOS version and package changes.""" - execute_command(op('show version'), 'VyOS Version and Package Changes') - - -def show_config_file() -> None: - """Prints the contents of a configuration file with a header.""" - execute_command('cat /opt/vyatta/etc/config/config.boot', 'Configuration file') - - -def show_running_config() -> None: - """Prints the running configuration.""" - execute_command(op('show configuration'), 'Running configuration') - - -def show_package_repository_config() -> None: - """Prints the package repository configuration file.""" - execute_command('cat /etc/apt/sources.list', 'Package Repository Configuration File') - execute_command('ls -l /etc/apt/sources.list.d/', 'Repositories') - - -def show_user_startup_scripts() -> None: - """Prints the user startup scripts.""" - execute_command('cat /config/scripts/vyos-preconfig-bootup.script', 'User Startup Scripts (Preconfig)') - execute_command('cat /config/scripts/vyos-postconfig-bootup.script', 'User Startup Scripts (Postconfig)') - - -def show_frr_config() -> None: - """Prints the FRR configuration.""" - execute_command('vtysh -c "show run"', 'FRR configuration') - - -def show_interfaces() -> None: - """Prints the interfaces.""" - execute_command(op('show interfaces'), 'Interfaces') - - -def show_interface_statistics() -> None: - """Prints the interface statistics.""" - execute_command('ip -s link show', 'Interface statistics') - - -def show_physical_interface_statistics() -> None: +def show_physical_interface_statistics(r: Runner): """Prints the physical interface statistics.""" - execute_command('/usr/bin/true', 'Physical Interface statistics') + for iface in get_ethernet_interfaces(): - # Exclude vlans - if '.' in iface: + if '.' in iface: # exclude VLANs continue - execute_command(f'ethtool --driver {iface}', f'ethtool --driver {iface}') - execute_command(f'ethtool --statistics {iface}', f'ethtool --statistics {iface}') - execute_command(f'ethtool --show-ring {iface}', f'ethtool --show-ring {iface}') - execute_command(f'ethtool --show-coalesce {iface}', f'ethtool --show-coalesce {iface}') - execute_command(f'ethtool --pause {iface}', f'ethtool --pause {iface}') - execute_command(f'ethtool --show-features {iface}', f'ethtool --show-features {iface}') - execute_command(f'ethtool --phy-statistics {iface}', f'ethtool --phy-statistics {iface}') - execute_command('netstat --interfaces', 'netstat --interfaces') - execute_command('netstat --listening', 'netstat --listening') - execute_command('cat /proc/net/dev', 'cat /proc/net/dev') + r.exec(f'ethtool --driver {iface}') + r.exec(f'ethtool --statistics {iface}') + r.exec(f'ethtool --show-ring {iface}') + r.exec(f'ethtool --show-coalesce {iface}') + r.exec(f'ethtool --pause {iface}') + r.exec(f'ethtool --show-features {iface}') + r.exec(f'ethtool --phy-statistics {iface}') + r.exec(f'ethtool --module-info {iface}') -def show_bridge() -> None: - """Show bridge interfaces.""" - execute_command(op('show bridge'), 'Show bridge') + for path in Path('/run/udev/vyos').glob('*'): + if path.is_file(): + r.exec(f'cat {path.resolve()}') -def show_arp() -> None: - """Prints ARP entries.""" - execute_command(op('show arp'), 'ARP Table (Total entries)') - execute_command(op('show ipv6 neighbors'), 'show ipv6 neighbors') +def _exec_list_op(r: Runner, commands: list): + for command in commands: + r.exec(op(command)) -def show_route() -> None: +def show_route(r: Runner): """Prints routing information.""" - cmd_list_route = [ - "show ip route bgp | head -108", - "show ip route cache", - "show ip route connected", - "show ip route forward", - "show ip route isis | head -108", - "show ip route kernel", - "show ip route ospf | head -108", - "show ip route rip", - "show ip route static", - "show ip route summary", - "show ip route supernets-only", - "show ip route table all", - "show ip route vrf all", - "show ipv6 route bgp | head -108", - "show ipv6 route cache", - "show ipv6 route connected", - "show ipv6 route forward", - "show ipv6 route isis", - "show ipv6 route kernel", - "show ipv6 route ospfv3", - "show ipv6 route rip", - "show ipv6 route static", - "show ipv6 route summary", - "show ipv6 route table all", - "show ipv6 route vrf all", + commands = [ + 'show ip route bgp | head -108', + 'show ip route connected', + 'show ip route forward | head -108', + 'show ip route isis | head -108', + 'show ip route kernel', + 'show ip route ospf | head -108', + 'show ip route rip | head -108', + 'show ip route static', + 'show ip route summary', + 'show ip route supernets-only | head -108', + 'show ip route table all | head -108', + 'show ip route vrf all | head -108', + 'show ipv6 route bgp | head -108', + 'show ipv6 route connected', + 'show ipv6 route forward | head -108', + 'show ipv6 route isis | head -108', + 'show ipv6 route kernel', + 'show ipv6 route ospfv3 | head -108', + 'show ipv6 route rip | head -108', + 'show ipv6 route static', + 'show ipv6 route summary', + 'show ipv6 route table all | head -108', + 'show ipv6 route vrf all | head -108', ] - for command in cmd_list_route: - execute_command(op(command), command) - - -def show_firewall() -> None: - """Prints firweall information.""" - execute_command('sudo nft list ruleset', 'nft list ruleset') - + _exec_list_op(r, commands) -def show_system() -> None: - """Prints system parameters.""" - execute_command(op('show version'), 'Show System Version') - execute_command(op('show system storage'), 'Show System Storage') - execute_command(op('show system image details'), 'Show System Image Details') +def show_evpn(r: Runner): + """Prints EVPN information.""" -def show_date() -> None: - """Print the current date.""" - execute_command('date', 'Current Time') - - -def show_installed_packages() -> None: - """Prints installed packages.""" - execute_command('dpkg --list', 'Installed Packages') - - -def show_loaded_modules() -> None: - """Prints loaded modules /proc/modules""" - execute_command('cat /proc/modules', 'Loaded Modules') - - -def show_cpu_statistics() -> None: - """Prints CPU statistics.""" - execute_command('/usr/bin/true', 'CPU') - execute_command('lscpu', 'Installed CPU\'s') - execute_command('top --iterations 1 --batch-mode --accum-time-toggle', 'Cumulative CPU Time Used by Running Processes') - execute_command('cat /proc/loadavg', 'Load Average') - - -def show_system_interrupts() -> None: - """Prints system interrupts.""" - execute_command('cat /proc/interrupts', 'Hardware Interrupt Counters') - - -def show_soft_irqs() -> None: - """Prints soft IRQ's.""" - execute_command('cat /proc/softirqs', 'Soft IRQ\'s') + commands = [ + 'show evpn mac vni all', + 'show evpn next-hops vni all', + 'show evpn rmac vni all', + 'show evpn access-vlan', + 'show evpn arp-cache vni all', + 'show evpn es', + 'show evpn es-evi', + ] + _exec_list_op(r, commands) -def show_softnet_statistics() -> None: - """Prints softnet statistics.""" - execute_command('cat /proc/net/softnet_stat', 'cat /proc/net/softnet_stat') +def show_mpls(r: Runner): + """Prints MPLS information.""" + commands = [ + 'show mpls pseudowire', + 'show mpls table', + 'show mpls ldp binding', + 'show mpls ldp discovery', + 'show mpls ldp interface', + 'show mpls ldp neighbor', + ] + _exec_list_op(r, commands) -def show_running_processes() -> None: - """Prints current running processes""" - execute_command('ps -ef', 'Running Processes') +def show_rpki(r: Runner): + """Prints RPKI information.""" -def show_memory_usage() -> None: - """Prints memory usage""" - execute_command('/usr/bin/true', 'Memory') - execute_command('cat /proc/meminfo', 'Installed Memory') - execute_command('free', 'Memory Usage') + commands = [ + 'show rpki cache-server', + 'show rpki cache-connection', + ] + _exec_list_op(r, commands) -def list_disks(): +def _list_disks(): disks = set() with open('/proc/partitions') as partitions_file: for line in partitions_file: @@ -254,60 +340,211 @@ def list_disks(): return disks -def show_storage() -> None: +def show_storage(r: Runner): """Prints storage information.""" - execute_command('cat /proc/devices', 'Devices') - execute_command('cat /proc/partitions', 'Partitions') - - for disk in list_disks(): - execute_command(f'fdisk --list /dev/{disk}', f'Partitioning for disk {disk}') - - -def main(): - # Configuration data - show_version() - show_config_file() - show_running_config() - show_package_repository_config() - show_user_startup_scripts() - show_frr_config() - - # Interfaces - show_interfaces() - show_interface_statistics() - show_physical_interface_statistics() - show_bridge() - show_arp() - - # Routing - show_route() - - # Firewall - show_firewall() - - # System - show_system() - show_date() - show_installed_packages() - show_loaded_modules() - - # CPU - show_cpu_statistics() - show_system_interrupts() - show_soft_irqs() - show_softnet_statistics() - - # Memory - show_memory_usage() - - # Storage - show_storage() - - # Processes - show_running_processes() - - # TODO: Get information from clouds - -if __name__ == "__main__": - main() + r.exec('cat /proc/mounts', 'Mount table') + r.exec('cat /proc/partitions', 'Partitions table') + + for disk in _list_disks(): + r.exec(f'fdisk --list /dev/{disk}', f'Partitioning for disk {disk}') + + r.exec('df -ah', 'Filesystem usage') + r.exec('df -ahi', 'Filesystem inode usage') + + +def show_kernel_interface_counters(r: Runner): + """Prints kernel network interface counters by fixed format.""" + + headers = ( + 'bytes', + 'packets', + 'errs', + 'drop', + 'fifo', + 'frame', + 'compressed', + 'multicast', + ) + new_headers = ( + ['Interface'] + [f'RX-{h}' for h in headers] + [f'TX-{h}' for h in headers] + ) + echo_template = ' '.join(new_headers) + awk_template = ','.join([f'${i}' for i in range(1, len(new_headers) + 1)]) + + cmd = ( + """(echo "{0}" && awk 'NR>2 {{print {1}}}' /proc/net/dev) | column -t""".format( + echo_template, awk_template + ) + ) + r.exec(cmd, 'cat /proc/net/dev | column -t') + + +REPORTS: dict[str, tuple[BaseSpec]] = { + 'vyos-main-info': ( + CommandSpec('VyOS version and package info', op('show version')), + CommandSpec( + 'Running configuration (commands)', op('show configuration commands') + ), + CommandSpec('Running configuration (structured)', op('show configuration')), + CommandSpec( + 'Configuration file (config.boot)', + 'cat /opt/vyatta/etc/config/config.boot', + report_only=True, # Ignored because already exists in 'tech-support archive' + ), + CommandSpec('Interfaces summary', op('show interfaces')), + CommandSpec('Bridge status', op('show bridge')), + CommandSpec('ARP table', op('show arp')), + CommandSpec('IPv6 neighbor table', op('show ipv6 neighbors')), + CommandSpec('System storage overview', op('show system storage')), + CommandSpec('Installed images details', op('show system image details')), + CommandSpec( + 'User startup script (preconfig)', + 'cat /config/scripts/vyos-preconfig-bootup.script', + report_only=True, + ), + CommandSpec( + 'User startup script (postconfig)', + 'cat /config/scripts/vyos-postconfig-bootup.script', + report_only=True, + ), + ), + 'routing-info': ( + FuncSpec('Routing table (IPv4/IPv6)', show_route), + CommandSpec('BFD peers', op('show bfd peers')), + FuncSpec('EVPN status', show_evpn), + FuncSpec('MPLS status', show_mpls), + FuncSpec('RPKI status', show_rpki), + ), + 'frr-info': ( + CommandSpec('FRR running configuration', 'vtysh -c "show running-config"'), + CommandSpec('FRR memory usage', 'vtysh -c "show memory"'), + CommandSpec('FRR work queues', 'vtysh -c "show work-queues"'), + CommandSpec('FRR IPv4 nexthop tracking (NHT)', 'vtysh -c "show ip nht"'), + CommandSpec('FRR IPv6 nexthop tracking (NHT)', 'vtysh -c "show ipv6 nht"'), + CommandSpec('FRR DMVPN status', 'vtysh -c "show dmvpn"'), + CommandSpec('FRR event CPU stats', 'vtysh -c "show event cpu"'), + CommandSpec('FRR event poll stats', 'vtysh -c "show event poll"'), + CommandSpec('FRR event timers', 'vtysh -c "show event timers"'), + CommandSpec( + 'FRR SRv6 locator', + 'vtysh -c "show segment-routing srv6 locator"', + ), + CommandSpec( + 'FRR SRv6 manager', + 'vtysh -c "show segment-routing srv6 manager"', + ), + ), + 'proc-and-sysctl-info': ( + CommandSpec('System load average', 'cat /proc/loadavg'), + FuncSpec('Kernel network interface counters', show_kernel_interface_counters), + CommandSpec('Loaded kernel modules', 'cat /proc/modules'), + CommandSpec('Hardware interrupt counters', 'cat /proc/interrupts'), + CommandSpec('SoftIRQ counters', 'cat /proc/softirqs'), + CommandSpec('Softnet statistics', 'cat /proc/net/softnet_stat'), + CommandSpec('Memory info', 'cat /proc/meminfo'), + CommandSpec( + 'NUMA node memory info', + 'cat /sys/devices/system/node/node*/meminfo', + ), + CommandSpec('VM statistics', 'cat /proc/vmstat'), + CommandSpec('Registered character/block devices', 'cat /proc/devices'), + CommandSpec('Kernel command line', 'cat /proc/cmdline'), + CommandSpec('All sysctl values', 'sysctl -a'), + ), + 'net-and-processes-info': ( + CommandSpec('Network interfaces', 'netstat --interfaces'), + CommandSpec('Listening sockets', 'netstat --listening'), + CommandSpec('Socket summary', 'ss -s'), + CommandSpec('All sockets with details', 'ss -a -e -m -p'), + CommandSpec('Full process listing', 'ps -eF'), + ), + 'ethtool-info': ( + CommandSpec( + 'Link details and interface counters', + 'ip -s -d link show', + ), + FuncSpec('Physical interface statistics', show_physical_interface_statistics), + ), + 'lspci-and-numa-info': ( + CommandSpec('PCI devices', 'lspci -knnv'), + CommandSpec('', 'numactl --hardware'), + CommandSpec('', 'numastat -cm'), + ), + 'nftables-info': ( + CommandSpec('nftables ruleset', 'nft list ruleset'), + CommandSpec('VyOS firewall configuration', op('show firewall')), + CommandSpec('VyOS firewall zone policy', op('show firewall zone-policy')), + ), + 'dpkg-and-modules-info': ( + CommandSpec('Installed packages', 'dpkg --list'), + CommandSpec( + 'Diff dpkg status (image vs running system)', + 'diff /usr/lib/live/mount/rootfs/*.squashfs/var/lib/dpkg/status /var/lib/dpkg/status', + ), + CommandSpec('APT sources list', 'cat /etc/apt/sources.list'), + CommandSpec( + 'APT sources.d directory listing', 'ls -l /etc/apt/sources.list.d/' + ), + CommandSpec('Loaded kernel modules', 'lsmod'), + ), + 'system-resources-info': ( + CommandSpec('Current time (date)', 'date'), + CommandSpec('CPU information', 'lscpu'), + CommandSpec( + 'Per-process cumulative CPU usage snapshot (top batch, accumulated)', + 'top --iterations 1 --batch-mode --accum-time-toggle', + ), + CommandSpec('atop snapshot (CPU view)', 'atop -a -y -1 -c -g -C 1 1 | tee'), + CommandSpec('atop snapshot (memory view)', 'atop -a -y -1 -c -m -M 1 1 | tee'), + CommandSpec('atop snapshot (disk view)', 'atop -a -y -1 -c -d -D 1 1 | tee'), + CommandSpec('atop snapshot (network view)', 'atop -a -y -1 -c -n -N 1 1 | tee'), + CommandSpec('Memory usage', 'free -lhv'), + FuncSpec('Storage overview', show_storage), + ), + 'ipsec-debug-info': ( + CommandSpec('strongSwan connections', 'swanctl -L'), + CommandSpec('strongSwan loaded connections', 'swanctl -l'), + CommandSpec('strongSwan policies', 'swanctl -P'), + CommandSpec('XFRM security associations', 'ip x sa show'), + CommandSpec('XFRM policies', 'ip x policy show'), + CommandSpec('XFRM state', 'ip xfrm state'), + CommandSpec('Tunnels', 'ip tunnel show'), + CommandSpec('Addresses', 'ip address'), + CommandSpec('Policy routing rules', 'ip rule show'), + CommandSpec('Routes', 'ip route | head -100'), + CommandSpec('Routes from table 220', 'ip route show table 220'), + ), + 'vpp-info': ( + CommandSpec('', 'cat /run/vpp/vpp.conf'), + CommandSpec('', 'vppctl show version verbose cmdline'), + CommandSpec('', 'vppctl show hardware-interfaces'), + CommandSpec('', 'vppctl show interface address'), + CommandSpec('', 'vppctl show interface'), + CommandSpec('', 'vppctl show errors'), + CommandSpec('', 'vppctl show runtime'), + CommandSpec( + '', + 'vppctl show memory api-segment stats-segment numa-heaps main-heap map verbose', + ), + CommandSpec('', 'vppctl show buffers'), + CommandSpec('', 'vppctl show physmem detail'), + CommandSpec('', 'vppctl show physmem map'), + CommandSpec('', 'vppctl show cpu'), + CommandSpec('', 'vppctl show threads'), + CommandSpec('', 'vppctl show node counters'), + CommandSpec('', 'vppctl show l2fib'), + CommandSpec('', 'vppctl show bridge-domain'), + CommandSpec('', 'vppctl show ip fib | head -100'), + CommandSpec('', 'vppctl show ip neighbors'), + CommandSpec('', 'vppctl show ip6 fib | head -100'), + CommandSpec('', 'vppctl show ip6 neighbors'), + CommandSpec('', 'vppctl show mpls fib'), + CommandSpec('', 'vppctl show mpls tunnel'), + CommandSpec('', 'vppctl show trace'), + ), +} + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/src/op_mode/show_usb_serial.py b/src/op_mode/show_usb_serial.py index 973bf19c8..7c1d2a07f 100755 --- a/src/op_mode/show_usb_serial.py +++ b/src/op_mode/show_usb_serial.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/show_users.py b/src/op_mode/show_users.py index 82bd585c9..086c8b1e2 100755 --- a/src/op_mode/show_users.py +++ b/src/op_mode/show_users.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,15 +13,15 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. + import argparse -import pwd import struct import sys from time import ctime from tabulate import tabulate from vyos.config import Config - +from vyos.utils.auth import get_local_passwd_entries class UserInfo: def __init__(self, uid, name, user_type, is_locked, login_time, tty, host): @@ -79,7 +79,9 @@ def list_users(): vyos_users = cfg.list_effective_nodes('system login user') users = [] with open('/var/log/lastlog', 'rb') as lastlog_file: - for (name, _, uid, _, _, _, _) in pwd.getpwall(): + for entry in get_local_passwd_entries(): + name = entry.pw_name + uid = entry.pw_uid lastlog_info = decode_lastlog(lastlog_file, uid) if lastlog_info is None: continue diff --git a/src/op_mode/show_virtual_server.py b/src/op_mode/show_virtual_server.py index 7880edc97..5c6ba4f4e 100755 --- a/src/op_mode/show_virtual_server.py +++ b/src/op_mode/show_virtual_server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/show_vpp_interfaces.py b/src/op_mode/show_vpp_interfaces.py new file mode 100755 index 000000000..64f1eb086 --- /dev/null +++ b/src/op_mode/show_vpp_interfaces.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import argparse +import json +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import rc_cmd + +from vyos.vpp import VPPControl +from vyos.vpp.utils import ( + vpp_ifaces_list, + vpp_ip_addresses_by_index, + vpp_ifaces_stats, +) + + +def get_iproute_address_list(interface: str) -> list: + """Get data from the Linux command 'ip --json address list dev {interface}' and return a list info + for the given interface. + + Args: + interface (str): Interface name. + + Returns: + list: A dictionary containing the JSON data from the 'ip --json address list' command for the specified interface. + """ + rc, out = rc_cmd(f'ip --json address list dev {interface}') + if rc: + return [] + return json.loads(out) + + +def get_iproute_link_list(interface): + """Get data from the Linux command 'ip --json link show dev {interface}' and return a list info + for the given interface. + + Args: + interface (str): Interface name. + + Returns: + list: A dictionary containing the JSON data from the 'ip --json link show' command for the specified interface. + """ + rc, out = rc_cmd(f'ip --json link list dev {interface}') + if rc: + return [] + return json.loads(out) + + +def merge_dicts(*dicts) -> dict: + """Merge dictionaries into a new dictionary. + + Args: + *dicts: Any number of dictionaries. + + Returns: + dict: A new dictionary containing all the key-value pairs from the given dictionaries. + """ + merged = {} + for dictionary in dicts: + merged.update(dictionary) + return merged + + +def show_interfaces(interfaces_list: list) -> str: + """Get JSON info from linux and represent it in a table format + Use tabulate to generate table + + Interface IP Address Mtu S/L Description + --------- ---------- --- --- ----------- + dum0 203.0.113.1/32 1500 u/u + 100.64.1.1/24 + eth0 192.168.122.14/24 1500 u/u WAN + + :return: + """ + table = [] + for interface in interfaces_list: + # Get the data for the interface + ip_address_data = get_iproute_address_list(interface) + link_data = get_iproute_link_list(interface) + + # Skip this interface if data is not available + if not link_data: + continue + interface_data = merge_dicts(ip_address_data[0], link_data[0]) + + # Get the interface name + interface_name = interface_data['ifname'] + + # Get the IP addresses and their corresponding prefixes + ip_info = [ + (address['local'], address.get('prefixlen', '')) + for address in interface_data['addr_info'] + ] + + # Format the IP addresses with prefixes and line breaks + ip_addresses = '\n'.join(f'{ip}/{prefix}' for ip, prefix in ip_info) + + # Get the MAC address + mac = interface_data.get('address', 'n/a') + + # Get the MTU + mtu = interface_data.get('mtu') + + # Get the state of the interface + state = interface_data['operstate'].lower() + + # Get the description of the interface + description = interface_data.get('ifalias', '') + + # Create the list of values for the table + values = [interface_name, ip_addresses, mac, mtu, state, description] + + # Append the list of values to the table + table.append(values) + + # Print the table with IP addresses listed on separate lines + headers = ['Interface', 'IP Address', 'MAC', 'MTU', 'State', 'Description'] + return tabulate(table, headers=headers, tablefmt='simple') + + +def show_interfaces_dataplane(interfaces_list: list, filter_type: str = 'all') -> str: + table = [] + interface_dp_filter = ('tun', 'tap') + lcp_pair_list = vpp.lcp_pairs_list() + vpp_name_kernel_to_kernel_name = { + entry['vpp_name_kernel']: entry['kernel_name'] for entry in lcp_pair_list + } + for interface in interfaces_list: + interface_name = interface.get('interface_name') + if filter_type == 'no_tun_tap' and interface_name.startswith( + interface_dp_filter + ): + continue + if filter_type == 'only_tun_tap' and not interface_name.startswith( + interface_dp_filter + ): + continue + kernel_name = vpp_name_kernel_to_kernel_name.get(interface_name, '') + + dp_ip_addresses = vpp_ip_addresses_by_index( + vpp.api, interface.get('sw_if_index') + ) + dp_ipv6_addresses = vpp_ip_addresses_by_index( + vpp.api, interface.get('sw_if_index'), is_ipv6=True + ) + ip_addresses = '\n'.join(dp_ip_addresses + dp_ipv6_addresses) + + mac = str(interface.get('l2_address', 'n/a')) + mtu = interface.get('mtu', [])[0] + # state + flags = interface.get('flags') + state = 'up' if flags == 3 else 'down' + + iftype = interface.get('interface_dev_type').split()[0] + + values = [kernel_name, interface_name, iftype, ip_addresses, mac, mtu, state] + table.append(values) + headers = [ + 'Kernel', + 'Dataplane', + 'Type', + 'IP Address', + 'MAC', + 'MTU', + 'State', + ] + table = sorted(table) + return tabulate(table, headers=headers, tablefmt='simple') + + +def show_interfaces_hardware(intf_name) -> str: + if not intf_name: + intf_name = '' + + statistics = vpp_ifaces_stats(intf_name) + for intf, stats in sorted(statistics.items()): + print(f'\n---------------------------------\nInterface {intf}:\n') + table = [] + for k, v in stats.items(): + if isinstance(v, dict): + for i, j in v.items(): + table.append([f"{k} {i}", j]) + else: + table.append([k, v]) + print(tabulate(table, tablefmt="presto")) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Show VPP interfaces') + parser.add_argument( + '--summary', + action='store_true', + help='Show summary of VPP interfaces (ethernet and kernel tun)', + ) + parser.add_argument( + '--dataplane', action='store_true', help='Show VPP ethernet interfaces' + ) + parser.add_argument( + '--kernel', action='store_true', help='Show VPP kernel interfaces' + ) + parser.add_argument( + '--iproute', action='store_true', help='Show interfaces (iproute2)' + ) + parser.add_argument( + '--hardware', + action='store_true', + help='Show more detailed statistics for VPP interfaces', + ) + parser.add_argument('--intf-name', action='store', help='Kernel interface name') + + args = parser.parse_args() + + config = ConfigTreeQuery() + + if not config.exists('vpp settings interface'): + print('VPP interfaces not configured') + exit(0) + + vpp = VPPControl() + dp_ifaces_list = vpp_ifaces_list(vpp.api) + + if args.summary: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='all')) + + if args.dataplane: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='no_tun_tap')) + exit(0) + + if args.kernel: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='only_tun_tap')) + + if args.iproute: + vpp_interfaces = [] + vpp_ethernet = config.list_nodes('vpp settings interface') + vpp_interfaces.extend(vpp_ethernet) + if config.exists('interfaces vpp'): + for iface_type in config.list_nodes('interfaces vpp'): + vpp_kernel_interfaces = config.list_nodes( + f'interfaces vpp {iface_type}' + ) + vpp_interfaces.extend(vpp_kernel_interfaces) + print(show_interfaces(interfaces_list=vpp_interfaces)) + + if args.hardware: + show_interfaces_hardware(intf_name=args.intf_name) diff --git a/src/op_mode/show_wwan.py b/src/op_mode/show_wwan.py index bd97bb0e5..05e7d0e75 100755 --- a/src/op_mode/show_wwan.py +++ b/src/op_mode/show_wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/snmp.py b/src/op_mode/snmp.py index 3d6cd220a..c7dfb51ef 100755 --- a/src/op_mode/snmp.py +++ b/src/op_mode/snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,7 +22,7 @@ from vyos.utils.process import call config_file_daemon = r'/etc/snmp/snmpd.conf' -parser = argparse.ArgumentParser(description='Retrieve infomration from running SNMP daemon') +parser = argparse.ArgumentParser(description='Retrieve information from running SNMP daemon') parser.add_argument('--allowed', action="store_true", help='Show available SNMP communities') parser.add_argument('--community', action="store", help='Show status of given SNMP community', type=str) parser.add_argument('--host', action="store", help='SNMP host to connect to', type=str, default='localhost') diff --git a/src/op_mode/snmp_ifmib.py b/src/op_mode/snmp_ifmib.py index c71febac9..d733540f5 100755 --- a/src/op_mode/snmp_ifmib.py +++ b/src/op_mode/snmp_ifmib.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -28,7 +28,7 @@ from vyos.utils.process import popen parser = argparse.ArgumentParser(description='Retrieve SNMP interfaces information') parser.add_argument('--ifindex', action='store', nargs='?', const='all', help='Show interface index') -parser.add_argument('--ifalias', action='store', nargs='?', const='all', help='Show interface aliase') +parser.add_argument('--ifalias', action='store', nargs='?', const='all', help='Show interface alias') parser.add_argument('--ifdescr', action='store', nargs='?', const='all', help='Show interface description') def show_ifindex(intf): diff --git a/src/op_mode/snmp_v3.py b/src/op_mode/snmp_v3.py index abeb524dd..94c6691b0 100755 --- a/src/op_mode/snmp_v3.py +++ b/src/op_mode/snmp_v3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/ssh.py b/src/op_mode/ssh.py index 0c51576b0..a4442c301 100755 --- a/src/op_mode/ssh.py +++ b/src/op_mode/ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/op_mode/storage.py b/src/op_mode/storage.py index 8fd2ffea1..0f3fabe32 100755 --- a/src/op_mode/storage.py +++ b/src/op_mode/storage.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,10 +13,8 @@ # # 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 jinja2 import Template @@ -28,9 +26,6 @@ Used: {{used}} ({{use_percentage}}%) Available: {{avail}} ({{avail_percentage}}%) """ -def _get_formatted_output(): - return _get_system_storage() - def show(raw: bool): from vyos.utils.disk import get_persistent_storage_stats @@ -49,7 +44,7 @@ def show(raw: bool): tmpl = Template(output_tmpl) return tmpl.render(data).strip() - return output + return None if __name__ == '__main__': try: @@ -59,4 +54,3 @@ if __name__ == '__main__': except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1) - diff --git a/src/op_mode/stp.py b/src/op_mode/stp.py new file mode 100755 index 000000000..c2a897183 --- /dev/null +++ b/src/op_mode/stp.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import typing +import json +from tabulate import tabulate + +import vyos.opmode +from vyos.utils.process import cmd +from vyos.utils.network import interface_exists + +def detailed_output(dataset, headers): + for data in dataset: + adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action + transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char + + print(tabulate(transformed_rule, tablefmt="presto")) + print() + +def _get_bridge_vlan_data(iface): + allowed_vlans = [] + native_vlan = None + vlanData = json.loads(cmd(f"bridge -j -d vlan show")) + for vlans in vlanData: + if vlans['ifname'] == iface: + for allowed in vlans['vlans']: + if "flags" in allowed and "PVID" in allowed["flags"]: + native_vlan = allowed['vlan'] + elif allowed.get('vlanEnd', None): + allowed_vlans.append(f"{allowed['vlan']}-{allowed['vlanEnd']}") + else: + allowed_vlans.append(str(allowed['vlan'])) + + if not allowed_vlans: + allowed_vlans = ["none"] + if not native_vlan: + native_vlan = "none" + + return ",".join(allowed_vlans), native_vlan + +def _get_stp_data(ifname, brInfo, brStatus): + tmpInfo = {} + + tmpInfo['bridge_name'] = brInfo.get('ifname') + tmpInfo['up_state'] = brInfo.get('operstate') + tmpInfo['priority'] = brInfo.get('linkinfo').get('info_data').get('priority') + tmpInfo['vlan_filtering'] = "Enabled" if brInfo.get('linkinfo').get('info_data').get('vlan_filtering') == 1 else "Disabled" + tmpInfo['vlan_protocol'] = brInfo.get('linkinfo').get('info_data').get('vlan_protocol') + + # The version of VyOS I tested had am issue with the "ip -d link show type bridge" + # output. The root_id was always the local bridge, even though the underlying system + # understood when it wasn't. Could be an upstream Bug. I pull from the "/sys/class/net" + # structure instead. This can be changed later if the "ip link" behavior is corrected. + + #tmpInfo['bridge_id'] = brInfo.get('linkinfo').get('info_data').get('bridge_id') + #tmpInfo['root_id'] = brInfo.get('linkinfo').get('info_data').get('root_id') + + tmpInfo['bridge_id'] = cmd(f"cat /sys/class/net/{brInfo.get('ifname')}/bridge/bridge_id").split('.') + tmpInfo['root_id'] = cmd(f"cat /sys/class/net/{brInfo.get('ifname')}/bridge/root_id").split('.') + + # The "/sys/class/net" structure stores the IDs without separators like ':' or '.' + # This adds a ':' after every 2 characters to make it resemble a MAC Address + tmpInfo['bridge_id'][1] = ':'.join(tmpInfo['bridge_id'][1][i:i+2] for i in range(0, len(tmpInfo['bridge_id'][1]), 2)) + tmpInfo['root_id'][1] = ':'.join(tmpInfo['root_id'][1][i:i+2] for i in range(0, len(tmpInfo['root_id'][1]), 2)) + + tmpInfo['stp_state'] = "Enabled" if brInfo.get('linkinfo', {}).get('info_data', {}).get('stp_state') == 1 else "Disabled" + + # I don't call any of these values, but I created them to be called within raw output if desired + + tmpInfo['mcast_snooping'] = "Enabled" if brInfo.get('linkinfo').get('info_data').get('mcast_snooping') == 1 else "Disabled" + tmpInfo['rxbytes'] = brInfo.get('stats64').get('rx').get('bytes') + tmpInfo['rxpackets'] = brInfo.get('stats64').get('rx').get('packets') + tmpInfo['rxerrors'] = brInfo.get('stats64').get('rx').get('errors') + tmpInfo['rxdropped'] = brInfo.get('stats64').get('rx').get('dropped') + tmpInfo['rxover_errors'] = brInfo.get('stats64').get('rx').get('over_errors') + tmpInfo['rxmulticast'] = brInfo.get('stats64').get('rx').get('multicast') + tmpInfo['txbytes'] = brInfo.get('stats64').get('tx').get('bytes') + tmpInfo['txpackets'] = brInfo.get('stats64').get('tx').get('packets') + tmpInfo['txerrors'] = brInfo.get('stats64').get('tx').get('errors') + tmpInfo['txdropped'] = brInfo.get('stats64').get('tx').get('dropped') + tmpInfo['txcarrier_errors'] = brInfo.get('stats64').get('tx').get('carrier_errors') + tmpInfo['txcollosions'] = brInfo.get('stats64').get('tx').get('collisions') + + tmpStatus = [] + for members in brStatus: + if members.get('master') == brInfo.get('ifname'): + allowed_vlans, native_vlan = _get_bridge_vlan_data(members['ifname']) + tmpStatus.append({'interface': members.get('ifname'), + 'state': members.get('state').capitalize(), + 'mtu': members.get('mtu'), + 'pathcost': members.get('cost'), + 'bpduguard': "Enabled" if members.get('guard') == True else "Disabled", + 'rootguard': "Enabled" if members.get('root_block') == True else "Disabled", + 'mac_learning': "Enabled" if members.get('learning') == True else "Disabled", + 'neigh_suppress': "Enabled" if members.get('neigh_suppress') == True else "Disabled", + 'vlan_tunnel': "Enabled" if members.get('vlan_tunnel') == True else "Disabled", + 'isolated': "Enabled" if members.get('isolated') == True else "Disabled", + **({'allowed_vlans': allowed_vlans} if allowed_vlans else {}), + **({'native_vlan': native_vlan} if native_vlan else {})}) + + tmpInfo['members'] = tmpStatus + return tmpInfo + +def show_stp(raw: bool, ifname: typing.Optional[str], detail: bool): + rawList = [] + rawDict = {'stp': []} + + if ifname: + if not interface_exists(ifname): + raise vyos.opmode.Error(f"{ifname} does not exist!") + else: + ifname = "" + + bridgeInfo = json.loads(cmd(f"ip -j -d -s link show type bridge {ifname}")) + + if not bridgeInfo: + raise vyos.opmode.Error(f"No Bridges configured!") + + bridgeStatus = json.loads(cmd(f"bridge -j -s -d link show")) + + for bridges in bridgeInfo: + output_list = [] + amRoot = "" + bridgeDict = _get_stp_data(ifname, bridges, bridgeStatus) + + if bridgeDict['bridge_id'][1] == bridgeDict['root_id'][1]: + amRoot = " (This bridge is the root)" + + print('-' * 80) + print(f"Bridge interface {bridgeDict['bridge_name']} ({bridgeDict['up_state']}):\n") + print(f"Spanning Tree is {bridgeDict['stp_state']}") + print(f"Bridge ID {bridgeDict['bridge_id'][1]}, Priority {int(bridgeDict['bridge_id'][0], 16)}") + print(f"Root ID {bridgeDict['root_id'][1]}, Priority {int(bridgeDict['root_id'][0], 16)}{amRoot}") + print(f"VLANs {bridgeDict['vlan_filtering'].capitalize()}, Protocol {bridgeDict['vlan_protocol']}") + print() + + for members in bridgeDict['members']: + output_list.append([members['interface'], + members['state'], + *([members['pathcost']] if detail else []), + members['bpduguard'], + members['rootguard'], + members['mac_learning'], + *([members['neigh_suppress']] if detail else []), + *([members['vlan_tunnel']] if detail else []), + *([members['isolated']] if detail else []), + *([members['allowed_vlans']] if detail else []), + *([members['native_vlan']] if detail else [])]) + + if raw: + rawList.append(bridgeDict) + elif detail: + headers = ['Interface', 'State', 'Pathcost', 'BPDU_Guard', 'Root_Guard', 'Learning', 'Neighbor_Suppression', 'Q-in-Q', 'Port_Isolation', 'Allowed VLANs', 'Native VLAN'] + detailed_output(output_list, headers) + else: + headers = ['Interface', 'State', 'BPDU_Guard', 'Root_Guard', 'Learning'] + print(tabulate(output_list, headers)) + print() + + if raw: + rawDict['stp'] = rawList + return rawDict + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/system.py b/src/op_mode/system.py index 854b4b699..6d0815a4a 100755 --- a/src/op_mode/system.py +++ b/src/op_mode/system.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/tcpdump.py b/src/op_mode/tcpdump.py index 607b59603..351b3d520 100644 --- a/src/op_mode/tcpdump.py +++ b/src/op_mode/tcpdump.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,6 +16,7 @@ import sys +from vyos.utils.io import catch_broken_pipe from vyos.utils.process import call options = { @@ -51,8 +52,6 @@ options = { }, } -tcpdump = 'sudo /usr/bin/tcpdump' - class List(list): def first(self): return self.pop(0) if self else '' @@ -92,7 +91,8 @@ def complete(prefix): return [o for o in options if o.startswith(prefix)] -def convert(command, args): +def convert(args): + command = 'sudo /usr/bin/tcpdump' while args: shortname = args.first() longnames = complete(shortname) @@ -109,6 +109,9 @@ def convert(command, args): command=command, value=args.first()) return command +@catch_broken_pipe +def run_tcpdump(command: str, ifname: str) -> None: + call(f'{command} -i {ifname}') if __name__ == '__main__': args = List(sys.argv[1:]) @@ -161,5 +164,4 @@ if __name__ == '__main__': sys.stdout.write(helplines) sys.exit(0) - command = convert(tcpdump, args) - call(f'{command} -i {ifname}') + run_tcpdump(convert(args), ifname) diff --git a/src/op_mode/tech_support.py b/src/op_mode/tech_support.py index 24ac0af1b..6055cbf15 100644 --- a/src/op_mode/tech_support.py +++ b/src/op_mode/tech_support.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,6 +20,7 @@ import json import vyos.opmode from vyos.utils.process import cmd +from vyos.base import Warning def _get_version_data(): from vyos.version import get_version_data @@ -51,7 +52,12 @@ def _get_storage(): def _get_devices(): devices = {} devices["pci"] = cmd("lspci") - devices["usb"] = cmd("lsusb") + + try: + devices["usb"] = cmd("lsusb") + except OSError: + Warning("Could not retrieve information about USB devices") + devices["usb"] = {} return devices diff --git a/src/op_mode/toggle_help_binding.sh b/src/op_mode/toggle_help_binding.sh index a8708f3da..7c8bc05ce 100755 --- a/src/op_mode/toggle_help_binding.sh +++ b/src/op_mode/toggle_help_binding.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py index d2bac3f7c..ef67b7416 100755 --- a/src/op_mode/traceroute.py +++ b/src/op_mode/traceroute.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -85,7 +85,7 @@ options = { 'help': 'Use TCP SYN for tracerouting (default port is 80)' }, 'tos': { - 'traceroute': '{commad} -t {value}', + 'traceroute': '{command} -t {value}', 'type': '<tos>', 'help': 'Mark packets with specified TOS' }, @@ -222,11 +222,23 @@ if __name__ == '__main__': args.append(name) args.append(option['dflt']) + af = socket.AF_UNSPEC + for i in range(len(args)): + matched = complete(args[i]) + if len(matched) == 1 and matched[0] == 'source-address' and i + 1 < len(args): + try: + src_version = ipaddress.ip_address(args[i + 1]).version + af = socket.AF_INET6 if src_version == 6 else socket.AF_INET + except ValueError: + pass + break + try: - ip = socket.gethostbyname(host) + info = socket.getaddrinfo(host, None, af, socket.SOCK_STREAM) + ip = info[0][4][0] except UnicodeError: - sys.exit(f'tracroute: Unknown host: {host}') - except socket.gaierror: + sys.exit(f'traceroute: Unknown host: {host}') + except OSError: ip = host try: diff --git a/src/op_mode/update_suricata.sh b/src/op_mode/update_suricata.sh new file mode 100755 index 000000000..6e4e605f4 --- /dev/null +++ b/src/op_mode/update_suricata.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +if test -f /run/suricata/suricata.yaml; then + suricata-update --suricata-conf /run/suricata/suricata.yaml; + systemctl restart suricata; +else + echo "Service Suricata not configured"; +fi diff --git a/src/op_mode/uptime.py b/src/op_mode/uptime.py index 1c1a149ec..90475cb50 100755 --- a/src/op_mode/uptime.py +++ b/src/op_mode/uptime.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/version.py b/src/op_mode/version.py index 71a40dd50..b93e3081b 100755 --- a/src/op_mode/version.py +++ b/src/op_mode/version.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py index 9385bcd0c..0cd192174 100755 --- a/src/op_mode/vpn_ike_sa.py +++ b/src/op_mode/vpn_ike_sa.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index ef89e605f..3d7049a14 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,13 +23,13 @@ SWANCTL_CONF = '/etc/swanctl/swanctl.conf' def get_peer_connections(peer, tunnel, return_all = False): - search = rf'^[\s]*(peer_{peer}_(tunnel_[\d]+|vti)).*' + search = rf'^[\s]*({peer}-(tunnel-[\d]+|vti))[\s]*{{' matches = [] with open(SWANCTL_CONF, 'r') as f: for line in f.readlines(): result = re.match(search, line) if result: - suffix = f'tunnel_{tunnel}' if tunnel.isnumeric() else tunnel + suffix = f'tunnel-{tunnel}' if tunnel.isnumeric() else tunnel if return_all or (result[2] == suffix): matches.append(result[1]) return matches @@ -66,7 +66,8 @@ def debug_peer(peer, tunnel): return for conn in conns: - call(f'/usr/sbin/ipsec statusall | grep {conn}') + command = f'/usr/sbin/ipsec statusall | grep {conn}' + call(command) if __name__ == '__main__': diff --git a/src/op_mode/vpp.py b/src/op_mode/vpp.py new file mode 100755 index 000000000..80697f503 --- /dev/null +++ b/src/op_mode/vpp.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import json +import typing + +from tabulate import tabulate +from vyos.vpp import VPPControl +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.configquery import ConfigTreeQuery +import vyos.opmode + +NO_INDEX = 0xFFFFFFFF + +class VPPShow: + RX_STATES = { + 0: 'INITIALIZE', + 1: 'PORT_DISABLED', + 2: 'EXPIRED', + 3: 'LACP_DISABLED', + 4: 'DEFAULTED', + 5: 'CURRENT', + } + TX_STATES = {0: 'TRANSMIT'} + MUX_STATES = { + 0: 'DETACHED', + 1: 'WAITING', + 2: 'ATTACHED', + 3: 'COLLECTING_DISTRIBUTING', + } + PTX_STATES = {0: 'NO_PERIODIC', 1: 'FAST', 2: 'SLOW', 3: 'PERIODIC_TX'} + BOND_MODE = { + 1: 'round-robin', + 2: 'active-backup', + 3: 'xor', + 4: 'broadcast', + 5: 'lacp', + } + BOND_LB = { + 0: 'layer2', + 1: 'layer3+4', + 2: 'layer2+3', + 3: 'round-robin', + 4: 'broadcast', + 5: 'active-backup', + } + + def __init__(self): + self.config = ConfigTreeQuery() + self.vpp = VPPControl() + + # ----------------------------- + # IPFIX Interfaces + # ----------------------------- + def _get_ipfix_interfaces_raw(self) -> typing.List[dict]: + interfaces = self.vpp.api.flowprobe_interface_dump() + index_map = { + i.sw_if_index: i.interface_name for i in self.vpp.api.sw_interface_dump() + } + + return [ + { + 'interface': index_map.get(e.sw_if_index, f'if{e.sw_if_index}'), + 'sw_if_index': e.sw_if_index, + 'which': e.which.name.replace('FLOWPROBE_WHICH_', '').lower(), + 'direction': e.direction.name.replace( + 'FLOWPROBE_DIRECTION_', '' + ).lower(), + } + for e in interfaces + ] + + def _show_ipfix_interfaces_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No flowprobe interfaces configured.' + table_data = [ + { + 'Interface': d['interface'], + 'VppIfIndex': d['sw_if_index'], + 'Flow-variant': d['which'], + 'Direction': d['direction'], + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple') + + def ipfix_interfaces(self, raw: bool): + base = ['vpp', 'ipfix', 'interface'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix interface is not configured' + ) + + data = self._get_ipfix_interfaces_raw() + return data if raw else self._show_ipfix_interfaces_formatted(data) + + # ----------------------------- + # IPFIX Collectors + # ----------------------------- + def _get_ipfix_collectors_raw(self) -> typing.List[dict]: + _, collectors = self.vpp.api.ipfix_all_exporter_get() + return [ + { + 'collector_address': str(c.collector_address), + 'collector_port': c.collector_port, + 'src_address': str(c.src_address), + 'vrf_id': c.vrf_id, + 'path_mtu': c.path_mtu, + 'template_interval': c.template_interval, + 'udp_checksum': bool(c.udp_checksum), + } + for c in collectors + ] + + def _show_ipfix_collectors_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No IPFIX collectors configured.' + table_data = [ + { + 'Collector': f"{d['collector_address']}:{d['collector_port']}", + 'Source': d['src_address'], + 'VRF': d['vrf_id'], + 'MTU': d['path_mtu'], + 'Template Intvl': d['template_interval'], + 'UDP Cksum': 'on' if d['udp_checksum'] else 'off', + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple') + + def ipfix_collectors(self, raw: bool): + base = ['vpp', 'ipfix', 'collector'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix collector is not configured' + ) + + data = self._get_ipfix_collectors_raw() + return data if raw else self._show_ipfix_collectors_formatted(data) + + # ----------------------------- + # IPFIX table + # ----------------------------- + def _get_ipfix_table_raw(self): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show flowprobe table') + return [data.reply] + + def _show_ipfix_table_formatted(self) -> str: + data = self.vpp.cli_cmd('show flowprobe table') + return data.reply + + def ipfix_table(self, raw: bool): + base = ['vpp', 'ipfix', 'collector'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix collector is not configured' + ) + + data = self._get_ipfix_table_raw() + return data if raw else self._show_ipfix_table_formatted() + + # ----------------------------- + # Bonding information + # ----------------------------- + def _get_raw_output(self, data_dump: typing.List[dict]) -> list[dict]: + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + def _get_lacp_raw(self, ifname: typing.Optional[str]) -> list[dict]: + lacp_dump = self.vpp.api.sw_interface_lacp_dump() + data = self._get_raw_output(lacp_dump) + + if ifname: + res = next((d for d in data if d['interface_name'] == ifname), None) + if not res: + raise vyos.opmode.IncorrectValue( + f'Interface {ifname} is not a member of any LACP bond' + ) + data = [res] + + return data + + def _get_lacp_info_formatted(self, data): + + def bit(x, n): + return (x >> n) & 1 + + def bits_to_str(x): + return ' '.join(f'{bit(x, n):3d}' for n in range(7, -1, -1)) + + # Headers (exactly like VPP) + print(f'{"":55} {"actor state":32} {"partner state":32}') + print( + 'interface name'.ljust(26) + + 'sw_if_index'.ljust(13) + + 'bond interface'.ljust(17) + + 'exp/def/dis/col/syn/agg/tim/act'.ljust(33) + + 'exp/def/dis/col/syn/agg/tim/act'.ljust(32) + ) + + for d in data: + iface = d['interface_name'] + sw_if = str(d['sw_if_index']) + bond_if = d['bond_interface_name'] + actor_bits = bits_to_str(d['actor_state']) + partner_bits = bits_to_str(d['partner_state']) + + print( + f'{iface:25} {sw_if:12} {bond_if:16} {actor_bits:32} {partner_bits:32}' + ) + + # LAG ID formatting + lag_line = ( + f' LAG ID: ' + f'[({d["actor_system_priority"]:04x},{d["actor_system"].replace(":", "-")},' + f'{d["actor_key"]:04x},{d["actor_port_priority"]:04x},{d["actor_port_number"]:04x}), ' + f'({d["partner_system_priority"]:04x},{d["partner_system"].replace(":", "-")},' + f'{d["partner_key"]:04x},{d["partner_port_priority"]:04x},{d["partner_port_number"]:04x})]' + ) + print(lag_line) + + # State machine line + print( + f' RX-state: {self.RX_STATES[d["rx_state"]]}, ' + f'TX-state: {self.TX_STATES[d["tx_state"]]}, ' + f'MUX-state: {self.MUX_STATES[d["mux_state"]]}, ' + f'PTX-state: {self.PTX_STATES[d["ptx_state"]]}' + ) + + def _get_bond_raw(self, index: typing.Optional[str]) -> list[dict]: + bond_dump = self.vpp.api.sw_bond_interface_dump(sw_if_index=index) + + result = [] + for bond in bond_dump: + bond_info = { + 'interface_name': bond.interface_name, + 'sw_if_index': bond.sw_if_index, + 'mode': self.BOND_MODE[bond.mode], + 'hash_policy': self.BOND_LB[bond.lb], + 'active_members': bond.active_members, + 'members': {}, + } + members = self.vpp.api.sw_member_interface_dump( + sw_if_index=bond.sw_if_index + ) + for member in members: + bond_info['members'][member.interface_name] = { + 'sw_if_index': member.sw_if_index, + 'is_passive': member.is_passive, + 'is_long_timeout': member.is_long_timeout, + 'is_local_numa': member.is_local_numa, + 'weight': member.weight, + } + result.append(bond_info) + + return result + + def _show_bond_info_formatted(self, data: typing.List[dict]) -> str: + table_data = [ + { + 'Interface': d['interface_name'], + 'Mode': d['mode'], + 'Hash': d['hash_policy'], + 'Members': '\n'.join(sorted(d['members'].keys())), + 'Active members': d['active_members'], + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple', numalign='left') + + def lacp_info(self, raw: bool, ifname: typing.Optional[str]): + data = self._get_lacp_raw(ifname) + + if not data: + raise vyos.opmode.DataUnavailable( + 'No VPP interface is configured with LACP (802.3ad) mode' + ) + + if raw: + return data + + return self._get_lacp_info_formatted(data) + + def lacp_details(self, raw: bool, ifname: typing.Optional[str]) -> str: + # Check if interface is a part of any LACP bond + self._get_lacp_raw(ifname) + + # VPP does not have API call to get this data + cmd_command = f'show lacp{f" {ifname}" if ifname else ""} details' + data = self.vpp.cli_cmd(cmd_command) + + if raw: + return [data.reply] + + return data.reply + + def bond_info(self, raw: bool, ifname: typing.Optional[str]) -> str: + index = NO_INDEX + if ifname: + if not ifname.startswith('vppbond') or not ifname[7:].isdigit(): + raise vyos.opmode.IncorrectValue( + f'"{ifname}" is not a valid bonding interface name (expected vppbondN)' + ) + + ifname_vpp = vpp_iface_name_transform(ifname) + index = self.vpp.get_sw_if_index(ifname_vpp) + if index is None: + raise vyos.opmode.IncorrectValue( + f'Bonding interface {ifname} does not exist in VPP' + ) + + data = self._get_bond_raw(index) + + return data if raw else self._show_bond_info_formatted(data) + + def bond_details(self, raw: bool) -> str: + # VPP API call is not so informative -> use CLI command + cmd_command = 'show bond details' + data = self.vpp.cli_cmd(cmd_command) + return [data.reply] if raw else data.reply + + # ----------------------------- + # Bridge-domain information + # ----------------------------- + def _parse_bridge_id(self, ifname: typing.Optional[str]) -> typing.Optional[int]: + if ifname is None: + return None + + if not ifname.startswith('vppbr') and not ifname[5:].isdigit(): + raise vyos.opmode.IncorrectValue( + f'"{ifname}" is not a valid bridge interface name (expected vppbrN)' + ) + + if not self.config.exists(['interfaces', 'vpp', 'bridge', ifname]): + raise vyos.opmode.IncorrectValue( + f'Bridge interface {ifname} does not exist' + ) + + return int(ifname[5:]) + + def _get_bridge_domain_raw( + self, bd_id: typing.Optional[int] = None + ) -> typing.List[dict]: + # Dump bridge domains + domains = self.vpp.api.bridge_domain_dump( + bd_id=bd_id if bd_id is not None else NO_INDEX + ) + + result = [] + for d in domains: + domain_info = { + 'bd_id': d.bd_id, + 'learning': bool(d.learn), + 'forward': bool(d.forward), + 'uu_flood': bool(d.uu_flood), + 'flood': bool(d.flood), + 'arp_term': bool(d.arp_term), + 'arp_ufwd': bool(d.arp_ufwd), + 'mac_age': d.mac_age, + 'bvi_interface': d.bvi_sw_if_index, + 'n_sw_ifs': d.n_sw_ifs, + 'members': [ + { + 'ifname': self.vpp.get_interface_name(m.sw_if_index), + 'sw_if_index': m.sw_if_index, + 'shg': m.shg, + } + for m in d.sw_if_details + ], + } + result.append(domain_info) + + result.sort(key=lambda x: x['bd_id']) + + return result + + def _show_bridge_domain_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No bridge domains configured.' + + table_data = [ + { + 'BD-ID': d['bd_id'], + 'Age(min)': 'off' if d['mac_age'] == 0 else d['mac_age'], + 'Learning': 'on' if d['learning'] else 'off', + 'U-Forwrd': 'on' if d['forward'] else 'off', + 'UU-Flood': 'flood' if d['uu_flood'] else 'drop', + 'Flooding': 'on' if d['flood'] else 'off', + 'ARP-Term': 'on' if d['arp_term'] else 'off', + 'arp-ufwd': 'on' if d['arp_ufwd'] else 'off', + 'BVI-Intf': ( + self.vpp.get_interface_name(d['bvi_interface']) + if d['bvi_interface'] != NO_INDEX + else 'N/A' + ), + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple', numalign='left') + + def bridge_domain(self, raw: bool, ifname: typing.Optional[str] = None): + bd_id = self._parse_bridge_id(ifname) + data = self._get_bridge_domain_raw(bd_id) + return data if raw else self._show_bridge_domain_formatted(data) + + def bridge_domain_details(self, raw: bool, ifname: typing.List): + bd_id = self._parse_bridge_id(ifname) + + # VPP API call is not so informative -> use CLI command + cmd_command = f'show bridge-domain {bd_id} detail' + data = self.vpp.cli_cmd(cmd_command) + + if raw: + return [data.reply] + + return data.reply + + # ----------------------------- + # Runtime table + # ----------------------------- + def _get_runtime_raw(self): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show runtime') + return [data.reply] + + def _show_runtime_formatted(self) -> str: + data = self.vpp.cli_cmd('show runtime') + return data.reply + + def runtime(self, raw: bool): + data = self._get_runtime_raw() + return data if raw else self._show_runtime_formatted() + + # ----------------------------- + # Interfaces mode + # ----------------------------- + def mode(self, raw: bool): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show mode') + return [data.reply] if raw else data.reply + + +# ----------------------------- +# VyOS IPFIX op-mode entries +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix', 'interface']) +def show_ipfix_interfaces(raw: bool): + return VPPShow().ipfix_interfaces(raw) + +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix', 'collector']) +def show_ipfix_collectors(raw: bool): + return VPPShow().ipfix_collectors(raw) + +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix']) +def show_ipfix_table(raw: bool): + return VPPShow().ipfix_table(raw) + +# ----------------------------- +# VPP Bonding information +# ----------------------------- +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_lacp(raw: bool, ifname: typing.Optional[str]): + return VPPShow().lacp_info(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_lacp_details(raw: bool, ifname: typing.Optional[str]): + return VPPShow().lacp_details(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_bond(raw: bool, ifname: typing.Optional[str]): + return VPPShow().bond_info(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_bond_details(raw: bool): + return VPPShow().bond_details(raw) + +# ----------------------------- +# Bridge op-mode entry +# ----------------------------- +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bridge']) +def show_bridge(raw: bool, ifname: typing.Optional[str] = None): + return VPPShow().bridge_domain(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bridge']) +def show_bridge_details(raw: bool, ifname: typing.Optional[str] = None): + return VPPShow().bridge_domain_details(raw, ifname) + +# ----------------------------- +# show runtime +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp']) +def show_runtime(raw: bool): + return VPPShow().runtime(raw) + +# ----------------------------- +# show mode +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp']) +def show_mode(raw: bool): + return VPPShow().mode(raw) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/vpp_acl.py b/src/op_mode/vpp_acl.py new file mode 100644 index 000000000..6d5daba06 --- /dev/null +++ b/src/op_mode/vpp_acl.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.config import Config +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +NO_ACL_INDEX = 0xFFFFFFFF + +# ACL action flags +action_map = { + 0: 'deny', + 1: 'permit', + 2: 'permit-reflect', +} + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + + +def _verify(target): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + if target not in ['ip', 'mac', 'no_target']: + raise ValueError('Invalid target') + + def _verify_target(func): + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + path = 'vpp acl' + if target == 'ip': + path += ' ip' + elif target == 'mac': + path += ' mac' + if not config.exists(path): + raise vyos.opmode.UnconfiguredSubsystem(f'"{path}" is not configured') + return func(*args, **kwargs) + + return _wrapper + + return _verify_target + + +def _get_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_mac_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.macip_acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_tcp_flag_states(value, mask): + set_flags = [] + unset_flags = [] + for flag, bit in TCP_FLAGS.items(): + if mask & bit: # This flag is being checked + if value & bit: + set_flags.append(flag) + else: + unset_flags.append(flag) + return sorted(set_flags), sorted(unset_flags) + + +def _get_raw_output_acls(data_dump): + out = [] + for data in data_dump: + rules = [json.loads(json.dumps(d._asdict(), default=str)) for d in data.r] + out.append( + { + 'acl_index': data.acl_index, + 'tag': data.tag, + 'count': data.count, + 'r': rules, + } + ) + return out + + +def _get_raw_output_interfaces(data_dump): + ifaces_list = [] + for iface in data_dump: + if iface.count != 0: + ifaces_list.append(json.loads(json.dumps(iface._asdict(), default=str))) + return ifaces_list + + +def _get_formatted_output_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + input_acls = [] + for acl_index in interface.get('acls')[: interface.get('n_input')]: + input_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + output_acls = [] + for acl_index in interface.get('acls')[interface.get('n_input') :]: + output_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + values = [ + name, + '\n'.join(input_acls), + '\n'.join(output_acls), + ] + data_entries.append(values) + + headers = ['Interface', 'Input ACLs', 'Output ACLs'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_mac_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + acl = _get_mac_acl_tag_by_index(vpp, int(interface.get('acls')[0])) + data_entries.append([name, acl]) + + headers = ['Interface', 'ACL'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'IP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'ip', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + srcport_first = str(rule.get('srcport_or_icmptype_first')) + srcport_last = str(rule.get('srcport_or_icmptype_last')) + dstport_first = str(rule.get('dstport_or_icmpcode_first')) + dstport_last = str(rule.get('dstport_or_icmpcode_last')) + set_flags, unset_flags = _get_tcp_flag_states( + rule.get('tcp_flags_value'), rule.get('tcp_flags_mask') + ) + + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + ( + f'{srcport_first}-{srcport_last}' + if srcport_first != srcport_last + else srcport_first + ), + rule.get('dst_prefix'), + ( + f'{dstport_first}-{dstport_last}' + if dstport_first != dstport_last + else dstport_first + ), + rule.get('proto'), + '\n'.join(set_flags), + '\n'.join(unset_flags), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'Src prefix', + 'Src port', + 'Dst prefix', + 'Dst port', + 'Proto', + 'TCP flags set', + 'TCP flags not set', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _get_formatted_output_mac_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'MACIP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'mac', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + rule.get('src_mac'), + rule.get('src_mac_mask'), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'IP prefix', + 'MAC address', + 'MAC mask', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _find_acl_by_tag(acls, tag_name): + return [acl for acl in acls if acl['tag'] == tag_name] + + +@_verify('ip') +def show_ip_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_acls(acls) + + +@_verify('mac') +def show_mac_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.macip_acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_mac_acls(acls) + + +@_verify('ip') +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify('mac') +def show_mac_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.macip_acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_mac_interfaces(vpp, interfaces) + + +@_verify('no_target') +def show_all_acls(raw: bool): + conf = Config() + acls_all = {} + path = ['vpp', 'acl'] + if conf.exists(path + ['ip']): + ip_acls = show_ip_acls(raw, tag_name=None) + acls_all['ip'] = ip_acls + if conf.exists(path + ['mac']): + mac_acls = show_mac_acls(raw, tag_name=None) + acls_all['mac'] = mac_acls + + if raw: + return acls_all + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/vpp_nat_cgnat.py b/src/op_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..6699d9c55 --- /dev/null +++ b/src/op_mode/vpp_nat_cgnat.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +def _verify(func): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat cgnat' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('CGNAT interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = 'in' if interface['is_inside'] else 'out' + print(f' {name} {iface_type}') + + +def _get_formatted_output_mappings(rules_list): + data_entries = [] + for rule in rules_list: + in_addr = rule.get('in_addr') + in_plen = str(rule.get('in_plen')) + out_addr = rule.get('out_addr') + out_plen = str(rule.get('out_plen')) + sharing_ratio = rule.get('sharing_ratio') + ports_per_host = rule.get('ports_per_host') + ses_num = rule.get('ses_num') + + values = [ + f'{in_addr}/{in_plen}', + f'{out_addr}/{out_plen}', + sharing_ratio, + ports_per_host, + ses_num, + ] + data_entries.append(values) + headers = [ + 'Inside', + 'Outside', + 'Sharing ratio', + 'Ports per host', + 'Sessions', + ] + out = sorted(data_entries, key=lambda x: x[0]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + out = vpp.cli_cmd('show det44 sessions').reply + out = out.replace('NAT44 deterministic', 'CGNAT') + return out + + +@_verify +def show_mappings(raw: bool): + vpp = VPPControl() + nat_static_dump = vpp.api.det44_map_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) + + if raw: + return rules_list + + else: + return _get_formatted_output_mappings(rules_list) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.det44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify +def show_exclude_rules(raw: bool): + """Show CGNAT exclude rules (identity mappings)""" + vpp = VPPControl() + identity_mappings_dump = vpp.api.det44_identity_mapping_dump() + mappings: list[dict] = _get_raw_output(identity_mappings_dump) + + if raw: + return mappings + + if not mappings: + return "No CGNAT exclude rules configured" + + data_entries = [] + for m in mappings: + proto_map = {0: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp', 255: 'all'} + proto_name = proto_map.get(m.get('protocol'), str(m.get('protocol'))) + port_str = str(m.get('port')) if m.get('port') else 'any' + + # Check if address-only (flag & 1) + if m.get('flags', 0) & 1: + proto_name = 'all' + port_str = 'any' + + tag_raw = m.get('tag') + if isinstance(tag_raw, bytes): + tag = tag_raw.decode('utf-8', errors='replace').rstrip('\x00') + else: + tag = str(tag_raw) if tag_raw else '' + + values = [m.get('addr'), proto_name, port_str, m.get('vrf_id', 0), tag] + data_entries.append(values) + + headers = ['Address', 'Protocol', 'Port', 'VRF', 'Description'] + out = sorted(data_entries, key=lambda x: x[0]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def clear_session(address: str, port: str, ext_address: str, ext_port: str): + vpp = VPPControl() + vpp.api.det44_close_session_in( + in_addr=address, + in_port=int(port), + ext_addr=ext_address, + ext_port=int(ext_port), + ) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/vpp_nat_nat44.py b/src/op_mode/vpp_nat_nat44.py new file mode 100644 index 000000000..97fca8dbc --- /dev/null +++ b/src/op_mode/vpp_nat_nat44.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +protocol_map = { + 0: 'all', + 1: 'icmp', + 6: 'tcp', + 17: 'udp', +} + +# NAT flags +flags_map = { + 'twice-nat': 0x01, + 'self-twice-nat': 0x02, + 'out2in-only': 0x04, + 'out': 0x10, + 'in': 0x20, +} + + +def _verify(func): + """Decorator checks if config for VPP NAT44 exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat nat44' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def decode_bitmask(bitmask: int) -> list: + """Decode a bitmask into a list of flag names""" + return [name for name, value in flags_map.items() if bitmask & value] + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + +def _get_raw_output_sessions(vpp_api): + users: list[dict] = vpp_api.nat44_user_dump() + sessions_list: list[dict] = [] + for user in users: + ip_address = str(user._asdict().get('ip_address')) + user_sessions_dump = vpp_api.nat44_user_session_v3_dump(ip_address=ip_address) + user_sessions = [ + json.loads(json.dumps(session._asdict(), default=str)) + for session in user_sessions_dump + ] + sessions_list.extend(user_sessions) + return sorted(sessions_list, key=lambda x: x["inside_ip_address"]) + + +def _get_formatted_output_sessions(sessions_list): + print('NAT44 ED sessions:') + print(f'--------------- {len(sessions_list)} sessions ---------------') + for session in sessions_list: + in_ip_addr = session.get('inside_ip_address') + in_port = session.get('inside_port') + out_ip_addr = session.get('outside_ip_address') + out_port = session.get('outside_port') + protocol = protocol_map[session.get('protocol')].upper() + last_heard = session.get('last_heard') + time_since_last_heard = session.get('time_since_last_heard') + total_bytes = session.get('total_bytes') + total_pkts = session.get('total_pkts') + ext_host_address = session.get('ext_host_address') + ext_host_port = session.get('ext_host_port') + is_timed_out = session.get('is_timed_out') + + print(f' i2o {in_ip_addr} proto {protocol} port {in_port}') + print(f' o2i {out_ip_addr} proto {protocol} port {out_port}') + print(f' external host {ext_host_address}:{ext_host_port}') + print( + f' i2o flow: match: saddr {in_ip_addr} sport {in_port} daddr {ext_host_address} dport {ext_host_port} proto {protocol} rewrite: saddr {out_ip_addr}' + + ( + f' sport {out_port}' + if protocol != 'ICMP' + else f' daddr {ext_host_address} icmp-id {ext_host_port}' + ) + ) + print( + f' o2i flow: match: saddr {ext_host_address} sport {ext_host_port} daddr {out_ip_addr} dport {out_port} proto {protocol} rewrite: ' + + ( + f'daddr {in_ip_addr} dport {in_port}' + if protocol != 'ICMP' + else f' saddr {ext_host_address} daddr {in_ip_addr} icmp-id {ext_host_port}' + ) + ) + print(f' last heard {last_heard}') + print(f' time since last heard {time_since_last_heard}') + print(f' total packets {total_pkts}, total bytes {total_bytes}') + if is_timed_out: + print(' session timed out') + print('\n') + + +def _get_formatted_output_addresses(addresses): + twice_nat_address = [] + translation_address = [] + for address_info in addresses: + address = address_info.get('ip_address') + if address_info.get('flags') & flags_map['twice-nat']: + twice_nat_address.append(address) + else: + translation_address.append(address) + + print('NAT44 pool addresses:') + for addr in translation_address: + print(f' {addr}') + print('NAT44 twice-nat pool addresses:') + for addr in twice_nat_address: + print(f' {addr}') + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('NAT44 interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = decode_bitmask(interface['flags']) + print(f' {name} {" ".join(iface_type)}') + + +def _get_formatted_output_rules(rules_list): + data_entries = [] + for rule in rules_list: + external_address = rule.get('external_ip_address') + external_port = rule.get('external_port') or '' + local_address = rule.get('local_ip_address') + local_port = rule.get('local_port') or '' + protocol = protocol_map[rule.get('protocol', 0)] + options = ' '.join(decode_bitmask(rule.get('flags'))) + + values = [ + external_address, + external_port, + local_address, + local_port, + protocol, + options, + ] + data_entries.append(values) + headers = [ + 'External address', + 'External port', + 'Local address', + 'Local port', + 'Protocol', + 'Options', + ] + out = sorted(data_entries, key=lambda x: x[2]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + sessions_list: list[dict] = _get_raw_output_sessions(vpp.api) + + if raw: + return sessions_list + + else: + return _get_formatted_output_sessions(sessions_list) + + +@_verify +def show_summary(raw: bool): + vpp = VPPControl() + return vpp.cli_cmd('show nat44 summary').reply + + +@_verify +def show_static(raw: bool): + vpp = VPPControl() + nat_static_dump = vpp.api.nat44_static_mapping_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) + + if raw: + return rules_list + + else: + return _get_formatted_output_rules(rules_list) + + +@_verify +def show_addresses(raw: bool): + vpp = VPPControl() + addresses_dump = vpp.api.nat44_address_dump() + addresses: list[dict] = _get_raw_output(addresses_dump) + + if raw: + return addresses + + else: + return _get_formatted_output_addresses(addresses) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.nat44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py index 51032a4b5..a13b48866 100755 --- a/src/op_mode/vrf.py +++ b/src/op_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/vrrp.py b/src/op_mode/vrrp.py index ef1338e23..92eb12d81 100755 --- a/src/op_mode/vrrp.py +++ b/src/op_mode/vrrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -191,7 +191,7 @@ def _get_formatted_statistics_output(data: list) -> str: Prepare formatted statistics output from the given data. Args: - data (list): A list of dictionaries containing vrrp grop information + data (list): A list of dictionaries containing vrrp group information and statistics. Returns: @@ -228,7 +228,7 @@ def _get_formatted_detail_output(data: list) -> str: Prepare formatted detail information output from the given data. Args: - data (list): A list of dictionaries containing vrrp grop information + data (list): A list of dictionaries containing vrrp group information and statistics. Returns: diff --git a/src/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh index 05ea86f9e..90594daf6 100755 --- a/src/op_mode/webproxy_update_blacklist.sh +++ b/src/op_mode/webproxy_update_blacklist.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/op_mode/wireguard_client.py b/src/op_mode/wireguard_client.py index 04d8ce28c..04d91cc47 100755 --- a/src/op_mode/wireguard_client.py +++ b/src/op_mode/wireguard_client.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -33,7 +33,7 @@ server_config = """WireGuard client configuration for interface: {{ interface }} To enable this configuration on a VyOS router you can use the following commands: -=== VyOS (server) configurtation === +=== VyOS (server) configuration === {% for addr in address if address is defined %} set interfaces wireguard {{ interface }} peer {{ name }} allowed-ips '{{ addr }}' diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py index df39549d2..8bdf373a8 100644 --- a/src/op_mode/zone.py +++ b/src/op_mode/zone.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as |
