From 7958c9794813f399a2e113896410c0c03d29663d Mon Sep 17 00:00:00 2001 From: Daniil Baturin Date: Mon, 1 Jul 2024 12:56:25 +0100 Subject: op-mode: T6498: add machine-readable tech support report script --- .../generate_tech-support_archive.xml.in | 6 +- op-mode-definitions/show-techsupport_report.xml.in | 8 + python/vyos/utils/strip_config.py | 211 +++++++++++ src/op_mode/tech_support.py | 394 +++++++++++++++++++++ 4 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 python/vyos/utils/strip_config.py create mode 100644 src/op_mode/tech_support.py diff --git a/op-mode-definitions/generate_tech-support_archive.xml.in b/op-mode-definitions/generate_tech-support_archive.xml.in index e95be3e28..fc664eb90 100644 --- a/op-mode-definitions/generate_tech-support_archive.xml.in +++ b/op-mode-definitions/generate_tech-support_archive.xml.in @@ -11,16 +11,16 @@ Generate tech support archive - sudo ${vyos_op_scripts_dir}/generate_tech-support_archive.py + sudo ${vyos_op_scripts_dir}/tech_support.py show --raw | gzip> $4.json.gz Generate tech support archive to defined location - <file> <scp://user:passwd@host> <ftp://user:passwd@host> + <file> - sudo ${vyos_op_scripts_dir}/generate_tech-support_archive.py $4 + sudo ${vyos_op_scripts_dir}/tech_support.py show --raw | gzip > $4.json.gz diff --git a/op-mode-definitions/show-techsupport_report.xml.in b/op-mode-definitions/show-techsupport_report.xml.in index ef051e940..4fd6e5d1e 100644 --- a/op-mode-definitions/show-techsupport_report.xml.in +++ b/op-mode-definitions/show-techsupport_report.xml.in @@ -12,6 +12,14 @@ Show consolidated tech-support report (contains private information) ${vyos_op_scripts_dir}/show_techsupport_report.py + + + + Show consolidated tech-support report in JSON + + sudo ${vyos_op_scripts_dir}/tech_support.py show --raw + + diff --git a/python/vyos/utils/strip_config.py b/python/vyos/utils/strip_config.py new file mode 100644 index 000000000..32f076564 --- /dev/null +++ b/python/vyos/utils/strip_config.py @@ -0,0 +1,211 @@ +#!/usr/bin/python3 +# +# Copyright 2024 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +# XXX: these functions assume that the config is at the top level, +# and aren't capable of anonymizing config subtress. +# They shouldn't be used as a basis for a strip-private filter +# until we figure out if we can pass the config path information to the filter. + +import sys +import copy + +import vyos.configtree + + +def __anonymize_password(v): + return "" + +def __anonymize_key(v): + return "" + +def __anonymize_data(v): + return "" + +__secret_paths = [ + # System user password hashes + {"base_path": ['system', 'login', 'user'], "secret_path": ["authentication", "encrypted-password"], "func": __anonymize_password}, + + # PKI data + {"base_path": ["pki", "ca"], "secret_path": ["private", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "ca"], "secret_path": ["certificate"], "func": __anonymize_key}, + {"base_path": ["pki", "ca"], "secret_path": ["crl"], "func": __anonymize_key}, + {"base_path": ["pki", "certificate"], "secret_path": ["private", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "certificate"], "secret_path": ["certificate"], "func": __anonymize_key}, + {"base_path": ["pki", "certificate"], "secret_path": ["acme", "email"], "func": __anonymize_data}, + {"base_path": ["pki", "key-pair"], "secret_path": ["private", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "key-pair"], "secret_path": ["public", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "openssh"], "secret_path": ["private", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "openssh"], "secret_path": ["public", "key"], "func": __anonymize_key}, + {"base_path": ["pki", "openvpn", "shared-secret"], "secret_path": ["key"], "func": __anonymize_key}, + {"base_path": ["pki", "dh"], "secret_path": ["parameters"], "func": __anonymize_key}, + + # IPsec pre-shared secrets + {"base_path": ['vpn', 'ipsec', 'authentication', 'psk'], "secret_path": ["secret"], "func": __anonymize_password}, + + # IPsec x509 passphrases + {"base_path": ['vpn', 'ipsec', 'site-to-site', 'peer'], "secret_path": ['authentication', 'x509'], "func": __anonymize_password}, + + # IPsec remote-access secrets and passwords + {"base_path": ["vpn", "ipsec", "remote-access", "connection"], "secret_path": ["authentication", "pre-shared-secret"], "func": __anonymize_password}, + # Passwords in remote-access IPsec local users have their own fixup + # due to deeper nesting. + + # PPTP passwords + {"base_path": ['vpn', 'pptp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password}, + + # L2TP passwords + {"base_path": ['vpn', 'l2tp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password}, + {"path": ['vpn', 'l2tp', 'remote-access', 'ipsec-settings', 'authentication', 'pre-shared-secret'], "func": __anonymize_password}, + + # SSTP passwords + {"base_path": ['vpn', 'sstp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password}, + + # OpenConnect passwords + {"base_path": ['vpn', 'openconnect', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password}, + + # PPPoE server passwords + {"base_path": ['service', 'pppoe-server', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password}, + + # RADIUS PSKs for VPN services + {"base_path": ["vpn", "sstp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + {"base_path": ["vpn", "l2tp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + {"base_path": ["vpn", "pptp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + {"base_path": ["vpn", "openconnect", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + {"base_path": ["service", "ipoe-server", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + {"base_path": ["service", "pppoe-server", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password}, + + # VRRP passwords + {"base_path": ['high-availability', 'vrrp', 'group'], "secret_path": ['authentication', 'password'], "func": __anonymize_password}, + + # BGP neighbor and peer group passwords + {"base_path": ['protocols', 'bgp', 'neighbor'], "secret_path": ["password"], "func": __anonymize_password}, + {"base_path": ['protocols', 'bgp', 'peer-group'], "secret_path": ["password"], "func": __anonymize_password}, + + # WireGuard private keys + {"base_path": ["interfaces", "wireguard"], "secret_path": ["private-key"], "func": __anonymize_password}, + + # NHRP passwords + {"base_path": ["protocols", "nhrp", "tunnel"], "secret_path": ["cisco-authentication"], "func": __anonymize_password}, + + # RIP passwords + {"base_path": ["protocols", "rip", "interface"], "secret_path": ["authentication", "plaintext-password"], "func": __anonymize_password}, + + # IS-IS passwords + {"path": ["protocols", "isis", "area-password", "plaintext-password"], "func": __anonymize_password}, + {"base_path": ["protocols", "isis", "interface"], "secret_path": ["password", "plaintext-password"], "func": __anonymize_password}, + + # HTTP API servers + {"base_path": ["service", "https", "api", "keys", "id"], "secret_path": ["key"], "func": __anonymize_password}, + + # Telegraf + {"path": ["service", "monitoring", "telegraf", "prometheus-client", "authentication", "password"], "func": __anonymize_password}, + {"path": ["service", "monitoring", "telegraf", "influxdb", "authentication", "token"], "func": __anonymize_password}, + {"path": ["service", "monitoring", "telegraf", "azure-data-explorer", "authentication", "client-secret"], "func": __anonymize_password}, + {"path": ["service", "monitoring", "telegraf", "splunk", "authentication", "token"], "func": __anonymize_password}, + + # SNMPv3 passwords + {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["privacy", "encrypted-password"], "func": __anonymize_password}, + {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["privacy", "plaintext-password"], "func": __anonymize_password}, + {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["auth", "encrypted-password"], "func": __anonymize_password}, + {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["auth", "encrypted-password"], "func": __anonymize_password}, +] + +def __prepare_secret_paths(config_tree, secret_paths): + """ Generate a list of secret paths for the current system, + adjusted for variable parts such as VRFs and remote access IPsec instances + """ + + # Fixup for remote-access IPsec local users that are nested under two tag nodes + # We generate the list of their paths dynamically + ipsec_ra_base = {"base_path": ["vpn", "ipsec", "remote-access", "connection"], "func": __anonymize_password} + if config_tree.exists(ipsec_ra_base["base_path"]): + for conn in config_tree.list_nodes(ipsec_ra_base["base_path"]): + if config_tree.exists(ipsec_ra_base["base_path"] + [conn] + ["authentication", "local-users", "username"]): + for u in config_tree.list_nodes(ipsec_ra_base["base_path"] + [conn] + ["authentication", "local-users", "username"]): + p = copy.copy(ipsec_ra_base) + p["base_path"] = p["base_path"] + [conn] + ["authentication", "local-users", "username"] + p["secret_path"] = ["password"] + secret_paths.append(p) + + # Fixup for VRFs that may contain routing protocols and other nodes nested under them + vrf_paths = [] + vrf_base_path = ["vrf", "name"] + if config_tree.exists(vrf_base_path): + for v in config_tree.list_nodes(vrf_base_path): + vrf_secret_paths = copy.deepcopy(secret_paths) + for sp in vrf_secret_paths: + if "base_path" in sp: + sp["base_path"] = vrf_base_path + [v] + sp["base_path"] + elif "path" in sp: + sp["path"] = vrf_base_path + [v] + sp["path"] + vrf_paths.append(sp) + + secret_paths = secret_paths + vrf_paths + + # Fixup for user SSH keys, that are nested under a tag node + #ssh_key_base_path = {"base_path": ['system', 'login', 'user'], "secret_path": ["authentication", "encrypted-password"], "func": __anonymize_password}, + user_base_path = ['system', 'login', 'user'] + ssh_key_paths = [] + if config_tree.exists(user_base_path): + for u in config_tree.list_nodes(user_base_path): + kp = {"base_path": user_base_path + [u, "authentication", "public-keys"], "secret_path": ["key"], "func": __anonymize_key} + ssh_key_paths.append(kp) + + secret_paths = secret_paths + ssh_key_paths + + # Fixup for OSPF passwords and keys that are nested under OSPF interfaces + ospf_base_path = ["protocols", "ospf", "interface"] + ospf_paths = [] + if config_tree.exists(ospf_base_path): + for i in config_tree.list_nodes(ospf_base_path): + # Plaintext password, there can be only one + opp = {"path": ospf_base_path + [i, "authentication", "plaintext-password"], "func": __anonymize_password} + md5kp = {"base_path": ospf_base_path + [i, "authentication", "md5", "key-id"], "secret_path": ["md5-key"], "func": __anonymize_password} + ospf_paths.append(opp) + ospf_paths.append(md5kp) + + secret_paths = secret_paths + ospf_paths + + return secret_paths + +def __strip_private(ct, secret_paths): + for sp in secret_paths: + if "base_path" in sp: + if ct.exists(sp["base_path"]): + for n in ct.list_nodes(sp["base_path"]): + if ct.exists(sp["base_path"] + [n] + sp["secret_path"]): + secret = ct.return_value(sp["base_path"] + [n] + sp["secret_path"]) + ct.set(sp["base_path"] + [n] + sp["secret_path"], value=sp["func"](secret)) + elif "path" in sp: + if ct.exists(sp["path"]): + secret = ct.return_value(sp["path"]) + ct.set(sp["path"], value=sp["func"](secret)) + else: + raise ValueError("Malformed secret path dict, has neither base_path nor path in it ") + + return ct.to_string() + +def strip_config_source(config_source): + config_tree = vyos.configtree.ConfigTree(config_source) + secret_paths = __prepare_secret_paths(config_tree, __secret_paths) + stripped_config = __strip_private(config_tree, secret_paths) + + return stripped_config + +def strip_config_tree(config_tree): + secret_paths = __prepare_secret_paths(config_tree, __secret_paths) + return __strip_private(config_tree, secret_paths) diff --git a/src/op_mode/tech_support.py b/src/op_mode/tech_support.py new file mode 100644 index 000000000..f60bb87ff --- /dev/null +++ b/src/op_mode/tech_support.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +import json + +import vyos.opmode + +from vyos.utils.process import cmd + +def _get_version_data(): + from vyos.version import get_version_data + return get_version_data() + +def _get_uptime(): + from vyos.utils.system import get_uptime_seconds + + return get_uptime_seconds() + +def _get_load_average(): + from vyos.utils.system import get_load_averages + + return get_load_averages() + +def _get_cpus(): + from vyos.utils.cpu import get_cpus + + return get_cpus() + +def _get_process_stats(): + return cmd('top --iterations 1 --batch-mode --accum-time-toggle') + +def _get_storage(): + from vyos.utils.disk import get_persistent_storage_stats + + return get_persistent_storage_stats() + +def _get_devices(): + devices = {} + devices["pci"] = cmd("lspci") + devices["usb"] = cmd("lsusb") + + return devices + +def _get_memory(): + from vyos.utils.file import read_file + + return read_file("/proc/meminfo") + +def _get_processes(): + res = cmd("ps aux") + + return res + +def _get_interrupts(): + from vyos.utils.file import read_file + + interrupts = read_file("/proc/interrupts") + softirqs = read_file("/proc/softirqs") + + return (interrupts, softirqs) + +def _get_partitions(): + # XXX: as of parted 3.5, --json is completely broken + # and cannot be used (outputs malformed JSON syntax) + res = cmd(f"parted --list") + + return res + +def _get_running_config(): + from os import getpid + from vyos.configsession import ConfigSession + from vyos.utils.strip_config import strip_config_source + + c = ConfigSession(getpid()) + return strip_config_source(c.show_config([])) + +def _get_boot_config(): + from vyos.utils.file import read_file + from vyos.utils.strip_config import strip_config_source + + config = read_file('/opt/vyatta/etc/config.boot.default') + + return strip_config_source(config) + +def _get_config_scripts(): + from os import listdir + from os.path import join + from vyos.utils.file import read_file + + scripts = [] + + dir = '/config/scripts' + for f in listdir(dir): + script = {} + path = join(dir, f) + data = read_file(path) + script["path"] = path + script["data"] = data + + scripts.append(script) + + return scripts + +def _get_nic_data(): + from vyos.utils.process import ip_cmd + link_data = ip_cmd("link show") + addr_data = ip_cmd("address show") + + return link_data, addr_data + +def _get_routes(proto): + from json import loads + from vyos.utils.process import ip_cmd + + # Only include complete routing tables if they are not too large + # At the moment "too large" is arbitrarily set to 1000 + MAX_ROUTES = 1000 + + data = {} + + summary = cmd(f"vtysh -c 'show {proto} route summary json'") + summary = loads(summary) + + data["summary"] = summary + + if summary["routesTotal"] < MAX_ROUTES: + rib_routes = cmd(f"vtysh -c 'show {proto} route json'") + data["routes"] = loads(rib_routes) + + if summary["routesTotalFib"] < MAX_ROUTES: + ip_proto = "-4" if proto == "ip" else "-6" + fib_routes = ip_cmd(f"{ip_proto} route show") + data["fib_routes"] = fib_routes + + return data + +def _get_ip_routes(): + return _get_routes("ip") + +def _get_ipv6_routes(): + return _get_routes("ipv6") + +def _get_ospfv2(): + # XXX: OSPF output when it's not configured is an empty string, + # which is not a valid JSON + output = cmd("vtysh -c 'show ip ospf json'") + if output: + return json.loads(output) + else: + return {} + +def _get_ospfv3(): + output = cmd("vtysh -c 'show ipv6 ospf6 json'") + if output: + return json.loads(output) + else: + return {} + +def _get_bgp_summary(): + output = cmd("vtysh -c 'show bgp summary json'") + return json.loads(output) + +def _get_isis(): + output = cmd("vtysh -c 'show isis summary json'") + if output: + return json.loads(output) + else: + return {} + +def _get_arp_table(): + from json import loads + from vyos.utils.process import cmd + + arp_table = cmd("ip --json -4 neighbor show") + return loads(arp_table) + +def _get_ndp_table(): + from json import loads + + arp_table = cmd("ip --json -6 neighbor show") + return loads(arp_table) + +def _get_nftables_rules(): + nft_rules = cmd("nft list ruleset") + return nft_rules + +def _get_connections(): + from vyos.utils.process import cmd + + return cmd("ss -apO") + +def _get_system_packages(): + from re import split + from vyos.utils.process import cmd + + dpkg_out = cmd(''' dpkg-query -W -f='${Package} ${Version} ${Architecture} ${db:Status-Abbrev}\n' ''') + pkg_lines = split(r'\n+', dpkg_out) + + # Discard the header, it's five lines long + pkg_lines = pkg_lines[5:] + + pkgs = [] + + for pl in pkg_lines: + parts = split(r'\s+', pl) + pkg = {} + pkg["name"] = parts[0] + pkg["version"] = parts[1] + pkg["architecture"] = parts[2] + pkg["status"] = parts[3] + + pkgs.append(pkg) + + return pkgs + +def _get_image_info(): + from vyos.system.image import get_images_details + + return get_images_details() + +def _get_kernel_modules(): + from vyos.utils.kernel import lsmod + + return lsmod() + +def _get_last_logs(max): + from systemd import journal + + r = journal.Reader() + + # Set the reader to use logs from the current boot + r.this_boot() + + # Jump to the last logs + r.seek_tail() + + # Only get logs of INFO level or more urgent + r.log_level(journal.LOG_INFO) + + # Retrieve the entries + entries = [] + + # I couldn't find a way to just get last/first N entries, + # so we'll use the cursor directly. + num = max + while num >= 0: + je = r.get_previous() + entry = {} + + # Extract the most useful and serializable fields + entry["timestamp"] = je.get("SYSLOG_TIMESTAMP") + entry["pid"] = je.get("SYSLOG_PID") + entry["identifier"] = je.get("SYSLOG_IDENTIFIER") + entry["facility"] = je.get("SYSLOG_FACILITY") + entry["systemd_unit"] = je.get("_SYSTEMD_UNIT") + entry["message"] = je.get("MESSAGE") + + entries.append(entry) + + num = num - 1 + + return entries + + +def _get_raw_data(): + data = {} + + # VyOS-specific information + data["vyos"] = {} + + ## The equivalent of "show version" + from vyos.version import get_version_data + data["vyos"]["version"] = _get_version_data() + + ## Installed images + data["vyos"]["images"] = _get_image_info() + + # System information + data["system"] = {} + + ## Uptime and load averages + data["system"]["uptime"] = _get_uptime() + data["system"]["load_average"] = _get_load_average() + data["system"]["process_stats"] = _get_process_stats() + + ## Debian packages + data["system"]["packages"] = _get_system_packages() + + ## Kernel modules + data["system"]["kernel"] = {} + data["system"]["kernel"]["modules"] = _get_kernel_modules() + + ## Processes + data["system"]["processes"] = _get_processes() + + ## Interrupts + interrupts, softirqs = _get_interrupts() + data["system"]["interrupts"] = interrupts + data["system"]["softirqs"] = softirqs + + # Hardware + data["hardware"] = {} + data["hardware"]["cpu"] = _get_cpus() + data["hardware"]["storage"] = _get_storage() + data["hardware"]["partitions"] = _get_partitions() + data["hardware"]["devices"] = _get_devices() + data["hardware"]["memory"] = _get_memory() + + # Configuration data + data["vyos"]["config"] = {} + + ## Running config text + ## We do not encode it so that it's possible to + ## see exactly what the user sees and detect any syntax/rendering anomalies — + ## exporting the config to JSON could obscure them + data["vyos"]["config"]["running"] = _get_running_config() + + ## Default boot config, exactly as in /config/config.boot + ## It may be different from the running config + ## _and_ may have its own syntax quirks that may point at bugs + data["vyos"]["config"]["boot"] = _get_boot_config() + + ## Config scripts + data["vyos"]["config"]["scripts"] = _get_config_scripts() + + # Network interfaces + data["network_interfaces"] = {} + + # Interface data from iproute2 + link_data, addr_data = _get_nic_data() + data["network_interfaces"]["links"] = link_data + data["network_interfaces"]["addresses"] = addr_data + + # Routing table data + data["routing"] = {} + data["routing"]["ip"] = _get_ip_routes() + data["routing"]["ipv6"] = _get_ipv6_routes() + + # Routing protocols + data["routing"]["ip"]["ospf"] = _get_ospfv2() + data["routing"]["ipv6"]["ospfv3"] = _get_ospfv3() + + data["routing"]["bgp"] = {} + data["routing"]["bgp"]["summary"] = _get_bgp_summary() + + data["routing"]["isis"] = _get_isis() + + # ARP and NDP neighbor tables + data["neighbor_tables"] = {} + data["neighbor_tables"]["arp"] = _get_arp_table() + data["neighbor_tables"]["ndp"] = _get_ndp_table() + + # nftables config + data["nftables_rules"] = _get_nftables_rules() + + # All connections + data["connections"] = _get_connections() + + # Logs + data["last_logs"] = _get_last_logs(1000) + + return data + +def show(raw: bool): + data = _get_raw_data() + if raw: + return data + else: + raise vyos.opmode.UnsupportedOperation("Formatted output is not implemented yet") + +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) + except (KeyboardInterrupt, BrokenPipeError): + sys.exit(1) -- cgit v1.2.3