diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/conf_mode/interfaces_wireless.py | 29 | ||||
-rwxr-xr-x | src/conf_mode/nat_cgnat.py | 110 | ||||
-rwxr-xr-x | src/conf_mode/service_monitoring_telegraf.py | 18 | ||||
-rwxr-xr-x | src/op_mode/firewall.py | 85 | ||||
-rwxr-xr-x | src/op_mode/qos.py | 242 | ||||
-rw-r--r-- | src/op_mode/show_techsupport_report.py | 23 |
6 files changed, 450 insertions, 57 deletions
diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py index 998ff9dba..73944dc8b 100755 --- a/src/conf_mode/interfaces_wireless.py +++ b/src/conf_mode/interfaces_wireless.py @@ -104,6 +104,15 @@ def get_config(config=None): tmp = {'security': {'wpa': {'cipher' : ['CCMP']}}} elif wpa_mode == 'both': tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'TKIP']}}} + elif wpa_mode == 'wpa3': + # According to WiFi specs (https://www.wi-fi.org/file/wpa3-specification) + # section 3.5: WPA3-Enterprise 192-bit mode + # WiFi NICs which would be able to connect to WPA3-Enterprise managed + # networks MUST support GCMP-256. + # Reasoning: Provided that chipsets would most likely _not_ be + # "private user only", they all would come with built-in support + # for GCMP-256. + tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'CCMP-256', 'GCMP', 'GCMP-256']}}} if tmp: wifi = dict_merge(tmp, wifi) @@ -143,6 +152,23 @@ def verify(wifi): if 'channel' not in wifi: raise ConfigError('Wireless channel must be configured!') + if 'capabilities' in wifi and 'he' in wifi['capabilities']: + if 'channel_set_width' not in wifi['capabilities']['he']: + raise ConfigError('Channel width must be configured!') + + # op_modes drawn from: + # https://w1.fi/cgit/hostap/tree/src/common/ieee802_11_common.c?id=195cc3d919503fb0d699d9a56a58a72602b25f51#n1525 + # 802.11ax (WiFi-6e - HE) can use up to 160MHz bandwidth channels + six_ghz_op_modes_he = ['131', '132', '133', '134', '135'] + # 802.11be (WiFi-7 - EHT) can use up to 320MHz bandwidth channels + six_ghz_op_modes_eht = six_ghz_op_modes_he.append('137') + if 'security' in wifi and 'wpa' in wifi['security'] and 'mode' in wifi['security']['wpa']: + if wifi['security']['wpa']['mode'] == 'wpa3': + if 'he' in wifi['capabilities']: + if wifi['capabilities']['he']['channel_set_width'] in six_ghz_op_modes_he: + if 'mgmt_frame_protection' not in wifi or wifi['mgmt_frame_protection'] != 'required': + raise ConfigError('Management Frame Protection (MFP) is required with WPA3 at 6GHz! Consider also enabling Beacon Frame Protection (BFP) if your device supports it.') + if 'security' in wifi: if {'wep', 'wpa'} <= set(wifi.get('security', {})): raise ConfigError('Must either use WEP or WPA security!') @@ -176,7 +202,8 @@ def verify(wifi): if capabilities['vht']['beamform'] == 'single-user-beamformer': if int(capabilities['vht']['antenna_count']) < 3: - # Nasty Gotcha: see https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf lines 692-705 + # Nasty Gotcha: see lines 708-721 in: + # https://w1.fi/cgit/hostap/tree/hostapd/hostapd.conf?h=hostap_2_10&id=cff80b4f7d3c0a47c052e8187d671710f48939e4#n708 raise ConfigError('Single-user beam former requires at least 3 antennas!') if 'station_interfaces' in wifi and wifi['type'] == 'station': diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py index 34ec64fce..3484e5873 100755 --- a/src/conf_mode/nat_cgnat.py +++ b/src/conf_mode/nat_cgnat.py @@ -119,37 +119,34 @@ class IPOperations: + [self.ip_network.broadcast_address] ] - def get_prefix_by_ip_range(self): + def get_prefix_by_ip_range(self) -> list[ipaddress.IPv4Network]: """Return the common prefix for the address range Example: % ip = IPOperations('100.64.0.1-100.64.0.5') % ip.get_prefix_by_ip_range() - 100.64.0.0/29 + [IPv4Network('100.64.0.1/32'), IPv4Network('100.64.0.2/31'), IPv4Network('100.64.0.4/31')] """ - if '-' in self.ip_prefix: - ip_start, ip_end = self.ip_prefix.split('-') - start_ip = ipaddress.IPv4Address(ip_start.strip()) - end_ip = ipaddress.IPv4Address(ip_end.strip()) - - start_int = int(start_ip) - end_int = int(end_ip) - - # XOR to find differing bits - xor = start_int ^ end_int - - # Count the number of leading zeros in the XOR result to find the prefix length - prefix_length = 32 - xor.bit_length() - - # Calculate the network address - network_int = start_int & (0xFFFFFFFF << (32 - prefix_length)) - network_address = ipaddress.IPv4Address(network_int) + # We do not need to convert the IP range to network + # if it is already in network format + if self.ip_network: + return [self.ip_network] + + # Raise an error if the IP range is not in the correct format + if '-' not in self.ip_prefix: + raise ValueError( + 'Invalid IP range format. Please provide the IP range in CIDR format or with "-" separator.' + ) + # Split the IP range and convert it to IP address objects + range_start, range_end = self.ip_prefix.split('-') + range_start = ipaddress.IPv4Address(range_start) + range_end = ipaddress.IPv4Address(range_end) - return f"{network_address}/{prefix_length}" - return self.ip_prefix + # Return the summarized IP networks list + return list(ipaddress.summarize_address_range(range_start, range_end)) -def _delete_conntrack_entries(source_prefixes: list) -> None: +def _delete_conntrack_entries(source_prefixes: list[ipaddress.IPv4Network]) -> None: """Delete all conntrack entries for the list of prefixes""" for source_prefix in source_prefixes: run(f'conntrack -D -s {source_prefix}') @@ -224,15 +221,31 @@ def get_config(config=None): with_recursive_defaults=True, ) - if conf.exists(base) and is_node_changed(conf, base + ['pool']): - config.update({'delete_conntrack_entries': {}}) + effective_config = conf.get_config_dict( + base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + effective=True, + ) + + # Check if the pool configuration has changed + if not conf.exists(base) or is_node_changed(conf, base + ['pool']): + config['delete_conntrack_entries'] = {} + + # add running config + if effective_config: + config['effective'] = effective_config + + if not conf.exists(base): + config['deleted'] = {} return config def verify(config): # bail out early - looks like removal from running config - if not config: + if 'deleted' in config: return None if 'pool' not in config: @@ -336,7 +349,7 @@ def verify(config): def generate(config): - if not config: + if 'deleted' in config: return None proto_maps = [] @@ -401,13 +414,38 @@ def generate(config): def apply(config): - if not config: + if 'deleted' in config: # Cleanup cgnat cmd('nft delete table ip cgnat') if os.path.isfile(nftables_cgnat_config): os.unlink(nftables_cgnat_config) - return None - cmd(f'nft --file {nftables_cgnat_config}') + else: + cmd(f'nft --file {nftables_cgnat_config}') + + # Delete conntrack entries + # if the pool configuration has changed + if 'delete_conntrack_entries' in config and 'effective' in config: + # Prepare the list of internal pool prefixes + internal_pool_prefix_list: list[ipaddress.IPv4Network] = [] + + # Get effective rules configurations + for rule_config in config['effective'].get('rule', {}).values(): + # Get effective internal pool configuration + internal_pool = rule_config['source']['pool'] + # Find the internal IP ranges for the internal pool + internal_ip_ranges: list[str] = config['effective']['pool']['internal'][ + internal_pool + ]['range'] + # Get the IP prefixes for the internal IP range + for internal_range in internal_ip_ranges: + ip_prefix: list[ipaddress.IPv4Network] = IPOperations( + internal_range + ).get_prefix_by_ip_range() + # Add the IP prefixes to the list of all internal pool prefixes + internal_pool_prefix_list += ip_prefix + + # Delete required sources for conntrack + _delete_conntrack_entries(internal_pool_prefix_list) # Logging allocations if 'log_allocation' in config: @@ -420,23 +458,11 @@ def apply(config): external_host, port_range = rest.split(' . ') # Log the parsed data logger.info( - f"Internal host: {internal_host.lstrip()}, external host: {external_host}, Port range: {port_range}") + f'Internal host: {internal_host.lstrip()}, external host: {external_host}, Port range: {port_range}') except ValueError as e: # Log error message logger.error(f"Error processing line '{allocation}': {e}") - # Delete conntrack entries - if 'delete_conntrack_entries' in config: - internal_pool_prefix_list = [] - for rule, rule_config in config['rule'].items(): - internal_pool = rule_config['source']['pool'] - internal_ip_ranges: list = config['pool']['internal'][internal_pool]['range'] - for internal_range in internal_ip_ranges: - ip_prefix = IPOperations(internal_range).get_prefix_by_ip_range() - internal_pool_prefix_list.append(ip_prefix) - # Deleta required sources for conntrack - _delete_conntrack_entries(internal_pool_prefix_list) - if __name__ == '__main__': try: diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index 40eb13e23..9455b6109 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright (C) 2021-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 @@ -113,6 +113,9 @@ def get_config(config=None): if not conf.exists(base + ['azure-data-explorer']): del monitoring['azure_data_explorer'] + if not conf.exists(base + ['loki']): + del monitoring['loki'] + return monitoring def verify(monitoring): @@ -159,6 +162,19 @@ def verify(monitoring): if 'url' not in monitoring['splunk']: raise ConfigError(f'Monitoring splunk "url" is mandatory!') + # Verify Loki + if 'loki' in monitoring: + if 'url' not in monitoring['loki']: + raise ConfigError(f'Monitoring loki "url" is mandatory!') + if 'authentication' in monitoring['loki']: + if ( + 'username' not in monitoring['loki']['authentication'] + or 'password' not in monitoring['loki']['authentication'] + ): + raise ConfigError( + f'Authentication "username" and "password" are mandatory!' + ) + return None def generate(monitoring): diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index 15fbb65a2..c197ca434 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -63,10 +63,10 @@ def get_nftables_details(family, hook, priority): aux='' if hook == 'name' or hook == 'ipv6-name': - command = f'sudo nft list chain {suffix} vyos_filter {name_prefix}{priority}' + command = f'nft list chain {suffix} vyos_filter {name_prefix}{priority}' else: up_hook = hook.upper() - command = f'sudo nft list chain {suffix} vyos_filter VYOS_{aux}{up_hook}_{priority}' + command = f'nft list chain {suffix} vyos_filter VYOS_{aux}{up_hook}_{priority}' try: results = cmd(command) @@ -90,12 +90,42 @@ def get_nftables_details(family, hook, priority): out[rule_id] = rule return out +def get_nftables_state_details(family): + if family == 'ipv6': + suffix = 'ip6' + name_suffix = 'POLICY6' + elif family == 'ipv4': + suffix = 'ip' + name_suffix = 'POLICY' + else: + # no state policy for bridge + return {} + + command = f'nft list chain {suffix} vyos_filter VYOS_STATE_{name_suffix}' + try: + results = cmd(command) + except: + return {} + + out = {} + for line in results.split('\n'): + rule = {} + for state in ['established', 'related', 'invalid']: + if state in line: + counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line) + if counter_search: + rule['packets'] = counter_search[1] + rule['bytes'] = counter_search[2] + rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip() + out[state] = rule + return out + def get_nftables_group_members(family, table, name): prefix = 'ip6' if family == 'ipv6' else 'ip' out = [] try: - results_str = cmd(f'sudo nft -j list set {prefix} {table} {name}') + results_str = cmd(f'nft -j list set {prefix} {table} {name}') results = json.loads(results_str) except: return out @@ -172,6 +202,34 @@ def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=N rows[rows.index(i)].pop(1) print(tabulate.tabulate(rows, header) + '\n') +def output_firewall_state_policy(family): + if family == 'bridge': + return {} + print(f'\n---------------------------------\n{family} State Policy\n') + + details = get_nftables_state_details(family) + rows = [] + + for state, state_conf in details.items(): + row = [state, state_conf['conditions']] + row.append(state_conf.get('packets', 0)) + row.append(state_conf.get('bytes', 0)) + row.append(state_conf.get('conditions')) + rows.append(row) + + if rows: + if args.rule: + rows.pop() + + if args.detail: + header = ['State', 'Conditions', 'Packets', 'Bytes'] + output_firewall_vertical(rows, header) + else: + header = ['State', 'Packets', 'Bytes', 'Conditions'] + for i in rows: + rows[rows.index(i)].pop(1) + print(tabulate.tabulate(rows, header) + '\n') + def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule_id=None): print(f'\n---------------------------------\n{family} Firewall "{hook} {prior}"\n') @@ -305,6 +363,10 @@ def show_firewall(): return for family in ['ipv4', 'ipv6', 'bridge']: + if 'global_options' in firewall: + if 'state_policy' in firewall['global_options']: + output_firewall_state_policy(family) + if family in firewall: for hook, hook_conf in firewall[family].items(): for prior, prior_conf in firewall[family][hook].items(): @@ -316,12 +378,17 @@ def show_firewall_family(family): conf = Config() firewall = get_config_node(conf) - if not firewall or family not in firewall: + if not firewall: return - for hook, hook_conf in firewall[family].items(): - for prior, prior_conf in firewall[family][hook].items(): - output_firewall_name(family, hook, prior, prior_conf) + if 'global_options' in firewall: + if 'state_policy' in firewall['global_options']: + output_firewall_state_policy(family) + + if family in firewall: + for hook, hook_conf in firewall[family].items(): + for prior, prior_conf in firewall[family][hook].items(): + output_firewall_name(family, hook, prior, prior_conf) def show_firewall_name(family, hook, priority): print('Ruleset Information') @@ -622,6 +689,10 @@ def show_statistics(): return for family in ['ipv4', 'ipv6', 'bridge']: + if 'global_options' in firewall: + if 'state_policy' in firewall['global_options']: + output_firewall_state_policy(family) + if family in firewall: for hook, hook_conf in firewall[family].items(): for prior, prior_conf in firewall[family][hook].items(): diff --git a/src/op_mode/qos.py b/src/op_mode/qos.py new file mode 100755 index 000000000..b8ca149a0 --- /dev/null +++ b/src/op_mode/qos.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# This script parses output from the 'tc' command and provides table or list output +import sys +import typing +import json +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import op_mode_config_dict +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_tc_info(interface_dict, interface_name, policy_type): + policy_name = interface_dict.get(interface_name, {}).get('egress') + if not policy_name: + return None, None + + class_dict = op_mode_config_dict(['qos', 'policy', policy_type, policy_name], key_mangling=('-', '_'), + get_first_key=True) + if not class_dict: + return None, None + + return policy_name, class_dict + +def format_data_type(num, suffix): + if num < 10**3: + return f"{num} {suffix}" + elif num < 10**6: + return f"{num / 10**3:.3f} K{suffix}" + elif num < 10**9: + return f"{num / 10**6:.3f} M{suffix}" + elif num < 10**12: + return f"{num / 10**9:.3f} G{suffix}" + elif num < 10**15: + return f"{num / 10**12:.3f} T{suffix}" + elif num < 10**18: + return f"{num / 10**15:.3f} P{suffix}" + else: + return f"{num / 10**18:.3f} E{suffix}" + +def show_shaper(raw: bool, ifname: typing.Optional[str], classn: typing.Optional[str], detail: bool): + # Scope which interfaces will output data + if ifname: + if not interface_exists(ifname): + raise vyos.opmode.Error(f"{ifname} does not exist!") + + interface_dict = {ifname: op_mode_config_dict(['qos', 'interface', ifname], key_mangling=('-', '_'), + get_first_key=True)} + if not interface_dict[ifname]: + raise vyos.opmode.Error(f"QoS is not applied to {ifname}!") + + else: + interface_dict = op_mode_config_dict(['qos', 'interface'], key_mangling=('-', '_'), + get_first_key=True) + if not interface_dict: + raise vyos.opmode.Error(f"QoS is not applied to any interface!") + + + raw_dict = {'qos': {}} + for i in interface_dict.keys(): + interface_name = i + output_list = [] + output_dict = {'classes': {}} + raw_dict['qos'][interface_name] = {} + + # Get configuration node data + policy_name, class_dict = get_tc_info(interface_dict, interface_name, 'shaper') + if not policy_name: + continue + + class_data = json.loads(cmd(f"tc -j -s class show dev {i}")) + qdisc_data = json.loads(cmd(f"tc -j qdisc show dev {i}")) + + if class_dict: + # Gather qdisc information (e.g. Queue Type) + qdisc_dict = {} + for qdisc in qdisc_data: + if qdisc.get('root'): + qdisc_dict['root'] = qdisc + continue + + class_id = int(qdisc.get('parent').split(':')[1], 16) + + if class_dict.get('class', {}).get(str(class_id)): + qdisc_dict[str(class_id)] = qdisc + else: + qdisc_dict['default'] = qdisc + + # Gather class information + for classes in class_data: + if classes.get('rate'): + class_id = int(classes.get('handle').split(':')[1], 16) + + # Get name of class + if classes.get('root'): + class_name = 'root' + output_dict['classes'][class_name] = {} + elif class_dict.get('class', {}).get(str(class_id)): + class_name = str(class_id) + output_dict['classes'][class_name] = {} + else: + class_name = 'default' + output_dict['classes'][class_name] = {} + + if classn: + if classn != class_name and class_name != 'default' and class_name != 'root': + output_dict['classes'].pop(class_name, None) + continue + + tmp = output_dict['classes'][class_name] + + tmp['interface_name'] = interface_name + tmp['policy_name'] = policy_name + tmp['direction'] = 'egress' + tmp['class_name'] = class_name + tmp['queue_type'] = qdisc_dict.get(class_name, {}).get('kind') + tmp['rate'] = str(round(int(classes.get('rate'))*8)) + tmp['ceil'] = str(round(int(classes.get('ceil'))*8)) + tmp['bytes'] = classes.get('stats', {}).get('bytes', 0) + tmp['packets'] = classes.get('stats', {}).get('packets', 0) + tmp['drops'] = classes.get('stats', {}).get('drops', 0) + tmp['queued'] = classes.get('stats', {}).get('backlog', 0) + tmp['overlimits'] = classes.get('stats', {}).get('overlimits', 0) + tmp['requeues'] = classes.get('stats', {}).get('requeues', 0) + tmp['lended'] = classes.get('stats', {}).get('lended', 0) + tmp['borrowed'] = classes.get('stats', {}).get('borrowed', 0) + tmp['giants'] = classes.get('stats', {}).get('giants', 0) + + output_dict['classes'][class_name] = tmp + raw_dict['qos'][interface_name][class_name] = tmp + + # Skip printing of values for this interface. All interfaces will be returned in a single dictionary if 'raw' is called + if raw: + continue + + # Default class may be out of order in original JSON. This moves it to the end + move_default = output_dict.get('classes', {}).pop('default', None) + if move_default: + output_dict.get('classes')['default'] = move_default + + # Create the tables for outputs + for output in output_dict.get('classes'): + data = output_dict.get('classes').get(output) + + # Add values for detailed (list view) output + if detail: + output_list.append([data['interface_name'], + data['policy_name'], + data['direction'], + data['class_name'], + data['queue_type'], + data['rate'], + data['ceil'], + data['bytes'], + data['packets'], + data['drops'], + data['queued'], + data['overlimits'], + data['requeues'], + data['lended'], + data['borrowed'], + data['giants']] + ) + # Add values for normal (table view) output + else: + output_list.append([data['class_name'], + data['queue_type'], + format_data_type(int(data['rate']), 'b'), + format_data_type(int(data['ceil']), 'b'), + format_data_type(int(data['bytes']), 'B'), + data['packets'], + data['drops'], + data['queued']] + ) + + if output_list: + if detail: + # Headers for detailed (list view) output + headers = ['Interface', 'Policy Name', 'Direction', 'Class', 'Type', 'Bandwidth', 'Max. BW', 'Bytes', 'Packets', 'Drops', 'Queued', 'Overlimit', 'Requeue', 'Lended', 'Borrowed', 'Giants'] + + print('-' * 35) + print(f"Interface: {interface_name}") + print(f"Policy Name: {policy_name}\n") + detailed_output(output_list, headers) + else: + # Headers for table output + headers = ['Class', 'Type', 'Bandwidth', 'Max. BW', 'Bytes', 'Pkts', 'Drops', 'Queued'] + align = ('left','left','right','right','right','right','right','right') + + print('-' * 80) + print(f"Interface: {interface_name}") + print(f"Policy Name: {policy_name}\n") + print(tabulate(output_list, headers, colalign=align)) + print(" \n") + + # Return dictionary with all interfaces if 'raw' is called + if raw: + return raw_dict + +def show_cake(raw: bool, ifname: typing.Optional[str]): + if not interface_exists(ifname): + raise vyos.opmode.Error(f"{ifname} does not exist!") + + cake_data = json.loads(cmd(f"tc -j -s qdisc show dev {ifname}"))[0] + if cake_data: + if cake_data.get('kind') == 'cake': + if raw: + return {'qos': {ifname: cake_data}} + else: + print(cmd(f"tc -s qdisc show dev {ifname}")) + +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/show_techsupport_report.py b/src/op_mode/show_techsupport_report.py index 230fb252d..32cf67778 100644 --- a/src/op_mode/show_techsupport_report.py +++ b/src/op_mode/show_techsupport_report.py @@ -14,10 +14,12 @@ # 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 sys from typing import List -from vyos.utils.process import rc_cmd from vyos.ifconfig import Section from vyos.ifconfig import Interface +from vyos.utils.process import rc_cmd def print_header(command: str) -> None: @@ -50,7 +52,15 @@ def execute_command(command: str, header_text: str) -> None: print_header(header_text) try: rc, output = rc_cmd(command) - print(output) + # 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}") @@ -155,13 +165,13 @@ def show_route() -> None: "show ip route supernets-only", "show ip route table all", "show ip route vrf all", - "show ipv6 route bgp | head 108", + "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 ospf", + "show ipv6 route ospfv3", "show ipv6 route rip", "show ipv6 route static", "show ipv6 route summary", @@ -179,8 +189,9 @@ def show_firewall() -> None: def show_system() -> None: """Prints system parameters.""" - execute_command(op('show system image version'), 'Show System Image Version') - execute_command(op('show system image storage'), 'Show System Image Storage') + 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_date() -> None: |