diff options
| author | kumvijaya <kuvmijaya@gmail.com> | 2024-09-26 11:31:07 +0530 |
|---|---|---|
| committer | kumvijaya <kuvmijaya@gmail.com> | 2024-09-26 11:31:07 +0530 |
| commit | a950059053f7394acfb453cc0d8194aa3dc721fa (patch) | |
| tree | eb0acf278f649b5d1417e18e34d728efcd16e745 /src/op_mode | |
| parent | f0815f3e9b212f424f5adb0c572a71119ad4a8a0 (diff) | |
| download | vyos-workflow-test-temp-a950059053f7394acfb453cc0d8194aa3dc721fa.tar.gz vyos-workflow-test-temp-a950059053f7394acfb453cc0d8194aa3dc721fa.zip | |
T6732: added same as vyos 1x
Diffstat (limited to 'src/op_mode')
113 files changed, 18090 insertions, 0 deletions
diff --git a/src/op_mode/accelppp.py b/src/op_mode/accelppp.py new file mode 100644 index 0000000..67ce786 --- /dev/null +++ b/src/op_mode/accelppp.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import sys + +import vyos.accel_ppp +import vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import rc_cmd + + +accel_dict = { + 'ipoe': { + 'port': 2002, + 'path': 'service ipoe-server', + 'base_path': 'service ipoe-server' + }, + 'pppoe': { + 'port': 2001, + 'path': 'service pppoe-server', + 'base_path': 'service pppoe-server' + }, + 'pptp': { + 'port': 2003, + 'path': 'vpn pptp', + 'base_path': 'vpn pptp' + }, + 'l2tp': { + 'port': 2004, + 'path': 'vpn l2tp', + 'base_path': 'vpn l2tp remote-access' + }, + 'sstp': { + 'port': 2005, + 'path': 'vpn sstp', + 'base_path': 'vpn sstp' + } +} + + +def _get_config_settings(protocol): + '''Get config dict from VyOS configuration''' + conf = ConfigTreeQuery() + base_path = accel_dict[protocol]['base_path'] + data = conf.get_config_dict(base_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + if conf.exists(f'{base_path} authentication local-users'): + # Delete sensitive data + del data['authentication']['local_users'] + return {'config_option': data} + + +def _get_raw_statistics(accel_output, pattern, protocol): + return { + **vyos.accel_ppp.get_server_statistics(accel_output, pattern, sep=':'), + **_get_config_settings(protocol) + } + + +def _get_raw_sessions(port): + cmd_options = 'show sessions ifname,username,ip,ip6,ip6-dp,type,rate-limit,' \ + 'state,uptime-raw,calling-sid,called-sid,sid,comp,rx-bytes-raw,' \ + 'tx-bytes-raw,rx-pkts,tx-pkts' + output = vyos.accel_ppp.accel_cmd(port, cmd_options) + parsed_data: list[dict[str, str]] = vyos.accel_ppp.accel_out_parse( + output.splitlines()) + return parsed_data + + +def _verify(func): + """Decorator checks if accel-ppp protocol + ipoe/pppoe/pptp/l2tp/sstp is configured + + for example: + service ipoe-server + vpn sstp + """ + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + protocol_list = accel_dict.keys() + protocol = kwargs.get('protocol') + # unknown or incorrect protocol query + if protocol not in protocol_list: + unconf_message = f'unknown protocol "{protocol}"' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + # Check if config does not exist + config_protocol_path = accel_dict[protocol]['path'] + if not config.exists(config_protocol_path): + unconf_message = f'"{config_protocol_path}" is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + + return _wrapper + + +@_verify +def show_statistics(raw: bool, protocol: str): + """show accel-cmd statistics + CPU utilization and amount of sessions + + protocol: ipoe/pppoe/ppptp/l2tp/sstp + """ + pattern = f'{protocol}:' + port = accel_dict[protocol]['port'] + rc, output = rc_cmd(f'/usr/bin/accel-cmd -p {port} show stat') + + if raw: + return _get_raw_statistics(output, pattern, protocol) + + return output + + +@_verify +def show_sessions(raw: bool, protocol: str): + """show accel-cmd sessions + + protocol: ipoe/pppoe/ppptp/l2tp/sstp + """ + port = accel_dict[protocol]['port'] + if raw: + return _get_raw_sessions(port) + + return vyos.accel_ppp.accel_cmd(port, + 'show sessions ifname,username,ip,ip6,ip6-dp,' + 'calling-sid,rate-limit,state,uptime,rx-bytes,tx-bytes') + + +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 new file mode 100644 index 0000000..096113c --- /dev/null +++ b/src/op_mode/bgp.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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/>. +# +# Purpose: +# Displays BGP neighbors and tables information. + +import re +import sys +import typing + +from jinja2 import Template + +import vyos.opmode + +frr_command_template = Template(""" +show bgp + +{## VRF and family modifiers that may precede any options ##} + +{% if vrf %} + vrf {{vrf}} +{% endif %} + +{% if family == "inet" %} + ipv4 +{% elif family == "inet6" %} + ipv6 +{% elif family == "l2vpn" %} + l2vpn evpn +{% endif %} + +{% if family_modifier == "unicast" %} + unicast +{% elif family_modifier == "multicast" %} + multicast +{% elif family_modifier == "flowspec" %} + flowspec +{% elif family_modifier == "vpn" %} + vpn +{% endif %} + +{## Mutually exclusive query parameters ##} + +{# Network prefix #} +{% if prefix %} + {{prefix}} + + {% if longer_prefixes %} + longer-prefixes + {% elif best_path %} + bestpath + {% endif %} +{% endif %} + +{# Regex #} +{% if regex %} + regex {{regex}} +{% endif %} + +{## Raw modifier ##} + +{% if raw %} + json +{% endif %} +""") + +ArgFamily = typing.Literal['inet', 'inet6', 'l2vpn'] +ArgFamilyModifier = typing.Literal['unicast', 'labeled_unicast', 'multicast', 'vpn', 'flowspec'] + +def show_summary(raw: bool): + from vyos.utils.process import cmd + + if raw: + from json import loads + + output = cmd(f"vtysh -c 'show bgp summary json'").strip() + + # FRR 8.5 correctly returns an empty object when BGP is not running, + # we don't need to do anything special here + return loads(output) + else: + output = cmd(f"vtysh -c 'show bgp summary'") + return output + +def show_neighbors(raw: bool): + from vyos.utils.process import cmd + from vyos.utils.dict import dict_to_list + + if raw: + from json import loads + + output = cmd(f"vtysh -c 'show bgp neighbors json'").strip() + d = loads(output) + return dict_to_list(d, save_key_to="neighbor") + else: + output = cmd(f"vtysh -c 'show bgp neighbors'") + return output + +def show(raw: bool, + family: ArgFamily, + family_modifier: ArgFamilyModifier, + prefix: typing.Optional[str], + longer_prefixes: typing.Optional[bool], + best_path: typing.Optional[bool], + regex: typing.Optional[str], + vrf: typing.Optional[str]): + from vyos.utils.dict import dict_to_list + + if (longer_prefixes or best_path) and (prefix is None): + raise ValueError("longer_prefixes and best_path can only be used when prefix is given") + elif (family == "l2vpn") and (family_modifier is not None): + raise ValueError("l2vpn family does not accept any modifiers") + else: + kwargs = dict(locals()) + + frr_command = frr_command_template.render(kwargs) + frr_command = re.sub(r'\s+', ' ', frr_command) + + from vyos.utils.process import cmd + output = cmd(f"vtysh -c '{frr_command}'") + + if raw: + from json import loads + d = loads(output) + if not ("routes" in d): + raise vyos.opmode.InternalError("FRR returned a BGP table with no routes field") + d = d["routes"] + routes = dict_to_list(d, save_key_to="route_key") + return routes + else: + return output + +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/bonding.py b/src/op_mode/bonding.py new file mode 100644 index 0000000..07bccbd --- /dev/null +++ b/src/op_mode/bonding.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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 will parse 'sudo cat /proc/net/bonding/<interface name>' and return table output for lacp related info + +import subprocess +import re +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +def list_to_dict(data, headers, basekey): + data_list = {basekey: []} + + for row in data: + row_dict = {headers[i]: row[i] for i in range(len(headers))} + data_list[basekey].append(row_dict) + + return data_list + +def show_lacp_neighbors(raw: bool, interface: typing.Optional[str]): + headers = ["Interface", "Member", "Local ID", "Remote ID"] + data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8') + if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data: + raise vyos.opmode.DataUnavailable(f"{interface} is not present or not configured with mode 802.3ad") + + pattern = re.compile( + r"Slave Interface: (?P<member>\w+\d+).*?" + r"system mac address: (?P<local_id>[0-9a-f:]+).*?" + r"details partner lacp pdu:.*?" + r"system mac address: (?P<remote_id>[0-9a-f:]+)", + re.DOTALL + ) + + interfaces = [] + + for match in re.finditer(pattern, data): + member = match.group("member") + local_id = match.group("local_id") + remote_id = match.group("remote_id") + interfaces.append([interface, member, local_id, remote_id]) + + if raw: + return list_to_dict(interfaces, headers, 'lacp') + else: + return tabulate(interfaces, headers) + +def show_lacp_detail(raw: bool, interface: typing.Optional[str]): + headers = ["Interface", "Members", "Mode", "Rate", "System-MAC", "Hash"] + query = ConfigTreeQuery() + + if interface: + intList = [interface] + else: + intList = query.list_nodes(['interfaces', 'bonding']) + + bondList = [] + + for interface in intList: + data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8') + if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data: + continue + + mode_active = "active" if "LACP active: on" in data else "passive" + lacp_rate = re.search(r"LACP rate: (\w+)", data).group(1) if re.search(r"LACP rate: (\w+)", data) else "N/A" + hash_policy = re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data).group(1) if re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data) else "N/A" + system_mac = re.search(r"System MAC address: ([0-9a-f:]+)", data).group(1) if re.search(r"System MAC address: ([0-9a-f:]+)", data) else "N/A" + if raw: + members = re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data) + else: + members = ",".join(set(re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data))) + + bondList.append([interface, members, mode_active, lacp_rate, system_mac, hash_policy]) + + if raw: + return list_to_dict(bondList, headers, 'lacp') + else: + return tabulate(bondList, headers) + +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/bridge.py b/src/op_mode/bridge.py new file mode 100644 index 0000000..e80b1c2 --- /dev/null +++ b/src/op_mode/bridge.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import jmespath +import json +import sys +import typing + +from tabulate import tabulate + +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.utils.process import call + +import vyos.opmode + +def _get_json_data(): + """ + Get bridge data format JSON + """ + return cmd(f'bridge --json link show') + + +def _get_raw_data_summary(): + """Get interested rules + :returns dict + """ + data = _get_json_data() + data_dict = json.loads(data) + return data_dict + + +def _get_raw_data_vlan(tunnel:bool=False): + """ + :returns dict + """ + show = 'show' + if tunnel: + show = 'tunnel' + json_data = cmd(f'bridge --json --compressvlans vlan {show}') + data_dict = json.loads(json_data) + return data_dict + +def _get_raw_data_vni() -> dict: + """ + :returns dict + """ + json_data = cmd(f'bridge --json vni show') + data_dict = json.loads(json_data) + return data_dict + +def _get_raw_data_fdb(bridge): + """Get MAC-address for the bridge brX + :returns list + """ + code, json_data = rc_cmd(f'bridge --json fdb show br {bridge}') + # From iproute2 fdb.c, fdb_show() will only exit(-1) in case of + # non-existent bridge device; raise error. + if code == 255: + raise vyos.opmode.UnconfiguredObject(f"bridge {bridge} does not exist in the system") + data_dict = json.loads(json_data) + return data_dict + + +def _get_raw_data_mdb(bridge): + """Get MAC-address multicast gorup for the bridge brX + :return list + """ + json_data = cmd(f'bridge --json mdb show br {bridge}') + data_dict = json.loads(json_data) + return data_dict + + +def _get_bridge_members(bridge: str) -> list: + """ + Get list of interface bridge members + :param bridge: str + :default: ['n/a'] + :return: list + """ + data = _get_raw_data_summary() + members = jmespath.search(f'[?master == `{bridge}`].ifname', data) + return [member for member in members] if members else ['n/a'] + + +def _get_member_options(bridge: str): + data = _get_raw_data_summary() + options = jmespath.search(f'[?master == `{bridge}`]', data) + return options + + +def _get_formatted_output_summary(data): + data_entries = '' + bridges = set(jmespath.search('[*].master', data)) + for bridge in bridges: + member_options = _get_member_options(bridge) + member_entries = [] + for option in member_options: + interface = option.get('ifname') + ifindex = option.get('ifindex') + state = option.get('state') + mtu = option.get('mtu') + flags = ','.join(option.get('flags')).lower() + prio = option.get('priority') + member_entries.append([interface, state, mtu, flags, prio]) + member_headers = ["Member", "State", "MTU", "Flags", "Prio"] + output_members = tabulate(member_entries, member_headers, numalign="left") + output_bridge = f"""Bridge interface {bridge}: +{output_members} + +""" + data_entries += output_bridge + output = data_entries + return output + + +def _get_formatted_output_vlan(data): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + vlans = entry.get('vlans') + for vlan_entry in vlans: + vlan = vlan_entry.get('vlan') + if vlan_entry.get('vlanEnd'): + vlan_end = vlan_entry.get('vlanEnd') + vlan = f'{vlan}-{vlan_end}' + flags_raw = vlan_entry.get('flags') + flags = ', '.join(flags_raw if isinstance(flags_raw,list) else "").lower() + data_entries.append([interface, vlan, flags]) + + headers = ["Interface", "VLAN", "Flags"] + output = tabulate(data_entries, headers) + return output + +def _get_formatted_output_vlan_tunnel(data): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + first = True + for tunnel_entry in entry.get('tunnels'): + vlan = tunnel_entry.get('vlan') + vni = tunnel_entry.get('tunid') + if first: + data_entries.append([interface, vlan, vni]) + first = False + else: + # Group by VXLAN interface only - no need to repeat + # VXLAN interface name for every VLAN <-> VNI mapping + # + # Interface VLAN VNI + # ----------- ------ ----- + # vxlan0 100 100 + # 200 200 + data_entries.append(['', vlan, vni]) + + headers = ["Interface", "VLAN", "VNI"] + output = tabulate(data_entries, headers) + return output + +def _get_formatted_output_vni(data): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + vlans = entry.get('vnis') + for vlan_entry in vlans: + vlan = vlan_entry.get('vni') + if vlan_entry.get('vniEnd'): + vlan_end = vlan_entry.get('vniEnd') + vlan = f'{vlan}-{vlan_end}' + data_entries.append([interface, vlan]) + + headers = ["Interface", "VNI"] + output = tabulate(data_entries, headers) + return output + +def _get_formatted_output_fdb(data): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + mac = entry.get('mac') + state = entry.get('state') + flags = ','.join(entry['flags']) + data_entries.append([interface, mac, state, flags]) + + headers = ["Interface", "Mac address", "State", "Flags"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def _get_formatted_output_mdb(data): + data_entries = [] + for entry in data: + for mdb_entry in entry['mdb']: + interface = mdb_entry.get('port') + group = mdb_entry.get('grp') + state = mdb_entry.get('state') + flags = ','.join(mdb_entry.get('flags')) + data_entries.append([interface, group, state, flags]) + headers = ["Interface", "Group", "State", "Flags"] + output = tabulate(data_entries, headers) + return output + +def _get_bridge_detail(iface): + """Get interface detail statistics""" + return call(f'vtysh -c "show interface {iface}"') + +def _get_bridge_detail_nexthop_group(iface): + """Get interface detail nexthop_group statistics""" + return call(f'vtysh -c "show interface {iface} nexthop-group"') + +def _get_bridge_detail_nexthop_group_raw(iface): + out = cmd(f'vtysh -c "show interface {iface} nexthop-group"') + return out + +def _get_bridge_detail_raw(iface): + """Get interface detail json statistics""" + data = cmd(f'vtysh -c "show interface {iface} json"') + data_dict = json.loads(data) + return data_dict + +def show(raw: bool): + bridge_data = _get_raw_data_summary() + if raw: + return bridge_data + else: + return _get_formatted_output_summary(bridge_data) + + +def show_vlan(raw: bool, tunnel: typing.Optional[bool]): + bridge_vlan = _get_raw_data_vlan(tunnel) + if raw: + return bridge_vlan + else: + if tunnel: + return _get_formatted_output_vlan_tunnel(bridge_vlan) + else: + return _get_formatted_output_vlan(bridge_vlan) + +def show_vni(raw: bool): + bridge_vni = _get_raw_data_vni() + if raw: + return bridge_vni + else: + return _get_formatted_output_vni(bridge_vni) + +def show_fdb(raw: bool, interface: str): + fdb_data = _get_raw_data_fdb(interface) + if raw: + return fdb_data + else: + return _get_formatted_output_fdb(fdb_data) + + +def show_mdb(raw: bool, interface: str): + mdb_data = _get_raw_data_mdb(interface) + if raw: + return mdb_data + else: + return _get_formatted_output_mdb(mdb_data) + +def show_detail(raw: bool, nexthop_group: typing.Optional[bool], interface: str): + if raw: + if nexthop_group: + return _get_bridge_detail_nexthop_group_raw(interface) + else: + return _get_bridge_detail_raw(interface) + else: + if nexthop_group: + return _get_bridge_detail_nexthop_group(interface) + else: + return _get_bridge_detail(interface) + +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/cgnat.py b/src/op_mode/cgnat.py new file mode 100644 index 0000000..9ad8f92 --- /dev/null +++ b/src/op_mode/cgnat.py @@ -0,0 +1,96 @@ +#!/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/>. + +import json +import sys +import typing + +from tabulate import tabulate + +import vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd + +CGNAT_TABLE = 'cgnat' + + +def _get_raw_data(external_address: str = '', internal_address: str = '') -> list[dict]: + """Get CGNAT dictionary and filter by external or internal address if provided.""" + cmd_output = cmd(f'nft --json list table ip {CGNAT_TABLE}') + data = json.loads(cmd_output) + + elements = data['nftables'][2]['map']['elem'] + allocations = [] + for elem in elements: + internal = elem[0] # internal + external = elem[1]['concat'][0] # external + start_port = elem[1]['concat'][1]['range'][0] + end_port = elem[1]['concat'][1]['range'][1] + port_range = f'{start_port}-{end_port}' + + if (internal_address and internal != internal_address) or ( + external_address and external != external_address + ): + continue + + allocations.append( + { + 'internal_address': internal, + 'external_address': external, + 'port_range': port_range, + } + ) + + return allocations + + +def _get_formatted_output(allocations: list[dict]) -> str: + # Convert the list of dictionaries to a list of tuples for tabulate + headers = ['Internal IP', 'External IP', 'Port range'] + data = [ + (alloc['internal_address'], alloc['external_address'], alloc['port_range']) + for alloc in allocations + ] + output = tabulate(data, headers, numalign="left") + return output + + +def show_allocation( + raw: bool, + external_address: typing.Optional[str], + internal_address: typing.Optional[str], +) -> str: + config = ConfigTreeQuery() + if not config.exists('nat cgnat'): + raise vyos.opmode.UnconfiguredSubsystem('CGNAT is not configured') + + if raw: + return _get_raw_data(external_address, internal_address) + + else: + raw_data = _get_raw_data(external_address, internal_address) + return _get_formatted_output(raw_data) + + +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/clear_conntrack.py b/src/op_mode/clear_conntrack.py new file mode 100644 index 0000000..fec7cf1 --- /dev/null +++ b/src/op_mode/clear_conntrack.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys + +from vyos.utils.io import ask_yes_no +from vyos.utils.process import cmd +from vyos.utils.process import DEVNULL + +if not ask_yes_no("This will clear all currently tracked and expected connections. Continue?"): + sys.exit(1) +else: + cmd('/usr/sbin/conntrack -F', stderr=DEVNULL) + cmd('/usr/sbin/conntrack -F expect', stderr=DEVNULL) diff --git a/src/op_mode/config_mgmt.py b/src/op_mode/config_mgmt.py new file mode 100644 index 0000000..66de26d --- /dev/null +++ b/src/op_mode/config_mgmt.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import typing + +import vyos.opmode +from vyos.config_mgmt import ConfigMgmt + +def show_commit_diff(raw: bool, rev: int, rev2: typing.Optional[int], + commands: bool): + config_mgmt = ConfigMgmt() + config_diff = config_mgmt.show_commit_diff(rev, rev2, commands) + + if raw: + rev2 = (rev+1) if rev2 is None else rev2 + if commands: + d = {f'config_command_diff_{rev2}_{rev}': config_diff} + else: + d = {f'config_file_diff_{rev2}_{rev}': config_diff} + return d + + return config_diff + +def show_commit_file(raw: bool, rev: int): + config_mgmt = ConfigMgmt() + config_file = config_mgmt.show_commit_file(rev) + + if raw: + d = {f'config_revision_{rev}': config_file} + return d + + return config_file + +def show_commit_log(raw: bool): + config_mgmt = ConfigMgmt() + + msg = '' + if config_mgmt.max_revisions == 0: + msg = ('commit-revisions is not configured;\n' + 'commit log is empty or stale:\n\n') + + data = config_mgmt.get_raw_log_data() + if raw: + return data + + out = config_mgmt.format_log_data(data) + out = msg + out + + return out + +def show_commit_log_brief(raw: bool): + # used internally for completion help for 'rollback' + # option 'raw' will return same as 'show_commit_log' + config_mgmt = ConfigMgmt() + + data = config_mgmt.get_raw_log_data() + if raw: + return data + + out = config_mgmt.format_log_data_brief(data) + + return out + +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/connect_disconnect.py b/src/op_mode/connect_disconnect.py new file mode 100644 index 0000000..8903f91 --- /dev/null +++ b/src/op_mode/connect_disconnect.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-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/>. + +import os +import argparse + +from psutil import process_iter + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import call +from vyos.utils.commit import commit_in_progress +from vyos.utils.network import is_wwan_connected +from vyos.utils.process import DEVNULL + +def check_ppp_interface(interface): + if not os.path.isfile(f'/etc/ppp/peers/{interface}'): + print(f'Interface {interface} does not exist!') + exit(1) + +def check_ppp_running(interface): + """ Check if PPP process is running in the interface in question """ + for p in process_iter(): + if "pppd" in p.name(): + if interface in p.cmdline(): + return True + + return False + +def connect(interface): + """ Connect dialer interface """ + + if interface.startswith('pppoe') or interface.startswith('sstpc'): + check_ppp_interface(interface) + # Check if interface is already dialed + if os.path.isdir(f'/sys/class/net/{interface}'): + print(f'Interface {interface}: already connected!') + elif check_ppp_running(interface): + print(f'Interface {interface}: connection is being established!') + else: + print(f'Interface {interface}: connecting...') + call(f'systemctl restart ppp@{interface}.service') + elif interface.startswith('wwan'): + if is_wwan_connected(interface): + print(f'Interface {interface}: already connected!') + else: + call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces_wwan.py') + else: + print(f'Unknown interface {interface}, cannot connect. Aborting!') + + # Reaply QoS configuration + config = ConfigTreeQuery() + if config.exists(f'qos interface {interface}'): + count = 1 + while commit_in_progress(): + if ( count % 60 == 0 ): + print(f'Commit still in progress after {count}s - waiting') + count += 1 + time.sleep(1) + call('/usr/libexec/vyos/conf_mode/qos.py') + +def disconnect(interface): + """ Disconnect dialer interface """ + + if interface.startswith('pppoe') or interface.startswith('sstpc'): + check_ppp_interface(interface) + + # Check if interface is already down + if not check_ppp_running(interface): + print(f'Interface {interface}: connection is already down') + else: + print(f'Interface {interface}: disconnecting...') + call(f'systemctl stop ppp@{interface}.service') + elif interface.startswith('wwan'): + if not is_wwan_connected(interface): + print(f'Interface {interface}: connection is already down') + else: + modem = interface.lstrip('wwan') + call(f'mmcli --modem {modem} --simple-disconnect', stdout=DEVNULL) + else: + print(f'Unknown interface {interface}, cannot disconnect. Aborting!') + +def main(): + parser = argparse.ArgumentParser() + 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") + 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) + + else: + parser.print_help() + + exit(0) + +if __name__ == '__main__': + main() diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py new file mode 100644 index 0000000..c379c3e --- /dev/null +++ b/src/op_mode/conntrack.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import sys +import typing +import xmltodict + +from tabulate import tabulate +from vyos.utils.process import cmd + +import vyos.opmode + +ArgFamily = typing.Literal['inet', 'inet6'] + +def _get_xml_data(family): + """ + Get conntrack XML output + """ + return cmd(f'sudo conntrack --dump --family {family} --output xml') + + +def _xml_to_dict(xml): + """ + Convert XML to dictionary + Return: dictionary + """ + parse = xmltodict.parse(xml, attr_prefix='') + # If only one conntrack entry we must change dict + if 'meta' in parse['conntrack']['flow']: + return dict(conntrack={'flow': [parse['conntrack']['flow']]}) + return parse + + +def _get_raw_data(family): + """ + Return: dictionary + """ + xml = _get_xml_data(family) + if len(xml) == 0: + output = {'conntrack': + { + 'error': True, + 'reason': 'entries not found' + } + } + return output + return _xml_to_dict(xml) + + +def _get_raw_statistics(): + entries = [] + data = cmd('sudo conntrack --stats') + data = data.replace(' \t', '').split('\n') + for entry in data: + entries.append(entry.split()) + return entries + + +def get_formatted_statistics(entries): + headers = [ + "CPU", + "Found", + "Invalid", + "Insert", + "Insert fail", + "Drop", + "Early drop", + "Errors", + "Search restart", + "", + "", + ] + # Process each entry to extract and format the values after '=' + processed_entries = [ + [value.split('=')[-1] for value in entry] + for entry in entries + ] + output = tabulate(processed_entries, headers, numalign="left") + return output + + +def get_formatted_output(dict_data): + """ + :param xml: + :return: formatted output + """ + data_entries = [] + if 'error' in dict_data['conntrack']: + return 'Entries not found' + for entry in dict_data['conntrack']['flow']: + orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {} + reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {} + proto = {} + for meta in entry['meta']: + direction = meta['direction'] + if direction in ['original']: + if 'layer3' in meta: + orig_src = meta['layer3']['src'] + orig_dst = meta['layer3']['dst'] + if 'layer4' in meta: + if meta.get('layer4').get('sport'): + orig_sport = meta['layer4']['sport'] + if meta.get('layer4').get('dport'): + orig_dport = meta['layer4']['dport'] + proto = meta['layer4']['protoname'] + if direction in ['reply']: + if 'layer3' in meta: + reply_src = meta['layer3']['src'] + reply_dst = meta['layer3']['dst'] + if 'layer4' in meta: + if meta.get('layer4').get('sport'): + reply_sport = meta['layer4']['sport'] + if meta.get('layer4').get('dport'): + 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 + orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst + reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src + reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst + 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"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def show(raw: bool, family: ArgFamily): + family = 'ipv6' if family == 'inet6' else 'ipv4' + conntrack_data = _get_raw_data(family) + if raw: + return conntrack_data + else: + return get_formatted_output(conntrack_data) + + +def show_statistics(raw: bool): + conntrack_statistics = _get_raw_statistics() + if raw: + return conntrack_statistics + else: + return get_formatted_statistics(conntrack_statistics) + + +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/conntrack_sync.py b/src/op_mode/conntrack_sync.py new file mode 100644 index 0000000..f3b09b4 --- /dev/null +++ b/src/op_mode/conntrack_sync.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import os +import sys +import syslog +import xmltodict + +from tabulate import tabulate + +import vyos.opmode + +from vyos.configquery import CliShellApiConfigQuery +from vyos.configquery import ConfigTreeQuery +from vyos.utils.commit import commit_in_progress +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import run + +conntrackd_bin = '/usr/sbin/conntrackd' +conntrackd_config = '/run/conntrackd/conntrackd.conf' +failover_state_file = '/var/run/vyatta-conntrackd-failover-state' + +def is_configured(): + """ Check if conntrack-sync service is configured """ + config = CliShellApiConfigQuery() + if not config.exists(['service', 'conntrack-sync']): + raise vyos.opmode.UnconfiguredSubsystem("conntrack-sync is not configured!") + +def send_bulk_update(): + """ send bulk update of internal-cache to other systems """ + tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -B') + if tmp > 0: + raise vyos.opmode.Error('Failed to send bulk update to other conntrack-sync systems') + +def request_sync(): + """ request resynchronization with other systems """ + tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -n') + if tmp > 0: + raise vyos.opmode.Error('Failed to request resynchronization of external cache') + +def flush_cache(direction): + """ flush conntrackd cache (internal or external) """ + if direction not in ['internal', 'external']: + raise ValueError() + tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -f {direction}') + if tmp > 0: + raise vyos.opmode.Error('Failed to clear {direction} cache') + +def get_formatted_output(data): + data_entries = [] + for parsed in data: + for meta in parsed.get('flow', {}).get('meta', []): + direction = meta['@direction'] + if direction == 'original': + src = meta['layer3']['src'] + dst = meta['layer3']['dst'] + sport = meta['layer4'].get('sport') + dport = meta['layer4'].get('dport') + protocol = meta['layer4'].get('@protoname') + orig_src = f'{src}:{sport}' if sport else src + orig_dst = f'{dst}:{dport}' if dport else dst + + data_entries.append([orig_src, orig_dst, protocol]) + + headers = ["Source", "Destination", "Protocol"] + output = tabulate(data_entries, headers, tablefmt="simple") + return output + +def from_xml(raw, xml): + out = [] + for line in xml.splitlines(): + if line == '\n': + continue + parsed = xmltodict.parse(line) + out.append(parsed) + + if raw: + return out + else: + return get_formatted_output(out) + +def restart(): + is_configured() + if commit_in_progress(): + raise vyos.opmode.CommitInProgress('Cannot restart conntrackd while a commit is in progress') + + syslog.syslog('Restarting conntrack sync service...') + cmd('systemctl restart conntrackd.service') + # request resynchronization with other systems + request_sync() + # send bulk update of internal-cache to other systems + send_bulk_update() + +def reset_external_cache(): + is_configured() + syslog.syslog('Resetting external cache of conntrack sync service...') + + # flush the external cache + flush_cache('external') + # request resynchronization with other systems + request_sync() + +def reset_internal_cache(): + is_configured() + syslog.syslog('Resetting internal cache of conntrack sync service...') + # flush the internal cache + flush_cache('internal') + + # request resynchronization of internal cache with kernel conntrack table + tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -R') + if tmp > 0: + print('ERROR: failed to resynchronize internal cache with kernel conntrack table') + + # send bulk update of internal-cache to other systems + send_bulk_update() + +def _show_cache(raw, opts): + is_configured() + out = cmd(f'{conntrackd_bin} -C {conntrackd_config} {opts} -x') + return from_xml(raw, out) + +def show_external_cache(raw: bool): + opts = '-e ct' + return _show_cache(raw, opts) + +def show_external_expect(raw: bool): + opts = '-e expect' + return _show_cache(raw, opts) + +def show_internal_cache(raw: bool): + opts = '-i ct' + return _show_cache(raw, opts) + +def show_internal_expect(raw: bool): + opts = '-i expect' + return _show_cache(raw, opts) + +def show_statistics(raw: bool): + if raw: + raise vyos.opmode.UnsupportedOperation("Machine-readable conntrack-sync statistics are not available yet") + else: + is_configured() + config = ConfigTreeQuery() + print('\nMain Table Statistics:\n') + call(f'{conntrackd_bin} -C {conntrackd_config} -s') + print() + if config.exists(['service', 'conntrack-sync', 'expect-sync']): + print('\nExpect Table Statistics:\n') + call(f'{conntrackd_bin} -C {conntrackd_config} -s exp') + print() + +def show_status(raw: bool): + is_configured() + config = ConfigTreeQuery() + ct_sync_intf = config.list_nodes(['service', 'conntrack-sync', 'interface']) + ct_sync_intf = ', '.join(ct_sync_intf) + failover_state = "no transition yet!" + expect_sync_protocols = [] + + if config.exists(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp']): + failover_mechanism = "vrrp" + vrrp_sync_grp = config.value(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group']) + + if os.path.isfile(failover_state_file): + with open(failover_state_file, "r") as f: + failover_state = f.readline() + + if config.exists(['service', 'conntrack-sync', 'expect-sync']): + expect_sync_protocols = config.values(['service', 'conntrack-sync', 'expect-sync']) + if 'all' in expect_sync_protocols: + expect_sync_protocols = ["ftp", "sip", "h323", "nfs", "sqlnet"] + + if raw: + status_data = { + "sync_interface": ct_sync_intf, + "failover_mechanism": failover_mechanism, + "sync_group": vrrp_sync_grp, + "last_transition": failover_state, + "sync_protocols": expect_sync_protocols + } + + return status_data + else: + if expect_sync_protocols: + expect_sync_protocols = ', '.join(expect_sync_protocols) + else: + expect_sync_protocols = "disabled" + show_status = (f'\nsync-interface : {ct_sync_intf}\n' + f'failover-mechanism : {failover_mechanism} [sync-group {vrrp_sync_grp}]\n' + f'last state transition : {failover_state}\n' + f'ExpectationSync : {expect_sync_protocols}') + + return show_status + +if __name__ == '__main__': + syslog.openlog(ident='conntrack-tools', logoption=syslog.LOG_PID, facility=syslog.LOG_INFO) + + 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/container.py b/src/op_mode/container.py new file mode 100644 index 0000000..05f65df --- /dev/null +++ b/src/op_mode/container.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import json +import sys + +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +import vyos.opmode + +def _get_json_data(command: str) -> list: + """ + Get container command format JSON + """ + return cmd(f'{command} --format json') + +def _get_raw_data(command: str) -> list: + json_data = _get_json_data(command) + data = json.loads(json_data) + return data + +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 """ + from vyos.configquery import ConfigTreeQuery + + conf = ConfigTreeQuery() + container = conf.get_config_dict(['container', 'registry']) + + do_logout = False + if 'registry' in container: + for registry, registry_config in container['registry'].items(): + if 'disable' in registry_config: + continue + if 'authentication' in registry_config: + do_logout = True + if {'username', 'password'} <= set(registry_config['authentication']): + username = registry_config['authentication']['username'] + password = registry_config['authentication']['password'] + cmd = f'podman login --username {username} --password {password} {registry}' + rc, out = rc_cmd(cmd) + if rc != 0: raise vyos.opmode.InternalError(out) + + rc, output = rc_cmd(f'podman image pull {name}') + if rc != 0: + raise vyos.opmode.InternalError(output) + + if do_logout: + rc_cmd('podman logout --all') + +def delete_image(name: str): + from vyos.utils.process import rc_cmd + + if name == 'all': + # gather list of all images and pass them to the removal list + name = cmd('sudo podman image ls --quiet') + # If there are no container images left, we can not delete them all + 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) + +def show_container(raw: bool): + command = 'podman ps --all' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + +def show_image(raw: bool): + command = 'podman image ls' + container_data = _get_raw_data('podman image ls') + if raw: + return container_data + else: + return cmd(command) + +def show_network(raw: bool): + command = 'podman network ls' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + +def restart(name: str): + from vyos.utils.process import rc_cmd + + rc, output = rc_cmd(f'systemctl restart vyos-container-{name}.service') + if rc != 0: + print(output) + return None + print(f'Container "{name}" restarted!') + return output + +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/cpu.py b/src/op_mode/cpu.py new file mode 100644 index 0000000..1a0f739 --- /dev/null +++ b/src/op_mode/cpu.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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/>. + +import sys + +import vyos.opmode +from vyos.utils.cpu import get_cpus +from vyos.utils.cpu import get_core_count + +from jinja2 import Template + +cpu_template = Template(""" +{% for cpu in cpus %} +{% if 'physical id' in cpu %}CPU socket: {{cpu['physical id']}}{% endif %} +{% if 'vendor_id' in cpu %}CPU Vendor: {{cpu['vendor_id']}}{% endif %} +{% if 'model name' in cpu %}Model: {{cpu['model name']}}{% endif %} +{% if 'cpu cores' in cpu %}Cores: {{cpu['cpu cores']}}{% endif %} +{% if 'cpu MHz' in cpu %}Current MHz: {{cpu['cpu MHz']}}{% endif %} +{% endfor %} +""") + +cpu_summary_template = Template(""" +Physical CPU cores: {{count}} +CPU model(s): {{models | join(", ")}} +""") + +def _get_raw_data(): + return get_cpus() + +def _format_cpus(cpu_data): + env = {'cpus': cpu_data} + return cpu_template.render(env).strip() + +def _get_summary_data(): + count = get_core_count() + cpu_data = get_cpus() + models = [c['model name'] for c in cpu_data] + env = {'count': count, "models": models} + + return env + +def _format_cpu_summary(summary_data): + return cpu_summary_template.render(summary_data).strip() + +def show(raw: bool): + cpu_data = _get_raw_data() + + if raw: + return cpu_data + else: + return _format_cpus(cpu_data) + +def show_summary(raw: bool): + cpu_summary_data = _get_summary_data() + + if raw: + return cpu_summary_data + else: + return _format_cpu_summary(cpu_summary_data) + + +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/dhcp.py b/src/op_mode/dhcp.py new file mode 100644 index 0000000..e5455c8 --- /dev/null +++ b/src/op_mode/dhcp.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import os +import sys +import typing + +from datetime import datetime +from glob import glob +from ipaddress import ip_address +from tabulate import tabulate + +import vyos.opmode + +from vyos.base import Warning +from vyos.configquery import ConfigTreeQuery + +from vyos.kea import kea_get_active_config +from vyos.kea import kea_get_leases +from vyos.kea import kea_get_pool_from_subnet_id +from vyos.kea import kea_delete_lease +from vyos.utils.process import is_systemd_service_running +from vyos.utils.process import call + +time_string = "%a %b %d %H:%M:%S %Z %Y" + +config = ConfigTreeQuery() +lease_valid_states = ['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] +sort_valid_inet = ['end', 'mac', 'hostname', 'ip', 'pool', 'remaining', 'start', 'state'] +sort_valid_inet6 = ['end', 'duid', 'ip', 'last_communication', 'pool', 'remaining', 'state', 'type'] +mapping_sort_valid = ['mac', 'ip', 'pool', 'duid'] + +ArgFamily = typing.Literal['inet', 'inet6'] +ArgState = typing.Literal['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] +ArgOrigin = typing.Literal['local', 'remote'] + +def _utc_to_local(utc_dt): + return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds()) + + +def _format_hex_string(in_str): + out_str = "" + # if input is divisible by 2, add : every 2 chars + if len(in_str) > 0 and len(in_str) % 2 == 0: + out_str = ':'.join(a+b for a,b in zip(in_str[::2], in_str[1::2])) + else: + out_str = in_str + + return out_str + + +def _find_list_of_dict_index(lst, key='ip', value='') -> int: + """ + Find the index entry of list of dict matching the dict value + Exampe: + % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}] + % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2') + % 1 + """ + idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) + return idx + + +def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list: + """ + Get DHCP server leases + :return list + """ + inet_suffix = '6' if family == 'inet6' else '4' + try: + leases = kea_get_leases(inet_suffix) + except: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server lease information') + + if pool is None: + pool = _get_dhcp_pools(family=family) + else: + pool = [pool] + + try: + active_config = kea_get_active_config(inet_suffix) + except: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + + data = [] + for lease in leases: + lifetime = lease['valid-lft'] + expiry = (lease['cltt'] + lifetime) + + lease['start_timestamp'] = datetime.utcfromtimestamp(expiry - lifetime) + lease['expire_timestamp'] = datetime.utcfromtimestamp(expiry) if expiry else None + + data_lease = {} + data_lease['ip'] = lease['ip-address'] + lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'} + data_lease['state'] = lease_state_long[lease['state']] + data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet-id']) if active_config else '-' + data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None + data_lease['origin'] = 'local' # TODO: Determine remote in HA + + if family == 'inet': + data_lease['mac'] = lease['hw-address'] + data_lease['start'] = lease['start_timestamp'].timestamp() + data_lease['hostname'] = lease['hostname'] + + if family == 'inet6': + data_lease['last_communication'] = lease['start_timestamp'].timestamp() + data_lease['duid'] = _format_hex_string(lease['duid']) + data_lease['type'] = lease['type'] + + if lease['type'] == 'IA_PD': + prefix_len = lease['prefix-len'] + data_lease['ip'] += f'/{prefix_len}' + + data_lease['remaining'] = '-' + + if lease['valid-lft'] > 0: + data_lease['remaining'] = lease['expire_timestamp'] - datetime.utcnow() + + if data_lease['remaining'].days >= 0: + # substraction gives us a timedelta object which can't be formatted with strftime + # so we use str(), split gets rid of the microseconds + data_lease['remaining'] = str(data_lease["remaining"]).split('.')[0] + + # Do not add old leases + if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free': + if not state or state == 'all' or data_lease['state'] in state: + data.append(data_lease) + + # deduplicate + checked = [] + for entry in data: + addr = entry.get('ip') + if addr not in checked: + checked.append(addr) + else: + idx = _find_list_of_dict_index(data, key='ip', value=addr) + data.pop(idx) + + if sorted: + if sorted == 'ip': + data.sort(key = lambda x:ip_address(x['ip'])) + else: + data.sort(key = lambda x:x[sorted]) + return data + + +def _get_formatted_server_leases(raw_data, family='inet'): + data_entries = [] + if family == 'inet': + for lease in raw_data: + ipaddr = lease.get('ip') + hw_addr = lease.get('mac') + state = lease.get('state') + start = lease.get('start') + start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') + end = lease.get('end') + end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-' + remain = lease.get('remaining') + pool = lease.get('pool') + hostname = lease.get('hostname') + origin = lease.get('origin') + data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin]) + + headers = ['IP Address', 'MAC address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool', + 'Hostname', 'Origin'] + + if family == 'inet6': + for lease in raw_data: + ipaddr = lease.get('ip') + state = lease.get('state') + start = lease.get('last_communication') + start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') + end = lease.get('end') + end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') + remain = lease.get('remaining') + lease_type = lease.get('type') + pool = lease.get('pool') + host_identifier = lease.get('duid') + data_entries.append([ipaddr, state, start, end, remain, lease_type, pool, host_identifier]) + + headers = ['IPv6 address', 'State', 'Last communication', 'Lease expiration', 'Remaining', 'Type', 'Pool', + 'DUID'] + + output = tabulate(data_entries, headers, numalign='left') + return output + + +def _get_dhcp_pools(family='inet') -> list: + v = 'v6' if family == 'inet6' else '' + pools = config.list_nodes(f'service dhcp{v}-server shared-network-name') + return pools + + +def _get_pool_size(pool, family='inet'): + v = 'v6' if family == 'inet6' else '' + base = f'service dhcp{v}-server shared-network-name {pool}' + size = 0 + subnets = config.list_nodes(f'{base} subnet') + for subnet in subnets: + ranges = config.list_nodes(f'{base} subnet {subnet} range') + for range in ranges: + if family == 'inet6': + start = config.value(f'{base} subnet {subnet} range {range} start') + stop = config.value(f'{base} subnet {subnet} range {range} stop') + else: + start = config.value(f'{base} subnet {subnet} range {range} start') + stop = config.value(f'{base} subnet {subnet} range {range} stop') + # Add +1 because both range boundaries are inclusive + size += int(ip_address(stop)) - int(ip_address(start)) + 1 + return size + + +def _get_raw_pool_statistics(family='inet', pool=None): + if pool is None: + pool = _get_dhcp_pools(family=family) + else: + pool = [pool] + + v = 'v6' if family == 'inet6' else '' + stats = [] + for p in pool: + subnet = config.list_nodes(f'service dhcp{v}-server shared-network-name {p} subnet') + size = _get_pool_size(family=family, pool=p) + leases = len(_get_raw_server_leases(family=family, pool=p)) + use_percentage = round(leases / size * 100) if size != 0 else 0 + pool_stats = {'pool': p, 'size': size, 'leases': leases, + 'available': (size - leases), 'use_percentage': use_percentage, 'subnet': subnet} + stats.append(pool_stats) + return stats + + +def _get_formatted_pool_statistics(pool_data, family='inet'): + data_entries = [] + for entry in pool_data: + pool = entry.get('pool') + size = entry.get('size') + leases = entry.get('leases') + available = entry.get('available') + use_percentage = entry.get('use_percentage') + use_percentage = f'{use_percentage}%' + data_entries.append([pool, size, leases, available, use_percentage]) + + headers = ['Pool', 'Size','Leases', 'Available', 'Usage'] + output = tabulate(data_entries, headers, numalign='left') + return output + +def _get_raw_server_static_mappings(family='inet', pool=None, sorted=None): + if pool is None: + pool = _get_dhcp_pools(family=family) + else: + pool = [pool] + + v = 'v6' if family == 'inet6' else '' + mappings = [] + for p in pool: + pool_config = config.get_config_dict(['service', f'dhcp{v}-server', 'shared-network-name', p], + get_first_key=True) + if 'subnet' in pool_config: + for subnet, subnet_config in pool_config['subnet'].items(): + if 'static-mapping' in subnet_config: + for name, mapping_config in subnet_config['static-mapping'].items(): + mapping = {'pool': p, 'subnet': subnet, 'name': name} + mapping.update(mapping_config) + mappings.append(mapping) + + if sorted: + if sorted == 'ip': + data.sort(key = lambda x:ip_address(x['ip-address'])) + else: + data.sort(key = lambda x:x[sorted]) + return mappings + +def _get_formatted_server_static_mappings(raw_data, family='inet'): + data_entries = [] + for entry in raw_data: + pool = entry.get('pool') + subnet = entry.get('subnet') + name = entry.get('name') + ip_addr = entry.get('ip-address', 'N/A') + mac_addr = entry.get('mac', 'N/A') + duid = entry.get('duid', 'N/A') + description = entry.get('description', 'N/A') + data_entries.append([pool, subnet, name, ip_addr, mac_addr, duid, description]) + + headers = ['Pool', 'Subnet', 'Name', 'IP Address', 'MAC Address', 'DUID', 'Description'] + output = tabulate(data_entries, headers, numalign='left') + return output + +def _verify(func): + """Decorator checks if DHCP(v6) config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + family = kwargs.get('family') + 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) + return func(*args, **kwargs) + return _wrapper + +def _verify_client(func): + """Decorator checks if interface is configured as DHCP client""" + from functools import wraps + from vyos.ifconfig import Section + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + family = kwargs.get('family') + v = 'v6' if family == 'inet6' else '' + interface = kwargs.get('interface') + interface_path = Section.get_config_path(interface) + 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}'): + raise vyos.opmode.UnconfiguredObject(unconf_message) + return func(*args, **kwargs) + return _wrapper + +@_verify +def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str]): + pool_data = _get_raw_pool_statistics(family=family, pool=pool) + if raw: + return pool_data + else: + return _get_formatted_pool_statistics(pool_data, family=family) + + +@_verify +def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str], + sorted: typing.Optional[str], state: typing.Optional[ArgState], + origin: typing.Optional[ArgOrigin] ): + # if dhcp server is down, inactive leases may still be shown as active, so warn the user. + v = '6' if family == 'inet6' else '4' + if not is_systemd_service_running(f'kea-dhcp{v}-server.service'): + Warning('DHCP server is configured but not started. Data may be stale.') + + v = 'v6' if family == 'inet6' else '' + if pool and pool not in _get_dhcp_pools(family=family): + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + + if state and state not in lease_valid_states: + raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!') + + sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet + if sorted and sorted not in sort_valid: + raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') + + lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state, origin=origin) + if raw: + return lease_data + else: + return _get_formatted_server_leases(lease_data, family=family) + +@_verify +def show_server_static_mappings(raw: bool, family: ArgFamily, pool: typing.Optional[str], + sorted: typing.Optional[str]): + v = 'v6' if family == 'inet6' else '' + if pool and pool not in _get_dhcp_pools(family=family): + 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!') + + static_mappings = _get_raw_server_static_mappings(family=family, pool=pool, sorted=sorted) + if raw: + return static_mappings + else: + return _get_formatted_server_static_mappings(static_mappings, family=family) + +def _lease_valid(inet, address): + leases = kea_get_leases(inet) + for lease in leases: + if address == lease['ip-address']: + return True + return False + +@_verify +def clear_dhcp_server_lease(family: ArgFamily, address: str): + v = 'v6' if family == 'inet6' else '' + inet = '6' if family == 'inet6' else '4' + + if not _lease_valid(inet, address): + print(f'Lease not found on DHCP{v} server') + return None + + if not kea_delete_lease(inet, address): + print(f'Failed to clear lease for "{address}"') + return None + + print(f'Lease "{address}" has been cleared') + +def _get_raw_client_leases(family='inet', interface=None): + from time import mktime + from datetime import datetime + from vyos.defaults import directories + from vyos.utils.network import get_interface_vrf + + lease_dir = directories['isc_dhclient_dir'] + lease_files = [] + lease_data = [] + + if interface: + tmp = f'{lease_dir}/dhclient_{interface}.lease' + if os.path.exists(tmp): + lease_files.append(tmp) + else: + # All DHCP leases + lease_files = glob(f'{lease_dir}/dhclient_*.lease') + + for lease in lease_files: + tmp = {} + with open(lease, 'r') as f: + for line in f.readlines(): + line = line.rstrip() + if 'last_update' not in tmp: + # ISC dhcp client contains least_update timestamp in human readable + # format this makes less sense for an API and also the expiry + # timestamp is provided in UNIX time. Convert string (e.g. Sun Jul + # 30 18:13:44 CEST 2023) to UNIX time (1690733624) + tmp.update({'last_update' : int(mktime(datetime.strptime(line, time_string).timetuple()))}) + continue + + k, v = line.split('=') + tmp.update({k : v.replace("'", "")}) + + if 'interface' in tmp: + vrf = get_interface_vrf(tmp['interface']) + if vrf: tmp.update({'vrf' : vrf}) + + lease_data.append(tmp) + + return lease_data + +def _get_formatted_client_leases(lease_data, family): + from time import localtime + from time import strftime + + from vyos.utils.network import is_intf_addr_assigned + + data_entries = [] + for lease in lease_data: + if not lease.get('new_ip_address'): + continue + data_entries.append(["Interface", lease['interface']]) + if 'new_ip_address' in lease: + tmp = '[Active]' if is_intf_addr_assigned(lease['interface'], lease['new_ip_address']) else '[Inactive]' + data_entries.append(["IP address", lease['new_ip_address'], tmp]) + if 'new_subnet_mask' in lease: + data_entries.append(["Subnet Mask", lease['new_subnet_mask']]) + if 'new_domain_name' in lease: + data_entries.append(["Domain Name", lease['new_domain_name']]) + if 'new_routers' in lease: + data_entries.append(["Router", lease['new_routers']]) + if 'new_domain_name_servers' in lease: + data_entries.append(["Name Server", lease['new_domain_name_servers']]) + 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']]) + if 'vrf' in lease: + data_entries.append(["VRF", lease['vrf']]) + if 'last_update' in lease: + tmp = strftime(time_string, localtime(int(lease['last_update']))) + data_entries.append(["Last Update", tmp]) + if 'new_expiry' in lease: + tmp = strftime(time_string, localtime(int(lease['new_expiry']))) + data_entries.append(["Expiry", tmp]) + + # Add empty marker + data_entries.append(['']) + + output = tabulate(data_entries, tablefmt='plain') + + return output + +def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[str]): + lease_data = _get_raw_client_leases(family=family, interface=interface) + if raw: + return lease_data + else: + return _get_formatted_client_leases(lease_data, family=family) + +@_verify_client +def renew_client_lease(raw: bool, family: ArgFamily, interface: str): + if not raw: + v = 'v6' if family == 'inet6' else '' + print(f'Restarting DHCP{v} client on interface {interface}...') + if family == 'inet6': + call(f'systemctl restart dhcp6c@{interface}.service') + else: + call(f'systemctl restart dhclient@{interface}.service') + +@_verify_client +def release_client_lease(raw: bool, family: ArgFamily, interface: str): + if not raw: + v = 'v6' if family == 'inet6' else '' + print(f'Release DHCP{v} client on interface {interface}...') + if family == 'inet6': + call(f'systemctl stop dhcp6c@{interface}.service') + else: + call(f'systemctl stop dhclient@{interface}.service') + +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/dns.py b/src/op_mode/dns.py new file mode 100644 index 0000000..16c462f --- /dev/null +++ b/src/op_mode/dns.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + + +import os +import sys +import time +import typing +import vyos.opmode + +from tabulate import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd, rc_cmd +from vyos.template import is_ipv4, is_ipv6 + +_dynamic_cache_file = r'/run/ddclient/ddclient.cache' + +_dynamic_status_columns = { + 'host': 'Hostname', + 'ipv4': 'IPv4 address', + 'status-ipv4': 'IPv4 status', + 'ipv6': 'IPv6 address', + 'status-ipv6': 'IPv6 status', + 'mtime': 'Last update', +} + +_forwarding_statistics_columns = { + 'cache-entries': 'Cache entries', + 'max-cache-entries': 'Max cache entries', + 'cache-size': 'Cache size', +} + +def _forwarding_data_to_dict(data, sep="\t") -> dict: + """ + Return dictionary from plain text + separated by tab + + cache-entries 73 + cache-hits 0 + uptime 2148 + user-msec 172 + + { + 'cache-entries': '73', + 'cache-hits': '0', + 'uptime': '2148', + 'user-msec': '172' + } + """ + dictionary = {} + mylist = [line for line in data.split('\n')] + + for line in mylist: + if sep in line: + key, value = line.split(sep) + dictionary[key] = value + return dictionary + +def _get_dynamic_host_records_raw() -> dict: + + data = [] + + if os.path.isfile(_dynamic_cache_file): # A ddclient status file might not always exist + with open(_dynamic_cache_file, 'r') as f: + for line in f: + if line.startswith('#'): + continue + + props = {} + # ddclient cache rows have properties in 'key=value' format separated by comma + # we pick up the ones we are interested in + for kvraw in line.split(' ')[0].split(','): + k, v = kvraw.split('=') + if k in list(_dynamic_status_columns.keys()) + ['ip', 'status']: # ip and status are legacy keys + props[k] = v + + # Extract IPv4 and IPv6 address and status from legacy keys + # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6 + if 'ip' in props: + if is_ipv4(props['ip']): + props['ipv4'] = props['ip'] + props['status-ipv4'] = props['status'] + elif is_ipv6(props['ip']): + props['ipv6'] = props['ip'] + props['status-ipv6'] = props['status'] + del props['ip'] + + # Convert mtime to human readable format + if 'mtime' in props: + props['mtime'] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10))) + + data.append(props) + + return data + +def _get_dynamic_host_records_formatted(data): + data_entries = [] + for entry in data: + data_entries.append([entry.get(key) for key in _dynamic_status_columns.keys()]) + header = _dynamic_status_columns.values() + output = tabulate(data_entries, header, numalign='left') + return output + +def _get_forwarding_statistics_raw() -> dict: + command = cmd('rec_control get-all') + data = _forwarding_data_to_dict(command) + data['cache-size'] = "{0:.2f} kbytes".format( int( + cmd('rec_control get cache-bytes')) / 1024 ) + return data + +def _get_forwarding_statistics_formatted(data): + data_entries = [] + data_entries.append([data.get(key) for key in _forwarding_statistics_columns.keys()]) + header = _forwarding_statistics_columns.values() + output = tabulate(data_entries, header, numalign='left') + return output + +def _verify(target): + """Decorator checks if config for DNS related service exists""" + from functools import wraps + + if target not in ['dynamic', 'forwarding']: + raise ValueError('Invalid target') + + def _verify_target(func): + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + if not config.exists(f'service dns {target}'): + _prefix = f'Dynamic DNS' if target == 'dynamic' else 'DNS Forwarding' + raise vyos.opmode.UnconfiguredSubsystem(f'{_prefix} is not configured') + return func(*args, **kwargs) + return _wrapper + return _verify_target + +@_verify('dynamic') +def show_dynamic_status(raw: bool): + host_data = _get_dynamic_host_records_raw() + if raw: + return host_data + else: + return _get_dynamic_host_records_formatted(host_data) + +@_verify('dynamic') +def reset_dynamic(): + """ + Reset Dynamic DNS cache + """ + if os.path.exists(_dynamic_cache_file): + os.remove(_dynamic_cache_file) + rc, output = rc_cmd('systemctl restart ddclient.service') + if rc != 0: + print(output) + return None + print(f'Dynamic DNS state reset!') + +@_verify('forwarding') +def show_forwarding_statistics(raw: bool): + dns_data = _get_forwarding_statistics_raw() + if raw: + return dns_data + else: + return _get_forwarding_statistics_formatted(dns_data) + +@_verify('forwarding') +def reset_forwarding(all: bool, domain: typing.Optional[str]): + """ + Reset DNS Forwarding cache + + :param all (bool): reset cache all domains + :param domain (str): reset cache for specified domain + """ + if all: + rc, output = rc_cmd('rec_control wipe-cache ".$"') + if rc != 0: + print(output) + return None + print('DNS Forwarding cache reset for all domains!') + return output + elif domain: + rc, output = rc_cmd(f'rec_control wipe-cache "{domain}$"') + if rc != 0: + print(output) + return None + print(f'DNS Forwarding cache reset for domain "{domain}"!') + return output + +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/evpn.py b/src/op_mode/evpn.py new file mode 100644 index 0000000..cae4ab9 --- /dev/null +++ b/src/op_mode/evpn.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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 is a helper to run VTYSH commands for "show evpn", allowing for the --raw flag to output JSON + +import sys +import typing +import json + +import vyos.opmode +from vyos.utils.process import cmd + +def show_evpn(raw: bool, command: typing.Optional[str]): + if raw: + command = f"{command} json" + evpnDict = {} + try: + evpnDict['evpn'] = json.loads(cmd(f"vtysh -c '{command}'")) + except: + raise vyos.opmode.DataUnavailable(f"\"{command.replace(' json', '')}\" is invalid or has no JSON option") + + return evpnDict + else: + return cmd(f"vtysh -c '{command}'") + +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/execute_bandwidth_test.sh b/src/op_mode/execute_bandwidth_test.sh new file mode 100644 index 0000000..a6ad0b4 --- /dev/null +++ b/src/op_mode/execute_bandwidth_test.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# +# Copyright (C) 2020 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/>. + +if ipaddrcheck --is-ipv6 $1; then + # Set address family to IPv6 when an IPv6 address was specified + OPT="-V" +elif [[ $(dig $1 AAAA +short | grep -v '\.$' | wc -l) -gt 0 ]]; then + # CNAME is also part of the dig answer thus we must remove any + # CNAME response and only shot the AAAA response(s), this is done + # by grep -v '\.$' + + # Set address family to IPv6 when FQDN has at least one AAAA record + OPT="-V" +else + # It's not IPv6, no option needed + OPT="" +fi + +/usr/bin/iperf $OPT -c $1 $2 + diff --git a/src/op_mode/execute_port-scan.py b/src/op_mode/execute_port-scan.py new file mode 100644 index 0000000..bf17d03 --- /dev/null +++ b/src/op_mode/execute_port-scan.py @@ -0,0 +1,155 @@ +#! /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/>. + +import sys + +from vyos.utils.process import call + + +options = { + 'port': { + 'cmd': '{command} -p {value}', + 'type': '<1-65535> <list>', + 'help': 'Scan specified ports.' + }, + 'tcp': { + 'cmd': '{command} -sT', + 'type': 'noarg', + 'help': 'Use TCP scan.' + }, + 'udp': { + 'cmd': '{command} -sU', + 'type': 'noarg', + 'help': 'Use UDP scan.' + }, + 'skip-ping': { + 'cmd': '{command} -Pn', + 'type': 'noarg', + 'help': 'Skip the Nmap discovery stage altogether.' + }, + 'ipv6': { + 'cmd': '{command} -6', + 'type': 'noarg', + 'help': 'Enable IPv6 scanning.' + }, +} + +nmap = 'sudo /usr/bin/nmap' + + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expansion_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expansion_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['cmd'].format( + command=command, value='') + elif not args: + sys.exit(f'port-scan: missing argument for {longname} option') + else: + command = options[longname]['cmd'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + host = args.first() + + if host == '--get-options-nested': + args.first() # pop execute + args.first() # pop port-scan + args.first() # pop host + args.first() # pop <host> + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + if not matched: + sys.stdout.write('<nocomps>') + else: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + sys.stdout.write(helplines) + sys.exit(0) + + command = convert(nmap, args) + call(f'{command} -T4 {host}') diff --git a/src/op_mode/file.py b/src/op_mode/file.py new file mode 100644 index 0000000..bf13bed --- /dev/null +++ b/src/op_mode/file.py @@ -0,0 +1,383 @@ +#!/usr/bin/python3 + +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import contextlib +import datetime +import grp +import os +import pwd +import shutil +import sys +import tempfile + +from vyos.remote import download +from vyos.remote import upload +from vyos.utils.io import ask_yes_no +from vyos.utils.io import print_error +from vyos.utils.process import cmd +from vyos.utils.process import run + + +parser = argparse.ArgumentParser(description='view, copy or remove files and directories', + formatter_class=argparse.RawDescriptionHelpFormatter) +parser.epilog = """ +TYPE is one of 'remote', 'image' and 'local'. +A local path is <path> or ~/<path>. +A remote path is <scheme>://<urn>. +An image path is <image>:<path>. + +Clone operation is between images only. +Copy operation does not support directories from remote locations. +Delete operation does not support remote paths. +""" +operations = parser.add_mutually_exclusive_group(required=True) +operations.add_argument('--show', nargs=1, help='show the contents of file PATH of type TYPE', + metavar=('PATH')) +operations.add_argument('--copy', nargs=2, help='copy SRC to DEST', + metavar=('SRC', 'DEST')) +operations.add_argument('--delete', nargs=1, help='delete file PATH', + metavar=('PATH')) +operations.add_argument('--clone', help='clone config from running image to IMG', + metavar='IMG') +operations.add_argument('--clone-from', nargs=2, help='clone config from image SRC to image DEST', + metavar=('SRC', 'DEST')) + +## Helper procedures +def fix_terminal() -> None: + """ + Reset terminal after potential breakage caused by abrupt exits. + """ + run('stty sane') + +def get_types(arg: str) -> tuple[str, str]: + """ + Determine whether the argument shows a local, image or remote path. + """ + schemes = ['http', 'https', 'ftp', 'ftps', 'sftp', 'ssh', 'scp', 'tftp'] + s = arg.split("://", 1) + if len(s) != 2: + return 'local', arg + elif s[0] in schemes: + return 'remote', arg + else: + return 'image', arg + +def zealous_copy(source: str, destination: str) -> None: + # Even shutil.copy2() doesn't preserve ownership across copies. + # So we need to resort to this. + stats = os.stat(source) + shutil.copy2(source, destination) + os.chown(destination, stats.st_uid, stats.st_gid) + +def get_file_type(path: str) -> str: + return cmd(['file', '-sb', path]) + +def print_header(string: str) -> None: + print('#' * 10, string, '#' * 10) + +def octal_to_symbolic(octal: str) -> str: + perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] + result = "" + # We discard all but the last three digits because we're only + # interested in the permission bits. + for i in octal[-3:]: + result += perms[int(i)] + return result + +def get_user_and_group(stats: os.stat_result) -> tuple[str, str]: + try: + user = pwd.getpwuid(stats.st_uid).pw_name + except (KeyError, PermissionError): + user = str(stats.st_uid) + try: + group = grp.getgrgid(stats.st_gid).gr_name + except (KeyError, PermissionError): + group = str(stats.st_gid) + return user, group + +def print_file_info(path: str) -> None: + stats = os.stat(path) + username, groupname = get_user_and_group(stats) + mtime = datetime.datetime.fromtimestamp(stats.st_mtime).strftime("%F %X") + print_header('FILE INFO') + print(f'Path:\t\t{path}') + # File type is determined through `file(1)`. + print(f'Type:\t\t{get_file_type(path)}') + # Owner user and group + print(f'Owner:\t\t{username}:{groupname}') + # Permissions are converted from raw int to octal string to symbolic string. + print(f'Permissions:\t{octal_to_symbolic(oct(stats.st_mode))}') + # Last date of modification + print(f'Modified:\t{mtime}') + +def print_file_data(path: str) -> None: + print_header('FILE DATA') + file_type = get_file_type(path) + # Human-readable files are streamed line-by-line. + if 'text' in file_type: + 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])) + +def parse_image_path(image_path: str) -> str: + """ + my-image:/foo/bar -> /lib/live/mount/persistence/boot/my-image/rw/foo/bar + """ + image_name, path = image_path.split('://', 1) + if image_name == 'running': + image_root = '/' + elif image_name == 'disk-install': + image_root = '/lib/live/mount/persistence/' + else: + image_root = os.path.join('/lib/live/mount/persistence/boot', image_name, 'rw') + if not os.path.isdir(image_root): + print_error(f'Image {image_name} not found.') + sys.exit(1) + return os.path.join(image_root, path) + + +## Show procedures +def show_locally(path: str) -> None: + """ + Display the contents of a local file or directory. + """ + location = os.path.realpath(os.path.expanduser(path)) + # Temporarily redirect stdout to a throwaway file for `less(1)` to read. + # The output could be potentially too hefty for an in-memory StringIO. + temp = tempfile.NamedTemporaryFile('w', delete=False) + try: + with contextlib.redirect_stdout(temp): + # Just a directory. Call `ls(1)` and bail. + if os.path.isdir(location): + print_header('DIRECTORY LISTING') + print('Path:\t', location) + print(cmd(['ls', '-hlFGL', '--group-directories-first', location])) + elif os.path.isfile(location): + print_file_info(location) + print() + print_file_data(location) + else: + print_error(f'File or directory {path} not found.') + sys.exit(1) + sys.stdout.flush() + # Call `less(1)` and wait for it to terminate before going forward. + cmd(['/usr/bin/less', '-X', temp.name], stdout=sys.stdout) + # The stream to the temporary file could break for any reason. + # It's much less fragile than if we streamed directly to the process stdin. + # But anything could still happen and we don't want to scare the user. + except (BrokenPipeError, EOFError, KeyboardInterrupt, OSError): + fix_terminal() + sys.exit(1) + finally: + os.remove(temp.name) + +def show(type: str, path: str) -> None: + if type == 'remote': + temp = tempfile.NamedTemporaryFile(delete=False) + download(temp.name, path) + show_locally(temp.name) + os.remove(temp.name) + elif type == 'image': + show_locally(parse_image_path(path)) + elif type == 'local': + show_locally(path) + else: + print_error(f'Unknown target for showing: {type}') + print_error('Valid types are "remote", "image" and "local".') + sys.exit(1) + + +## Copying procedures +def copy(source_type: str, source_path: str, + destination_type: str, destination_path: str) -> None: + """ + Copy a file or directory locally, remotely or to and from an image. + Directory uploads and downloads not supported. + """ + source = '' + try: + # Download to a temporary file and use that as the source. + if source_type == 'remote': + source = tempfile.NamedTemporaryFile(delete=False).name + download(source, source_path) + # Prepend the image root to the path. + elif source_type == 'image': + source = parse_image_path(source_path) + elif source_type == 'local': + source = source_path + else: + print_error(f'Unknown source type: {source_type}') + print_error(f'Valid source types are "remote", "image" and "local".') + sys.exit(1) + + # Directly upload the file. + if destination_type == 'remote': + if os.path.isdir(source): + print_error(f'Cannot upload {source}. Directory uploads not supported.') + sys.exit(1) + upload(source, destination_path) + # No need to duplicate local copy operations for image copying. + elif destination_type == 'image': + copy('local', source, 'local', parse_image_path(destination_path)) + # Try to preserve metadata when copying. + elif destination_type == 'local': + if os.path.isdir(destination_path): + destination_path = os.path.join(destination_path, os.path.basename(source)) + if os.path.isdir(source): + shutil.copytree(source, destination_path, copy_function=zealous_copy) + else: + zealous_copy(source, destination_path) + else: + print_error(f'Unknown destination type: {source_type}') + print_error(f'Valid destination types are "remote", "image" and "local".') + sys.exit(1) + except OSError: + import traceback + # We can't check for every single user error (eg copying a directory to a file) + # so we just let a curtailed stack trace provide a descriptive error. + print_error(f'Failed to copy {source_path} to {destination_path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + else: + # To prevent a duplicate message. + if destination_type != 'image': + print('Copy successful.') + finally: + # Clean up temporary file. + if source_type == 'remote': + os.remove(source) + + +## Deletion procedures +def delete_locally(path: str) -> None: + """ + Remove a local file or directory. + """ + try: + if os.path.isdir(path): + if (ask_yes_no(f'Do you want to remove {path} with all its contents?')): + shutil.rmtree(path) + print(f'Directory {path} removed.') + else: + print('Operation aborted.') + elif os.path.isfile(path): + if (ask_yes_no(f'Do you want to remove {path}?')): + os.remove(path) + print(f'File {path} removed.') + else: + print('Operation aborted.') + else: + raise OSError(f'File or directory {path} not found.') + except OSError: + import traceback + print_error(f'Failed to delete {path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + +def delete(type: str, path: str) -> None: + if type == 'local': + delete_locally(path) + elif type == 'image': + delete_locally(parse_image_path(path)) + else: + print_error(f'Unknown target for deletion: {type}') + print_error('Valid types are "image" and "local".') + sys.exit(1) + + +## Cloning procedures +def clone(source: str, destination: str) -> None: + if os.geteuid(): + print_error('Only the superuser can run this command.') + sys.exit(1) + if destination == 'running' or destination == 'disk-install': + print_error(f'Cannot clone config to {destination}.') + sys.exit(1) + # If `source` is None, then we're going to copy from the running image. + if source is None or source == 'running': + source_path = '/config' + # For the warning message only. + source = 'the current' + else: + source_path = parse_image_path(source + ':/config') + destination_path = parse_image_path(destination + ':/config') + backup_path = destination_path + '.preclone' + + if not os.path.isdir(source_path): + print_error(f'Source image {source} does not exist.') + sys.exit(1) + if not os.path.isdir(destination_path): + print_error(f'Destination image {destination} does not exist.') + sys.exit(1) + print(f'WARNING: This operation will erase /config data in image {destination}.') + print(f'/config data in {source} image will be copied over in its place.') + print(f'The existing /config data in {destination} image will be backed up to /config.preclone.') + + if ask_yes_no('Are you sure you want to continue?'): + try: + if os.path.isdir(backup_path): + print('Removing previous backup...') + shutil.rmtree(backup_path) + print('Making new backup...') + shutil.move(destination_path, backup_path) + except: + print('Something went wrong during the backup process!') + print('Cowardly refusing to proceed with cloning.') + raise + # Copy new config from image. + try: + shutil.copytree(source_path, destination_path, copy_function=zealous_copy) + except: + print('Cloning failed! Reverting to backup!') + # Delete leftover files from the botched cloning. + shutil.rmtree(destination_path, ignore_errors=True) + # Restore backup before bailing out. + shutil.copytree(backup_path, destination_path, copy_function=zealous_copy) + raise + else: + print(f'Successfully cloned config from {source} to {destination}.') + finally: + shutil.rmtree(backup_path) + else: + print('Operation aborted.') + +if __name__ == '__main__': + args = parser.parse_args() + try: + if args.show: + show(*get_types(args.show[0])) + elif args.copy: + copy(*get_types(args.copy[0]), + *get_types(args.copy[1])) + elif args.delete: + delete(*get_types(args.delete[0])) + elif args.clone_from: + clone(*args.clone_from) + elif args.clone: + # Pass None as source image to copy from local image. + clone(None, args.clone) + except KeyboardInterrupt: + print_error('Operation cancelled by user.') + sys.exit(1) + sys.exit(0) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py new file mode 100644 index 0000000..c197ca4 --- /dev/null +++ b/src/op_mode/firewall.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import argparse +import ipaddress +import json +import re +import tabulate +import textwrap + +from vyos.config import Config +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search_args + +def get_config_node(conf, node=None, family=None, hook=None, priority=None): + if node == 'nat': + if family == 'ipv6': + config_path = ['nat66'] + else: + config_path = ['nat'] + + elif node == 'policy': + config_path = ['policy'] + else: + config_path = ['firewall'] + if family: + config_path += [family] + if hook: + config_path += [hook] + if priority: + config_path += [priority] + + node_config = conf.get_config_dict(config_path, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return node_config + +def get_nftables_details(family, hook, priority): + if family == 'ipv6': + suffix = 'ip6' + name_prefix = 'NAME6_' + aux='IPV6_' + elif family == 'ipv4': + suffix = 'ip' + name_prefix = 'NAME_' + aux='' + else: + suffix = 'bridge' + name_prefix = 'NAME_' + aux='' + + if hook == 'name' or hook == 'ipv6-name': + command = f'nft list chain {suffix} vyos_filter {name_prefix}{priority}' + else: + up_hook = hook.upper() + command = f'nft list chain {suffix} vyos_filter VYOS_{aux}{up_hook}_{priority}' + + try: + results = cmd(command) + except: + return {} + + out = {} + for line in results.split('\n'): + comment_search = re.search(rf'{priority}[\- ](\d+|default-action)', line) + if not comment_search: + continue + + rule = {} + rule_id = comment_search[1] + 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[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'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']: + if isinstance(elem, str): + out.append(elem) + elif isinstance(elem, dict) and 'elem' in elem: + out.append(elem['elem']) + + 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 + transformed_rule = [[header, textwrap.fill(adjusted_rule[i].replace('\n', ' '), 65)] 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.tabulate(transformed_rule, tablefmt="presto")) + print() + +def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=None): + print(f'\n---------------------------------\n{family} Firewall "{hook} {priority}"\n') + + details = get_nftables_details(family, hook, priority) + rows = [] + + if 'rule' in firewall_conf: + for rule_id, rule_conf in firewall_conf['rule'].items(): + if single_rule_id and rule_id != single_rule_id: + continue + + if 'disable' in rule_conf: + continue + + row = [rule_id, textwrap.fill(rule_conf.get('description') or '', 50), rule_conf['action'], rule_conf['protocol'] if 'protocol' in rule_conf else 'all'] + if rule_id in details: + rule_details = details[rule_id] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + row.append(rule_details['conditions']) + rows.append(row) + + if hook in ['input', 'forward', 'output']: + 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' + row = ['default', '', def_action, 'all'] + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + + rows.append(row) + + if rows: + if args.rule: + rows.pop() + + if args.detail: + header = ['Rule', 'Description', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] + output_firewall_vertical(rows, header) + else: + header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] + for i in rows: + 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') + + details = get_nftables_details(family, hook, prior) + rows = [] + + if 'rule' in prior_conf: + for rule_id, rule_conf in prior_conf['rule'].items(): + if single_rule_id and rule_id != single_rule_id: + continue + + if 'disable' in rule_conf: + continue + + # Get source + source_addr = dict_search_args(rule_conf, 'source', 'address') + if not source_addr: + source_addr = dict_search_args(rule_conf, 'source', 'group', 'address_group') + if not source_addr: + source_addr = dict_search_args(rule_conf, 'source', 'group', 'network_group') + if not source_addr: + source_addr = dict_search_args(rule_conf, 'source', 'group', 'domain_group') + if not source_addr: + source_addr = dict_search_args(rule_conf, 'source', 'fqdn') + if not source_addr: + source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code') + if source_addr: + source_addr = str(source_addr)[1:-1].replace('\'','') + if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'): + source_addr = 'NOT ' + str(source_addr) + if not source_addr: + source_addr = 'any' + + # Get destination + dest_addr = dict_search_args(rule_conf, 'destination', 'address') + if not dest_addr: + dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'address_group') + if not dest_addr: + dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'network_group') + if not dest_addr: + dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'domain_group') + if not dest_addr: + dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn') + if not dest_addr: + dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code') + if dest_addr: + dest_addr = str(dest_addr)[1:-1].replace('\'','') + if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'): + dest_addr = 'NOT ' + str(dest_addr) + if not dest_addr: + dest_addr = 'any' + + # Get inbound interface + iiface = dict_search_args(rule_conf, 'inbound_interface', 'name') + if not iiface: + iiface = dict_search_args(rule_conf, 'inbound_interface', 'group') + if not iiface: + iiface = 'any' + + # Get outbound interface + oiface = dict_search_args(rule_conf, 'outbound_interface', 'name') + if not oiface: + oiface = dict_search_args(rule_conf, 'outbound_interface', 'group') + if not oiface: + oiface = 'any' + + row = [rule_id, textwrap.fill(rule_conf.get('description') or '', 50)] + if rule_id in details: + rule_details = details[rule_id] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + else: + row.append('0') + row.append('0') + row.append(rule_conf['action']) + row.append(source_addr) + row.append(dest_addr) + row.append(iiface) + row.append(oiface) + rows.append(row) + + + if hook in ['input', 'forward', 'output']: + row = ['default', ''] + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + if 'default_action' in prior_conf: + row.append(prior_conf['default_action']) + else: + row.append('accept') + row.append('any') + row.append('any') + row.append('any') + row.append('any') + rows.append(row) + + elif 'default_action' in prior_conf and not single_rule_id: + row = ['default', ''] + if 'default-action' in details: + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + else: + row.append('0') + row.append('0') + row.append(prior_conf['default_action']) + row.append('any') # Source + row.append('any') # Dest + row.append('any') # inbound-interface + row.append('any') # outbound-interface + rows.append(row) + + if rows: + if args.detail: + header = ['Rule', 'Description', 'Packets', 'Bytes', 'Action', 'Source', 'Destination', 'Inbound-Interface', 'Outbound-interface'] + output_firewall_vertical(rows, header) + else: + header = ['Rule', 'Packets', 'Bytes', 'Action', 'Source', 'Destination', 'Inbound-Interface', 'Outbound-interface'] + for i in rows: + rows[rows.index(i)].pop(1) + print(tabulate.tabulate(rows, header) + '\n') + +def show_firewall(): + print('Rulesets Information') + + conf = Config() + firewall = get_config_node(conf) + + if not 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(): + output_firewall_name(family, hook, prior, prior_conf) + +def show_firewall_family(family): + print(f'Rulesets {family} Information') + + conf = Config() + firewall = get_config_node(conf) + + if not firewall: + return + + 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') + + conf = Config() + firewall = get_config_node(conf, 'firewall', family, hook, priority) + if firewall: + output_firewall_name(family, hook, priority, firewall) + +def show_firewall_rule(family, hook, priority, rule_id): + print('Rule Information') + + conf = Config() + firewall = get_config_node(conf, 'firewall', family, hook, priority) + if firewall: + output_firewall_name(family, hook, priority, firewall, rule_id) + +def show_firewall_group(name=None): + conf = Config() + firewall = get_config_node(conf, node='firewall') + + if 'group' not in firewall: + return + + nat = get_config_node(conf, node='nat') + policy = get_config_node(conf, node='policy') + + def find_references(group_type, group_name): + out = [] + family = [] + if group_type in ['address_group', 'network_group']: + family = ['ipv4'] + elif group_type == 'ipv6_address_group': + family = ['ipv6'] + group_type = 'address_group' + elif group_type == 'ipv6_network_group': + family = ['ipv6'] + group_type = 'network_group' + else: + family = ['ipv4', 'ipv6', 'bridge'] + + for item in family: + # Look references in firewall + for name_type in ['name', 'ipv6_name', 'forward', 'input', 'output']: + if item in firewall: + if name_type not in firewall[item]: + continue + for priority, priority_conf in firewall[item][name_type].items(): + if priority not in firewall[item][name_type]: + continue + if 'rule' not in priority_conf: + continue + for rule_id, rule_conf in priority_conf['rule'].items(): + source_group = dict_search_args(rule_conf, 'source', 'group', group_type) + dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + dyn_group_source = dict_search_args(rule_conf, 'add_address_to_group', 'source_address', group_type) + dyn_group_dst = dict_search_args(rule_conf, 'add_address_to_group', 'destination_address', group_type) + if source_group: + if source_group[0] == "!": + source_group = source_group[1:] + if group_name == source_group: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + if dest_group: + if dest_group[0] == "!": + dest_group = dest_group[1:] + if group_name == dest_group: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + if in_interface: + if in_interface[0] == "!": + in_interface = in_interface[1:] + if group_name == in_interface: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + if out_interface: + if out_interface[0] == "!": + out_interface = out_interface[1:] + if group_name == out_interface: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + + if dyn_group_source: + if group_name == dyn_group_source: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + if dyn_group_dst: + if group_name == dyn_group_dst: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + + + # Look references in route | route6 + for name_type in ['route', 'route6']: + if name_type not in policy: + continue + if name_type == 'route' and item == 'ipv6': + continue + elif name_type == 'route6' and item == 'ipv4': + continue + else: + for policy_name, policy_conf in policy[name_type].items(): + if 'rule' not in policy_conf: + continue + for rule_id, rule_conf in policy_conf['rule'].items(): + source_group = dict_search_args(rule_conf, 'source', 'group', group_type) + dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + if source_group: + if source_group[0] == "!": + source_group = source_group[1:] + if group_name == source_group: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if dest_group: + if dest_group[0] == "!": + dest_group = dest_group[1:] + if group_name == dest_group: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if in_interface: + if in_interface[0] == "!": + in_interface = in_interface[1:] + if group_name == in_interface: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if out_interface: + if out_interface[0] == "!": + out_interface = out_interface[1:] + if group_name == out_interface: + out.append(f'{name_type}-{policy_name}-{rule_id}') + + ## Look references in nat table + for direction in ['source', 'destination']: + if direction in nat: + if 'rule' not in nat[direction]: + continue + for rule_id, rule_conf in nat[direction]['rule'].items(): + source_group = dict_search_args(rule_conf, 'source', 'group', group_type) + dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + if source_group: + if source_group[0] == "!": + source_group = source_group[1:] + if group_name == source_group: + out.append(f'nat-{direction}-{rule_id}') + if dest_group: + if dest_group[0] == "!": + dest_group = dest_group[1:] + if group_name == dest_group: + out.append(f'nat-{direction}-{rule_id}') + if in_interface: + if in_interface[0] == "!": + in_interface = in_interface[1:] + if group_name == in_interface: + out.append(f'nat-{direction}-{rule_id}') + if out_interface: + if out_interface[0] == "!": + out_interface = out_interface[1:] + if group_name == out_interface: + out.append(f'nat-{direction}-{rule_id}') + + return out + + rows = [] + 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']))) + else: + row.append('N/D') + rows.append(row) + + else: + if not args.detail: + header_tail = ['Timeout', 'Expires'] + + for dynamic_type in ['address_group', 'ipv6_address_group']: + family = 'ipv4' if dynamic_type == 'address_group' else 'ipv6' + 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(): + 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'] + + members = get_nftables_group_members(family, 'vyos_filter', f'{prefix}{dynamic_name}') + + if not members: + if args.detail: + row.append('N/D') + else: + row += ["N/D"] * 3 + rows.append(row) + continue + + for idx, member in enumerate(members): + if isinstance(member, str): + # Only member, and no timeout: + val = member + timeout = "N/D" + expires = "N/D" + else: + val = member.get('val', 'N/D') + timeout = str(member.get('timeout', 'N/D')) + expires = str(member.get('expires', 'N/D')) + + if args.detail: + row.append(f'{val} (timeout: {timeout}, expires: {expires})') + continue + + if idx > 0: + row = [""] * 4 + + row += [val, timeout, expires] + rows.append(row) + + if args.detail: + header_tail += [""] * (len(members) - 1) + rows.append(row) + + if rows: + print('Firewall Groups\n') + if args.detail: + header = ['Name', 'Description', 'Type', 'References', 'Members'] + header_tail + output_firewall_vertical(rows, header, adjust=False) + else: + header = ['Name', 'Type', 'References', 'Members'] + header_tail + for i in rows: + rows[rows.index(i)].pop(1) + print(tabulate.tabulate(rows, header)) + +def show_summary(): + print('Ruleset Summary') + + conf = Config() + firewall = get_config_node(conf) + + if not firewall: + return + + header = ['Ruleset Hook', 'Ruleset Priority', 'Description', 'References'] + v4_out = [] + v6_out = [] + br_out = [] + + if 'ipv4' in firewall: + for hook, hook_conf in firewall['ipv4'].items(): + for prior, prior_conf in firewall['ipv4'][hook].items(): + description = prior_conf.get('description', '') + v4_out.append([hook, prior, description]) + + if 'ipv6' in firewall: + for hook, hook_conf in firewall['ipv6'].items(): + for prior, prior_conf in firewall['ipv6'][hook].items(): + description = prior_conf.get('description', '') + v6_out.append([hook, prior, description]) + + if 'bridge' in firewall: + for hook, hook_conf in firewall['bridge'].items(): + for prior, prior_conf in firewall['bridge'][hook].items(): + description = prior_conf.get('description', '') + br_out.append([hook, prior, description]) + + if v6_out: + print('\nIPv6 Ruleset:\n') + print(tabulate.tabulate(v6_out, header) + '\n') + + if v4_out: + print('\nIPv4 Ruleset:\n') + print(tabulate.tabulate(v4_out, header) + '\n') + + if br_out: + print('\nBridge Ruleset:\n') + print(tabulate.tabulate(br_out, header) + '\n') + + show_firewall_group() + +def show_statistics(): + print('Rulesets Statistics') + + conf = Config() + firewall = get_config_node(conf) + + if not 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(): + output_firewall_name_statistics(family, hook,prior, prior_conf) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Action', required=False) + parser.add_argument('--name', help='Firewall name', required=False, action='store', nargs='?', default='') + parser.add_argument('--family', help='IP family', required=False, action='store', nargs='?', default='') + parser.add_argument('--hook', help='Firewall hook', required=False, action='store', nargs='?', default='') + parser.add_argument('--priority', help='Firewall priority', required=False, action='store', nargs='?', default='') + parser.add_argument('--rule', help='Firewall Rule ID', required=False) + parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true') + parser.add_argument('--detail', help='Firewall view select', required=False) + + args = parser.parse_args() + + if args.action == 'show': + if not args.rule: + show_firewall_name(args.family, args.hook, args.priority) + else: + show_firewall_rule(args.family, args.hook, args.priority, args.rule) + elif args.action == 'show_all': + show_firewall() + elif args.action == 'show_family': + show_firewall_family(args.family) + elif args.action == 'show_group': + show_firewall_group(args.name) + elif args.action == 'show_statistics': + show_statistics() + elif args.action == 'show_summary': + show_summary() diff --git a/src/op_mode/flow_accounting_op.py b/src/op_mode/flow_accounting_op.py new file mode 100644 index 0000000..497ccaf --- /dev/null +++ b/src/op_mode/flow_accounting_op.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import 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.process import cmd +from vyos.utils.process import run +from vyos.logger import syslog + +# some default values +uacctd_pidfile = '/var/run/uacctd.pid' +uacctd_pipefile = '/tmp/uacctd.pipe' + +def parse_port(port): + try: + port_num = int(port) + if (port_num >= 0) and (port_num <= 65535): + return port_num + else: + raise ValueError("out of the 0-65535 range".format(port)) + except ValueError as e: + raise ValueError("Incorrect port number \'{0}\': {1}".format(port, e)) + +def parse_ports(arg): + if re.match(r'^\d+$', arg): + # Single port + port = parse_port(arg) + return {"type": "single", "value": port} + elif re.match(r'^\d+\-\d+$', arg): + # Port range + ports = arg.split("-") + ports = list(map(parse_port, ports)) + 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])} + elif re.match(r'^\d+,.*\d$', arg): + # Port list + ports = re.split(r',+', arg) # This allows duplicate commad like '1,,2,3,4' + ports = list(map(parse_port, ports)) + return {"type": "list", "value": ports} + else: + raise ValueError("Malformed port spec \'{0}\'".format(arg)) + +# check if host argument have correct format +def check_host(host): + # define regex for checking + if not ipaddress.ip_address(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 + + +# get list of interfaces +def _get_ifaces_dict(): + # run command to get ifaces list + out = cmd('/bin/ip link show') + + # read output + ifaces_out = out.splitlines() + + # make a dictionary with interfaces and indexes + ifaces_dict = {} + regex_filter = re.compile(r'^(?P<iface_index>\d+):\ (?P<iface_name>[\w\d\.]+)[:@].*$') + for iface_line in ifaces_out: + 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 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') + + # 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)) + + # return list of flows + return flows_list + + +# filter and format flows +def _flows_filter(flows, ifaces): + # predefine filtered flows list + flows_filtered = [] + + # 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' + + # iterate through flows list + for flow in flows: + # filter by interface + if cmd_args.interface: + if flow['iface_in_name'] != cmd_args.interface: + continue + # filter by host + if cmd_args.host: + if flow['ip_src'] != cmd_args.host and flow['ip_dst'] != 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 + # add filtered flows to new list + flows_filtered.append(flow) + + # stop adding if we already reached top count + if cmd_args.top: + if len(flows_filtered) == cmd_args.top: + break + + # return filtered flows + return flows_filtered + + +# 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_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('packets'), + flow.get('flows'), + flow.get('bytes') + ] + table_body.append(table_line) + # configure and fill table + table = tabulate(table_body, table_headers, tablefmt="simple") + + # print formatted table + try: + print(table) + except IOError: + sys.exit(0) + except KeyboardInterrupt: + 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') +# parse arguments +cmd_args = cmd_args_parser.parse_args() + +try: + if cmd_args.host: + check_host(cmd_args.host) + + if cmd_args.ports: + cmd_args.ports = parse_ports(cmd_args.ports) +except ValueError as e: + print(e) + sys.exit(1) + +# main logic +# do nothing if uacctd daemon is not running +if not _uacctd_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 + flows_list = _get_flows_list() + + # filter and format flows + tabledata = _flows_filter(flows_list, ifaces_dict) + + # print flows + _flows_table_print(tabledata) + +sys.exit(0) diff --git a/src/op_mode/force_mtu_host.sh b/src/op_mode/force_mtu_host.sh new file mode 100644 index 0000000..c72fc24 --- /dev/null +++ b/src/op_mode/force_mtu_host.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Copyright (C) 2020 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 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/>. + +target=$1 +interface=$2 + +# IPv4 header 20 byte + TCP header 20 byte +ipv4_overhead=40 + +# IPv6 headter 40 byte + TCP header 20 byte +ipv6_overhead=60 + +# If no arguments +if [[ $# -eq 0 ]] ; then + echo "Target host not defined" + exit 1 +fi + +# If one argument, it's ip address. If 2, the second arg "interface" +if [[ $# -eq 1 ]] ; then + mtu=$(sudo nmap -T4 --script path-mtu -F $target | grep "PMTU" | awk {'print $NF'}) +elif [[ $# -eq 2 ]]; then + mtu=$(sudo nmap -T4 -e $interface --script path-mtu -F $target | grep "PMTU" | awk {'print $NF'}) +fi + +tcpv4_mss=$(($mtu-$ipv4_overhead)) +tcpv6_mss=$(($mtu-$ipv6_overhead)) + +echo " +Recommended maximum values (or less) for target $target: +--- +MTU: $mtu +TCP-MSS: $tcpv4_mss +TCP-MSS_IPv6: $tcpv6_mss +" + diff --git a/src/op_mode/force_root-partition-auto-resize.sh b/src/op_mode/force_root-partition-auto-resize.sh new file mode 100644 index 0000000..b39e875 --- /dev/null +++ b/src/op_mode/force_root-partition-auto-resize.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Copyright (C) 2021 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 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/>. + +# ROOT_PART_DEV – root partition device path +# ROOT_PART_NAME – root partition device name +# ROOT_DEV_NAME – disk device name +# ROOT_DEV – disk device path +# ROOT_PART_NUM – number of root partition on disk +# ROOT_DEV_SIZE – disk total size in 512 bytes sectors +# ROOT_PART_SIZE – root partition total size in 512 bytes sectors +# ROOT_PART_START – number of 512 bytes sector where root partition starts +# AVAILABLE_EXTENSION_SIZE – calculation available disk space after root partition in 512 bytes sectors +ROOT_PART_DEV=$(findmnt /usr/lib/live/mount/persistence -o source -n) +ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3) +ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4) +ROOT_DEV="/dev/${ROOT_DEV_NAME}" +ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition") +ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size") +ROOT_PART_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/size") +ROOT_PART_START=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/start") +AVAILABLE_EXTENSION_SIZE=$((ROOT_DEV_SIZE - ROOT_PART_START - ROOT_PART_SIZE - 8)) + +# +# Check if device have space for root partition growing up. +# +if [ $AVAILABLE_EXTENSION_SIZE -lt 1 ]; then + echo "There is no available space for root partition extension" + exit 0; +fi + +# +# Resize the partition and grow the filesystem. +# +# "print" and "Fix" directives were added to fix GPT table if it corrupted after virtual drive extension. +# If GPT table is corrupted we'll get Fix/Ignore dialogue after "print" command. +# "Fix" will be the answer for this dialogue. +# If GPT table is fine and no auto-fix dialogue appeared the directive "Fix" simply will print parted utility help info. +parted -m ${ROOT_DEV} ---pretend-input-tty > /dev/null 2>&1 <<EOF +print +Fix +resizepart +${ROOT_PART_NUM} +Yes +100% +EOF +partprobe > /dev/null 2>&1 +resize2fs ${ROOT_PART_DEV} > /dev/null 2>&1 diff --git a/src/op_mode/format_disk.py b/src/op_mode/format_disk.py new file mode 100644 index 0000000..dc3c963 --- /dev/null +++ b/src/op_mode/format_disk.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os +import re + +from datetime import datetime + +from vyos.utils.io import ask_yes_no +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import DEVNULL +from vyos.utils.disk import device_from_id + +def list_disks(): + disks = set() + with open('/proc/partitions') as partitions_file: + for line in partitions_file: + fields = line.strip().split() + if len(fields) == 4 and fields[3].isalpha() and fields[3] != 'name': + disks.add(fields[3]) + return disks + + +def is_busy(disk: str): + """Check if given disk device is busy by re-reading it's partition table""" + return call(f'blockdev --rereadpt /dev/{disk}', stderr=DEVNULL) != 0 + + +def backup_partitions(disk: str): + """Save sfdisk partitions output to a backup file""" + + device_path = f'/dev/{disk}' + backup_ts = datetime.now().strftime('%Y%m%d-%H%M') + backup_file = f'/var/tmp/backup_{disk}.{backup_ts}' + call(f'sfdisk -d {device_path} > {backup_file}') + print(f'Partition table backup saved to {backup_file}') + + +def list_partitions(disk: str): + """List partition numbers of a given disk""" + + parts = set() + part_num_expr = re.compile(disk + '([0-9]+)') + with open('/proc/partitions') as partitions_file: + for line in partitions_file: + fields = line.strip().split() + if len(fields) == 4 and fields[3] != 'name' and part_num_expr.match(fields[3]): + part_idx = part_num_expr.match(fields[3]).group(1) + parts.add(int(part_idx)) + return parts + + +def delete_partition(disk: str, partition_idx: int): + cmd(f'parted /dev/{disk} rm {partition_idx}') + + +def format_disk_like(target: str, proto: str): + cmd(f'sfdisk -d /dev/{proto} | sfdisk --force /dev/{target}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + group = parser.add_argument_group() + group.add_argument('-t', '--target', type=str, required=True, help='Target device to format') + group.add_argument('-p', '--proto', type=str, required=True, help='Prototype device to use as reference') + parser.add_argument('--by-id', action='store_true', help='Specify device by disk id') + args = parser.parse_args() + target = args.target + proto = args.proto + if args.by_id: + target = device_from_id(target) + proto = device_from_id(proto) + + target_disk = target + eligible_target_disks = list_disks() + + proto_disk = proto + eligible_proto_disks = eligible_target_disks.copy() + eligible_proto_disks.remove(target_disk) + + if proto_disk == target_disk: + print('The two disk drives must be different.') + exit(1) + + if not os.path.exists(f'/dev/{proto_disk}'): + print(f'Device /dev/{proto_disk} does not exist') + exit(1) + + if not os.path.exists('/dev/' + target_disk): + print(f'Device /dev/{target_disk} does not exist') + exit(1) + + if target_disk not in eligible_target_disks: + print(f'Device {target_disk} can not be formatted') + exit(1) + + if proto_disk not in eligible_proto_disks: + print(f'Device {proto_disk} can not be used as a prototype for {target_disk}') + exit(1) + + if is_busy(target_disk): + print(f'Disk device {target_disk} is busy, unable to format') + exit(1) + + print(f'\nThis will re-format disk {target_disk} so that it has the same disk' + f'\npartion sizes and offsets as {proto_disk}. This will not copy' + f'\ndata from {proto_disk} to {target_disk}. But this will erase all' + 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') + exit(0) + + print(f'Re-formating disk drive {target_disk}...') + + print('Making backup copy of partitions...') + backup_partitions(target_disk) + + print('Deleting old partitions...') + for p in list_partitions(target_disk): + delete_partition(disk=target_disk, partition_idx=p) + + print(f'Creating new partitions on {target_disk} based on {proto_disk}...') + format_disk_like(target=target_disk, proto=proto_disk) + print('Done!') diff --git a/src/op_mode/generate_interfaces_debug_archive.py b/src/op_mode/generate_interfaces_debug_archive.py new file mode 100644 index 0000000..3059aad --- /dev/null +++ b/src/op_mode/generate_interfaces_debug_archive.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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/>. + +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 +import os + +# define a list of commands that needs to be executed + +CMD_LIST: list[str] = [ + "journalctl -b -n 500", + "journalctl -b -k -n 500", + "ip -s l", + "cat /proc/interrupts", + "cat /proc/softirqs", + "top -b -d 1 -n 2 -1", + "netstat -l", + "cat /proc/net/dev", + "cat /proc/net/softnet_stat", + "cat /proc/net/icmp", + "cat /proc/net/udp", + "cat /proc/net/tcp", + "cat /proc/net/netstat", + "sysctl net", + "timeout 10 tcpdump -c 500 -eni any port not 22" +] + +CMD_INTERFACES_LIST: list[str] = [ + "ethtool -i ", + "ethtool -S ", + "ethtool -g ", + "ethtool -c ", + "ethtool -a ", + "ethtool -k ", + "ethtool -i ", + "ethtool --phy-statistics " +] + +# get intefaces info +interfaces_list = os.popen('ls /sys/class/net/').read().split() + +# modify CMD_INTERFACES_LIST for all interfaces +CMD_INTERFACES_LIST_MOD=[] +for command_interface in interfaces_list: + for command_interfacev2 in CMD_INTERFACES_LIST: + CMD_INTERFACES_LIST_MOD.append (f'{command_interfacev2}{command_interface}') + +# execute a command and save the output to a file + +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) + +# get local host name +hostname: str = gethostname() +# get current time +time_now: str = datetime.now().isoformat(timespec='seconds') + +# define a temporary directory for logs and collected data +tmp_dir: Path = Path(f'/tmp/drops-debug_{time_now}') +# set file paths +drops_file: Path = Path(f'{tmp_dir}/drops.txt') +interfaces_file: Path = Path(f'{tmp_dir}/interfaces.txt') +archive_file: str = f'/tmp/packet-drops-debug_{time_now}.tar.bz2' + +# create files +tmp_dir.mkdir() +drops_file.touch() +interfaces_file.touch() + +try: + # execute all commands + for command in CMD_LIST: + save_stdout(command, drops_file) + for command_interface in CMD_INTERFACES_LIST_MOD: + save_stdout(command_interface, interfaces_file) + + # create an archive + with tar_open(name=archive_file, mode='x:bz2') as tar_file: + tar_file.add(tmp_dir) + + # inform user about success + print(f'Debug file is generated and located in {archive_file}') +except Exception as err: + print(f'Error during generating a debug file: {err}') +finally: + # cleanup + rmtree(tmp_dir) + exit() diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py new file mode 100644 index 0000000..ca2eeb5 --- /dev/null +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +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 + +# define a list of commands that needs to be executed +CMD_LIST: list[str] = [ + 'swanctl -L', + 'swanctl -l', + 'swanctl -P', + 'ip x sa show', + 'ip x policy show', + 'ip tunnel show', + 'ip address', + 'ip rule show', + 'ip route | head -100', + 'ip route show table 220' +] +JOURNALCTL_CMD: str = 'journalctl --no-hostname --boot --unit strongswan.service' + +# execute a command and save the output to a file +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) + + +# get local host name +hostname: str = gethostname() +# get current time +time_now: str = datetime.now().isoformat(timespec='seconds') + +# define a temporary directory for logs and collected data +tmp_dir: Path = Path(f'/tmp/ipsec_debug_{time_now}') +# set file paths +ipsec_status_file: Path = Path(f'{tmp_dir}/ipsec_status.txt') +journalctl_charon_file: Path = Path(f'{tmp_dir}/journalctl_charon.txt') +archive_file: str = f'/tmp/ipsec_debug_{time_now}.tar.bz2' + +# create files +tmp_dir.mkdir() +ipsec_status_file.touch() +journalctl_charon_file.touch() + +try: + # execute all commands + for command in CMD_LIST: + save_stdout(command, ipsec_status_file) + save_stdout(JOURNALCTL_CMD, journalctl_charon_file) + + # create an archive + with tar_open(name=archive_file, mode='x:bz2') as tar_file: + tar_file.add(tmp_dir) + + # inform user about success + print(f'Debug file is generated and located in {archive_file}') +except Exception as err: + print(f'Error during generating a debug file: {err}') +finally: + # cleanup + rmtree(tmp_dir) + exit() diff --git a/src/op_mode/generate_openconnect_otp_key.py b/src/op_mode/generate_openconnect_otp_key.py new file mode 100644 index 0000000..99b67d2 --- /dev/null +++ b/src/op_mode/generate_openconnect_otp_key.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os + +from vyos.utils.process import popen +from secrets import token_hex +from base64 import b32encode + +if os.geteuid() != 0: + exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True) + parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval', default="30", required=False) + parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False) + args = parser.parse_args() + + hostname = os.uname()[1] + username = args.username + digits = args.digits + period = args.interval + + # check variables: + if int(digits) < 6 or int(digits) > 8: + print("") + quit("The number of digits in the one-time password must be between '6' and '8'") + + if int(period) < 5 or int(period) > 86400: + print("") + quit("Time token interval must be between '5' and '86400' seconds") + + # generate OTP key, URL & QR: + key_hex = token_hex(20) + key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + + otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period]) + qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + + print("# You can share it with the user, he just needs to scan the QR in his OTP app") + print("# username: ", username) + print("# OTP KEY: ", key_base32) + print("# OTP URL: ", otp_url) + print(qrcode) + print('# To add this OTP key to configuration, run the following commands:') + print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'") + if period != "30": + print(f"set vpn openconnect authentication local-users username {username} otp interval '{period}'") + if digits != "6": + print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{digits}'") diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py new file mode 100644 index 0000000..1d2f106 --- /dev/null +++ b/src/op_mode/generate_ovpn_client_file.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import argparse + +from jinja2 import Template +from textwrap import fill + +from vyos.config import Config +from vyos.ifconfig import Section + +client_config = """ + +client +nobind +remote {{ local_host if local_host else 'x.x.x.x' }} {{ port }} +remote-cert-tls server +proto {{ 'tcp-client' if protocol == 'tcp-passive' else 'udp' }} +dev {{ device_type }} +dev-type {{ device_type }} +persist-key +persist-tun +verb 3 + +# Encryption options +{# Define the encryption map #} +{% set encryption_map = { + 'des': 'DES-CBC', + '3des': 'DES-EDE3-CBC', + 'bf128': 'BF-CBC', + 'bf256': 'BF-CBC', + 'aes128gcm': 'AES-128-GCM', + 'aes128': 'AES-128-CBC', + 'aes192gcm': 'AES-192-GCM', + 'aes192': 'AES-192-CBC', + 'aes256gcm': 'AES-256-GCM', + 'aes256': 'AES-256-CBC' +} %} + +{% if encryption is defined and encryption is not none %} +{% if encryption.data_ciphers is defined and encryption.data_ciphers is not none %} +cipher {% for algo in encryption.data_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} + +data-ciphers {% for algo in encryption.data_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} +{% endif %} +{% endif %} + +{% if hash is defined and hash is not none %} +auth {{ hash }} +{% endif %} +{{ 'comp-lzo' if use_lzo_compression is defined else '' }} + +<ca> +-----BEGIN CERTIFICATE----- +{{ ca }} +-----END CERTIFICATE----- + +</ca> + +<cert> +-----BEGIN CERTIFICATE----- +{{ cert }} +-----END CERTIFICATE----- + +</cert> + +<key> +-----BEGIN PRIVATE KEY----- +{{ key }} +-----END PRIVATE KEY----- + +</key> + +""" + +config = Config() +base = ['interfaces', 'openvpn'] + +if not config.exists(base): + print('OpenVPN not configured') + exit(0) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + "-i", + "--interface", + type=str, + help='OpenVPN interface the client is connecting to', + required=True, + ) + parser.add_argument( + "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True + ) + parser.add_argument( + "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True + ) + parser.add_argument( + "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store" + ) + args = parser.parse_args() + + interface = args.interface + ca = args.ca + cert = args.cert + key = args.key + if not key: + key = args.cert + + if interface not in Section.interfaces('openvpn'): + exit(f'OpenVPN interface "{interface}" does not exist!') + + if not config.exists(['pki', 'ca', ca, 'certificate']): + exit(f'OpenVPN CA certificate "{ca}" does not exist!') + + if not config.exists(['pki', 'certificate', cert, 'certificate']): + exit(f'OpenVPN certificate "{cert}" does not exist!') + + if not config.exists(['pki', 'certificate', cert, 'private', 'key']): + exit(f'OpenVPN certificate key "{key}" does not exist!') + + config = config.get_config_dict( + base + [interface], + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + with_pki=True, + ) + + ca = config['pki']['ca'][ca]['certificate'] + ca = fill(ca, width=64) + cert = config['pki']['certificate'][cert]['certificate'] + cert = fill(cert, width=64) + key = config['pki']['certificate'][key]['private']['key'] + key = fill(key, width=64) + + config['ca'] = ca + config['cert'] = cert + config['key'] = key + config['port'] = '1194' if 'local_port' not in config else config['local_port'] + + client = Template(client_config, trim_blocks=True).render(config) + print(client) diff --git a/src/op_mode/generate_public_key_command.py b/src/op_mode/generate_public_key_command.py new file mode 100644 index 0000000..8ba55c9 --- /dev/null +++ b/src/op_mode/generate_public_key_command.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import urllib.parse + +import vyos.remote +from vyos.template import generate_uuid4 + + +def get_key(path) -> list: + """Get public keys from a local file or remote URL + + Args: + path: Path to the public keys file + + Returns: list of public keys split by new line + + """ + url = urllib.parse.urlparse(path) + if url.scheme == 'file' or url.scheme == '': + with open(os.path.expanduser(path), 'r') as f: + key_string = f.read() + else: + key_string = vyos.remote.get_remote_config(path) + return key_string.split('\n') + + +if __name__ == "__main__": + first_loop = True + + for k in get_key(sys.argv[2]): + k = k.split() + # Skip empty list entry + if k == []: + continue + + try: + username = sys.argv[1] + # Github keys don't have identifier for example 'vyos@localhost' + # 'ssh-rsa AAAA... vyos@localhost' + # Generate uuid4 identifier + identifier = f'github@{generate_uuid4("")}' if sys.argv[2].startswith('https://github.com') else k[2] + algorithm, key = k[0], k[1] + except Exception as e: + print("Failed to retrieve the public key: {}".format(e)) + sys.exit(1) + + if first_loop: + print('# To add this key as an embedded key, run the following commands:') + print('configure') + print(f'set system login user {username} authentication public-keys {identifier} key {key}') + print(f'set system login user {username} authentication public-keys {identifier} type {algorithm}') + + first_loop = False diff --git a/src/op_mode/generate_service_rule-resequence.py b/src/op_mode/generate_service_rule-resequence.py new file mode 100644 index 0000000..9333d63 --- /dev/null +++ b/src/op_mode/generate_service_rule-resequence.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import argparse +from vyos.configquery import ConfigTreeQuery + + +def convert_to_set_commands(config_dict, parent_key=''): + """ + Converts a configuration dictionary into a list of set commands. + + Args: + config_dict (dict): The configuration dictionary. + parent_key (str): The parent key for nested dictionaries. + + Returns: + list: A list of set commands. + """ + commands = [] + for key, value in config_dict.items(): + current_key = parent_key + key if parent_key else key + + if isinstance(value, dict): + if not value: + commands.append(f"set {current_key}") + else: + commands.extend( + convert_to_set_commands(value, f"{current_key} ")) + + elif isinstance(value, list): + for item in value: + commands.append(f"set {current_key} '{item}'") + + elif isinstance(value, str): + commands.append(f"set {current_key} '{value}'") + + return commands + + +def change_rule_numbers(config_dict, start, step): + """ + Changes rule numbers in the configuration dictionary. + + Args: + config_dict (dict): The configuration dictionary. + start (int): The starting rule number. + step (int): The step to increment the rule numbers. + + Returns: + None + """ + if 'rule' in config_dict: + rule_dict = config_dict['rule'] + updated_rule_dict = {} + rule_num = start + for rule_key in sorted(rule_dict.keys()): + updated_rule_dict[str(rule_num)] = rule_dict[rule_key] + rule_num += step + config_dict['rule'] = updated_rule_dict + + for key in config_dict: + if isinstance(config_dict[key], dict): + change_rule_numbers(config_dict[key], start, step) + + +def convert_rule_keys_to_int(config_dict, prev_key=None): + """ + Converts rule keys in the configuration dictionary to integers. + + Args: + config_dict (dict or list): The configuration dictionary or list. + + Returns: + dict or list: The modified dictionary or list. + """ + if isinstance(config_dict, dict): + new_dict = {} + for key, value in config_dict.items(): + # Convert key to integer if possible + new_key = int(key) if key.isdigit() and prev_key == 'rule' else key + + # Recur for nested dictionaries + if isinstance(value, dict): + new_value = convert_rule_keys_to_int(value, key) + else: + new_value = value + + new_dict[new_key] = new_value + + return new_dict + elif isinstance(config_dict, list): + return [convert_rule_keys_to_int(item) for item in config_dict] + else: + return config_dict + + +if __name__ == "__main__": + # Parse command-line arguments + parser = argparse.ArgumentParser(description='Convert dictionary to set commands with rule number modifications.') + parser.add_argument('--service', type=str, help='Name of service') + parser.add_argument('--start', type=int, default=100, help='Start rule number (default: 100)') + parser.add_argument('--step', type=int, default=10, help='Step for rule numbers (default: 10)') + args = parser.parse_args() + + config = ConfigTreeQuery() + if not config.exists(args.service): + print(f'{args.service} is not configured') + exit(1) + + config_dict = config.get_config_dict(args.service) + + if 'firewall' in config_dict: + # Remove global-options, group and flowtable as they don't need sequencing + for item in ['global-options', 'group', 'flowtable']: + if item in config_dict['firewall']: + del config_dict['firewall'][item] + + # Convert rule keys to integers, rule "10" -> rule 10 + # This is necessary for sorting the rules + config_dict = convert_rule_keys_to_int(config_dict) + + # Apply rule number modifications + change_rule_numbers(config_dict, start=args.start, step=args.step) + + # Convert to 'set' commands + set_commands = convert_to_set_commands(config_dict) + + print() + for command in set_commands: + print(command) + print() diff --git a/src/op_mode/generate_ssh_server_key.py b/src/op_mode/generate_ssh_server_key.py new file mode 100644 index 0000000..d6063c4 --- /dev/null +++ b/src/op_mode/generate_ssh_server_key.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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/>. + +from sys import exit +from vyos.utils.io import ask_yes_no +from vyos.utils.process import cmd +from vyos.utils.commit import commit_in_progress + +if not ask_yes_no('Do you really want to remove the existing SSH host keys?'): + exit(0) + +if commit_in_progress(): + print('Cannot restart SSH while a commit is in progress') + exit(1) + +cmd('rm -v /etc/ssh/ssh_host_*') +cmd('dpkg-reconfigure openssh-server') +cmd('systemctl restart ssh.service') diff --git a/src/op_mode/generate_system_login_user.py b/src/op_mode/generate_system_login_user.py new file mode 100644 index 0000000..1b328ea --- /dev/null +++ b/src/op_mode/generate_system_login_user.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os + +from vyos.utils.process import popen +from secrets import token_hex +from base64 import b32encode + +if os.geteuid() != 0: + exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True) + parser.add_argument("-l", "--rate_limit", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 3)', default="3", required=False) + parser.add_argument("-t", "--rate_time", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 30)', default="30", required=False) + parser.add_argument("-w", "--window_size", type=str, help='Set window of concurrently valid codes (default: 3)', default="3", required=False) + parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval', default="30", required=False) + parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False) + args = parser.parse_args() + + hostname = os.uname()[1] + username = args.username + rate_limit = args.rate_limit + rate_time = args.rate_time + window_size = args.window_size + digits = args.digits + period = args.interval + + # check variables: + if int(rate_limit) < 1 or int(rate_limit) > 10: + print("") + quit("Number of logins (rate-limit) must be between '1' and '10'") + + if int(rate_time) < 15 or int(rate_time) > 600: + print("") + quit("The rate-time must be between '15' and '600' seconds") + + if int(window_size) < 1 or int(window_size) > 21: + print("") + quit("Window of concurrently valid codes must be between '1' and '21' seconds") + + # generate OTP key, URL & QR: + key_hex = token_hex(20) + key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + + otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period]) + qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + + print("# You can share it with the user, he just needs to scan the QR in his OTP app") + print("# username: ", username) + print("# OTP KEY: ", key_base32) + print("# OTP URL: ", otp_url) + print(qrcode) + print('# To add this OTP key to configuration, run the following commands:') + print(f"set system login user {username} authentication otp key '{key_base32}'") + if rate_limit != "3": + print(f"set system login user {username} authentication otp rate-limit '{rate_limit}'") + if rate_time != "30": + print(f"set system login user {username} authentication otp rate-time '{rate_time}'") + if window_size != "3": + print(f"set system login user {username} authentication otp window-size '{window_size}'") diff --git a/src/op_mode/generate_tech-support_archive.py b/src/op_mode/generate_tech-support_archive.py new file mode 100644 index 0000000..41b53cd --- /dev/null +++ b/src/op_mode/generate_tech-support_archive.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import 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.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) +def __rotate_logs(path: str, log_pattern:str): + files_list = glob.glob(f'{path}/{log_pattern}') + if len(files_list) > 5: + oldest_file = min(files_list, key=os.path.getctime) + os.remove(oldest_file) + + +def __generate_archived_files(location_path: str) -> None: + """ + Generate arhives of main directories + :param location_path: path to temporary directory + :type location_path: str + """ + # Dictionary arhive_name:directory_to_arhive + archive_dict = { + 'etc': '/etc', + 'home': '/home', + 'var-log': '/var/log', + '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' + } + for archive_name, path in archive_dict.items(): + archive_file: str = 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) + + +def __generate_main_archive_file(archive_file: str, tmp_dir_path: str) -> None: + """ + Generate main arhive file + :param archive_file: name of arhive file + :type archive_file: str + :param tmp_dir_path: path to arhive memeber + :type tmp_dir_path: str + """ + 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)) + + +if __name__ == '__main__': + defualt_tmp_dir = '/tmp' + parser = argparse.ArgumentParser() + parser.add_argument("path", nargs='?', default=defualt_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 + else: + tmp_path = location_path + archive_pattern = f'_tech-support-archive_' + archive_file_name = f'{hostname}{archive_pattern}{time_now}.tar.gz' + + # Log rotation in tmp directory + if tmp_path == defualt_tmp_dir: + __rotate_logs(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.mkdir(parents=True) + + report_file: Path = Path(f'{tmp_dir_path}/show_tech-support_report.txt') + report_file.touch() + try: + + save_stdout(op('show tech-support report'), report_file) + # Generate included archives + __generate_archived_files(tmp_dir_path) + + # Generate main archive + __generate_main_archive_file(f'{tmp_path}/{archive_file_name}', tmp_dir_path) + # Delete temporary directory + rmtree(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}') + except Exception as err: + print(f'Error during generating a debug file: {err}') + # cleanup + if tmp_dir.exists(): + rmtree(tmp_dir) + finally: + # cleanup + exit() diff --git a/src/op_mode/igmp-proxy.py b/src/op_mode/igmp-proxy.py new file mode 100644 index 0000000..709e259 --- /dev/null +++ b/src/op_mode/igmp-proxy.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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/>. + +# File: show_igmpproxy.py +# Purpose: +# Display istatistics from IPv4 IGMP proxy. +# Used by the "run show ip multicast" command tree. + +import ipaddress +import json +import socket +import sys +import tabulate + +import vyos.config +import vyos.opmode + +from vyos.utils.convert import bytes_to_human +from vyos.utils.io import print_error +from vyos.utils.process import process_named_running + +def _is_configured(): + """Check if IGMP proxy is configured""" + return vyos.config.Config().exists_effective('protocols igmp-proxy') + +def _kernel_to_ip(addr): + """ + Convert any given address from Linux kernel to a proper, IPv4 address + using the correct host byte order. + """ + # Convert from hex 'FE000A0A' to decimal '4261415434' + addr = int(addr, 16) + # Kernel ABI _always_ uses network byte order. + addr = socket.ntohl(addr) + return str(ipaddress.IPv4Address(addr)) + +def _process_mr_vif(): + """Read rows from /proc/net/ip_mr_vif into dicts.""" + result = [] + with open('/proc/net/ip_mr_vif', 'r') as f: + next(f) + for line in f: + result.append({ + 'Interface': line.split()[1], + 'PktsIn' : int(line.split()[3]), + 'PktsOut' : int(line.split()[5]), + 'BytesIn' : int(line.split()[2]), + 'BytesOut' : int(line.split()[4]), + 'Local' : _kernel_to_ip(line.split()[7]), + }) + return result + +def show_interface(raw: bool): + if data := _process_mr_vif(): + if raw: + # Make the interface name the key for each row. + table = {} + for v in data: + table[v.pop('Interface')] = v + return json.loads(json.dumps(table)) + # Make byte values human-readable for the table. + arr = [] + for x in data: + arr.append({k: bytes_to_human(v) if k.startswith('Bytes') \ + else v for k, v in x.items()}) + return tabulate.tabulate(arr, headers='keys') + + +if not _is_configured(): + print_error('IGMP proxy is not configured.') + sys.exit(0) +if not process_named_running('igmpproxy'): + print_error('IGMP proxy is not running.') + sys.exit(0) + + +if __name__ == "__main__": + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print_error(e) + sys.exit(1) diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py new file mode 100644 index 0000000..cf2bc6d --- /dev/null +++ b/src/op_mode/ikev2_profile_generator.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +# +# 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 +# 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 argparse + +from sys import exit +from socket import getfqdn +from cryptography.x509.oid import NameOID + +from vyos.configquery import ConfigTreeQuery +from vyos.config import config_dict_mangle_acme +from vyos.pki import CERT_BEGIN +from vyos.pki import CERT_END +from vyos.pki import find_chain +from vyos.pki import encode_certificate +from vyos.pki import load_certificate +from vyos.template import render_to_string +from vyos.utils.io import ask_input + +# Apple profiles only support one IKE/ESP encryption cipher and hash, whereas +# VyOS comes with a multitude of different proposals for a connection. +# +# We take all available proposals from the VyOS CLI and ask the user which one +# he would like to get enabled in his profile - thus there is limited possibility +# to select a proposal that is not supported on the connection profile. +# +# IOS supports IKE-SA encryption algorithms: +# - DES +# - 3DES +# - AES-128 +# - AES-256 +# - AES-128-GCM +# - AES-256-GCM +# - ChaCha20Poly1305 +# +vyos2apple_cipher = { + '3des' : '3DES', + 'aes128' : 'AES-128', + 'aes256' : 'AES-256', + 'aes128gcm128' : 'AES-128-GCM', + 'aes256gcm128' : 'AES-256-GCM', + 'chacha20poly1305' : 'ChaCha20Poly1305', +} + +# Windows supports IKE-SA encryption algorithms: +# - DES3 +# - AES128 +# - AES192 +# - AES256 +# - GCMAES128 +# - GCMAES192 +# - GCMAES256 +# +vyos2windows_cipher = { + '3des' : 'DES3', + 'aes128' : 'AES128', + 'aes192' : 'AES192', + 'aes256' : 'AES256', + 'aes128gcm128' : 'GCMAES128', + 'aes192gcm128' : 'GCMAES192', + 'aes256gcm128' : 'GCMAES256', +} + +# IOS supports IKE-SA integrity algorithms: +# - SHA1-96 +# - SHA1-160 +# - SHA2-256 +# - SHA2-384 +# - SHA2-512 +# +vyos2apple_integrity = { + 'sha1' : 'SHA1-96', + 'sha1_160' : 'SHA1-160', + 'sha256' : 'SHA2-256', + 'sha384' : 'SHA2-384', + 'sha512' : 'SHA2-512', +} + +# Windows supports IKE-SA integrity algorithms: +# - SHA1-96 +# - SHA1-160 +# - SHA2-256 +# - SHA2-384 +# - SHA2-512 +# +vyos2windows_integrity = { + 'sha1' : 'SHA196', + 'sha256' : 'SHA256', + 'aes128gmac' : 'GCMAES128', + 'aes192gmac' : 'GCMAES192', + 'aes256gmac' : 'GCMAES256', +} + +# IOS 14.2 and later do no support dh-group 1,2 and 5. Supported DH groups would +# be: 14, 15, 16, 17, 18, 19, 20, 21, 31, 32 +vyos2apple_dh_group = { + '14' : '14', + '15' : '15', + '16' : '16', + '17' : '17', + '18' : '18', + '19' : '19', + '20' : '20', + '21' : '21', + '31' : '31', + '32' : '32' +} + +# Newer versions of Windows support groups 19 and 20, albeit under a different naming convention +vyos2windows_dh_group = { + '1' : 'Group1', + '2' : 'Group2', + '14' : 'Group14', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'Group24' +} + +# For PFS, Windows also has its own inconsistent naming scheme for each group +vyos2windows_pfs_group = { + '1' : 'PFS1', + '2' : 'PFS2', + '14' : 'PFS2048', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'PFS24' +} + +parser = argparse.ArgumentParser() +parser.add_argument('--os', const='all', nargs='?', choices=['ios', 'windows'], help='Operating system used for config generation', required=True) +parser.add_argument("--connection", action="store", help='IPsec IKEv2 remote-access connection name from CLI', required=True) +parser.add_argument("--remote", action="store", help='VPN connection remote-address where the client will connect to', required=True) +parser.add_argument("--profile", action="store", help='IKEv2 profile name used in the profile list on the device') +parser.add_argument("--name", action="store", help='VPN connection name as seen in the VPN application later') +args = parser.parse_args() + +ipsec_base = ['vpn', 'ipsec'] +config_base = ipsec_base + ['remote-access', 'connection'] +pki_base = ['pki'] +conf = ConfigTreeQuery() +if not conf.exists(config_base): + exit('IPsec remote-access is not configured!') +if not conf.exists(pki_base): + exit('PKI is not configured!') + +profile_name = 'VyOS IKEv2 Profile' +if args.profile: + profile_name = args.profile + +vpn_name = 'VyOS IKEv2 VPN' +if args.name: + vpn_name = args.name + +conn_base = config_base + [args.connection] +if not conf.exists(conn_base): + exit(f'IPsec remote-access connection "{args.connection}" does not exist!') + +data = conf.get_config_dict(conn_base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + +data['profile_name'] = profile_name +data['vpn_name'] = vpn_name +data['remote'] = args.remote +# This is a reverse-DNS style unique identifier used to detect duplicate profiles +tmp = getfqdn().split('.') +tmp = reversed(tmp) +data['rfqdn'] = '.'.join(tmp) + +if args.os == 'ios': + pki = conf.get_config_dict(pki_base, get_first_key=True) + if 'certificate' in pki: + for certificate in pki['certificate']: + pki['certificate'][certificate] = config_dict_mangle_acme(certificate, pki['certificate'][certificate]) + + cert_name = data['authentication']['x509']['certificate'] + + + cert_data = load_certificate(pki['certificate'][cert_name]['certificate']) + data['cert_common_name'] = cert_data.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + data['ca_common_name'] = cert_data.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + data['ca_certificates'] = [] + + loaded_ca_certs = {load_certificate(c['certificate']) + for c in pki['ca'].values()} if 'ca' in pki else {} + + for ca_name in data['authentication']['x509']['ca_certificate']: + loaded_ca_cert = load_certificate(pki['ca'][ca_name]['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + for ca in ca_full_chain: + tmp = { + 'ca_name' : ca.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value, + 'ca_chain' : encode_certificate(ca).replace(CERT_BEGIN, '').replace(CERT_END, '').replace('\n', ''), + } + data['ca_certificates'].append(tmp) + + # Remove duplicate list entries for CA certificates, as they are added by their common name + # https://stackoverflow.com/a/9427216 + data['ca_certificates'] = [dict(t) for t in {tuple(d.items()) for d in data['ca_certificates']}] + +esp_group = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group']], + key_mangling=('-', '_'), get_first_key=True) +ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'], 'proposal'], + key_mangling=('-', '_'), get_first_key=True) + +# This script works only for Apple iOS/iPadOS and Windows. Both operating systems +# have different limitations thus we load the limitations based on the operating +# system used. + +vyos2client_cipher = vyos2apple_cipher if args.os == 'ios' else vyos2windows_cipher; +vyos2client_integrity = vyos2apple_integrity if args.os == 'ios' else vyos2windows_integrity; +vyos2client_dh_group = vyos2apple_dh_group if args.os == 'ios' else vyos2windows_dh_group + +def transform_pfs(pfs, ike_dh_group): + pfs_enabled = (pfs != 'disable') + if pfs == 'enable': + pfs_dh_group = ike_dh_group + elif pfs.startswith('dh-group'): + pfs_dh_group = pfs.removeprefix('dh-group') + + if args.os == 'ios': + if pfs_enabled: + if pfs_dh_group not in set(vyos2apple_dh_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return pfs_dh_group + else: + return None + else: + if pfs_enabled: + if pfs_dh_group not in set(vyos2windows_pfs_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return vyos2windows_pfs_group[ pfs_dh_group ] + else: + return 'None' + +# Create a dictionary containing client conform IKE settings +ike = {} +count = 1 +for _, proposal in ike_proposal.items(): + if {'dh_group', 'encryption', 'hash'} <= set(proposal): + if (proposal['encryption'] in set(vyos2client_cipher) and + proposal['hash'] in set(vyos2client_integrity) and + proposal['dh_group'] in set(vyos2client_dh_group)): + + # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme + proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] + proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # DH group will need to be transformed later after we calculate PFS group + + ike.update( { str(count) : proposal } ) + count += 1 + +# Create a dictionary containing client conform ESP settings +esp = {} +count = 1 +for _, proposal in esp_group['proposal'].items(): + if {'encryption', 'hash'} <= set(proposal): + if proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity): + # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme + proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] + proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # Copy PFS setting from the group, if present (we will need to + # transform this later once the IKE group is selected) + proposal['pfs'] = esp_group.get('pfs', 'enable') + + esp.update( { str(count) : proposal } ) + count += 1 +try: + if len(ike) > 1: + # Propare the input questions for the user + tmp = '\n' + for number, options in ike.items(): + tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}, DH group {options["dh_group"]}\n' + tmp += '\nSelect one of the above IKE groups: ' + data['ike_encryption'] = ike[ ask_input(tmp, valid_responses=list(ike)) ] + elif len(ike) == 1: + data['ike_encryption'] = ike['1'] + else: + exit(f'None of the configured IKE proposals for "{args.connection}" are supported by the client!') + + if len(esp) > 1: + tmp = '\n' + for number, options in esp.items(): + tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}\n' + tmp += '\nSelect one of the above ESP groups: ' + data['esp_encryption'] = esp[ ask_input(tmp, valid_responses=list(esp)) ] + elif len(esp) == 1: + data['esp_encryption'] = esp['1'] + else: + exit(f'None of the configured ESP proposals for "{args.connection}" are supported by the client!') + +except KeyboardInterrupt: + exit("Interrupted") + +# Transform the DH and PFS groups now that all selections are known +data['esp_encryption']['pfs'] = transform_pfs(data['esp_encryption']['pfs'], data['ike_encryption']['dh_group']) +data['ike_encryption']['dh_group'] = vyos2client_dh_group[ data['ike_encryption']['dh_group'] ] + +print('\n\n==== <snip> ====') +if args.os == 'ios': + print(render_to_string('ipsec/ios_profile.j2', data)) + print('==== </snip> ====\n') + print('Save the XML from above to a new file named "vyos.mobileconfig" and E-Mail it to your phone.') +elif args.os == 'windows': + print(render_to_string('ipsec/windows_profile.j2', data)) + print('==== </snip> ====\n') diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py new file mode 100644 index 0000000..56aefcd --- /dev/null +++ b/src/op_mode/image_info.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS 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 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +import sys +from typing import Union + +from tabulate import tabulate + +from vyos import opmode +from vyos.system import disk +from vyos.system import grub +from vyos.system import image +from vyos.utils.convert import bytes_to_human + + +def _format_show_images_summary(images_summary: image.BootDetails) -> str: + headers: list[str] = ['Name', 'Default boot', 'Running'] + table_data: list[list[str]] = list() + for image_item in images_summary.get('images_available', []): + name: str = image_item + if images_summary.get('image_default') == name: + default: str = 'Yes' + else: + default: str = '' + + if images_summary.get('image_running') == name: + running: str = 'Yes' + else: + running: str = '' + + table_data.append([name, default, running]) + tabulated: str = tabulate(table_data, headers) + + return tabulated + + +def _format_show_images_details( + images_details: list[image.ImageDetails]) -> str: + headers: list[str] = [ + 'Name', 'Version', 'Storage Read-Only', 'Storage Read-Write', + 'Storage Total' + ] + table_data: list[list[Union[str, int]]] = list() + for image_item in images_details: + name: str = image_item.get('name') + version: str = image_item.get('version') + disk_ro: str = bytes_to_human(image_item.get('disk_ro'), + precision=1, int_below_exponent=30) + disk_rw: str = bytes_to_human(image_item.get('disk_rw'), + precision=1, int_below_exponent=30) + disk_total: str = bytes_to_human(image_item.get('disk_total'), + precision=1, int_below_exponent=30) + table_data.append([name, version, disk_ro, disk_rw, disk_total]) + tabulated: str = tabulate(table_data, headers, + colalign=('left', 'left', 'right', 'right', 'right')) + + return tabulated + + +def show_images_summary(raw: bool) -> Union[image.BootDetails, str]: + images_available: list[str] = grub.version_list() + root_dir: str = disk.find_persistence() + boot_vars: dict = grub.vars_read(f'{root_dir}/{image.CFG_VYOS_VARS}') + + images_summary: image.BootDetails = dict() + + images_summary['image_default'] = image.get_default_image() + images_summary['image_running'] = image.get_running_image() + images_summary['images_available'] = images_available + images_summary['console_type'] = boot_vars.get('console_type') + images_summary['console_num'] = boot_vars.get('console_num') + + if raw: + return images_summary + else: + return _format_show_images_summary(images_summary) + + +def show_images_details(raw: bool) -> Union[list[image.ImageDetails], str]: + images_details = image.get_images_details() + + if raw: + return images_details + else: + return _format_show_images_details(images_details) + + +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/image_installer.py b/src/op_mode/image_installer.py new file mode 100644 index 0000000..bdc16de --- /dev/null +++ b/src/op_mode/image_installer.py @@ -0,0 +1,1056 @@ +#!/usr/bin/env python3 +# +# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS 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 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +from argparse import ArgumentParser, Namespace +from pathlib import Path +from shutil import copy, chown, rmtree, copytree +from glob import glob +from sys import exit +from os import environ +from os import readlink +from os import getpid, getppid +from typing import Union +from urllib.parse import urlparse +from passlib.hosts import linux_context +from errno import ENOSPC + +from psutil import disk_partitions + +from vyos.configtree import ConfigTree +from vyos.configquery import ConfigTreeQuery +from vyos.remote import download +from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER +from vyos.template import render +from vyos.utils.io import ask_input, ask_yes_no, select_entry +from vyos.utils.file import chmod_2775 +from vyos.utils.process import cmd, run +from vyos.version import get_remote_version, 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_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_ARCHITECTURE_MISMATCH: str = 'Upgrading to a different image architecture will break your system.' +MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.' +MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation' +MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.' +MSG_INFO_INSTALL_DISKS_LIST: str = 'The following disks were found:' +MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?' +MSG_INFO_INSTALL_RAID_CONFIGURE: str = 'Would you like to configure RAID-1 mirroring?' +MSG_INFO_INSTALL_RAID_FOUND_DISKS: str = 'Would you like to configure RAID-1 mirroring on them?' +MSG_INFO_INSTALL_RAID_CHOOSE_DISKS: str = 'Would you like to choose two disks for RAID-1 mirroring?' +MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. Continue?' +MSG_INFO_INSTALL_RAID_CONFIRM: str = 'Installation will delete all data on both drives. Continue?' +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_IMAGE_NAME: str = 'What would you like to name this image?' +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:' +MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?' +MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?' +MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial)?' +MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?' +MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?' +MSG_INPUT_COPY_ENC_DATA: str = 'Would you like to copy the encrypted config to the new image?' +MSG_INPUT_CHOOSE_COPY_ENC_DATA: str = 'From which image would you like to copy the encrypted config?' +MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?' +MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?' +MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.' +MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again' +MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\ +'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9' +MSG_WARN_PASSWORD_CONFIRM: str = 'The entered values did not match. Try again' +MSG_WARN_FLAVOR_MISMATCH: str = 'The running image flavor is "{0}". The new image flavor is "{1}".\n' \ +'Installing a different image flavor may cause functionality degradation or break your system.\n' \ +'Do you want to continue with installation?' +CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB +CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB +# a reserved space: 2MB for header, 1 MB for BIOS partition, 256 MB for EFI +CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2 + +# define directories and paths +DIR_INSTALLATION: str = '/mnt/installation' +DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src' +DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst' +DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src' +DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst' +DIR_KERNEL_SRC: str = '/boot/' +FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs' +ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso' + +external_download_script = '/usr/libexec/vyos/simple-download.py' + +# default boot variables +DEFAULT_BOOT_VARS: dict[str, str] = { + 'timeout': '5', + 'console_type': 'tty', + 'console_num': '0', + 'console_speed': '115200', + 'bootmode': 'normal' +} + + +def bytes_to_gb(size: int) -> float: + """Convert Bytes to GBytes, rounded to 1 decimal number + + Args: + size (int): input size in bytes + + Returns: + float: size in GB + """ + return round(size / 1024**3, 1) + + +def gb_to_bytes(size: float) -> int: + """Convert GBytes to Bytes + + Args: + size (float): input size in GBytes + + Returns: + int: size in bytes + """ + return int(size * 1024**3) + + +def find_disks() -> dict[str, int]: + """Find a target disk for installation + + Returns: + dict[str, int]: a list of available disks by name and size + """ + # check for available disks + print('Probing disks') + disks_available: dict[str, int] = disk.disks_size() + for disk_name, disk_size in disks_available.copy().items(): + if disk_size < CONST_MIN_DISK_SIZE: + del disks_available[disk_name] + if not disks_available: + print(MSG_ERR_NO_DISK) + exit(MSG_INFO_INSTALL_EXIT) + + num_disks: int = len(disks_available) + print(f'{num_disks} disk(s) found') + + return disks_available + + +def ask_root_size(available_space: int) -> int: + """Define a size of root partition + + Args: + available_space (int): available space in bytes for a root partition + + Returns: + int: defined size + """ + if ask_yes_no(MSG_INPUT_ROOT_SIZE_ALL, default=True): + return available_space + + while True: + root_size_gb: str = ask_input(MSG_INPUT_ROOT_SIZE_SET) + root_size_kbytes: int = (gb_to_bytes(float(root_size_gb))) // 1024 + + if root_size_kbytes > available_space: + print(MSG_WARN_ROOT_SIZE_TOOBIG) + continue + if root_size_kbytes < CONST_MIN_ROOT_SIZE / 1024: + print(MSG_WARN_ROOT_SIZE_TOOSMALL) + continue + + return root_size_kbytes + +def create_partitions(target_disk: str, target_size: int, + prompt: bool = True) -> None: + """Create partitions on a target disk + + Args: + target_disk (str): a target disk + target_size (int): size of disk in bytes + """ + # define target rootfs size in KB (smallest unit acceptable by sgdisk) + available_size: int = (target_size - CONST_RESERVED_SPACE) // 1024 + if prompt: + rootfs_size: int = ask_root_size(available_size) + else: + rootfs_size: int = available_size + + print(MSG_INFO_INSTALL_PARTITONING) + raid.clear() + disk.disk_cleanup(target_disk) + disk_details: disk.DiskDetails = disk.parttable_create(target_disk, + rootfs_size) + + return disk_details + + +def search_format_selection(image: tuple[str, str]) -> str: + """Format a string for selection of image + + Args: + image (tuple[str, str]): a tuple of image name and drive + + Returns: + str: formatted string + """ + return f'{image[0]} on {image[1]}' + + +def search_previous_installation(disks: list[str]) -> None: + """Search disks for previous installation config and SSH keys + + Args: + disks (list[str]): a list of available disks + """ + mnt_config = '/mnt/config' + mnt_encrypted_config = '/mnt/encrypted_config' + mnt_ssh = '/mnt/ssh' + mnt_tmp = '/mnt/tmp' + rmtree(Path(mnt_config), ignore_errors=True) + rmtree(Path(mnt_ssh), ignore_errors=True) + Path(mnt_tmp).mkdir(exist_ok=True) + Path(mnt_encrypted_config).unlink(missing_ok=True) + + print('Searching for data from previous installations') + image_data = [] + encrypted_configs = [] + 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(): + image_data.append((path.name, partition)) + if Path(mnt_tmp + '/luks').exists(): + for path in Path(mnt_tmp + '/luks').iterdir(): + encrypted_configs.append((path.name, partition)) + + disk.partition_umount(partition) + + image_name = None + image_drive = None + encrypted = False + + if len(image_data) > 0: + if len(image_data) == 1: + print('Found data from previous installation:') + print(f'\t{" on ".join(image_data[0])}') + if ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + image_name, image_drive = image_data[0] + + elif len(image_data) > 1: + print('Found data from previous installations') + if ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + image_name, image_drive = select_entry(image_data, + 'Available versions:', + MSG_INPUT_CHOOSE_COPY_DATA, + search_format_selection) + elif len(encrypted_configs) > 0: + if len(encrypted_configs) == 1: + print('Found encrypted config from previous installation:') + print(f'\t{" on ".join(encrypted_configs[0])}') + if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True): + image_name, image_drive = encrypted_configs[0] + encrypted = True + + elif len(encrypted_configs) > 1: + print('Found encrypted configs from previous installations') + if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True): + image_name, image_drive = select_entry(encrypted_configs, + 'Available versions:', + MSG_INPUT_CHOOSE_COPY_ENC_DATA, + search_format_selection) + encrypted = True + + else: + print('No previous installation found') + return + + if not image_name: + return + + disk.partition_mount(image_drive, mnt_tmp) + + if not encrypted: + copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + else: + copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config) + + Path(mnt_ssh).mkdir() + host_keys: list[str] = glob(f'{mnt_tmp}/boot/{image_name}/rw/etc/ssh/ssh_host*') + for host_key in host_keys: + copy(host_key, mnt_ssh) + + disk.partition_umount(image_drive) + +def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True): + if not Path(src).is_file(): + return + if Path(dst).is_dir(): + dst = Path(dst).joinpath(Path(src).name) + st = Path(src).stat() + copy(src, dst, follow_symlinks=follow_symlinks) + chown(dst, user=st.st_uid) + + +def copy_previous_installation_data(target_dir: str) -> None: + if Path('/mnt/config').exists(): + copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config', + dirs_exist_ok=True) + if Path('/mnt/ssh').exists(): + copytree('/mnt/ssh', f'{target_dir}/etc/ssh', + dirs_exist_ok=True) + + +def copy_previous_encrypted_config(target_dir: str, image_name: str) -> None: + if Path('/mnt/encrypted_config').exists(): + Path(target_dir).mkdir(exist_ok=True) + copy('/mnt/encrypted_config', Path(target_dir).joinpath(image_name)) + + +def ask_single_disk(disks_available: dict[str, int]) -> str: + """Ask user to select a disk for installation + + Args: + disks_available (dict[str, int]): a list of available disks + """ + print(MSG_INFO_INSTALL_DISKS_LIST) + default_disk: str = list(disks_available)[0] + for disk_name, disk_size in disks_available.items(): + disk_size_human: str = bytes_to_gb(disk_size) + print(f'Drive: {disk_name} ({disk_size_human} GB)') + disk_selected: str = ask_input(MSG_INFO_INSTALL_DISK_SELECT, + default=default_disk, + valid_responses=list(disks_available)) + + # create partitions + if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM): + print(MSG_INFO_INSTALL_EXIT) + exit() + + search_previous_installation(list(disks_available)) + + disk_details: disk.DiskDetails = create_partitions(disk_selected, + disks_available[disk_selected]) + + disk.filesystem_create(disk_details.partition['efi'], 'efi') + disk.filesystem_create(disk_details.partition['root'], 'ext4') + + return disk_details + + +def check_raid_install(disks_available: dict[str, int]) -> Union[str, None]: + """Ask user to select disks for RAID installation + + Args: + disks_available (dict[str, int]): a list of available disks + """ + if len(disks_available) < 2: + return None + + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIGURE, default=True): + return None + + def format_selection(disk_name: str) -> str: + return f'{disk_name}\t({bytes_to_gb(disks_available[disk_name])} GB)' + + disk0, disk1 = list(disks_available)[0], list(disks_available)[1] + disks_selected: dict[str, int] = { disk0: disks_available[disk0], + disk1: disks_available[disk1] } + + target_size: int = min(disks_selected[disk0], disks_selected[disk1]) + + print(MSG_INFO_INSTALL_DISKS_LIST) + for disk_name, disk_size in disks_selected.items(): + disk_size_human: str = bytes_to_gb(disk_size) + print(f'\t{disk_name} ({disk_size_human} GB)') + if not ask_yes_no(MSG_INFO_INSTALL_RAID_FOUND_DISKS, default=True): + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CHOOSE_DISKS, default=True): + return None + else: + disks_selected = {} + disk0 = select_entry(list(disks_available), 'Disks available:', + 'Select first disk:', format_selection) + + disks_selected[disk0] = disks_available[disk0] + del disks_available[disk0] + disk1 = select_entry(list(disks_available), 'Remaining disks:', + 'Select second disk:', format_selection) + disks_selected[disk1] = disks_available[disk1] + + target_size: int = min(disks_selected[disk0], + disks_selected[disk1]) + + # create partitions + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIRM): + print(MSG_INFO_INSTALL_EXIT) + exit() + + search_previous_installation(list(disks_available)) + + disks: list[disk.DiskDetails] = [] + for disk_selected in list(disks_selected): + print(f'Creating partitions on {disk_selected}') + disk_details = create_partitions(disk_selected, target_size, + prompt=False) + disk.filesystem_create(disk_details.partition['efi'], 'efi') + + disks.append(disk_details) + + print('Creating RAID array') + members = [disk.partition['root'] for disk in disks] + raid_details: raid.RaidDetails = raid.raid_create(members) + # raid init stuff + print('Updating initramfs') + raid.update_initramfs() + # end init + print('Creating filesystem on RAID array') + disk.filesystem_create(raid_details.name, 'ext4') + + return raid_details + + +def prepare_tmp_disr() -> None: + """Create temporary directories for installation + """ + print('Creating temporary directories') + for dir in [DIR_ROOTFS_SRC, DIR_ROOTFS_DST, DIR_DST_ROOT]: + dirpath = Path(dir) + dirpath.mkdir(mode=0o755, parents=True) + + +def setup_grub(root_dir: str) -> None: + """Install GRUB configurations + + Args: + root_dir (str): a path to the root of target filesystem + """ + print('Installing GRUB configuration files') + grub_cfg_main = f'{root_dir}/{grub.GRUB_DIR_MAIN}/grub.cfg' + grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}' + grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}' + grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}' + grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}' + + # create new files + render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {}) + grub.common_write(root_dir) + grub.vars_write(grub_cfg_vars, DEFAULT_BOOT_VARS) + grub.modules_write(grub_cfg_modules, []) + grub.write_cfg_ver(1, root_dir) + render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) + render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) + + +def configure_authentication(config_file: str, password: str) -> None: + """Write encrypted password to config file + + Args: + config_file (str): path of target config file + password (str): plaintext password + + N.B. this can not be deferred by simply setting the plaintext password + and relying on the config mode script to process at boot, as the config + will not automatically be saved in that case, thus leaving the + plaintext exposed + """ + encrypted_password = linux_context.hash(password) + + with open(config_file) as f: + config_string = f.read() + + config = ConfigTree(config_string) + config.set([ + 'system', 'login', 'user', 'vyos', 'authentication', + 'encrypted-password' + ], + value=encrypted_password, + replace=True) + config.set_tag(['system', 'login', 'user']) + + 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 + + Args: + file_path (str): a path to file + sign_type (str): a signature type + """ + print('Validating signature') + signature_valid: bool = False + # validate with minisig + if sign_type == 'minisig': + pub_key_list = glob('/usr/share/vyos/keys/*.minisign.pub') + for pubkey in pub_key_list: + if run(f'minisign -V -q -p {pubkey} -m {file_path} -x {file_path}.minisig' + ) == 0: + signature_valid = True + break + Path(f'{file_path}.minisig').unlink() + # validate with GPG + if sign_type == 'asc': + if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0: + signature_valid = True + Path(f'{file_path}.asc').unlink() + + # warn or pass + if not signature_valid: + if not ask_yes_no(MSG_WARN_ISO_SIGN_INVALID, default=False): + exit(MSG_INFO_INSTALL_EXIT) + else: + 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 + if vrf is None: + download(local_file, remote_path, progressbar=progressbar, + check_space=check_space, raise_error=True) + else: + vrf_cmd = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \ + ip vrf exec {vrf} {external_download_script} \ + --local-file {local_file} --remote-path {remote_path}' + cmd(vrf_cmd) + +def image_fetch(image_path: str, vrf: str = None, + username: str = '', password: str = '', + no_prompt: bool = False) -> Path: + """Fetch an ISO image + + Args: + image_path (str): a path, remote or local + + Returns: + Path: a path to a local file + """ + # Latest version gets url from configured "system update-check url" + if image_path == 'latest': + config = ConfigTreeQuery() + if config.exists('system update-check url'): + configured_url_version = config.value('system update-check url') + remote_url_list = get_remote_version(configured_url_version) + image_path = remote_url_list[0].get('url') + + try: + # check a type of path + if urlparse(image_path).scheme: + # download an image + download_file(ISO_DOWNLOAD_PATH, image_path, vrf, + username, password, + progressbar=True, check_space=True) + + # download a signature + sign_file = (False, '') + for sign_type in ['minisig', 'asc']: + try: + download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}', + f'{image_path}.{sign_type}', vrf, + username, password) + sign_file = (True, sign_type) + break + except Exception: + print(f'{sign_type} signature is not available') + # validate a signature if it is available + if sign_file[0]: + validate_signature(ISO_DOWNLOAD_PATH, sign_file[1]) + else: + if (not no_prompt and + not ask_yes_no(MSG_WARN_ISO_SIGN_UNAVAL, default=False)): + cleanup() + exit(MSG_INFO_INSTALL_EXIT) + + return Path(ISO_DOWNLOAD_PATH) + else: + local_path: Path = Path(image_path) + if local_path.is_file(): + return local_path + else: + raise FileNotFoundError + except Exception as e: + print(f'The image cannot be fetched from: {image_path} {e}') + exit(1) + + +def migrate_config() -> bool: + """Check for active config and ask user for migration + + Returns: + bool: user's decision + """ + active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot') + if active_config_path.exists(): + if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True): + return True + return False + + +def copy_ssh_host_keys() -> bool: + """Ask user to copy SSH host keys + + Returns: + bool: user's decision + """ + if ask_yes_no('Would you like to copy SSH host keys?', default=True): + return True + return False + + +def console_hint() -> str: + pid = getppid() if 'SUDO_USER' in environ else getpid() + try: + path = readlink(f'/proc/{pid}/fd/1') + except OSError: + path = '/dev/tty' + + name = Path(path).name + if name == 'ttyS0': + return 'S' + else: + return 'K' + + +def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: + """Clean up after installation + + Args: + mounts (list[str], optional): List of mounts to unmount. + Defaults to []. + remove_items (list[str], optional): List of files or directories + to remove. Defaults to []. + """ + print('Cleaning up') + # clean up installation directory by default + mounts_all = disk_partitions(all=True) + for mounted_device in mounts_all: + if mounted_device.mountpoint.startswith(DIR_INSTALLATION) and not ( + mounted_device.device in mounts or + mounted_device.mountpoint in mounts): + mounts.append(mounted_device.mountpoint) + # add installation dir to cleanup list + if DIR_INSTALLATION not in remove_items: + remove_items.append(DIR_INSTALLATION) + # also delete an ISO file + if Path(ISO_DOWNLOAD_PATH).exists( + ) and ISO_DOWNLOAD_PATH not in remove_items: + remove_items.append(ISO_DOWNLOAD_PATH) + + if mounts: + print('Unmounting target filesystems') + for mountpoint in mounts: + disk.partition_umount(mountpoint) + for mountpoint in mounts: + disk.wait_for_umount(mountpoint) + if remove_items: + print('Removing temporary files') + for remove_item in remove_items: + if Path(remove_item).exists(): + if Path(remove_item).is_file(): + Path(remove_item).unlink() + if Path(remove_item).is_dir(): + rmtree(remove_item, ignore_errors=True) + + +def cleanup_raid(details: raid.RaidDetails) -> None: + efiparts = [] + for raid_disk in details.disks: + efiparts.append(raid_disk.partition['efi']) + cleanup([details.name, *efiparts], + ['/mnt/installation']) + + +def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -> bool: + """Check if installation target is a RAID array + + Args: + install_object (Union[disk.DiskDetails, raid.RaidDetails]): a target disk + + Returns: + bool: True if it is a RAID array + """ + if isinstance(install_object, raid.RaidDetails): + return True + return False + + +def validate_compatibility(iso_path: str) -> None: + """Check architecture and flavor compatibility with the running image + + Args: + iso_path (str): a path to the mounted ISO image + """ + old_data = get_version_data() + old_flavor = old_data.get('flavor', '') + old_architecture = old_data.get('architecture') or cmd('dpkg --print-architecture') + + new_data = get_version_data(f'{iso_path}/version.json') + new_flavor = new_data.get('flavor', '') + new_architecture = new_data.get('architecture', '') + + if not old_architecture == new_architecture: + print(MSG_ERR_ARCHITECTURE_MISMATCH) + cleanup() + exit(MSG_INFO_INSTALL_EXIT) + + if not old_flavor == new_flavor: + if not ask_yes_no(MSG_WARN_FLAVOR_MISMATCH.format(old_flavor, new_flavor), default=False): + cleanup() + exit(MSG_INFO_INSTALL_EXIT) + + +def install_image() -> None: + """Install an image to a disk + """ + if not image.is_live_boot(): + exit(MSG_ERR_NOT_LIVE) + + print(MSG_INFO_INSTALL_WELCOME) + if not ask_yes_no('Would you like to continue?'): + print(MSG_INFO_INSTALL_EXIT) + exit() + + # configure image name + running_image_name: str = image.get_running_image() + while True: + image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, + running_image_name) + if image.validate_name(image_name): + break + print(MSG_WARN_IMAGE_NAME_WRONG) + + # ask for password + while True: + user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True, + non_empty=True) + confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True, + non_empty=True) + if user_password == confirm: + break + print(MSG_WARN_PASSWORD_CONFIRM) + + # ask for default console + 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'} + + config_boot_list = ['/opt/vyatta/etc/config/config.boot', + '/opt/vyatta/etc/config.boot.default'] + default_config = config_boot_list[0] + + disks: dict[str, int] = find_disks() + + install_target: Union[disk.DiskDetails, raid.RaidDetails, None] = None + try: + install_target = check_raid_install(disks) + if install_target is None: + install_target = ask_single_disk(disks) + + # if previous install was selected in search_previous_installation, + # directory /mnt/config was prepared for copy below; if not, prompt: + if not Path('/mnt/config').exists(): + default_config: str = select_entry(config_boot_list, + MSG_INPUT_CONFIG_CHOICE, + MSG_INPUT_CONFIG_CHOOSE, + default_entry=1) # select_entry indexes from 1 + + # create directories for installation media + prepare_tmp_disr() + + # mount target filesystem and create required dirs inside + print('Mounting new partitions') + if is_raid_install(install_target): + disk.partition_mount(install_target.name, DIR_DST_ROOT) + Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) + else: + disk.partition_mount(install_target.partition['root'], DIR_DST_ROOT) + 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 + # 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/' + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + # copy config + copy(default_config, f'{target_config_dir}/config.boot') + configure_authentication(f'{target_config_dir}/config.boot', + user_password) + Path(f'{target_config_dir}/.vyatta_config').touch() + + # create a persistence.conf + Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n') + + # copy system image and kernel files + print('Copying system image files') + for file in Path(DIR_KERNEL_SRC).iterdir(): + if file.is_file(): + copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/') + copy(FILE_ROOTFS_SRC, + f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs') + + # copy saved config data and SSH keys + # owner restored on copy of config data by chmod_2775, above + copy_previous_installation_data(f'{DIR_DST_ROOT}/boot/{image_name}/rw') + + # copy saved encrypted config volume + copy_previous_encrypted_config(f'{DIR_DST_ROOT}/luks', image_name) + + if is_raid_install(install_target): + write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw' + raid.update_default(write_dir) + + setup_grub(DIR_DST_ROOT) + # add information about version + grub.create_structure() + grub.version_add(image_name, DIR_DST_ROOT) + grub.set_default(image_name, DIR_DST_ROOT) + grub.set_console_type(console_dict[console_type], DIR_DST_ROOT) + + if is_raid_install(install_target): + # add RAID specific modules + grub.modules_write(f'{DIR_DST_ROOT}/{grub.CFG_VYOS_MODULES}', + ['part_msdos', 'part_gpt', 'diskfilter', + 'ext2','mdraid1x']) + # install GRUB + if is_raid_install(install_target): + print('Installing GRUB to the drives') + l = install_target.disks + 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})') + disk.partition_umount(disk_target.partition['efi']) + else: + print('Installing GRUB to the drive') + grub.install(install_target.name, f'{DIR_DST_ROOT}/boot/', + f'{DIR_DST_ROOT}/boot/efi') + + # sort inodes (to make GRUB read config files in alphabetical order) + grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS}') + grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS_VERS}') + + # umount filesystems and remove temporary files + if is_raid_install(install_target): + cleanup([install_target.name], + ['/mnt/installation']) + else: + cleanup([install_target.partition['efi'], + install_target.partition['root']], + ['/mnt/installation']) + + # we are done + print(MSG_INFO_INSTALL_SUCCESS) + exit() + + except Exception as err: + print(f'Unable to install VyOS: {err}') + # unmount filesystems and clenup + try: + if install_target is not None: + if is_raid_install(install_target): + cleanup_raid(install_target) + else: + cleanup([install_target.partition['efi'], + install_target.partition['root']], + ['/mnt/installation']) + except Exception as err: + print(f'Cleanup failed: {err}') + + exit(1) + + +@compat.grub_cfg_update +def add_image(image_path: str, vrf: str = None, username: str = '', + password: str = '', no_prompt: bool = False) -> None: + """Add a new image + + Args: + image_path (str): a path to an ISO image + """ + if image.is_live_boot(): + exit(MSG_ERR_LIVE) + + # fetch an image + iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt) + try: + # mount an ISO + Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True) + disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660') + + print('Validating image compatibility') + validate_compatibility(DIR_ISO_MOUNT) + + # check sums + print('Validating image checksums') + if not Path(DIR_ISO_MOUNT).joinpath('sha256sum.txt').exists(): + cleanup() + exit(MSG_ERR_IMPROPER_IMAGE) + if run(f'cd {DIR_ISO_MOUNT} && sha256sum --status -c sha256sum.txt'): + cleanup() + exit('Image checksum verification failed.') + + # mount rootfs (to get a system version) + Path(DIR_ROOTFS_SRC).mkdir(mode=0o755, parents=True) + disk.partition_mount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', + DIR_ROOTFS_SRC, 'squashfs') + + cfg_ver: str = image.get_image_tools_version(DIR_ROOTFS_SRC) + version_name: str = image.get_image_version(DIR_ROOTFS_SRC) + + disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs') + + if cfg_ver < SYSTEM_CFG_VER: + raise compat.DowngradingImageTools( + f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed') + + if not no_prompt: + while True: + image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) + if image.validate_name(image_name): + break + print(MSG_WARN_IMAGE_NAME_WRONG) + set_as_default: bool = ask_yes_no(MSG_INPUT_IMAGE_DEFAULT, default=True) + else: + image_name: str = version_name + set_as_default: bool = True + + # find target directory + root_dir: str = disk.find_persistence() + + # a config dir. It is the deepest one, so the comand will + # create all the rest in a single step + target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/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, + copy_function=copy_preserve_owner, dirs_exist_ok=True) + else: + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + Path(f'{target_config_dir}/.vyatta_config').touch() + + target_ssh_dir: str = f'{root_dir}/boot/{image_name}/rw/etc/ssh/' + if no_prompt or copy_ssh_host_keys(): + print('Copying SSH host keys') + Path(target_ssh_dir).mkdir(parents=True) + host_keys: list[str] = glob('/etc/ssh/ssh_host*') + for host_key in host_keys: + copy(host_key, target_ssh_dir) + + # copy system image and kernel files + print('Copying system image files') + for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir(): + if file.is_file() and (file.match('initrd*') or + file.match('vmlinuz*')): + copy(file, f'{root_dir}/boot/{image_name}/') + copy(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', + f'{root_dir}/boot/{image_name}/{image_name}.squashfs') + + # unmount an ISO and cleanup + cleanup([str(iso_path)]) + + # add information about version + grub.version_add(image_name, root_dir) + if set_as_default: + grub.set_default(image_name, root_dir) + + except OSError as e: + # if no space error, remove image dir and cleanup + if e.errno == ENOSPC: + cleanup(mounts=[str(iso_path)], + remove_items=[f'{root_dir}/boot/{image_name}']) + else: + # unmount an ISO and cleanup + cleanup([str(iso_path)]) + exit(f'Error: {e}') + + except Exception as err: + # unmount an ISO and cleanup + cleanup([str(iso_path)]) + exit(f'Error: {err}') + + +def parse_arguments() -> Namespace: + """Parse arguments + + Returns: + Namespace: a namespace with parsed arguments + """ + parser: ArgumentParser = ArgumentParser( + description='Install new system images') + parser.add_argument('--action', + choices=['install', 'add'], + required=True, + help='action to perform with an image') + parser.add_argument('--vrf', + help='vrf name for image download') + parser.add_argument('--no-prompt', action='store_true', + help='perform action non-interactively') + parser.add_argument('--username', default='', + help='username for image download') + parser.add_argument('--password', default='', + help='password for image download') + parser.add_argument('--image-path', + help='a path (HTTP or local file) to an image that needs to be installed' + ) + # parser.add_argument('--image_new_name', help='a new name for image') + args: Namespace = parser.parse_args() + # Validate arguments + if args.action == 'add' and not args.image_path: + exit('A path to image is required for add action') + + return args + + +if __name__ == '__main__': + try: + args: Namespace = parse_arguments() + if args.action == 'install': + install_image() + if args.action == 'add': + add_image(args.image_path, args.vrf, + args.username, args.password, args.no_prompt) + + exit() + + except KeyboardInterrupt: + print('Stopped by Ctrl+C') + cleanup() + exit() + + except Exception as err: + exit(f'{err}') diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py new file mode 100644 index 0000000..fb4286d --- /dev/null +++ b/src/op_mode/image_manager.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# +# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS 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 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +from argparse import ArgumentParser, Namespace +from pathlib import Path +from shutil import rmtree +from sys import exit +from typing import Optional, Literal, TypeAlias, get_args + +from vyos.system import disk, grub, image, compat +from vyos.utils.io import ask_yes_no, select_entry + +SET_IMAGE_LIST_MSG: str = 'The following images are available:' +SET_IMAGE_PROMPT_MSG: str = 'Select an image to set as default:' +DELETE_IMAGE_LIST_MSG: str = 'The following images are installed:' +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'] + +def annotate_list(images_list: list[str]) -> list[str]: + """Annotate list of images with additional info + + Args: + images_list (list[str]): a list of image names + + Returns: + dict[str, str]: a dict of annotations indexed by image name + """ + running = image.get_running_image() + default = image.get_default_image() + annotated = {} + for image_name in images_list: + annotated[image_name] = f'{image_name}' + if running in images_list: + annotated[running] = annotated[running] + ' (running)' + if default in images_list: + annotated[default] = annotated[default] + ' (default boot)' + return annotated + +def define_format(images): + annotated = annotate_list(images) + def format_selection(image_name): + return annotated[image_name] + return format_selection + +@compat.grub_cfg_update +def delete_image(image_name: Optional[str] = None, + no_prompt: bool = False) -> None: + """Remove installed image files and boot entry + + Args: + image_name (str): a name of image to delete + """ + available_images: list[str] = grub.version_list() + format_selection = define_format(available_images) + if image_name is None: + if no_prompt: + exit('An image name is required for delete action') + else: + image_name = select_entry(available_images, + DELETE_IMAGE_LIST_MSG, + DELETE_IMAGE_PROMPT_MSG, + format_selection) + if image_name == image.get_running_image(): + exit(MSG_DELETE_IMAGE_RUNNING) + if image_name == image.get_default_image(): + exit(MSG_DELETE_IMAGE_DEFAULT) + if image_name not in available_images: + exit(f'The image "{image_name}" cannot be found') + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + if (not no_prompt and + not ask_yes_no(f'Do you really want to delete the image {image_name}?', + default=False)): + exit() + + # remove files and menu entry + version_path: Path = Path(f'{persistence_storage}/boot/{image_name}') + try: + rmtree(version_path) + grub.version_del(image_name, persistence_storage) + print(f'The image "{image_name}" was successfully deleted') + except Exception as err: + exit(f'Unable to remove the image "{image_name}": {err}') + + # remove LUKS volume if it exists + luks_path: Path = Path(f'{persistence_storage}/luks/{image_name}') + if luks_path.is_file(): + try: + luks_path.unlink() + print(f'The encrypted config for "{image_name}" was successfully deleted') + except Exception as err: + exit(f'Unable to remove the encrypted config for "{image_name}": {err}') + + +@compat.grub_cfg_update +def set_image(image_name: Optional[str] = None, + prompt: bool = True) -> None: + """Set default boot image + + Args: + image_name (str): an image name + """ + available_images: list[str] = grub.version_list() + format_selection = define_format(available_images) + if image_name is None: + if not prompt: + exit('An image name is required for set action') + else: + image_name = select_entry(available_images, + SET_IMAGE_LIST_MSG, + SET_IMAGE_PROMPT_MSG, + format_selection) + if image_name == image.get_default_image(): + exit(f'The image "{image_name}" already configured as default') + if image_name not in available_images: + exit(f'The image "{image_name}" cannot be found') + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + # set default boot image + try: + grub.set_default(image_name, persistence_storage) + print(f'The image "{image_name}" is now default boot image') + except Exception as err: + exit(f'Unable to set default image "{image_name}": {err}') + + +@compat.grub_cfg_update +def rename_image(name_old: str, name_new: str) -> None: + """Rename installed image + + Args: + name_old (str): old name + name_new (str): new name + """ + if name_old == image.get_running_image(): + exit('Currently running image cannot be renamed') + available_images: list[str] = grub.version_list() + if name_old not in available_images: + exit(f'The image "{name_old}" cannot be found') + if name_new in available_images: + exit(f'The image "{name_new}" already exists') + if not image.validate_name(name_new): + exit(f'The image name "{name_new}" is not allowed') + + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + if not ask_yes_no( + f'Do you really want to rename the image {name_old} ' + f'to the {name_new}?', + default=False): + exit() + + try: + # replace default boot item + if name_old == image.get_default_image(): + grub.set_default(name_new, persistence_storage) + + # rename files and dirs + old_path: Path = Path(f'{persistence_storage}/boot/{name_old}') + new_path: Path = Path(f'{persistence_storage}/boot/{name_new}') + old_path.rename(new_path) + + # replace boot item + grub.version_del(name_old, persistence_storage) + grub.version_add(name_new, persistence_storage) + + print(f'The image "{name_old}" was renamed to "{name_new}"') + except Exception as err: + exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}') + + # rename LUKS volume if it exists + old_luks_path: Path = Path(f'{persistence_storage}/luks/{name_old}') + if old_luks_path.is_file(): + try: + new_luks_path: Path = Path(f'{persistence_storage}/luks/{name_new}') + old_luks_path.rename(new_luks_path) + print(f'The encrypted config for "{name_old}" was successfully renamed to "{name_new}"') + except Exception as err: + exit(f'Unable to rename the encrypted config for "{name_old}" to "{name_new}": {err}') + + +@compat.grub_cfg_update +def set_console_type(console_type: ConsoleType) -> None: + console_choice = get_args(ConsoleType) + if console_type not in console_choice: + exit(f'console type \'{console_type}\' not available') + + grub.set_console_type(console_type) + + +def list_images() -> None: + """Print list of available images for CLI hints""" + images_list: list[str] = grub.version_list() + for image_name in images_list: + print(image_name) + + +def list_console_types() -> None: + """Print list of console types for CLI hints""" + console_types: list[str] = list(get_args(ConsoleType)) + for console_type in console_types: + print(console_type) + + +def parse_arguments() -> Namespace: + """Parse arguments + + Returns: + Namespace: a namespace with parsed arguments + """ + parser: ArgumentParser = ArgumentParser(description='Manage system images') + parser.add_argument('--action', + choices=['delete', 'set', 'set_console_type', + 'rename', 'list', 'list_console_types'], + required=True, + help='action to perform with an image') + parser.add_argument('--no-prompt', action='store_true', + help='perform action non-interactively') + parser.add_argument( + '--image-name', + help= + 'a name of an image to add, delete, install, rename, or set as default') + parser.add_argument('--image-new-name', help='a new name for image') + parser.add_argument('--console-type', help='console type for boot') + args: Namespace = parser.parse_args() + # Validate arguments + if args.action == 'rename' and (not args.image_name or + not args.image_new_name): + exit('Both old and new image names are required for rename action') + + return args + + +if __name__ == '__main__': + try: + args: Namespace = parse_arguments() + if args.action == 'delete': + delete_image(args.image_name, args.no_prompt) + if args.action == 'set': + set_image(args.image_name) + if args.action == 'set_console_type': + set_console_type(args.console_type) + if args.action == 'rename': + rename_image(args.image_name, args.image_new_name) + if args.action == 'list': + list_images() + if args.action == 'list_console_types': + list_console_types() + + exit() + + except KeyboardInterrupt: + print('Stopped by Ctrl+C') + exit() + + except Exception as err: + exit(f'{err}') diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py new file mode 100644 index 0000000..e7afc4c --- /dev/null +++ b/src/op_mode/interfaces.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import re +import sys +import glob +import json +import typing +from datetime import datetime +from tabulate import tabulate + +import vyos.opmode +from vyos.ifconfig import Section +from vyos.ifconfig import Interface +from vyos.ifconfig import VRRP +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 + +# The original implementation of filtered_interfaces has signature: +# (ifnames: list, iftypes: typing.Union[str, list], vif: bool, vrrp: bool) -> intf: Interface: +# Arg types allowed in CLI (ifnames: str, iftypes: str) were manually +# re-typed from argparse args. +# We include the function in a general form, however op-mode standard +# functions will restrict to the CLI-allowed arg types, wrapped in Optional. +def filtered_interfaces(ifnames: typing.Union[str, list], + iftypes: typing.Union[str, list], + vif: bool, vrrp: bool) -> Interface: + """ + get all interfaces from the OS and return them; ifnames can be used to + filter which interfaces should be considered + + ifnames: a list of interface names to consider, empty do not filter + + return an instance of the Interface class + """ + if isinstance(ifnames, str): + ifnames = [ifnames] if ifnames else [] + if isinstance(iftypes, list): + for iftype in iftypes: + yield from filtered_interfaces(ifnames, iftype, vif, vrrp) + + for ifname in Section.interfaces(iftypes): + # Bail out early if interface name not part of our search list + if ifnames and ifname not in ifnames: + continue + + # As we are only "reading" from the interface - we must use the + # generic base class which exposes all the data via a common API + interface = Interface(ifname, create=False, debug=False) + + # VLAN interfaces have a '.' in their name by convention + if vif and not '.' in ifname: + continue + + if vrrp: + vrrp_interfaces = VRRP.active_interfaces() + if ifname not in vrrp_interfaces: + continue + + yield interface + +def _split_text(text, used=0): + """ + take a string and attempt to split it to fit with the width of the screen + + text: the string to split + used: number of characted already used in the screen + """ + no_tty = call('tty -s') + + returned = cmd('stty size') if not no_tty else '' + returned = returned.split() + if len(returned) == 2: + _, columns = tuple(int(_) for _ in returned) + else: + _, columns = (40, 80) + + desc_len = columns - used + + line = '' + for word in text.split(): + if len(line) + len(word) < desc_len: + line = f'{line} {word}' + continue + if line: + yield line[1:] + else: + line = f'{line} {word}' + + yield line[1:] + +def _get_counter_val(prev, now): + """ + attempt to correct a counter if it wrapped, copied from perl + + prev: previous counter + now: the current counter + """ + # This function has to deal with both 32 and 64 bit counters + if prev == 0: + return now + + # device is using 64 bit values assume they never wrap + value = now - prev + if (now >> 32) != 0: + return value + + # The counter has rolled. If the counter has rolled + # multiple times since the prev value, then this math + # is meaningless. + if value < 0: + value = (4294967296 - prev) + now + + return value + +def _pppoe(ifname): + out = cmd('ps -C pppd -f') + if ifname in out: + return 'C' + if ifname in [_.split('/')[-1] for _ in glob.glob('/etc/ppp/peers/pppoe*')]: + return 'D' + return '' + +def _find_intf_by_ifname(intf_l: list, name: str): + for d in intf_l: + if d['ifname'] == name: + return d + return {} + +# lifted out of operational.py to separate formatting from data +def _format_stats(stats, indent=4): + stat_names = { + 'rx': ['bytes', 'packets', 'errors', 'dropped', 'overrun', 'mcast'], + 'tx': ['bytes', 'packets', 'errors', 'dropped', 'carrier', 'collisions'], + } + + stats_dir = { + 'rx': ['rx_bytes', 'rx_packets', 'rx_errors', 'rx_dropped', 'rx_over_errors', 'multicast'], + 'tx': ['tx_bytes', 'tx_packets', 'tx_errors', 'tx_dropped', 'tx_carrier_errors', 'collisions'], + } + tabs = [] + for rtx in list(stats_dir): + tabs.append([f'{rtx.upper()}:', ] + stat_names[rtx]) + tabs.append(['', ] + [stats[_] for _ in stats_dir[rtx]]) + + s = tabulate( + tabs, + stralign="right", + numalign="right", + tablefmt="plain" + ) + + p = ' '*indent + return f'{p}' + s.replace('\n', f'\n{p}') + +def _get_raw_data(ifname: typing.Optional[str], + iftype: typing.Optional[str], + vif: bool, vrrp: bool) -> list: + if ifname is None: + ifname = '' + if iftype is None: + iftype = '' + ret =[] + for interface in filtered_interfaces(ifname, iftype, vif, vrrp): + res_intf = {} + cache = interface.operational.load_counters() + + out = cmd(f'ip -json addr show {interface.ifname}') + res_intf_l = json.loads(out) + res_intf = res_intf_l[0] + + if res_intf['link_type'] == 'tunnel6': + # Note that 'ip -6 tun show {interface.ifname}' is not json + # aware, so find in list + out = cmd('ip -json -6 tun show') + tunnel = json.loads(out) + res_intf['tunnel6'] = _find_intf_by_ifname(tunnel, + interface.ifname) + if 'ip6_tnl_f_use_orig_tclass' in res_intf['tunnel6']: + res_intf['tunnel6']['tclass'] = 'inherit' + del res_intf['tunnel6']['ip6_tnl_f_use_orig_tclass'] + + res_intf['counters_last_clear'] = int(cache.get('timestamp', 0)) + + res_intf['description'] = interface.get_alias() + + stats = interface.operational.get_stats() + for k in list(stats): + stats[k] = _get_counter_val(cache[k], stats[k]) + + res_intf['stats'] = stats + + ret.append(res_intf) + + # find pppoe interfaces that are in a transitional/dead state + if ifname.startswith('pppoe') and not _find_intf_by_ifname(ret, ifname): + pppoe_intf = {} + pppoe_intf['unhandled'] = None + pppoe_intf['ifname'] = ifname + pppoe_intf['state'] = _pppoe(ifname) + ret.append(pppoe_intf) + + return ret + +def _get_summary_data(ifname: typing.Optional[str], + iftype: typing.Optional[str], + vif: bool, vrrp: bool) -> list: + if ifname is None: + ifname = '' + if iftype is None: + 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 = {} + + res_intf['ifname'] = interface.ifname + res_intf['oper_state'] = interface.operational.get_state() + res_intf['admin_state'] = interface.get_admin_state() + res_intf['addr'] = [_ for _ in interface.get_addr() if not _.startswith('fe80::')] + res_intf['description'] = interface.get_alias() + res_intf['mtu'] = interface.get_mtu() + res_intf['mac'] = interface.get_mac() if is_interface_has_mac(interface.ifname) else 'n/a' + res_intf['vrf'] = interface.get_vrf() + + ret.append(res_intf) + + # find pppoe interfaces that are in a transitional/dead state + if ifname.startswith('pppoe') and not _find_intf_by_ifname(ret, ifname): + pppoe_intf = {} + pppoe_intf['unhandled'] = None + pppoe_intf['ifname'] = ifname + pppoe_intf['state'] = _pppoe(ifname) + ret.append(pppoe_intf) + + return ret + +def _get_counter_data(ifname: typing.Optional[str], + iftype: typing.Optional[str], + vif: bool, vrrp: bool) -> list: + if ifname is None: + ifname = '' + if iftype is None: + iftype = '' + ret = [] + for interface in filtered_interfaces(ifname, iftype, vif, vrrp): + res_intf = {} + + oper = interface.operational.get_state() + + if oper not in ('up','unknown'): + continue + + stats = interface.operational.get_stats() + cache = interface.operational.load_counters() + res_intf['ifname'] = interface.ifname + res_intf['rx_packets'] = _get_counter_val(cache['rx_packets'], stats['rx_packets']) + res_intf['rx_bytes'] = _get_counter_val(cache['rx_bytes'], stats['rx_bytes']) + res_intf['tx_packets'] = _get_counter_val(cache['tx_packets'], stats['tx_packets']) + res_intf['tx_bytes'] = _get_counter_val(cache['tx_bytes'], stats['tx_bytes']) + res_intf['rx_dropped'] = _get_counter_val(cache['rx_dropped'], stats['rx_dropped']) + res_intf['tx_dropped'] = _get_counter_val(cache['tx_dropped'], stats['tx_dropped']) + res_intf['rx_over_errors'] = _get_counter_val(cache['rx_over_errors'], stats['rx_over_errors']) + res_intf['tx_carrier_errors'] = _get_counter_val(cache['tx_carrier_errors'], stats['tx_carrier_errors']) + + ret.append(res_intf) + + return ret + +@catch_broken_pipe +def _format_show_data(data: list): + unhandled = [] + for intf in data: + if 'unhandled' in intf: + unhandled.append(intf) + continue + # instead of reformatting data, call non-json output: + rc, out = rc_cmd(f"ip addr show {intf['ifname']}") + if rc != 0: + continue + out = re.sub('^\d+:\s+','',out) + # add additional data already collected + if 'tunnel6' in intf: + t6_d = intf['tunnel6'] + t6_str = 'encaplimit %s hoplimit %s tclass %s flowlabel %s (flowinfo %s)' % ( + t6_d.get('encap_limit', ''), t6_d.get('hoplimit', ''), + t6_d.get('tclass', ''), t6_d.get('flowlabel', ''), + t6_d.get('flowinfo', '')) + out = re.sub('(\n\s+)(link/tunnel6)', f'\g<1>{t6_str}\g<1>\g<2>', out) + print(out) + ts = intf.get('counters_last_clear', 0) + if ts: + when = datetime.fromtimestamp(ts).strftime("%a %b %d %R:%S %Z %Y") + print(f' Last clear: {when}') + description = intf.get('description', '') + if description: + print(f' Description: {description}') + + stats = intf.get('stats', {}) + if stats: + print() + print(_format_stats(stats)) + + for intf in unhandled: + string = { + 'C': 'Coming up', + 'D': 'Link down' + }[intf['state']] + print(f"{intf['ifname']}: {string}") + + return 0 + +@catch_broken_pipe +def _format_show_summary(data): + format1 = '%-16s %-33s %-4s %s' + format2 = '%-16s %s' + + print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down') + print(format1 % ("Interface", "IP Address", "S/L", "Description")) + print(format1 % ("---------", "----------", "---", "-----------")) + + unhandled = [] + for intf in data: + if 'unhandled' in intf: + unhandled.append(intf) + continue + ifname = [intf['ifname'],] + oper = ['u',] if intf['oper_state'] in ('up', 'unknown') else ['D',] + admin = ['u',] if intf['admin_state'] in ('up', 'unknown') else ['A',] + addrs = intf['addr'] or ['-',] + descs = list(_split_text(intf['description'], 0)) + + while ifname or oper or admin or addrs or descs: + i = ifname.pop(0) if ifname else '' + a = addrs.pop(0) if addrs else '' + d = descs.pop(0) if descs else '' + s = [admin.pop(0)] if admin else [] + l = [oper.pop(0)] if oper else [] + if len(a) < 33: + print(format1 % (i, a, '/'.join(s+l), d)) + else: + print(format2 % (i, a)) + print(format1 % ('', '', '/'.join(s+l), d)) + + for intf in unhandled: + string = { + 'C': 'u/D', + 'D': 'A/D' + }[intf['state']] + print(format1 % (ifname, '', string, '')) + + return 0 + +@catch_broken_pipe +def _format_show_summary_extended(data): + headers = ["Interface", "IP Address", "MAC", "VRF", "MTU", "S/L", "Description"] + table_data = [] + + print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down') + + for intf in data: + if 'unhandled' in intf: + continue + + ifname = intf['ifname'] + oper_state = 'u' if intf['oper_state'] in ('up', 'unknown') else 'D' + admin_state = 'u' if intf['admin_state'] in ('up', 'unknown') else 'A' + addrs = intf['addr'] or ['-'] + description = '\n'.join(_split_text(intf['description'], 0)) + mac = intf['mac'] if intf['mac'] else 'n/a' + mtu = intf['mtu'] if intf['mtu'] else 'n/a' + vrf = intf['vrf'] if intf['vrf'] else 'default' + + ip_addresses = '\n'.join(ip for ip in addrs) + + # Create a row for the table + row = [ + ifname, + ip_addresses, + mac, + vrf, + mtu, + f"{admin_state}/{oper_state}", + description, + ] + + # Append the row to the table data + table_data.append(row) + + for intf in data: + if 'unhandled' in intf: + string = {'C': 'u/D', 'D': 'A/D'}[intf['state']] + table_data.append([intf['ifname'], '', '', '', '', string, '']) + + print(tabulate(table_data, headers)) + + return 0 + +@catch_broken_pipe +def _format_show_counters(data: list): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + rx_packets = entry.get('rx_packets') + rx_bytes = entry.get('rx_bytes') + tx_packets = entry.get('tx_packets') + tx_bytes = entry.get('tx_bytes') + rx_dropped = entry.get('rx_dropped') + tx_dropped = entry.get('tx_dropped') + rx_errors = entry.get('rx_over_errors') + tx_errors = entry.get('tx_carrier_errors') + data_entries.append([interface, rx_packets, rx_bytes, tx_packets, tx_bytes, rx_dropped, tx_dropped, rx_errors, tx_errors]) + + headers = ['Interface', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes', 'Rx Dropped', 'Tx Dropped', 'Rx Errors', 'Tx Errors'] + output = tabulate(data_entries, headers, numalign="left") + print (output) + return output + + +def _show_raw(data: list, intf_name: str): + if intf_name is not None and len(data) <= 1: + try: + return data[0] + except IndexError: + raise vyos.opmode.UnconfiguredObject( + f"Interface {intf_name} does not exist") + else: + return data + + +def show(raw: bool, intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + data = _get_raw_data(intf_name, intf_type, vif, vrrp) + if raw: + return _show_raw(data, intf_name) + return _format_show_data(data) + +def show_summary(raw: bool, intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + data = _get_summary_data(intf_name, intf_type, vif, vrrp) + if raw: + return _show_raw(data, intf_name) + return _format_show_summary(data) + +def show_summary_extended(raw: bool, intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + data = _get_summary_data(intf_name, intf_type, vif, vrrp) + if raw: + return _show_raw(data, intf_name) + return _format_show_summary_extended(data) + +def show_counters(raw: bool, intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + data = _get_counter_data(intf_name, intf_type, vif, vrrp) + if raw: + return _show_raw(data, intf_name) + return _format_show_counters(data) + +def clear_counters(intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + for interface in filtered_interfaces(intf_name, intf_type, vif, vrrp): + interface.operational.clear_counters() + +def reset_counters(intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + for interface in filtered_interfaces(intf_name, intf_type, vif, vrrp): + interface.operational.reset_counters() + +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/interfaces_wireguard.py b/src/op_mode/interfaces_wireguard.py new file mode 100644 index 0000000..627af05 --- /dev/null +++ b/src/op_mode/interfaces_wireguard.py @@ -0,0 +1,53 @@ +#!/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/>. + +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 +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__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/interfaces_wireless.py b/src/op_mode/interfaces_wireless.py new file mode 100644 index 0000000..bf6e462 --- /dev/null +++ b/src/op_mode/interfaces_wireless.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import re +import sys +import vyos.opmode + +from copy import deepcopy +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 + +def _get_raw_info_data(): + output_data = [] + + config = ConfigTreeQuery() + raw = config.get_config_dict(['interfaces', 'wireless'], effective=True, + get_first_key=True, key_mangling=('-', '_')) + for interface, interface_config in raw.items(): + tmp = {'name' : interface} + + if 'type' in interface_config: + tmp.update({'type' : interface_config['type']}) + else: + tmp.update({'type' : '-'}) + + if 'ssid' in interface_config: + tmp.update({'ssid' : interface_config['ssid']}) + else: + tmp.update({'ssid' : '-'}) + + if 'channel' in interface_config: + tmp.update({'channel' : interface_config['channel']}) + else: + tmp.update({'channel' : '-'}) + + output_data.append(tmp) + + return output_data + +def _get_formatted_info_output(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['name'], ssid['type'], ssid['ssid'], ssid['channel']]) + + headers = ["Interface", "Type", "SSID", "Channel"] + print(tabulate(output, headers, numalign="left")) + +def _get_raw_scan_data(intf_name): + # XXX: This ignores errors + tmp, _ = popen(f'iw dev {intf_name} scan ap-force') + networks = [] + data = { + 'ssid': '', + 'mac': '', + 'channel': '', + 'signal': '' + } + re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') + for line in tmp.splitlines(): + if line.startswith('BSS '): + ssid = deepcopy(data) + ssid['mac'] = re.search(re_mac, line).group() + + elif line.lstrip().startswith('SSID: '): + # SSID can be " SSID: WLAN-57 6405", thus strip all leading whitespaces + ssid['ssid'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('signal: '): + # Siganl 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'): + # Channel can be " DS Parameter set: channel 6" , thus + # strip all leading whitespaces + ssid['channel'] = line.lstrip().split(':')[-1].split()[-1] + networks.append(ssid) + continue + + return networks + +def _format_scan_data(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['mac'], ssid['ssid'], ssid['channel'], ssid['signal']]) + headers = ["Address", "SSID", "Channel", "Signal (dbm)"] + return tabulate(output, headers, numalign="left") + +def _get_raw_station_data(intf_name): + # XXX: This ignores errors + tmp, _ = popen(f'iw dev {intf_name} station dump') + clients = [] + data = { + 'mac': '', + 'signal': '', + 'rx_bytes': '', + 'rx_packets': '', + 'tx_bytes': '', + 'tx_packets': '' + } + re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') + for line in tmp.splitlines(): + if line.startswith('Station'): + client = deepcopy(data) + client['mac'] = re.search(re_mac, line).group() + + elif line.lstrip().startswith('signal avg:'): + client['signal'] = line.lstrip().split(':')[-1].lstrip().split()[0] + + elif line.lstrip().startswith('rx bytes:'): + client['rx_bytes'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('rx packets:'): + client['rx_packets'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('tx bytes:'): + client['tx_bytes'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('tx packets:'): + client['tx_packets'] = line.lstrip().split(':')[-1].lstrip() + clients.append(client) + continue + + return clients + +def _format_station_data(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['mac'], ssid['signal'], ssid['rx_bytes'], ssid['rx_packets'], ssid['tx_bytes'], ssid['tx_packets']]) + headers = ["Station", "Signal", "RX bytes", "RX packets", "TX bytes", "TX packets"] + return tabulate(output, headers, numalign="left") + +@_verify +def show_info(raw: bool): + info_data = _get_raw_info_data() + if raw: + return info_data + return _get_formatted_info_output(info_data) + +def show_scan(raw: bool, intf_name: str): + data = _get_raw_scan_data(intf_name) + if raw: + return data + return _format_scan_data(data) + +@_verify +def show_stations(raw: bool, intf_name: str): + data = _get_raw_station_data(intf_name) + if raw: + return data + return _format_station_data(data) + +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/ipoe-control.py b/src/op_mode/ipoe-control.py new file mode 100644 index 0000000..b7d6a0c --- /dev/null +++ b/src/op_mode/ipoe-control.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import argparse + +from vyos.config import Config +from vyos.utils.process import popen +from vyos.utils.process import run + +cmd_dict = { + 'cmd_base' : '/usr/bin/accel-cmd -p 2002 ', + 'selector' : ['if', 'username', 'sid'], + 'actions' : { + 'show_sessions' : 'show sessions', + 'show_stat' : 'show stat', + 'terminate' : 'teminate' + } +} + +def is_ipoe_configured(): + if not Config().exists_effective('service ipoe-server'): + print("Service IPoE is not configured") + sys.exit(1) + +def main(): + #parese args + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Control action', required=True) + parser.add_argument('--selector', help='Selector username|ifname|sid', required=False) + parser.add_argument('--target', help='Target must contain username|ifname|sid', required=False) + args = parser.parse_args() + + + # Check is IPoE configured + is_ipoe_configured() + + if args.action == "restart": + run(cmd_dict['cmd_base'] + "restart") + sys.exit(0) + + if args.action in cmd_dict['actions']: + if args.selector in cmd_dict['selector'] and args.target: + run(cmd_dict['cmd_base'] + "{0} {1} {2}".format(args.action, args.selector, args.target)) + else: + if args.action == "show_sessions": + ses_pattern = " ifname,username,calling-sid,ip,ip6,ip6-dp,rate-limit,type,comp,state,uptime" + else: + ses_pattern = "" + output, err = popen(cmd_dict['cmd_base'] + cmd_dict['actions'][args.action] + ses_pattern, decode='utf-8') + if not err: + print(output) + else: + print("IPoE server is not running") + +if __name__ == '__main__': + main() diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py new file mode 100644 index 0000000..02ba126 --- /dev/null +++ b/src/op_mode/ipsec.py @@ -0,0 +1,1053 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. +import re +import sys +import typing + +from hurry import filesize +from re import split as re_split +from tabulate import tabulate + +from vyos.utils.convert import convert_data +from vyos.utils.convert import seconds_to_human +from vyos.utils.process import cmd +from vyos.configquery import ConfigTreeQuery +from vyos.base import Warning + +import vyos.opmode +import vyos.ipsec + + +def _convert(text): + return int(text) if text.isdigit() else text.lower() + + +def _alphanum_key(key): + return [_convert(c) for c in re_split('([0-9]+)', str(key))] + + +def _get_raw_data_sas(): + try: + get_sas = vyos.ipsec.get_vici_sas() + sas = convert_data(get_sas) + return sas + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + + +def _get_output_swanctl_sas_from_list(ra_output_list: list) -> str: + """ + Template for output for VICI + Inserts \n after each IKE SA + :param ra_output_list: IKE SAs list + :type ra_output_list: list + :return: formatted string + :rtype: str + """ + output = '' + for sa_val in ra_output_list: + for sa in sa_val.values(): + swanctl_output: str = cmd(f'sudo swanctl -l --ike-id {sa["uniqueid"]}') + output = f'{output}{swanctl_output}\n\n' + return output + + +def _get_formatted_output_sas(sas): + sa_data = [] + for sa in sas: + for parent_sa in sa.values(): + # create an item for each child-sa + for child_sa in parent_sa.get('child-sas', {}).values(): + # prepare a list for output data + sa_out_name = sa_out_state = sa_out_uptime = sa_out_bytes = ( + sa_out_packets + ) = sa_out_remote_addr = sa_out_remote_id = sa_out_proposal = 'N/A' + + # collect raw data + sa_name = child_sa.get('name') + sa_state = child_sa.get('state') + sa_uptime = child_sa.get('install-time') + sa_bytes_in = child_sa.get('bytes-in') + sa_bytes_out = child_sa.get('bytes-out') + sa_packets_in = child_sa.get('packets-in') + sa_packets_out = child_sa.get('packets-out') + sa_remote_addr = parent_sa.get('remote-host') + sa_remote_id = parent_sa.get('remote-id') + sa_proposal_encr_alg = child_sa.get('encr-alg') + sa_proposal_integ_alg = child_sa.get('integ-alg') + sa_proposal_encr_keysize = child_sa.get('encr-keysize') + sa_proposal_dh_group = child_sa.get('dh-group') + + # format data to display + if sa_name: + sa_out_name = sa_name + if sa_state: + if sa_state == 'INSTALLED': + sa_out_state = 'up' + else: + sa_out_state = 'down' + if sa_uptime: + sa_out_uptime = seconds_to_human(sa_uptime) + if sa_bytes_in and sa_bytes_out: + bytes_in = filesize.size(int(sa_bytes_in)) + bytes_out = filesize.size(int(sa_bytes_out)) + sa_out_bytes = f'{bytes_in}/{bytes_out}' + if sa_packets_in and sa_packets_out: + packets_in = filesize.size(int(sa_packets_in), system=filesize.si) + packets_out = filesize.size(int(sa_packets_out), system=filesize.si) + packets_str = f'{packets_in}/{packets_out}' + sa_out_packets = re.sub(r'B', r'', packets_str) + if sa_remote_addr: + sa_out_remote_addr = sa_remote_addr + if sa_remote_id: + sa_out_remote_id = sa_remote_id + # format proposal + if sa_proposal_encr_alg: + sa_out_proposal = sa_proposal_encr_alg + if sa_proposal_encr_keysize: + sa_proposal_encr_keysize_str = sa_proposal_encr_keysize + sa_out_proposal = ( + f'{sa_out_proposal}_{sa_proposal_encr_keysize_str}' + ) + if sa_proposal_integ_alg: + sa_proposal_integ_alg_str = sa_proposal_integ_alg + sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_integ_alg_str}' + if sa_proposal_dh_group: + sa_proposal_dh_group_str = sa_proposal_dh_group + sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_dh_group_str}' + + # add a new item to output data + sa_data.append( + [ + sa_out_name, + sa_out_state, + sa_out_uptime, + sa_out_bytes, + sa_out_packets, + sa_out_remote_addr, + sa_out_remote_id, + sa_out_proposal, + ] + ) + + headers = [ + 'Connection', + 'State', + 'Uptime', + 'Bytes In/Out', + 'Packets In/Out', + 'Remote address', + 'Remote ID', + 'Proposal', + ] + sa_data = sorted(sa_data, key=_alphanum_key) + output = tabulate(sa_data, headers) + return output + + +# Connections block + + +def _get_convert_data_connections(): + try: + get_connections = vyos.ipsec.get_vici_connections() + connections = convert_data(get_connections) + return connections + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + + +def _get_parent_sa_proposal(connection_name: str, data: list) -> dict: + """Get parent SA proposals by connection name + if connections not in the 'down' state + + Args: + connection_name (str): Connection name + data (list): List of current SAs from vici + + Returns: + str: Parent SA connection proposal + AES_CBC/256/HMAC_SHA2_256_128/MODP_1024 + """ + if not data: + return {} + for sa in data: + # check if parent SA exist + if connection_name not in sa.keys(): + continue + if 'encr-alg' in sa[connection_name]: + encr_alg = sa.get(connection_name, '').get('encr-alg') + cipher = encr_alg.split('_')[0] + mode = encr_alg.split('_')[1] + encr_keysize = sa.get(connection_name, '').get('encr-keysize') + integ_alg = sa.get(connection_name, '').get('integ-alg') + # prf_alg = sa.get(connection_name, '').get('prf-alg') + dh_group = sa.get(connection_name, '').get('dh-group') + proposal = { + 'cipher': cipher, + 'mode': mode, + 'key_size': encr_keysize, + 'hash': integ_alg, + 'dh': dh_group, + } + return proposal + return {} + + +def _get_parent_sa_state(connection_name: str, data: list) -> str: + """Get parent SA state by connection name + + Args: + connection_name (str): Connection name + data (list): List of current SAs from vici + + Returns: + Parent SA connection state + """ + ike_state = 'down' + if not data: + return ike_state + for sa in data: + # check if parent SA exist + for connection, connection_conf in sa.items(): + if connection_name != connection: + continue + if connection_conf['state'].lower() == 'established': + ike_state = 'up' + return ike_state + + +def _get_child_sa_state(connection_name: str, tunnel_name: str, data: list) -> 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 + + Returns: + str: `up` if child SA state is 'installed' otherwise `down` + """ + child_sa = 'down' + if not data: + return child_sa + for sa in data: + # check if parent SA exist + if connection_name not in sa.keys(): + continue + child_sas = sa[connection_name]['child-sas'] + # Get all child SA states + # there can be multiple SAs per tunnel + child_sa_states = [ + v['state'] for k, v in child_sas.items() if v['name'] == tunnel_name + ] + return 'up' if 'INSTALLED' in child_sa_states else child_sa + + +def _get_child_sa_info(connection_name: str, tunnel_name: str, data: list) -> dict: + """Get child SA installed info by connection and tunnel name + + Args: + connection_name (str): Connection name + tunnel_name (str): Tunnel name + data (list): List of current SAs from vici + + Returns: + dict: Info of the child SA in the dictionary format + """ + for sa in data: + # check if parent SA exist + if connection_name not in sa.keys(): + continue + child_sas = sa[connection_name]['child-sas'] + # Get all child SA data + # Skip temp SA name (first key), get only SA values as dict + # {'OFFICE-B-tunnel-0-46': {'name': 'OFFICE-B-tunnel-0'}...} + # i.e get all data after 'OFFICE-B-tunnel-0-46' + child_sa_info = [ + v + for k, v in child_sas.items() + if 'name' in v and v['name'] == tunnel_name and v['state'] == 'INSTALLED' + ] + return child_sa_info[-1] if child_sa_info else {} + + +def _get_child_sa_proposal(child_sa_data: dict) -> dict: + if child_sa_data and 'encr-alg' in child_sa_data: + encr_alg = child_sa_data.get('encr-alg') + cipher = encr_alg.split('_')[0] + mode = encr_alg.split('_')[1] + key_size = child_sa_data.get('encr-keysize') + integ_alg = child_sa_data.get('integ-alg') + dh_group = child_sa_data.get('dh-group') + proposal = { + 'cipher': cipher, + 'mode': mode, + 'key_size': key_size, + 'hash': integ_alg, + 'dh': dh_group, + } + return proposal + return {} + + +def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: + """Get configured VPN IKE connections and IPsec states + + Args: + list_connections (list): List of configured connections from vici + list_sas (list): List of current SAs from vici + + Returns: + list: List and status of IKE/IPsec connections/tunnels + """ + base_dict = [] + for connections in list_connections: + base_list = {} + for connection, conn_conf in connections.items(): + base_list['ike_connection_name'] = connection + base_list['ike_connection_state'] = _get_parent_sa_state( + connection, list_sas + ) + base_list['ike_remote_address'] = conn_conf['remote_addrs'] + base_list['ike_proposal'] = _get_parent_sa_proposal(connection, list_sas) + 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') + base_list['children'] = [] + children = conn_conf['children'] + for tunnel, tun_options in children.items(): + state = _get_child_sa_state(connection, tunnel, list_sas) + local_ts = tun_options.get('local-ts') + remote_ts = tun_options.get('remote-ts') + dpd_action = tun_options.get('dpd_action') + close_action = tun_options.get('close_action') + sa_info = _get_child_sa_info(connection, tunnel, list_sas) + esp_proposal = _get_child_sa_proposal(sa_info) + base_list['children'].append( + { + 'name': tunnel, + 'state': state, + 'local_ts': local_ts, + 'remote_ts': remote_ts, + 'dpd_action': dpd_action, + 'close_action': close_action, + 'sa': sa_info, + 'esp_proposal': esp_proposal, + } + ) + base_dict.append(base_list) + return base_dict + + +def _get_raw_connections_summary(list_conn, list_sas): + import jmespath + + data = _get_raw_data_connections(list_conn, list_sas) + match = '[*].children[]' + child = jmespath.search(match, data) + tunnels_down = len([k for k in child if k['state'] == 'down']) + tunnels_up = len([k for k in child if k['state'] == 'up']) + tun_dict = { + 'tunnels': child, + 'total': len(child), + 'down': tunnels_down, + 'up': tunnels_up, + } + return tun_dict + + +def _get_formatted_output_conections(data): + from tabulate import tabulate + + connections = [] + for entry in data: + ike_name = entry['ike_connection_name'] + ike_state = entry['ike_connection_state'] + conn_type = entry.get('version', 'IKE') + remote_addrs = ','.join(entry['ike_remote_address']) + local_ts, remote_ts = '-', '-' + local_id = entry['local_id'] + remote_id = entry['remote_id'] + proposal = '-' + if entry.get('ike_proposal'): + proposal = ( + f'{entry["ike_proposal"]["cipher"]}_' + f'{entry["ike_proposal"]["mode"]}/' + f'{entry["ike_proposal"]["key_size"]}/' + f'{entry["ike_proposal"]["hash"]}/' + f'{entry["ike_proposal"]["dh"]}' + ) + connections.append( + [ + ike_name, + ike_state, + conn_type, + remote_addrs, + local_ts, + remote_ts, + local_id, + remote_id, + proposal, + ] + ) + for tun in entry['children']: + tun_name = tun.get('name') + tun_state = tun.get('state') + conn_type = 'IPsec' + local_ts = '\n'.join(tun.get('local_ts')) + remote_ts = '\n'.join(tun.get('remote_ts')) + proposal = '-' + if tun.get('esp_proposal'): + proposal = ( + f'{tun["esp_proposal"]["cipher"]}_' + f'{tun["esp_proposal"]["mode"]}/' + f'{tun["esp_proposal"]["key_size"]}/' + f'{tun["esp_proposal"]["hash"]}/' + f'{tun["esp_proposal"]["dh"]}' + ) + connections.append( + [ + tun_name, + tun_state, + conn_type, + remote_addrs, + local_ts, + remote_ts, + local_id, + remote_id, + proposal, + ] + ) + connection_headers = [ + 'Connection', + 'State', + 'Type', + 'Remote address', + 'Local TS', + 'Remote TS', + 'Local id', + 'Remote id', + 'Proposal', + ] + output = tabulate(connections, connection_headers, numalign='left') + return output + + +# Connections block end + + +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 + :param ike_sas: list of IKE SAs generated by vici + :type ike_sas: list + :return: list of IKE SAs ids + :rtype: list + """ + list_childsa_id: list = [] + for ike in ike_sas: + for ike_sa in ike.values(): + for child_sa in ike_sa['child-sas'].values(): + list_childsa_id.append(child_sa['uniqueid'].decode('ascii')) + return list_childsa_id + + +def _get_con_childsa_name_list( + ike_sas: list, filter_dict: typing.Optional[dict] = None +) -> list: + """ + Generate list of CHILD SA ids based on list of OrderingDict + wich 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 + :type filter_dict: dict + :return: list of IKE SAs name + :rtype: list + """ + list_childsa_name: list = [] + for ike in ike_sas: + for ike_name, ike_values in ike.items(): + for sa, sa_values in ike_values['children'].items(): + if filter_dict: + if filter_dict.items() <= sa_values.items(): + list_childsa_name.append(sa) + else: + list_childsa_name.append(sa) + return list_childsa_name + + +def _get_all_sitetosite_peers_name_list() -> list: + """ + Return site-to-site peers configuration + :return: site-to-site peers configuration + :rtype: list + """ + conf: ConfigTreeQuery = ConfigTreeQuery() + config_path = ['vpn', 'ipsec', 'site-to-site', 'peer'] + peers_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + peers_list: list = [] + for name in peers_config: + peers_list.append(name) + return peers_list + + +def _get_tunnel_sw_format(peer: str, tunnel: str) -> str: + """ + Convert tunnel to Strongwan format of CHILD_SA + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + :return: Converted tunnel name (CHILD_SA) + :rtype: str + """ + tunnel_sw = None + if tunnel: + if tunnel.isnumeric(): + tunnel_sw = f'{peer}-tunnel-{tunnel}' + elif tunnel == 'vti': + tunnel_sw = f'{peer}-vti' + return tunnel_sw + + +def _initiate_peer_with_childsas( + peer: str, tunnel: typing.Optional[str] = None +) -> None: + """ + Initiate IPSEC peer SAs by vici. + If tunnel is None it initiates all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + tunnel_sw = _get_tunnel_sw_format(peer, tunnel) + try: + con_list: list = vyos.ipsec.get_vici_connection_by_name(peer) + if not con_list: + raise vyos.opmode.IncorrectValue( + f"Peer's {peer} SA(s) not loaded. Initiation was failed" + ) + childsa_name_list: list = _get_con_childsa_name_list(con_list) + + if not tunnel_sw: + vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, childsa_name_list) + print(f'Peer {peer} initiate result: success') + return + + if tunnel_sw in childsa_name_list: + vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, [tunnel_sw]) + print(f'Peer {peer} tunnel {tunnel} initiate result: success') + return + + raise vyos.opmode.IncorrectValue(f'Peer {peer} SA {tunnel} not found, aborting') + + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + except vyos.ipsec.ViciCommandError as err: + raise vyos.opmode.IncorrectValue(err) + + +def _terminate_peer(peer: str, tunnel: typing.Optional[str] = None) -> None: + """ + Terminate IPSEC peer SAs by vici. + If tunnel is None it terminates all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + # Convert tunnel to Strongwan format of CHILD_SA + tunnel_sw = _get_tunnel_sw_format(peer, tunnel) + try: + sa_list: list = vyos.ipsec.get_vici_sas_by_name(peer, tunnel_sw) + if sa_list: + if tunnel: + childsa_id_list: list = _get_childsa_id_list(sa_list) + if childsa_id_list: + vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw) + print(f'Peer {peer} tunnel {tunnel} terminate result: success') + else: + Warning( + f'Peer {peer} tunnel {tunnel} SA is not initiated. Nothing to terminate' + ) + else: + vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw) + print(f'Peer {peer} terminate result: success') + else: + Warning(f"Peer's {peer} SAs are not initiated. Nothing to terminate") + + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + except vyos.ipsec.ViciCommandError as err: + raise vyos.opmode.IncorrectValue(err) + + +def reset_peer(peer: str, tunnel: typing.Optional[str] = None) -> None: + """ + Reset IPSEC peer SAs. + If tunnel is None it resets all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + _terminate_peer(peer, tunnel) + peer_config = _get_sitetosite_peer_config(peer) + # initiate SAs only if 'connection-type=initiate' + if ( + 'connection_type' in peer_config + and peer_config['connection_type'] == 'initiate' + ): + _initiate_peer_with_childsas(peer, tunnel) + + +def reset_all_peers() -> None: + sitetosite_list = _get_all_sitetosite_peers_name_list() + if sitetosite_list: + for peer_name in sitetosite_list: + try: + reset_peer(peer_name) + except vyos.opmode.IncorrectValue as err: + print(err) + print('Peers reset result: success') + else: + raise vyos.opmode.UnconfiguredSubsystem( + 'VPN IPSec site-to-site is not configured, aborting' + ) + + +def _get_ra_session_list_by_username(username: typing.Optional[str] = None): + """ + Return list of remote-access IKE_SAs uniqueids + :param username: + :type username: + :return: + :rtype: + """ + list_sa_id = [] + sa_list = _get_raw_data_sas() + for sa_val in sa_list: + for sa in sa_val.values(): + if 'remote-eap-id' in sa: + if username: + if username == sa['remote-eap-id']: + list_sa_id.append(sa['uniqueid']) + else: + list_sa_id.append(sa['uniqueid']) + return list_sa_id + + +def reset_ra(username: typing.Optional[str] = None): + # Reset remote-access ipsec sessions + if username: + list_sa_id = _get_ra_session_list_by_username(username) + else: + list_sa_id = _get_ra_session_list_by_username() + if list_sa_id: + vyos.ipsec.terminate_vici_ikeid_list(list_sa_id) + + +def reset_profile_dst(profile: str, tunnel: str, nbma_dst: str): + if profile and tunnel and nbma_dst: + ike_sa_name = f'dmvpn-{profile}-{tunnel}' + try: + # Get IKE SAs + sa_list = convert_data(vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None)) + if not sa_list: + raise vyos.opmode.IncorrectValue( + f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting' + ) + sa_nbma_list = list( + [ + x + for x in sa_list + if ike_sa_name in x and x[ike_sa_name]['remote-host'] == nbma_dst + ] + ) + if not sa_nbma_list: + raise vyos.opmode.IncorrectValue( + f'SA(s) for profile {profile} tunnel {tunnel} remote-host {nbma_dst} not found, aborting' + ) + # terminate IKE SAs + vyos.ipsec.terminate_vici_ikeid_list( + list( + [ + x[ike_sa_name]['uniqueid'] + for x in sa_nbma_list + if ike_sa_name in x + ] + ) + ) + # initiate IKE SAs + for ike in sa_nbma_list: + if ike_sa_name in ike: + vyos.ipsec.vici_initiate( + ike_sa_name, + 'dmvpn', + ike[ike_sa_name]['local-host'], + ike[ike_sa_name]['remote-host'], + ) + print( + f'Profile {profile} tunnel {tunnel} remote-host {nbma_dst} reset result: success' + ) + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + except vyos.ipsec.ViciCommandError as err: + raise vyos.opmode.IncorrectValue(err) + + +def reset_profile_all(profile: str, tunnel: str): + if profile and tunnel: + ike_sa_name = f'dmvpn-{profile}-{tunnel}' + try: + # Get IKE SAs + sa_list: list = convert_data( + vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None) + ) + if not sa_list: + raise vyos.opmode.IncorrectValue( + f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting' + ) + # terminate IKE SAs + vyos.ipsec.terminate_vici_by_name(ike_sa_name, None) + # initiate IKE SAs + for ike in sa_list: + if ike_sa_name in ike: + vyos.ipsec.vici_initiate( + ike_sa_name, + 'dmvpn', + ike[ike_sa_name]['local-host'], + ike[ike_sa_name]['remote-host'], + ) + print( + f'Profile {profile} tunnel {tunnel} remote-host {ike[ike_sa_name]["remote-host"]} reset result: success' + ) + print(f'Profile {profile} tunnel {tunnel} reset result: success') + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + except vyos.ipsec.ViciCommandError as err: + raise vyos.opmode.IncorrectValue(err) + + +def show_sa(raw: bool): + sa_data = _get_raw_data_sas() + if raw: + return sa_data + return _get_formatted_output_sas(sa_data) + + +def _get_output_sas_detail(ra_output_list: list) -> str: + """ + Formate all IKE SAs detail output + :param ra_output_list: IKE SAs list + :type ra_output_list: list + :return: formatted RA IKE SAs detail output + :rtype: str + """ + return _get_output_swanctl_sas_from_list(ra_output_list) + + +def show_sa_detail(raw: bool): + sa_data = _get_raw_data_sas() + if raw: + return sa_data + return _get_output_sas_detail(sa_data) + + +def show_connections(raw: bool): + list_conns = _get_convert_data_connections() + list_sas = _get_raw_data_sas() + if raw: + return _get_raw_data_connections(list_conns, list_sas) + + connections = _get_raw_data_connections(list_conns, list_sas) + return _get_formatted_output_conections(connections) + + +def show_connections_summary(raw: bool): + list_conns = _get_convert_data_connections() + list_sas = _get_raw_data_sas() + if raw: + return _get_raw_connections_summary(list_conns, list_sas) + + +def _get_ra_sessions(username: typing.Optional[str] = None) -> list: + """ + Return list of remote-access IKE_SAs from VICI by username. + If username unspecified, return all remote-access IKE_SAs + :param username: Username of RA connection + :type username: str + :return: list of ra remote-access IKE_SAs + :rtype: list + """ + list_sa = [] + sa_list = _get_raw_data_sas() + for conn in sa_list: + for sa in conn.values(): + if 'remote-eap-id' in sa: + if username: + if username == sa['remote-eap-id']: + list_sa.append(conn) + else: + list_sa.append(conn) + return list_sa + + +def _filter_ikesas(list_sa: list, filter_key: str, filter_value: str) -> list: + """ + Filter IKE SAs by specifice key + :param list_sa: list of IKE SAs + :type list_sa: list + :param filter_key: Filter Key + :type filter_key: str + :param filter_value: Filter Value + :type filter_value: str + :return: Filtered list of IKE SAs + :rtype: list + """ + filtered_sa_list = [] + for conn in list_sa: + for sa in conn.values(): + if sa[filter_key] and sa[filter_key] == filter_value: + filtered_sa_list.append(conn) + return filtered_sa_list + + +def _get_last_installed_childsa(sa: dict) -> str: + """ + Return name of last installed active Child SA + :param sa: Dictionary with Child SAs + :type sa: dict + :return: Name of the Last installed active Child SA + :rtype: str + """ + child_sa_name = None + child_sa_id = 0 + for sa_name, child_sa in sa['child-sas'].items(): + if child_sa['state'] == 'INSTALLED': + if child_sa_id == 0 or int(child_sa['uniqueid']) > child_sa_id: + child_sa_id = int(child_sa['uniqueid']) + child_sa_name = sa_name + return child_sa_name + + +def _get_formatted_ike_proposal(sa: dict) -> str: + """ + Return IKE proposal string in format + EncrALG-EncrKeySize/PFR/HASH/DH-GROUP + :param sa: IKE SA + :type sa: dict + :return: IKE proposal string + :rtype: str + """ + proposal = '' + proposal = f'{proposal}{sa["encr-alg"]}' if 'encr-alg' in sa else proposal + proposal = f'{proposal}-{sa["encr-keysize"]}' if 'encr-keysize' in sa else proposal + proposal = f'{proposal}/{sa["prf-alg"]}' if 'prf-alg' in sa else proposal + proposal = f'{proposal}/{sa["integ-alg"]}' if 'integ-alg' in sa else proposal + proposal = f'{proposal}/{sa["dh-group"]}' if 'dh-group' in sa else proposal + return proposal + + +def _get_formatted_ipsec_proposal(sa: dict) -> str: + """ + Return IPSec proposal string in format + Protocol: EncrALG-EncrKeySize/HASH/PFS + :param sa: Child SA + :type sa: dict + :return: IPSec proposal string + :rtype: str + """ + proposal = '' + proposal = f'{proposal}{sa["protocol"]}' if 'protocol' in sa else proposal + proposal = f'{proposal}:{sa["encr-alg"]}' if 'encr-alg' in sa else proposal + proposal = f'{proposal}-{sa["encr-keysize"]}' if 'encr-keysize' in sa else proposal + proposal = f'{proposal}/{sa["integ-alg"]}' if 'integ-alg' in sa else proposal + proposal = f'{proposal}/{sa["dh-group"]}' if 'dh-group' in sa else proposal + return proposal + + +def _get_output_ra_sas_detail(ra_output_list: list) -> str: + """ + Formate RA IKE SAs detail output + :param ra_output_list: IKE SAs list + :type ra_output_list: list + :return: formatted RA IKE SAs detail output + :rtype: str + """ + return _get_output_swanctl_sas_from_list(ra_output_list) + + +def _get_formatted_output_ra_summary(ra_output_list: list): + sa_data = [] + for conn in ra_output_list: + for sa in conn.values(): + sa_id = sa['uniqueid'] if 'uniqueid' in sa else '' + sa_username = sa['remote-eap-id'] if 'remote-eap-id' in sa else '' + sa_protocol = f'IKEv{sa["version"]}' if 'version' in sa else '' + sa_remotehost = sa['remote-host'] if 'remote-host' in sa else '' + sa_remoteid = sa['remote-id'] if 'remote-id' in sa else '' + sa_ike_proposal = _get_formatted_ike_proposal(sa) + sa_tunnel_ip = sa['remote-vips'][0] + child_sa_key = _get_last_installed_childsa(sa) + if child_sa_key: + child_sa = sa['child-sas'][child_sa_key] + sa_ipsec_proposal = _get_formatted_ipsec_proposal(child_sa) + sa_state = 'UP' + sa_uptime = seconds_to_human(sa['established']) + else: + sa_ipsec_proposal = '' + sa_state = 'DOWN' + sa_uptime = '' + sa_data.append( + [ + sa_id, + sa_username, + sa_protocol, + sa_state, + sa_uptime, + sa_tunnel_ip, + sa_remotehost, + sa_remoteid, + sa_ike_proposal, + sa_ipsec_proposal, + ] + ) + + headers = [ + 'Connection ID', + 'Username', + 'Protocol', + 'State', + 'Uptime', + 'Tunnel IP', + 'Remote Host', + 'Remote ID', + 'IKE Proposal', + 'IPSec Proposal', + ] + sa_data = sorted(sa_data, key=_alphanum_key) + output = tabulate(sa_data, headers) + return output + + +def show_ra_detail( + raw: bool, + username: typing.Optional[str] = None, + conn_id: typing.Optional[str] = None, +): + list_sa: list = _get_ra_sessions() + if username: + list_sa = _filter_ikesas(list_sa, 'remote-eap-id', username) + elif conn_id: + list_sa = _filter_ikesas(list_sa, 'uniqueid', conn_id) + if not list_sa: + raise vyos.opmode.IncorrectValue('No active connections found, aborting') + if raw: + return list_sa + return _get_output_ra_sas_detail(list_sa) + + +def show_ra_summary(raw: bool): + list_sa: list = _get_ra_sessions() + if not list_sa: + raise vyos.opmode.IncorrectValue('No active connections found, aborting') + if raw: + return list_sa + return _get_formatted_output_ra_summary(list_sa) + + +# PSK block +def _get_raw_psk(): + conf: ConfigTreeQuery = ConfigTreeQuery() + config_path = ['vpn', 'ipsec', 'authentication', 'psk'] + psk_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + psk_list = [] + for psk, psk_data in psk_config.items(): + psk_data['psk'] = psk + psk_list.append(psk_data) + + return psk_list + + +def _get_formatted_psk(psk_list): + headers = ['PSK', 'Id', 'Secret'] + formatted_data = [] + + for psk_data in psk_list: + formatted_data.append( + [psk_data['psk'], '\n'.join(psk_data['id']), psk_data['secret']] + ) + + return tabulate(formatted_data, headers=headers) + + +def show_psk(raw: bool): + config = ConfigTreeQuery() + if not config.exists('vpn ipsec authentication psk'): + raise vyos.opmode.UnconfiguredSubsystem( + 'VPN ipsec psk authentication is not configured' + ) + + psk = _get_raw_psk() + if raw: + return psk + return _get_formatted_psk(psk) + + +# PSK block end + + +def _get_sitetosite_peer_config(peer: str): + """ + Return site-to-site peers configuration + :return: site-to-site peers configuration + :rtype: list + """ + conf: ConfigTreeQuery = ConfigTreeQuery() + config_path = ['vpn', 'ipsec', 'site-to-site', 'peer', peer] + peers_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + return peers_config + + +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/kernel_modules.py b/src/op_mode/kernel_modules.py new file mode 100644 index 0000000..e381a1d --- /dev/null +++ b/src/op_mode/kernel_modules.py @@ -0,0 +1,82 @@ +#!/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/>. +# +# Purpose: +# Provides commands for retrieving information about kernel modules. + +import sys +import typing + +import vyos.opmode + + +lsmod_tmpl = """ +{% for m in modules -%} +Module: {{m.name}} + +{% if m.holders -%} +Holders: {{m.holders | join(", ")}} +{%- endif %} + +{% if m.drivers -%} +Drivers: {{m.drivers | join(", ")}} +{%- endif %} + +{% for k in m.fields -%} +{{k}}: {{m["fields"][k]}} +{% endfor %} +{% if m.parameters %} + +Parameters: + +{% for p in m.parameters -%} +{{p}}: {{m["parameters"][p]}} +{% endfor -%} +{% endif -%} + +------------- + +{% endfor %} +""" + +def _get_raw_data(module=None): + from vyos.utils.kernel import get_module_data, lsmod + + if module: + return [get_module_data(module)] + else: + return lsmod() + +def show(raw: bool, module: typing.Optional[str]): + from jinja2 import Template + + data = _get_raw_data(module=module) + + if raw: + return data + else: + t = Template(lsmod_tmpl) + output = t.render({"modules": data}) + return output + +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/lldp.py b/src/op_mode/lldp.py new file mode 100644 index 0000000..fac622b --- /dev/null +++ b/src/op_mode/lldp.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import jmespath +import json +import sys +import typing + +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search + +import vyos.opmode +unconf_message = 'LLDP is not configured' +capability_codes = """Capability Codes: R - Router, B - Bridge, W - Wlan r - Repeater, S - Station + D - Docsis, T - Telephone, O - Other + +""" + +def _verify(func): + """Decorator checks if LLDP config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + if not config.exists(['service', 'lldp']): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + return _wrapper + +def _get_raw_data(interface=None, detail=False): + """ + If interface name is not set - get all interfaces + """ + tmp = 'lldpcli -f json show neighbors' + if detail: + tmp += f' details' + if interface: + tmp += f' ports {interface}' + output = cmd(tmp) + data = json.loads(output) + if not data: + return [] + return data + +def _get_formatted_output(raw_data): + data_entries = [] + tmp = dict_search('lldp.interface', raw_data) + if not tmp: + return None + # One can not always ensure that "interface" is of type list, add safeguard. + # E.G. Juniper Networks, Inc. ex2300-c-12t only has a dict, not a list of dicts + if isinstance(tmp, dict): + tmp = [tmp] + for neighbor in tmp: + for local_if, values in neighbor.items(): + tmp = [] + + # Device field + if 'chassis' in values: + tmp.append(next(iter(values['chassis']))) + else: + tmp.append('') + + # Local Port field + tmp.append(local_if) + + # Protocol field + tmp.append(values['via']) + + # Capabilities + cap = '' + capabilities = jmespath.search('chassis.[*][0][0].capability', values) + # One can not always ensure that "capability" is of type list, add + # safeguard. E.G. Unify US-24-250W only has a dict, not a list of dicts + if isinstance(capabilities, dict): + capabilities = [capabilities] + if capabilities: + for capability in capabilities: + if capability['enabled']: + if capability['type'] == 'Router': + cap += 'R' + if capability['type'] == 'Bridge': + cap += 'B' + if capability['type'] == 'Wlan': + cap += 'W' + if capability['type'] == 'Station': + cap += 'S' + if capability['type'] == 'Repeater': + cap += 'r' + if capability['type'] == 'Telephone': + cap += 'T' + if capability['type'] == 'Docsis': + cap += 'D' + if capability['type'] == 'Other': + cap += 'O' + tmp.append(cap) + + # Remote software platform + platform = jmespath.search('chassis.[*][0][0].descr', values) + if platform: + tmp.append(platform[:37]) + else: + tmp.append('') + + # Remote interface + interface = None + if jmespath.search('port.id.type', values) == 'ifname': + # Remote peer has explicitly returned the interface name as the PortID + interface = jmespath.search('port.id.value', values) + if not interface: + interface = jmespath.search('port.descr', values) + if not interface: + interface = jmespath.search('port.id.value', values) + if not interface: + interface = 'Unknown' + tmp.append(interface) + + # Add individual neighbor to output list + data_entries.append(tmp) + + headers = ["Device", "Local Port", "Protocol", "Capability", "Platform", "Remote Port"] + output = tabulate(data_entries, headers, numalign="left") + return capability_codes + output + +@_verify +def show_neighbors(raw: bool, interface: typing.Optional[str], detail: typing.Optional[bool]): + if raw or not detail: + lldp_data = _get_raw_data(interface=interface, detail=detail) + if raw: + return lldp_data + else: + return _get_formatted_output(lldp_data) + else: # non-raw, detail + tmp = 'lldpcli -f text show neighbors details' + if interface: + tmp += f' ports {interface}' + return cmd(tmp) + +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/log.py b/src/op_mode/log.py new file mode 100644 index 0000000..797ba5a --- /dev/null +++ b/src/op_mode/log.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import json +import re +import sys +import typing + +from jinja2 import Template + +from vyos.utils.process import rc_cmd + +import vyos.opmode + +journalctl_command_template = Template(""" +--no-hostname +--quiet + +{% if boot %} + --boot +{% endif %} + +{% if count %} + --lines={{ count }} +{% endif %} + +{% if reverse %} + --reverse +{% endif %} + +{% if since %} + --since={{ since }} +{% endif %} + +{% if unit %} + --unit={{ unit }} +{% endif %} + +{% if utc %} + --utc +{% endif %} + +{% if raw %} +{# By default show 100 only lines for raw option if count does not set #} +{# Protection from parsing the full log by default #} +{% if not boot %} + --lines={{ '' ~ count if count else '100' }} +{% endif %} + --no-pager + --output=json +{% endif %} +""") + + +def show(raw: bool, + boot: typing.Optional[bool], + count: typing.Optional[int], + facility: typing.Optional[str], + reverse: typing.Optional[bool], + utc: typing.Optional[bool], + unit: typing.Optional[str]): + kwargs = dict(locals()) + + journalctl_options = journalctl_command_template.render(kwargs) + journalctl_options = re.sub(r'\s+', ' ', journalctl_options) + rc, output = rc_cmd(f'journalctl {journalctl_options}') + if raw: + # Each 'journalctl --output json' line is a separate JSON object + # So we should return list of dict + return [json.loads(line) for line in output.split('\n')] + return output + + +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/maya_date.py b/src/op_mode/maya_date.py new file mode 100644 index 0000000..847b543 --- /dev/null +++ b/src/op_mode/maya_date.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2013, 2018 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys + +class MayaDate(object): + """ Converts number of days since UNIX epoch + to the Maya calendar date. + + Ancient Maya people used three independent calendars for + different purposes. + + The long count calendar is for recording historical events. + It represents the number of days passed + since some date in the past the Maya believed is the day + our world was created. + + Tzolkin calendar is for religious purposes, it has + two independent cycles of 13 and 20 days, where 13 day + cycle days are numbered, and 20 day cycle days are named. + + Haab calendar is for agriculture and daily life, it's a + 365 day calendar with 18 months 20 days each, and 5 + nameless days. + + The smallest unit of the long count calendar is one day (kin). + """ + + """ The long count calendar uses five different base 18 or base 20 + cycles. Modern scholars write long count calendar dates in a dot separated format + from longest to shortest cycle, + <baktun>.<katun>.<tun>.<winal>.<kin> + for example, "13.0.0.9.2". + + Classic version actually used by the ancient Maya wraps around + every 13th baktun, but modern historians often use longer cycles + such as piktun = 20 baktun. + + """ + kin = 1 + winal = 20 # 20 kin + tun = 360 # 18 winal + katun = 7200 # 20 tun + baktun = 144000 # 20 katun + + """ Tzolk'in date is composed of two independent cycles. + Dates repeat every 260 days, 13 Ajaw is considered the end + of tzolk'in. + + Every day of the 20 day cycle has unique name, we number + them from zero so it's easier to map the remainder to day: + """ + tzolkin_days = { 0: "Imix'", + 1: "Ik'", + 2: "Ak'b'al", + 3: "K'an", + 4: "Chikchan", + 5: "Kimi", + 6: "Manik'", + 7: "Lamat", + 8: "Muluk", + 9: "Ok", + 10: "Chuwen", + 11: "Eb'", + 12: "B'en", + 13: "Ix", + 14: "Men", + 15: "Kib'", + 16: "Kab'an", + 17: "Etz'nab'", + 18: "Kawak", + 19: "Ajaw" } + + """ As said above, haab (year) has 19 months. Only 18 are + true months of 20 days each, the remaining 5 days called "wayeb" + do not really belong to any month, but we think of them as a pseudo-month + for convenience. + + Also, note that days of the month are actually numbered from 0, not from 1, + it's not for technical reasons. + """ + haab_months = { 0: "Pop", + 1: "Wo'", + 2: "Sip", + 3: "Sotz'", + 4: "Sek", + 5: "Xul", + 6: "Yaxk'in'", + 7: "Mol", + 8: "Ch'en", + 9: "Yax", + 10: "Sak'", + 11: "Keh", + 12: "Mak", + 13: "K'ank'in", + 14: "Muwan'", + 15: "Pax", + 16: "K'ayab", + 17: "Kumk'u", + 18: "Wayeb'" } + + """ Now we need to map the beginning of UNIX epoch + (Jan 1 1970 00:00 UTC) to the beginning of the long count + calendar (0.0.0.0.0, 4 Ajaw, 8 Kumk'u). + + The problem with mapping the long count calendar to + any other is that its start date is not known exactly. + + The most widely accepted hypothesis suggests it was + August 11, 3114 BC gregorian date. In this case UNIX epoch + starts on 12.17.16.7.5, 13 Chikchan, 3 K'ank'in + + It's known as Goodman-Martinez-Thompson (GMT) correlation + constant. + """ + start_days = 1856305 + + """ Seconds in day, for conversion from timestamp """ + seconds_in_day = 60 * 60 * 24 + + def __init__(self, timestamp): + if timestamp is None: + self.days = self.start_days + else: + self.days = self.start_days + (int(timestamp) // self.seconds_in_day) + + def long_count_date(self): + """ Returns long count date string """ + days = self.days + + cur_baktun = days // self.baktun + days = days % self.baktun + + cur_katun = days // self.katun + days = days % self.katun + + cur_tun = days // self.tun + days = days % self.tun + + cur_winal = days // self.winal + days = days % self.winal + + cur_kin = days + + longcount_string = "{0}.{1}.{2}.{3}.{4}".format( cur_baktun, + cur_katun, + cur_tun, + cur_winal, + cur_kin ) + return(longcount_string) + + def tzolkin_date(self): + """ Returns tzolkin date string """ + days = self.days + + """ 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. + """ + tzolkin_13 = (days + 4) % 13 + tzolkin_20 = (days - 1) % 20 + + tzolkin_string = "{0} {1}".format(tzolkin_13, self.tzolkin_days[tzolkin_20]) + + return(tzolkin_string) + + def haab_date(self): + """ 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 + from the current date to get correct result. + """ + days = self.days + + haab_day = (days - 17) % 365 + haab_month = haab_day // 20 + haab_day_of_month = haab_day % 20 + + haab_string = "{0} {1}".format(haab_day_of_month, self.haab_months[haab_month]) + + return(haab_string) + + def date(self): + return("{0}, {1}, {2}".format( self.long_count_date(), self.tzolkin_date(), self.haab_date() )) + +if __name__ == '__main__': + try: + timestamp = sys.argv[1] + except: + print("Please specify timestamp in the argument") + sys.exit(1) + + maya_date = MayaDate(timestamp) + print(maya_date.date()) diff --git a/src/op_mode/memory.py b/src/op_mode/memory.py new file mode 100644 index 0000000..eb53003 --- /dev/null +++ b/src/op_mode/memory.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import sys + +import vyos.opmode + + +def _get_raw_data(): + from re import search as re_search + + def find_value(keyword, mem_data): + regex = keyword + ':\s+(\d+)' + res = re_search(regex, mem_data).group(1) + return int(res) + + with open("/proc/meminfo", "r") as f: + mem_data = f.read() + + total = find_value('MemTotal', mem_data) + available = find_value('MemAvailable', mem_data) + buffers = find_value('Buffers', mem_data) + cached = find_value('Cached', mem_data) + + used = total - available + + mem_data = { + "total": total, + "free": available, + "used": used, + "buffers": buffers, + "cached": cached + } + + for key in mem_data: + # The Linux kernel exposes memory values in kilobytes, + # so we need to normalize them + mem_data[key] = mem_data[key] * 1024 + + return mem_data + +def _get_formatted_output(mem): + from vyos.utils.convert import bytes_to_human + + # For human-readable outputs, we convert bytes to more convenient units + # (100M, 1.3G...) + for key in mem: + mem[key] = bytes_to_human(mem[key]) + + out = "Total: {}\n".format(mem["total"]) + out += "Free: {}\n".format(mem["free"]) + out += "Used: {}".format(mem["used"]) + + return out + +def show(raw: bool): + ram_data = _get_raw_data() + + if raw: + return ram_data + else: + return _get_formatted_output(ram_data) + + +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/mtr.py b/src/op_mode/mtr.py new file mode 100644 index 0000000..de139f2 --- /dev/null +++ b/src/op_mode/mtr.py @@ -0,0 +1,306 @@ +#! /usr/bin/env python3 + +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import socket +import ipaddress + +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call + +options = { + 'report': { + 'mtr': '{command} --report', + 'type': 'noarg', + 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.' + }, + 'report-wide': { + 'mtr': '{command} --report-wide', + 'type': 'noarg', + 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.' + }, + 'raw': { + 'mtr': '{command} --raw', + 'type': 'noarg', + 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.' + }, + 'json': { + 'mtr': '{command} --json', + 'type': 'noarg', + 'help': 'Use this option to tell mtr to use the JSON output format.' + }, + 'split': { + 'mtr': '{command} --split', + 'type': 'noarg', + 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.' + }, + 'no-dns': { + 'mtr': '{command} --no-dns', + 'type': 'noarg', + 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.' + }, + 'show-ips': { + 'mtr': '{command} --show-ips {value}', + 'type': '<num>', + 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.' + }, + 'ipinfo': { + 'mtr': '{command} --ipinfo {value}', + 'type': '<num>', + 'help': 'Displays information about each IP hop.' + }, + 'aslookup': { + 'mtr': '{command} --aslookup', + 'type': 'noarg', + 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.' + }, + 'interval': { + 'mtr': '{command} --interval {value}', + 'type': '<num>', + 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.' + }, + 'report-cycles': { + 'mtr': '{command} --report-cycles {value}', + 'type': '<num>', + 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.' + }, + 'psize': { + 'mtr': '{command} --psize {value}', + 'type': '<num>', + 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.' + }, + 'bitpattern': { + 'mtr': '{command} --bitpattern {value}', + 'type': '<num>', + 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.' + }, + 'gracetime': { + 'mtr': '{command} --gracetime {value}', + 'type': '<num>', + 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.' + }, + 'tos': { + 'mtr': '{command} --tos {value}', + 'type': '<tos>', + 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.' + }, + 'mpls': { + 'mtr': '{command} --mpls {value}', + 'type': 'noarg', + 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.' + }, + 'interface': { + 'mtr': '{command} --interface {value}', + 'type': '<interface>', + 'helpfunction': interface_list, + 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.' + }, + 'address': { + 'mtr': '{command} --address {value}', + 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', + 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.' + }, + 'first-ttl': { + 'mtr': '{command} --first-ttl {value}', + 'type': '<num>', + 'help': 'Specifies with what TTL to start. Defaults to 1.' + }, + 'max-ttl': { + 'mtr': '{command} --max-ttl {value}', + 'type': '<num>', + 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.' + }, + 'max-unknown': { + 'mtr': '{command} --max-unknown {value}', + 'type': '<num>', + 'help': 'Specifies the maximum unknown host. Default is 5.' + }, + 'udp': { + 'mtr': '{command} --udp', + 'type': 'noarg', + 'help': 'Use UDP datagrams instead of ICMP ECHO.' + }, + 'tcp': { + 'mtr': '{command} --tcp', + 'type': 'noarg', + 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.' + }, + 'sctp': { + 'mtr': '{command} --sctp', + 'type': 'noarg', + 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.' + }, + 'port': { + 'mtr': '{command} --port {value}', + 'type': '<port>', + 'help': 'The target port number for TCP/SCTP/UDP traces.' + }, + 'localport': { + 'mtr': '{command} --localport {value}', + 'type': '<port>', + 'help': 'The source port number for UDP traces.' + }, + 'timeout': { + 'mtr': '{command} --timeout {value}', + 'type': '<num>', + 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.' + }, + 'mark': { + 'mtr': '{command} --mark {value}', + 'type': '<num>', + 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.' + }, + 'vrf': { + 'mtr': 'sudo ip vrf exec {value} {command}', + 'type': '<vrf>', + 'help': 'Use specified VRF table', + 'helpfunction': vrf_list, + 'dflt': 'default' + } + } + +mtr = { + 4: '/bin/mtr -4', + 6: '/bin/mtr -6', +} + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expension_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expension_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['mtr'].format( + command=command, value='') + elif not args: + sys.exit(f'mtr: missing argument for {longname} option') + else: + command = options[longname]['mtr'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + host = args.first() + + if not host: + sys.exit("mtr: Missing host") + + + if host == '--get-options' or host == '--get-options-nested': + if host == '--get-options-nested': + args.first() # pop monitor + args.first() # pop mtr | traceroute + args.first() # pop IP + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + # Run helpfunction to get list of possible values + if 'helpfunction' in options[matched[0]]: + result = options[matched[0]]['helpfunction']() + if result: + helplines = '\n' + ' '.join(result) + sys.stdout.write(helplines) + sys.exit(0) + + for name, option in options.items(): + if 'dflt' in option and name not in args: + args.append(name) + args.append(option['dflt']) + + try: + ip = socket.gethostbyname(host) + except UnicodeError: + sys.exit(f'mtr: Unknown host: {host}') + except socket.gaierror: + ip = host + + try: + version = ipaddress.ip_address(ip).version + except ValueError: + sys.exit(f'mtr: Unknown host: {host}') + + command = convert(mtr[version], args) + call(f'{command} --curses --displaymode 0 {host}') diff --git a/src/op_mode/multicast.py b/src/op_mode/multicast.py new file mode 100644 index 0000000..0666f8a --- /dev/null +++ b/src/op_mode/multicast.py @@ -0,0 +1,72 @@ +#!/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/>. + +import json +import sys +import typing + +from tabulate import tabulate +from vyos.utils.process import cmd + +import vyos.opmode + +ArgFamily = typing.Literal['inet', 'inet6'] + +def _get_raw_data(family, interface=None): + tmp = 'ip -4' + if family == 'inet6': + tmp = 'ip -6' + tmp = f'{tmp} -j maddr show' + if interface: + tmp = f'{tmp} dev {interface}' + output = cmd(tmp) + data = json.loads(output) + if not data: + return [] + return data + +def _get_formatted_output(raw_data): + data_entries = [] + + # sort result by interface name + for interface in sorted(raw_data, key=lambda x: x['ifname']): + for address in interface['maddr']: + tmp = [] + tmp.append(interface['ifname']) + tmp.append(address['family']) + tmp.append(address['address']) + + data_entries.append(tmp) + + headers = ["Interface", "Family", "Address"] + output = tabulate(data_entries, headers, numalign="left") + return output + +def show_group(raw: bool, family: ArgFamily, interface: typing.Optional[str]): + multicast_data = _get_raw_data(family=family, interface=interface) + if raw: + return multicast_data + else: + return _get_formatted_output(multicast_data) + +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/nat.py b/src/op_mode/nat.py new file mode 100644 index 0000000..c6cf477 --- /dev/null +++ b/src/op_mode/nat.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import jmespath +import json +import sys +import xmltodict +import typing + +from tabulate import tabulate + +import vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search + +ArgDirection = typing.Literal['source', 'destination'] +ArgFamily = typing.Literal['inet', 'inet6'] + + +def _get_xml_translation(direction, family, address=None): + """ + Get conntrack XML output --src-nat|--dst-nat + """ + if direction == 'source': + opt = '--src-nat' + if direction == 'destination': + opt = '--dst-nat' + tmp = f'conntrack --dump --family {family} {opt} --output xml' + if address: + tmp += f' --src {address}' + return cmd(tmp) + + +def _xml_to_dict(xml): + """ + Convert XML to dictionary + Return: dictionary + """ + parse = xmltodict.parse(xml, attr_prefix='') + # If only one conntrack entry we must change dict + if 'meta' in parse['conntrack']['flow']: + return dict(conntrack={'flow': [parse['conntrack']['flow']]}) + return parse + + +def _get_json_data(direction, family): + """ + Get NAT format JSON + """ + if direction == 'source': + chain = 'POSTROUTING' + if direction == 'destination': + chain = 'PREROUTING' + family = 'ip6' if family == 'inet6' else 'ip' + return cmd(f'nft --json list chain {family} vyos_nat {chain}') + + +def _get_raw_data_rules(direction, family): + """Get interested rules + :returns dict + """ + data = _get_json_data(direction, family) + data_dict = json.loads(data) + rules = [] + for rule in data_dict['nftables']: + if 'rule' in rule and 'comment' in rule['rule']: + rules.append(rule) + return rules + + +def _get_raw_translation(direction, family, address=None): + """ + Return: dictionary + """ + xml = _get_xml_translation(direction, family, address) + if len(xml) == 0: + output = {'conntrack': + { + 'error': True, + 'reason': 'entries not found' + } + } + return output + return _xml_to_dict(xml) + + +def _get_formatted_output_rules(data, direction, family): + + + def _get_ports_for_output(rules): + """ + Return: string of configured ports + """ + ports = [] + if 'set' in rules: + for index, port in enumerate(rules['set']): + if 'range' in str(rules['set'][index]): + output = rules['set'][index]['range'] + output = '-'.join(map(str, output)) + else: + output = str(port) + ports.append(output) + # When NAT rule contains port range or single port + # JSON will not contain keyword 'set' + elif 'range' in rules: + output = rules['range'] + output = '-'.join(map(str, output)) + ports.append(output) + else: + output = rules['right'] + ports.append(str(output)) + result = ','.join(ports) + # Handle case where ports in NAT rule are negated + if rules['op'] == '!=': + result = '!' + result + return(result) + + # Add default values before loop + sport, dport, proto = 'any', 'any', 'any' + saddr = '::/0' if family == 'inet6' else '0.0.0.0/0' + daddr = '::/0' if family == 'inet6' else '0.0.0.0/0' + + data_entries = [] + for rule in data: + if 'comment' in rule['rule']: + comment = rule.get('rule').get('comment') + rule_number = comment.split('-')[-1] + rule_number = rule_number.split(' ')[0] + 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' + 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 + if (isinstance(match['right'], dict) and + ('prefix' in match['right'] or 'set' in match['right'] or + 'range' in match['right'])): + # Merge dict src/dst l3_l4 parameters + 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'] == 'saddr': + saddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' + elif my_dict['field'] == 'daddr': + daddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' + elif my_dict['field'] == 'sport': + sport = _get_ports_for_output(my_dict) + elif my_dict['field'] == 'dport': + dport = _get_ports_for_output(my_dict) + # Handle NAT rule containing a single port + else: + field = jmespath.search('left.payload.field', match) + if field == 'saddr': + saddr = match.get('right') + elif field == 'daddr': + daddr = match.get('right') + elif field == 'sport': + sport = _get_ports_for_output(match) + elif field == 'dport': + dport = _get_ports_for_output(match) + else: + saddr = '::/0' if family == 'inet6' else '0.0.0.0/0' + daddr = '::/0' if family == 'inet6' else '0.0.0.0/0' + sport = 'any' + dport = 'any' + proto = 'any' + + source = f'''{saddr} +sport {sport}''' + destination = f'''{daddr} +dport {dport}''' + + if jmespath.search('left.payload.field', match) == 'protocol': + field_proto = match.get('right').upper() + + for expr in rule.get('rule').get('expr'): + if 'snat' in expr: + translation = dict_search('snat.addr', expr) + if expr['snat'] and 'port' in expr['snat']: + if jmespath.search('snat.port.range', expr): + port = dict_search('snat.port.range', expr) + port = '-'.join(map(str, port)) + else: + port = expr['snat']['port'] + translation = f'''{translation} +port {port}''' + + elif 'masquerade' in expr: + translation = 'masquerade' + if expr['masquerade'] and 'port' in expr['masquerade']: + if jmespath.search('masquerade.port.range', expr): + port = dict_search('masquerade.port.range', expr) + port = '-'.join(map(str, port)) + else: + port = expr['masquerade']['port'] + + translation = f'''{translation} +port {port}''' + elif 'dnat' in expr: + translation = dict_search('dnat.addr', expr) + if expr['dnat'] and 'port' in expr['dnat']: + if jmespath.search('dnat.port.range', expr): + port = dict_search('dnat.port.range', expr) + port = '-'.join(map(str, port)) + else: + port = expr['dnat']['port'] + translation = f'''{translation} +port {port}''' + else: + translation = 'exclude' + # Overwrite match loop 'proto' if specified filed 'protocol' exist + if 'protocol' in jmespath.search('rule.expr[*].match.left.payload.field', rule): + proto = jmespath.search('rule.expr[0].match.right', rule).upper() + + data_entries.append([rule_number, source, destination, proto, interface, translation]) + + interface_header = 'Out-Int' if direction == 'source' else 'In-Int' + headers = ["Rule", "Source", "Destination", "Proto", interface_header, "Translation"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def _get_formatted_output_statistics(data, direction): + data_entries = [] + for rule in data: + if 'comment' in rule['rule']: + comment = rule.get('rule').get('comment') + rule_number = comment.split('-')[-1] + rule_number = rule_number.split(' ')[0] + 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' + packets = jmespath.search('rule.expr[*].counter.packets | [0]', rule) + _bytes = jmespath.search('rule.expr[*].counter.bytes | [0]', rule) + data_entries.append([rule_number, packets, _bytes, interface]) + headers = ["Rule", "Packets", "Bytes", "Interface"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def _get_formatted_translation(dict_data, nat_direction, family, verbose): + data_entries = [] + if 'error' in dict_data['conntrack']: + return 'Entries not found' + for entry in dict_data['conntrack']['flow']: + orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {} + reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {} + proto = {} + for meta in entry['meta']: + direction = meta['direction'] + if direction in ['original']: + if 'layer3' in meta: + orig_src = meta['layer3']['src'] + orig_dst = meta['layer3']['dst'] + if 'layer4' in meta: + if meta.get('layer4').get('sport'): + orig_sport = meta['layer4']['sport'] + if meta.get('layer4').get('dport'): + orig_dport = meta['layer4']['dport'] + proto = meta['layer4']['protoname'] + if direction in ['reply']: + if 'layer3' in meta: + reply_src = meta['layer3']['src'] + reply_dst = meta['layer3']['dst'] + if 'layer4' in meta: + if meta.get('layer4').get('sport'): + reply_sport = meta['layer4']['sport'] + if meta.get('layer4').get('dport'): + reply_dport = meta['layer4']['dport'] + proto = meta['layer4']['protoname'] + if direction == 'independent': + conn_id = meta['id'] + timeout = meta.get('timeout', 'n/a') + orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src + orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst + reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src + reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst + state = meta['state'] if 'state' in meta else '' + mark = meta.get('mark', '') + zone = meta['zone'] if 'zone' in meta else '' + if nat_direction == 'source': + tmp = [orig_src, reply_dst, proto, timeout, mark, zone] + data_entries.append(tmp) + elif nat_direction == 'destination': + tmp = [orig_dst, reply_src, proto, timeout, mark, zone] + data_entries.append(tmp) + + headers = ["Pre-NAT", "Post-NAT", "Proto", "Timeout", "Mark", "Zone"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def _verify(func): + """Decorator checks if NAT config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'nat66' if 'inet6' in sys.argv[1:] else 'nat' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base.upper()} is not configured') + return func(*args, **kwargs) + return _wrapper + + +@_verify +def show_rules(raw: bool, direction: ArgDirection, family: ArgFamily): + nat_rules = _get_raw_data_rules(direction, family) + if raw: + return nat_rules + else: + return _get_formatted_output_rules(nat_rules, direction, family) + + +@_verify +def show_statistics(raw: bool, direction: ArgDirection, family: ArgFamily): + nat_statistics = _get_raw_data_rules(direction, family) + if raw: + return nat_statistics + else: + return _get_formatted_output_statistics(nat_statistics, direction) + + +@_verify +def show_translations(raw: bool, direction: ArgDirection, + family: ArgFamily, + address: typing.Optional[str], + verbose: typing.Optional[bool]): + family = 'ipv6' if family == 'inet6' else 'ipv4' + nat_translation = _get_raw_translation(direction, + family=family, + address=address) + + if raw: + return nat_translation + else: + return _get_formatted_translation(nat_translation, direction, family, + verbose) + + +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/neighbor.py b/src/op_mode/neighbor.py new file mode 100644 index 0000000..8b3c45c --- /dev/null +++ b/src/op_mode/neighbor.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 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/>. + +# Sample output of `ip --json neigh list`: +# +# [ +# { +# "dst": "192.168.1.1", +# "dev": "eth0", # Missing if `dev ...` option is used +# "lladdr": "00:aa:bb:cc:dd:ee", # May be missing for failed entries +# "state": [ +# "REACHABLE" +# ] +# }, +# ] + +import sys +import typing + +import vyos.opmode +from vyos.utils.network import interface_exists + +ArgFamily = typing.Literal['inet', 'inet6'] +ArgState = typing.Literal['reachable', 'stale', 'failed', 'permanent'] + +def get_raw_data(family, interface=None, state=None): + from json import loads + from vyos.utils.process import cmd + + if interface: + if not interface_exists(interface): + raise ValueError(f"Interface '{interface}' does not exist in the system") + interface = f"dev {interface}" + else: + interface = "" + + if state: + state = f"nud {state}" + else: + state = "" + + neigh_cmd = f"ip --family {family} --json neighbor list {interface} {state}" + + data = loads(cmd(neigh_cmd)) + + return data + +def format_neighbors(neighs, interface=None): + from tabulate import tabulate + + def entry_to_list(e, intf=None): + dst = e["dst"] + + # State is always a list in the iproute2 output + state = ", ".join(e["state"]) + + # Link layer address is absent from e.g. FAILED entries + if "lladdr" in e: + lladdr = e["lladdr"] + else: + lladdr = None + + # Device field is absent from outputs of `ip neigh list dev ...` + if "dev" in e: + dev = e["dev"] + elif interface: + dev = interface + else: + raise ValueError("interface is not defined") + + return [dst, dev, lladdr, state] + + neighs = map(entry_to_list, neighs) + + headers = ["Address", "Interface", "Link layer address", "State"] + return tabulate(neighs, headers) + +def show(raw: bool, family: ArgFamily, interface: typing.Optional[str], + state: typing.Optional[ArgState]): + """ Display neighbor table contents """ + data = get_raw_data(family, interface, state=state) + + if raw: + return data + else: + return format_neighbors(data, interface) + +def reset(family: ArgFamily, interface: typing.Optional[str], address: typing.Optional[str]): + from vyos.utils.process import run + + if address and interface: + raise ValueError("interface and address parameters are mutually exclusive") + elif address: + run(f"""ip --family {family} neighbor flush to {address}""") + elif interface: + run(f"""ip --family {family} neighbor flush dev {interface}""") + else: + # Flush an entire neighbor table + run(f"""ip --family {family} neighbor flush""") + +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/nhrp.py b/src/op_mode/nhrp.py new file mode 100644 index 0000000..e66f330 --- /dev/null +++ b/src/op_mode/nhrp.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import tabulate +import vyos.opmode + +from vyos.utils.process import cmd +from vyos.utils.process import process_named_running +from vyos.utils.dict import colon_separated_to_dict + + +def _get_formatted_output(output_dict: dict) -> str: + """ + Create formatted table for CLI output + :param output_dict: dictionary for API + :type output_dict: dict + :return: tabulate string + :rtype: str + """ + print(f"Status: {output_dict['Status']}") + output: str = tabulate.tabulate(output_dict['routes'], headers='keys', + numalign="left") + return output + + +def _get_formatted_dict(output_string: str) -> dict: + """ + Format string returned from CMD to API list + :param output_string: String received by CMD + :type output_string: str + :return: dictionary for API + :rtype: dict + """ + formatted_dict: dict = { + 'Status': '', + 'routes': [] + } + output_list: list = output_string.split('\n\n') + for list_a in output_list: + output_dict = colon_separated_to_dict(list_a, True) + if 'Status' in output_dict: + formatted_dict['Status'] = output_dict['Status'] + else: + formatted_dict['routes'].append(output_dict) + return formatted_dict + + +def show_interface(raw: bool): + """ + Command 'show nhrp interface' + :param raw: if API + :type raw: bool + """ + if not process_named_running('opennhrp'): + raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.') + interface_string: str = cmd('sudo opennhrpctl interface show') + interface_dict: dict = _get_formatted_dict(interface_string) + if raw: + return interface_dict + else: + return _get_formatted_output(interface_dict) + + +def show_tunnel(raw: bool): + """ + Command 'show nhrp tunnel' + :param raw: if API + :type raw: bool + """ + if not process_named_running('opennhrp'): + raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.') + tunnel_string: str = cmd('sudo opennhrpctl show') + tunnel_dict: list = _get_formatted_dict(tunnel_string) + if raw: + return tunnel_dict + else: + return _get_formatted_output(tunnel_dict) + + +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/ntp.py b/src/op_mode/ntp.py new file mode 100644 index 0000000..6ec0fed --- /dev/null +++ b/src/op_mode/ntp.py @@ -0,0 +1,177 @@ +#!/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/>. + +import csv +import sys +from itertools import chain + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd + +def _get_raw_data(command: str) -> dict: + # Returns returns chronyc output as a dictionary + + # Initialize dictionary keys to align with output of + # chrony -c. From some commands, its -c switch outputs + # more parameters, make sure to include them all below. + # See to chronyc(1) for definition of key variables + match command: + case "chronyc -c activity": + keys: list = [ + 'sources_online', + 'sources_offline', + 'sources_doing_burst_return_online', + 'sources_doing_burst_return_offline', + 'sources_with_unknown_address' + ] + + case "chronyc -c sources": + keys: list = [ + 'm', + 's', + 'name_ip_address', + 'stratum', + 'poll', + 'reach', + 'last_rx', + 'last_sample_adj_offset', + 'last_sample_mes_offset', + 'last_sample_est_error' + ] + + case "chronyc -c sourcestats": + keys: list = [ + 'name_ip_address', + 'np', + 'nr', + 'span', + 'frequency', + 'freq_skew', + 'offset', + 'std_dev' + ] + + case "chronyc -c tracking": + keys: list = [ + 'ref_id', + 'ref_id_name', + 'stratum', + 'ref_time', + 'system_time', + 'last_offset', + 'rms_offset', + 'frequency', + 'residual_freq', + 'skew', + 'root_delay', + 'root_dispersion', + 'update_interval', + 'leap_status' + ] + + case _: + raise ValueError(f"Raw mode: of {command} is not implemented") + + # Get -c option command line output, splitlines, + # and save comma-separated values as a flat list + output = cmd(command).splitlines() + values = csv.reader(output) + values = list(chain.from_iterable(values)) + + # Divide values into chunks of size keys and transpose + if len(values) > len(keys): + values = _chunk_list(values,keys) + values = zip(*values) + + return dict(zip(keys, values)) + +def _chunk_list(in_list, n): + # Yields successive n-sized chunks from in_list + for i in range(0, len(in_list), len(n)): + yield in_list[i:i + len(n)] + +def _is_configured(): + # Check if ntp is configured + config = ConfigTreeQuery() + if not config.exists("service ntp"): + raise vyos.opmode.UnconfiguredSubsystem("NTP service is not enabled.") + +def _extend_command_vrf(): + config = ConfigTreeQuery() + if config.exists('service ntp vrf'): + vrf = config.value('service ntp vrf') + return f'ip vrf exec {vrf} ' + return '' + + +def show_activity(raw: bool): + _is_configured() + command = f'chronyc' + + if raw: + command += f" -c activity" + return _get_raw_data(command) + else: + command = _extend_command_vrf() + command + command += f" activity" + return cmd(command) + +def show_sources(raw: bool): + _is_configured() + command = f'chronyc' + + if raw: + command += f" -c sources" + return _get_raw_data(command) + else: + command = _extend_command_vrf() + command + command += f" sources -v" + return cmd(command) + +def show_tracking(raw: bool): + _is_configured() + command = f'chronyc' + + if raw: + command += f" -c tracking" + return _get_raw_data(command) + else: + command = _extend_command_vrf() + command + command += f" tracking" + return cmd(command) + +def show_sourcestats(raw: bool): + _is_configured() + command = f'chronyc' + + if raw: + command += f" -c sourcestats" + return _get_raw_data(command) + else: + command = _extend_command_vrf() + command + command += f" sourcestats -v" + return cmd(command) + + +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/openconnect-control.py b/src/op_mode/openconnect-control.py new file mode 100644 index 0000000..b70d4fa --- /dev/null +++ b/src/op_mode/openconnect-control.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import argparse +import json + +from tabulate import tabulate + +from vyos.config import Config +from vyos.utils.process import popen +from vyos.utils.process import run +from vyos.utils.process import DEVNULL + +occtl = '/usr/bin/occtl' +occtl_socket = '/run/ocserv/occtl.socket' + +def show_sessions(): + out, code = popen("sudo {0} -j -s {1} show users".format(occtl, occtl_socket),stderr=DEVNULL) + if code: + sys.exit('Cannot get openconnect users information') + else: + headers = ["interface", "username", "ip", "remote IP", "RX", "TX", "state", "uptime"] + sessions = json.loads(out) + ses_list = [] + for ses in sessions: + ses_list.append([ses["Device"], ses["Username"], ses["IPv4"], ses["Remote IP"], ses["_RX"], ses["_TX"], ses["State"], ses["_Connected at"]]) + if len(ses_list) > 0: + print(tabulate(ses_list, headers)) + else: + print("No active openconnect sessions") + +def is_ocserv_configured(): + if not Config().exists_effective('vpn openconnect'): + print("vpn openconnect server is not configured") + sys.exit(1) + +def main(): + #parese args + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Control action', required=True) + parser.add_argument('--selector', help='Selector username|ifname|sid', required=False) + parser.add_argument('--target', help='Target must contain username|ifname|sid', required=False) + args = parser.parse_args() + + + # Check is Openconnect server configured + is_ocserv_configured() + + if args.action == "restart": + run("sudo systemctl restart ocserv.service") + sys.exit(0) + elif args.action == "show_sessions": + show_sessions() + +if __name__ == '__main__': + main() diff --git a/src/op_mode/openconnect.py b/src/op_mode/openconnect.py new file mode 100644 index 0000000..62c683e --- /dev/null +++ b/src/op_mode/openconnect.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import json + +from tabulate import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import rc_cmd + +import vyos.opmode + + +occtl = '/usr/bin/occtl' +occtl_socket = '/run/ocserv/occtl.socket' + + +def _get_raw_data_sessions(): + rc, out = rc_cmd(f'sudo {occtl} --json --socket-file {occtl_socket} show users') + if rc != 0: + raise vyos.opmode.DataUnavailable(out) + + sessions = json.loads(out) + return sessions + + +def _get_formatted_sessions(data): + headers = ["Interface", "Username", "IP", "Remote IP", "RX", "TX", "State", "Uptime"] + ses_list = [] + for ses in data: + ses_list.append([ + ses.get("Device", '(none)'), ses.get("Username", '(none)'), + ses.get("IPv4", '(none)'), ses.get("Remote IP", '(none)'), + ses.get("_RX", '(none)'), ses.get("_TX", '(none)'), + ses.get("State", '(none)'), ses.get("_Connected at", '(none)') + ]) + if len(ses_list) > 0: + output = tabulate(ses_list, headers) + else: + output = 'No active openconnect sessions' + return output + + +def show_sessions(raw: bool): + config = ConfigTreeQuery() + if not config.exists('vpn openconnect'): + raise vyos.opmode.UnconfiguredSubsystem('Openconnect is not configured') + + openconnect_data = _get_raw_data_sessions() + if raw: + return openconnect_data + return _get_formatted_sessions(openconnect_data) + + +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/openvpn.py b/src/op_mode/openvpn.py new file mode 100644 index 0000000..0928739 --- /dev/null +++ b/src/op_mode/openvpn.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + +import json +import os +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.utils.convert import bytes_to_human +from vyos.utils.commit import commit_in_progress +from vyos.utils.process import call +from vyos.utils.process import rc_cmd +from vyos.config import Config + +ArgMode = typing.Literal['client', 'server', 'site_to_site'] + +def _get_tunnel_address(peer_host, peer_port, status_file): + peer = peer_host + ':' + peer_port + lst = [] + + with open(status_file, 'r') as f: + lines = f.readlines() + for line in lines: + if peer in line: + lst.append(line) + + # filter out subnet entries if iroute: + # in the case that one sets, say: + # [ ..., 'vtun10', 'server', 'client', 'client1', 'subnet','10.10.2.0/25'] + # the status file will have an entry: + # 10.10.2.0/25,client1,... + lst = [l for l in lst[1:] if '/' not in l.split(',')[0]] + + if lst: + tunnel_ip = lst[0].split(',')[0] + + return tunnel_ip + + return 'n/a' + +def _get_interface_status(mode: str, interface: str) -> dict: + status_file = f'/run/openvpn/{interface}.status' + + data: dict = { + 'mode': mode, + 'intf': interface, + 'local_host': '', + 'local_port': '', + 'date': '', + 'clients': [], + } + + if not os.path.exists(status_file): + return data + + with open(status_file, 'r') as f: + lines = f.readlines() + for line_no, line in enumerate(lines): + # remove trailing newline character first + line = line.rstrip('\n') + + # check first line header + if line_no == 0: + if mode == 'server': + if not line == 'OpenVPN CLIENT LIST': + raise vyos.opmode.InternalError('Expected "OpenVPN CLIENT LIST"') + else: + if not line == 'OpenVPN STATISTICS': + raise vyos.opmode.InternalError('Expected "OpenVPN STATISTICS"') + + continue + + # second line informs us when the status file has been last updated + if line_no == 1: + data['date'] = line.lstrip('Updated,').rstrip('\n') + continue + + if mode == 'server': + # for line_no > 1, lines appear as follows: + # + # Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since + # client1,172.18.202.10:55904,2880587,2882653,Fri Aug 23 16:25:48 2019 + # client3,172.18.204.10:41328,2850832,2869729,Fri Aug 23 16:25:43 2019 + # client2,172.18.203.10:48987,2856153,2871022,Fri Aug 23 16:25:45 2019 + # ... + # ROUTING TABLE + # ... + if line_no >= 3: + # indicator that there are no more clients + if line == 'ROUTING TABLE': + break + # otherwise, get client data + remote = (line.split(',')[1]).rsplit(':', maxsplit=1) + + client = { + 'name': line.split(',')[0], + 'remote_host': remote[0], + 'remote_port': remote[1], + 'tunnel': 'N/A', + 'rx_bytes': bytes_to_human(int(line.split(',')[2]), + precision=1), + 'tx_bytes': bytes_to_human(int(line.split(',')[3]), + precision=1), + 'online_since': line.split(',')[4] + } + client['tunnel'] = _get_tunnel_address(client['remote_host'], + client['remote_port'], + status_file) + data['clients'].append(client) + continue + else: # mode == 'client' or mode == 'site-to-site' + if line_no == 2: + client = { + 'name': 'N/A', + 'remote_host': 'N/A', + 'remote_port': 'N/A', + 'tunnel': 'N/A', + 'rx_bytes': bytes_to_human(int(line.split(',')[1]), + precision=1), + 'tx_bytes': '', + 'online_since': 'N/A' + } + continue + + if line_no == 3: + client['tx_bytes'] = bytes_to_human(int(line.split(',')[1]), + precision=1) + data['clients'].append(client) + break + + return data + + +def _get_interface_state(iface): + rc, out = rc_cmd(f'ip --json link show dev {iface}') + try: + data = json.loads(out) + except: + return 'DOWN' + return data[0].get('operstate', 'DOWN') + + +def _get_interface_description(iface): + rc, out = rc_cmd(f'ip --json link show dev {iface}') + try: + data = json.loads(out) + except: + return '' + return data[0].get('ifalias', '') + + +def _get_raw_data(mode: str) -> list: + data: list = [] + conf = Config() + conf_dict = conf.get_config_dict(['interfaces', 'openvpn'], + get_first_key=True) + if not conf_dict: + return data + + interfaces = [x for x in list(conf_dict) if + conf_dict[x]['mode'].replace('-', '_') == mode] + for intf in interfaces: + d = _get_interface_status(mode, intf) + d['state'] = _get_interface_state(intf) + d['description'] = _get_interface_description(intf) + d['local_host'] = conf_dict[intf].get('local-host', '') + d['local_port'] = conf_dict[intf].get('local-port', '') + if conf.exists(f'interfaces openvpn {intf} server client'): + d['configured_clients'] = conf.list_nodes(f'interfaces openvpn {intf} server client') + if mode in ['client', 'site_to_site']: + for client in d['clients']: + if 'shared-secret-key-file' in list(conf_dict[intf]): + client['name'] = 'None (PSK)' + client['remote_host'] = conf_dict[intf].get('remote-host', [''])[0] + client['remote_port'] = conf_dict[intf].get('remote-port', '1194') + data.append(d) + + return data + +def _format_openvpn(data: list) -> str: + if not data: + out = 'No OpenVPN interfaces configured' + return out + + headers = ['Client CN', 'Remote Host', 'Tunnel IP', 'Local Host', + 'TX bytes', 'RX bytes', 'Connected Since'] + + out = '' + for d in data: + data_out = [] + intf = d['intf'] + l_host = d['local_host'] + l_port = d['local_port'] + out += f'\nOpenVPN status on {intf}\n\n' + for client in d['clients']: + r_host = client['remote_host'] + r_port = client['remote_port'] + + name = client['name'] + remote = r_host + ':' + r_port if r_host and r_port else 'N/A' + tunnel = client['tunnel'] + local = l_host + ':' + l_port if l_host and l_port else 'N/A' + tx_bytes = client['tx_bytes'] + rx_bytes = client['rx_bytes'] + online_since = client['online_since'] + data_out.append([name, remote, tunnel, local, tx_bytes, + rx_bytes, online_since]) + + out += tabulate(data_out, headers) + out += "\n" + + return out + +def show(raw: bool, mode: ArgMode) -> typing.Union[list,str]: + openvpn_data = _get_raw_data(mode) + + if raw: + return openvpn_data + + return _format_openvpn(openvpn_data) + +def reset(interface: str): + if os.path.isfile(f'/run/openvpn/{interface}.conf'): + if commit_in_progress(): + raise vyos.opmode.CommitInProgress('Retry OpenVPN reset: commit in progress.') + call(f'systemctl restart openvpn@{interface}.service') + else: + raise vyos.opmode.IncorrectValue(f'OpenVPN interface "{interface}" does not exist!') + +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/otp.py b/src/op_mode/otp.py new file mode 100644 index 0000000..a4ab9b2 --- /dev/null +++ b/src/op_mode/otp.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +# Copyright 2017, 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + + +import sys +import os +import vyos.opmode +from jinja2 import Template +from vyos.config import Config +from vyos.utils.process import popen + + +users_otp_template = Template(""" +{% if info == "full" %} +# You can share it with the user, he just needs to scan the QR in his OTP app +# username: {{username}} +# OTP KEY: {{key_base32}} +# OTP URL: {{otp_url}} +{{qrcode}} +# To add this OTP key to configuration, run the following commands: +set system login user {{username}} authentication otp key '{{key_base32}}' +{% if rate_limit != "3" %} +set system login user {{username}} authentication otp rate-limit '{{rate_limit}}' +{% endif %} +{% if rate_time != "30" %} +set system login user {{username}} authentication otp rate-time '{{rate_time}}' +{% endif %} +{% if window_size != "3" %} +set system login user {{username}} authentication otp window-size '{{window_size}}' +{% endif %} +{% elif info == "key-b32" %} +# OTP key in Base32 for system user {{username}}: +{{key_base32}} +{% elif info == "qrcode" %} +# QR code for system user '{{username}}' +{{qrcode}} +{% elif info == "uri" %} +# URI for system user '{{username}}' +{{otp_url}} +{% endif %} +""", trim_blocks=True, lstrip_blocks=True) + + +def _check_uname_otp(username:str): + """ + Check if "username" exists and have an OTP key + """ + config = Config() + base_key = ['system', 'login', 'user', username, 'authentication', 'otp', 'key'] + if not config.exists(base_key): + return None + return True + +def _get_login_otp(username: str, info:str): + """ + Retrieve user settings from configuration and set some defaults + """ + config = Config() + base = ['system', 'login', 'user', username] + if not config.exists(base): + return None + user_otp = config.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + result = user_otp['authentication']['otp'] + # Filling in the system and default options + result['info'] = info + result['hostname'] = os.uname()[1] + result['username'] = username + result['key_base32'] = result['key'] + result['otp_length'] = '6' + result['interval'] = '30' + result['token_type'] = 'hotp-time' + if result['token_type'] == 'hotp-time': + token_type_acrn = 'totp' + result['otp_url'] = ''.join(["otpauth://",token_type_acrn,"/",username,"@",\ + result['hostname'],"?secret=",result['key_base32'],"&digits=",\ + result['otp_length'],"&period=",result['interval']]) + result['qrcode'],_ = popen('qrencode -t ansiutf8', input=result['otp_url']) + return result + +def show_login(raw: bool, username: str, info:str): + ''' + Display OTP parameters for <username> + ''' + check_otp = _check_uname_otp(username) + if check_otp: + user_otp_params = _get_login_otp(username, info) + else: + print(f'There is no such user ("{username}") with an OTP key configured') + print('You can use the following command to generate a key for a user:\n') + print(f'generate system login username {username} otp-key hotp-time') + sys.exit(0) + if raw: + return user_otp_params + return users_otp_template.render(user_otp_params) + + +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/ping.py b/src/op_mode/ping.py new file mode 100644 index 0000000..583d879 --- /dev/null +++ b/src/op_mode/ping.py @@ -0,0 +1,282 @@ +#! /usr/bin/env python3 + +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import socket +import ipaddress + +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call + +options = { + 'audible': { + 'ping': '{command} -a', + 'type': 'noarg', + 'help': 'Make a noise on ping' + }, + 'adaptive': { + 'ping': '{command} -A', + 'type': 'noarg', + 'help': 'Adativly set interpacket interval' + }, + 'allow-broadcast': { + 'ping': '{command} -b', + 'type': 'noarg', + 'help': 'Ping broadcast address' + }, + 'bypass-route': { + 'ping': '{command} -r', + 'type': 'noarg', + 'help': 'Bypass normal routing tables' + }, + 'count': { + 'ping': '{command} -c {value}', + 'type': '<requests>', + 'help': 'Number of requests to send' + }, + 'deadline': { + 'ping': '{command} -w {value}', + 'type': '<seconds>', + 'help': 'Number of seconds before ping exits' + }, + 'do-not-fragment': { + 'ping': '{command} -M do', + 'type': 'noarg', + 'help': 'Set DF-bit flag to 1 for no fragmentation' + }, + 'flood': { + 'ping': 'sudo {command} -f', + 'type': 'noarg', + 'help': 'Send 100 requests per second' + }, + 'interface': { + 'ping': '{command} -I {value}', + 'type': '<interface>', + 'helpfunction': interface_list, + 'help': 'Source interface' + }, + 'interval': { + 'ping': '{command} -i {value}', + 'type': '<seconds>', + 'help': 'Number of seconds to wait between requests' + }, + 'ipv4': { + 'ping': '{command} -4', + 'type': 'noarg', + 'help': 'Use IPv4 only' + }, + 'ipv6': { + 'ping': '{command} -6', + 'type': 'noarg', + 'help': 'Use IPv6 only' + }, + 'mark': { + 'ping': '{command} -m {value}', + 'type': '<fwmark>', + 'help': 'Mark request for special processing' + }, + 'numeric': { + 'ping': '{command} -n', + 'type': 'noarg', + 'help': 'Do not resolve DNS names' + }, + 'no-loopback': { + 'ping': '{command} -L', + 'type': 'noarg', + 'help': 'Supress loopback of multicast pings' + }, + 'pattern': { + 'ping': '{command} -p {value}', + 'type': '<pattern>', + 'help': 'Pattern to fill out the packet' + }, + 'timestamp': { + 'ping': '{command} -D', + 'type': 'noarg', + 'help': 'Print timestamp of output' + }, + 'tos': { + 'ping': '{command} -Q {value}', + 'type': '<tos>', + 'help': 'Mark packets with specified TOS' + }, + 'quiet': { + 'ping': '{command} -q', + 'type': 'noarg', + 'help': 'Only print summary lines' + }, + 'record-route': { + 'ping': '{command} -R', + 'type': 'noarg', + 'help': 'Record route the packet takes' + }, + 'size': { + 'ping': '{command} -s {value}', + 'type': '<bytes>', + 'help': 'Number of bytes to send' + }, + 'source-address': { + 'ping': '{command} -I {value}', + 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', + }, + 'ttl': { + 'ping': '{command} -t {value}', + 'type': '<ttl>', + 'help': 'Maximum packet lifetime' + }, + 'vrf': { + 'ping': 'sudo ip vrf exec {value} {command}', + 'type': '<vrf>', + 'help': 'Use specified VRF table', + 'helpfunction': vrf_list, + 'dflt': 'default', + }, + 'verbose': { + 'ping': '{command} -v', + 'type': 'noarg', + 'help': 'Verbose output'} +} + +ping = { + 4: '/bin/ping', + 6: '/bin/ping6', +} + + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expension_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expension_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['ping'].format( + command=command, value='') + elif not args: + sys.exit(f'ping: missing argument for {longname} option') + else: + command = options[longname]['ping'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + host = args.first() + + if not host: + sys.exit("ping: Missing host") + + if host == '--get-options': + args.first() # pop ping + args.first() # pop IP + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + # Run helpfunction to get list of possible values + if 'helpfunction' in options[matched[0]]: + result = options[matched[0]]['helpfunction']() + if result: + helplines = '\n' + ' '.join(result) + sys.stdout.write(helplines) + sys.exit(0) + + for name, option in options.items(): + if 'dflt' in option and name not in args: + args.append(name) + args.append(option['dflt']) + + try: + ip = socket.gethostbyname(host) + except UnicodeError: + sys.exit(f'ping: Unknown host: {host}') + except socket.gaierror: + ip = host + + try: + version = ipaddress.ip_address(ip).version + except ValueError: + sys.exit(f'ping: Unknown host: {host}') + + command = convert(ping[version], args) + call(f'{command} {host}') diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py new file mode 100644 index 0000000..ab613e5 --- /dev/null +++ b/src/op_mode/pki.py @@ -0,0 +1,1111 @@ +#!/usr/bin/env python3 +# +# 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 +# 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 argparse +import ipaddress +import os +import re +import sys +import tabulate + +from cryptography import x509 +from cryptography.x509.oid import ExtendedKeyUsageOID + +from vyos.config import Config +from vyos.config import config_dict_mangle_acme +from vyos.pki import encode_certificate, encode_public_key, encode_private_key, encode_dh_parameters +from vyos.pki import get_certificate_fingerprint +from vyos.pki import create_certificate, create_certificate_request, create_certificate_revocation_list +from vyos.pki import create_private_key +from vyos.pki import create_dh_parameters +from vyos.pki import load_certificate, load_certificate_request, load_private_key +from vyos.pki import load_crl, load_dh_parameters, load_public_key +from vyos.pki import verify_certificate +from vyos.utils.io import ask_input +from vyos.utils.io import ask_yes_no +from vyos.utils.misc import install_into_config +from vyos.utils.process import cmd + +CERT_REQ_END = '-----END CERTIFICATE REQUEST-----' +auth_dir = '/config/auth' + +# Helper Functions +conf = Config() +def get_default_values(): + # Fetch default x509 values + base = ['pki', 'x509', 'default'] + x509_defaults = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + return x509_defaults + +def get_config_ca_certificate(name=None): + # Fetch ca certificates from config + base = ['pki', 'ca'] + if not conf.exists(base): + return False + + if name: + base = base + [name] + if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']): + return False + + return conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + +def get_config_certificate(name=None): + # Get certificates from config + base = ['pki', 'certificate'] + if not conf.exists(base): + return False + + if name: + base = base + [name] + if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']): + return False + + pki = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + if pki: + for certificate in pki: + pki[certificate] = config_dict_mangle_acme(certificate, pki[certificate]) + + return pki + +def get_certificate_ca(cert, ca_certs): + # Find CA certificate for given certificate + if not ca_certs: + return None + + for ca_name, ca_dict in ca_certs.items(): + if 'certificate' not in ca_dict: + continue + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + continue + + if verify_certificate(cert, ca_cert): + return ca_name + return None + +def get_config_revoked_certificates(): + # Fetch revoked certificates from config + ca_base = ['pki', 'ca'] + cert_base = ['pki', 'certificate'] + + certs = [] + + if conf.exists(ca_base): + ca_certificates = conf.get_config_dict(ca_base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + certs.extend(ca_certificates.values()) + + if conf.exists(cert_base): + certificates = conf.get_config_dict(cert_base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + certs.extend(certificates.values()) + + return [cert_dict for cert_dict in certs if 'revoke' in cert_dict] + +def get_revoked_by_serial_numbers(serial_numbers=[]): + # Return serial numbers of revoked certificates + certs_out = [] + certs = get_config_certificate() + ca_certs = get_config_ca_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + if cert.serial_number in serial_numbers: + certs_out.append(cert_name) + if ca_certs: + for cert_name, cert_dict in ca_certs.items(): + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + if cert.serial_number in serial_numbers: + certs_out.append(cert_name) + return certs_out + +def install_certificate(name, cert='', private_key=None, key_type=None, key_passphrase=None, is_ca=False): + # Show/install conf commands for certificate + prefix = 'ca' if is_ca else 'certificate' + + base = f"pki {prefix} {name}" + config_paths = [] + if cert: + cert_pem = "".join(encode_certificate(cert).strip().split("\n")[1:-1]) + config_paths.append(f"{base} certificate '{cert_pem}'") + + if private_key: + key_pem = "".join(encode_private_key(private_key, passphrase=key_passphrase).strip().split("\n")[1:-1]) + config_paths.append(f"{base} private key '{key_pem}'") + if key_passphrase: + config_paths.append(f"{base} private password-protected") + + install_into_config(conf, config_paths) + +def install_crl(ca_name, crl): + # Show/install conf commands for crl + crl_pem = "".join(encode_certificate(crl).strip().split("\n")[1:-1]) + install_into_config(conf, [f"pki ca {ca_name} crl '{crl_pem}'"]) + +def install_dh_parameters(name, params): + # Show/install conf commands for dh params + dh_pem = "".join(encode_dh_parameters(params).strip().split("\n")[1:-1]) + install_into_config(conf, [f"pki dh {name} parameters '{dh_pem}'"]) + +def install_ssh_key(name, public_key, private_key, passphrase=None): + # Show/install conf commands for ssh key + key_openssh = encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH') + username = os.getlogin() + type_key_split = key_openssh.split(" ") + + base = f"system login user {username} authentication public-keys {name}" + install_into_config(conf, [ + f"{base} key '{type_key_split[1]}'", + f"{base} type '{type_key_split[0]}'" + ]) + print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) + +def install_keypair(name, key_type, private_key=None, public_key=None, passphrase=None, prompt=True): + # Show/install conf commands for key-pair + + config_paths = [] + + if public_key: + install_public_key = not prompt or ask_yes_no('Do you want to install the public key?', default=True) + public_key_pem = encode_public_key(public_key) + + if install_public_key: + install_public_pem = "".join(public_key_pem.strip().split("\n")[1:-1]) + config_paths.append(f"pki key-pair {name} public key '{install_public_pem}'") + else: + print("Public key:") + print(public_key_pem) + + if private_key: + install_private_key = not prompt or ask_yes_no('Do you want to install the private key?', default=True) + private_key_pem = encode_private_key(private_key, passphrase=passphrase) + + if install_private_key: + install_private_pem = "".join(private_key_pem.strip().split("\n")[1:-1]) + config_paths.append(f"pki key-pair {name} private key '{install_private_pem}'") + if passphrase: + config_paths.append(f"pki key-pair {name} private password-protected") + else: + print("Private key:") + print(private_key_pem) + + install_into_config(conf, config_paths) + +def install_openvpn_key(name, key_data, key_version='1'): + config_paths = [ + f"pki openvpn shared-secret {name} key '{key_data}'", + f"pki openvpn shared-secret {name} version '{key_version}'" + ] + install_into_config(conf, config_paths) + +def install_wireguard_key(interface, private_key, public_key): + # Show conf commands for installing wireguard key pairs + from vyos.ifconfig import Section + if Section.section(interface) != 'wireguard': + print(f'"{interface}" is not a WireGuard interface name!') + exit(1) + + # Check if we are running in a config session - if yes, we can directly write to the CLI + install_into_config(conf, [f"interfaces wireguard {interface} private-key '{private_key}'"]) + + print(f"Corresponding public-key to use on peer system is: '{public_key}'") + +def install_wireguard_psk(interface, peer, psk): + from vyos.ifconfig import Section + if Section.section(interface) != 'wireguard': + print(f'"{interface}" is not a WireGuard interface name!') + exit(1) + + # Check if we are running in a config session - if yes, we can directly write to the CLI + install_into_config(conf, [f"interfaces wireguard {interface} peer {peer} preshared-key '{psk}'"]) + +def ask_passphrase(): + passphrase = None + print("Note: If you plan to use the generated key on this router, do not encrypt the private key.") + if ask_yes_no('Do you want to encrypt the private key with a passphrase?'): + passphrase = ask_input('Enter passphrase:') + return passphrase + +def write_file(filename, contents): + full_path = os.path.join(auth_dir, filename) + directory = os.path.dirname(full_path) + + if not os.path.exists(directory): + print('Failed to write file: directory does not exist') + return False + + if os.path.exists(full_path) and not ask_yes_no('Do you want to overwrite the existing file?'): + return False + + with open(full_path, 'w') as f: + f.write(contents) + + print(f'File written to {full_path}') + +# Generation functions + +def generate_private_key(): + key_type = ask_input('Enter private key type: [rsa, dsa, ec]', default='rsa', valid_responses=['rsa', 'dsa', 'ec']) + + size_valid = [] + size_default = 0 + + if key_type in ['rsa', 'dsa']: + size_default = 2048 + size_valid = [512, 1024, 2048, 4096] + elif key_type == 'ec': + size_default = 256 + size_valid = [224, 256, 384, 521] + + size = ask_input('Enter private key bits:', default=size_default, numeric_only=True, valid_responses=size_valid) + + return create_private_key(key_type, size), key_type + +def parse_san_string(san_string): + if not san_string: + return None + + output = [] + san_split = san_string.strip().split(",") + + for pair_str in san_split: + tag, value = pair_str.strip().split(":", 1) + if tag == 'ipv4': + output.append(ipaddress.IPv4Address(value)) + elif tag == 'ipv6': + output.append(ipaddress.IPv6Address(value)) + elif tag == 'dns' or tag == 'rfc822': + output.append(value) + return output + +def generate_certificate_request(private_key=None, key_type=None, return_request=False, name=None, install=False, file=False, ask_san=True): + if not private_key: + private_key, key_type = generate_private_key() + + default_values = get_default_values() + subject = {} + while True: + country = ask_input('Enter country code:', default=default_values['country']) + if len(country) != 2: + print("Country name must be a 2 character country code") + continue + subject['country'] = country + break + subject['state'] = ask_input('Enter state:', default=default_values['state']) + subject['locality'] = ask_input('Enter locality:', default=default_values['locality']) + subject['organization'] = ask_input('Enter organization name:', default=default_values['organization']) + subject['common_name'] = ask_input('Enter common name:', default='vyos.io') + subject_alt_names = None + + if ask_san and ask_yes_no('Do you want to configure Subject Alternative Names?'): + print("Enter alternative names in a comma separate list, example: ipv4:1.1.1.1,ipv6:fe80::1,dns:vyos.net,rfc822:user@vyos.net") + san_string = ask_input('Enter Subject Alternative Names:') + subject_alt_names = parse_san_string(san_string) + + cert_req = create_certificate_request(subject, private_key, subject_alt_names) + + if return_request: + return cert_req + + passphrase = ask_passphrase() + + if not install and not file: + print(encode_certificate(cert_req)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + print("Certificate request:") + print(encode_certificate(cert_req) + "\n") + install_certificate(name, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False) + + if file: + write_file(f'{name}.csr', encode_certificate(cert_req)) + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False, is_sub_ca=False): + valid_days = ask_input('Enter how many days certificate will be valid:', default='365' if not is_ca else '1825', numeric_only=True) + cert_type = None + if not is_ca: + cert_type = ask_input('Enter certificate type: (client, server)', default='server', valid_responses=['client', 'server']) + return create_certificate(cert_req, ca_cert, ca_private_key, valid_days, cert_type, is_ca, is_sub_ca) + +def generate_ca_certificate(name, install=False, file=False): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True, ask_san=False) + cert = generate_certificate(cert_req, cert_req, private_key, is_ca=True) + passphrase = ask_passphrase() + + if not install and not file: + print(encode_certificate(cert)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=True) + + if file: + write_file(f'{name}.pem', encode_certificate(cert)) + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_ca_certificate_sign(name, ca_name, install=False, file=False): + ca_dict = get_config_ca_certificate(ca_name) + + if not ca_dict: + print(f"CA certificate or private key for '{ca_name}' not found") + return None + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + print("Failed to load signing CA certificate, aborting") + return None + + ca_private = ca_dict['private'] + ca_private_passphrase = None + if 'password_protected' in ca_private: + ca_private_passphrase = ask_input('Enter signing CA private key passphrase:') + ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase) + + if not ca_private_key: + print("Failed to load signing CA private key, aborting") + return None + + private_key = None + key_type = None + + cert_req = None + if not ask_yes_no('Do you already have a certificate request?'): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True, ask_san=False) + else: + print("Paste certificate request and press enter:") + lines = [] + curr_line = '' + while True: + curr_line = input().strip() + if not curr_line or curr_line == CERT_REQ_END: + break + lines.append(curr_line) + + if not lines: + print("Aborted") + return None + + wrap = lines[0].find('-----') < 0 # Only base64 pasted, add the CSR tags for parsing + cert_req = load_certificate_request("\n".join(lines), wrap) + + if not cert_req: + print("Invalid certificate request") + return None + + cert = generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=True, is_sub_ca=True) + + passphrase = None + if private_key is not None: + passphrase = ask_passphrase() + + if not install and not file: + print(encode_certificate(cert)) + if private_key is not None: + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=True) + + if file: + write_file(f'{name}.pem', encode_certificate(cert)) + if private_key is not None: + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_certificate_sign(name, ca_name, install=False, file=False): + ca_dict = get_config_ca_certificate(ca_name) + + if not ca_dict: + print(f"CA certificate or private key for '{ca_name}' not found") + return None + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + print("Failed to load CA certificate, aborting") + return None + + ca_private = ca_dict['private'] + ca_private_passphrase = None + if 'password_protected' in ca_private: + ca_private_passphrase = ask_input('Enter CA private key passphrase:') + ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase) + + if not ca_private_key: + print("Failed to load CA private key, aborting") + return None + + private_key = None + key_type = None + + cert_req = None + if not ask_yes_no('Do you already have a certificate request?'): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True) + else: + print("Paste certificate request and press enter:") + lines = [] + curr_line = '' + while True: + curr_line = input().strip() + if not curr_line or curr_line == CERT_REQ_END: + break + lines.append(curr_line) + + if not lines: + print("Aborted") + return None + + wrap = lines[0].find('-----') < 0 # Only base64 pasted, add the CSR tags for parsing + cert_req = load_certificate_request("\n".join(lines), wrap) + + if not cert_req: + print("Invalid certificate request") + return None + + cert = generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False) + + passphrase = None + if private_key is not None: + passphrase = ask_passphrase() + + if not install and not file: + print(encode_certificate(cert)) + if private_key is not None: + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=False) + + if file: + write_file(f'{name}.pem', encode_certificate(cert)) + if private_key is not None: + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_certificate_selfsign(name, install=False, file=False): + private_key, key_type = generate_private_key() + cert_req = generate_certificate_request(private_key, key_type, return_request=True) + cert = generate_certificate(cert_req, cert_req, private_key, is_ca=False) + passphrase = ask_passphrase() + + if not install and not file: + print(encode_certificate(cert)) + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + install_certificate(name, cert, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False) + + if file: + write_file(f'{name}.pem', encode_certificate(cert)) + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_certificate_revocation_list(ca_name, install=False, file=False): + ca_dict = get_config_ca_certificate(ca_name) + + if not ca_dict: + print(f"CA certificate or private key for '{ca_name}' not found") + return None + + ca_cert = load_certificate(ca_dict['certificate']) + + if not ca_cert: + print("Failed to load CA certificate, aborting") + return None + + ca_private = ca_dict['private'] + ca_private_passphrase = None + if 'password_protected' in ca_private: + ca_private_passphrase = ask_input('Enter CA private key passphrase:') + ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase) + + if not ca_private_key: + print("Failed to load CA private key, aborting") + return None + + revoked_certs = get_config_revoked_certificates() + to_revoke = [] + + for cert_dict in revoked_certs: + if 'certificate' not in cert_dict: + continue + + cert_data = cert_dict['certificate'] + + try: + cert = load_certificate(cert_data) + + if cert.issuer == ca_cert.subject: + to_revoke.append(cert.serial_number) + except ValueError: + continue + + if not to_revoke: + print("No revoked certificates to add to the CRL") + return None + + crl = create_certificate_revocation_list(ca_cert, ca_private_key, to_revoke) + + if not crl: + print("Failed to create CRL") + return None + + if not install and not file: + print(encode_certificate(crl)) + return None + + if install: + install_crl(ca_name, crl) + + if file: + write_file(f'{name}.crl', encode_certificate(crl)) + +def generate_ssh_keypair(name, install=False, file=False): + private_key, key_type = generate_private_key() + public_key = private_key.public_key() + passphrase = ask_passphrase() + + if not install and not file: + print(encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH')) + print("") + print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) + return None + + if install: + install_ssh_key(name, public_key, private_key, passphrase) + + if file: + write_file(f'{name}.pem', encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH')) + write_file(f'{name}.key', encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) + +def generate_dh_parameters(name, install=False, file=False): + bits = ask_input('Enter DH parameters key size:', default=2048, numeric_only=True) + + print("Generating parameters...") + + dh_params = create_dh_parameters(bits) + if not dh_params: + print("Failed to create DH parameters") + return None + + if not install and not file: + print("DH Parameters:") + print(encode_dh_parameters(dh_params)) + + if install: + install_dh_parameters(name, dh_params) + + if file: + write_file(f'{name}.pem', encode_dh_parameters(dh_params)) + +def generate_keypair(name, install=False, file=False): + private_key, key_type = generate_private_key() + public_key = private_key.public_key() + passphrase = ask_passphrase() + + if not install and not file: + print(encode_public_key(public_key)) + print("") + print(encode_private_key(private_key, passphrase=passphrase)) + return None + + if install: + install_keypair(name, key_type, private_key, public_key, passphrase) + + if file: + write_file(f'{name}.pem', encode_public_key(public_key)) + write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase)) + +def generate_openvpn_key(name, install=False, file=False): + result = cmd('openvpn --genkey secret /dev/stdout | grep -o "^[^#]*"') + + if not result: + print("Failed to generate OpenVPN key") + return None + + if not install and not file: + print(result) + return None + + if install: + key_lines = result.split("\n") + key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings + key_version = '1' + + version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', result) # Future-proofing (hopefully) + if version_search: + key_version = version_search[1] + + install_openvpn_key(name, key_data, key_version) + + if file: + write_file(f'{name}.key', result) + +def generate_wireguard_key(interface=None, install=False): + private_key = cmd('wg genkey') + public_key = cmd('wg pubkey', input=private_key) + + if interface and install: + install_wireguard_key(interface, private_key, public_key) + else: + print(f'Private key: {private_key}') + print(f'Public key: {public_key}', end='\n\n') + +def generate_wireguard_psk(interface=None, peer=None, install=False): + psk = cmd('wg genpsk') + if interface and peer and install: + install_wireguard_psk(interface, peer, psk) + else: + print(f'Pre-shared key: {psk}') + +# Import functions +def import_ca_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + cert = None + + with open(path) as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if not cert: + print(f'Invalid certificate: {path}') + return + + install_certificate(name, cert, is_ca=True) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {key_path}') + return + + install_certificate(name, private_key=key, is_ca=True) + +def import_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + cert = None + + with open(path) as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if not cert: + print(f'Invalid certificate: {path}') + return + + install_certificate(name, cert, is_ca=False) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {key_path}') + return + + install_certificate(name, private_key=key, is_ca=False) + +def import_crl(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + crl = None + + with open(path) as f: + crl_data = f.read() + crl = load_crl(crl_data, wrap_tags=False) + + if not crl: + print(f'Invalid certificate: {path}') + return + + install_crl(name, crl) + +def import_dh_parameters(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + dh = None + + with open(path) as f: + dh_data = f.read() + dh = load_dh_parameters(dh_data, wrap_tags=False) + + if not dh: + print(f'Invalid DH parameters: {path}') + return + + install_dh_parameters(name, dh) + +def import_keypair(name, path=None, key_path=None, no_prompt=False, passphrase=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + key = None + + with open(path) as f: + key_data = f.read() + key = load_public_key(key_data, wrap_tags=False) + + if not key: + print(f'Invalid public key: {path}') + return + + install_keypair(name, None, public_key=key, prompt=False) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + if not no_prompt: + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {key_path}') + return + + install_keypair(name, None, private_key=key, prompt=False) + +def import_openvpn_secret(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + key_data = None + key_version = '1' + + with open(path) as f: + key_lines = f.read().strip().split("\n") + key_lines = list(filter(lambda line: not line.strip().startswith('#'), key_lines)) # Remove commented lines + key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings + + version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', key_lines[0]) # Future-proofing (hopefully) + if version_search: + key_version = version_search[1] + + install_openvpn_key(name, key_data, key_version) + +# Show functions +def show_certificate_authority(name=None, pem=False): + headers = ['Name', 'Subject', 'Issuer CN', 'Issued', 'Expiry', 'Private Key', 'Parent'] + data = [] + certs = get_config_ca_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + + if name and pem: + print(encode_certificate(cert)) + return + + parent_ca_name = get_certificate_ca(cert, certs) + cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0] + + if not parent_ca_name or parent_ca_name == cert_name: + parent_ca_name = 'N/A' + + if not cert: + continue + + have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No' + data.append([cert_name, cert.subject.rfc4514_string(), cert_issuer_cn, cert.not_valid_before, cert.not_valid_after, have_private, parent_ca_name]) + + print("Certificate Authorities:") + print(tabulate.tabulate(data, headers)) + +def show_certificate(name=None, pem=False, fingerprint_hash=None): + headers = ['Name', 'Type', 'Subject CN', 'Issuer CN', 'Issued', 'Expiry', 'Revoked', 'Private Key', 'CA Present'] + data = [] + certs = get_config_certificate() + if certs: + ca_certs = get_config_ca_certificate() + + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'certificate' not in cert_dict: + continue + + cert = load_certificate(cert_dict['certificate']) + + if not cert: + continue + + if name and pem: + print(encode_certificate(cert)) + return + elif name and fingerprint_hash: + print(get_certificate_fingerprint(cert, fingerprint_hash)) + return + + ca_name = get_certificate_ca(cert, ca_certs) + cert_subject_cn = cert.subject.rfc4514_string().split(",")[0] + cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0] + cert_type = 'Unknown' + + try: + ext = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + if ext and ExtendedKeyUsageOID.SERVER_AUTH in ext.value: + cert_type = 'Server' + elif ext and ExtendedKeyUsageOID.CLIENT_AUTH in ext.value: + cert_type = 'Client' + except: + pass + + revoked = 'Yes' if 'revoke' in cert_dict else 'No' + have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No' + have_ca = f'Yes ({ca_name})' if ca_name else 'No' + data.append([ + cert_name, cert_type, cert_subject_cn, cert_issuer_cn, + cert.not_valid_before, cert.not_valid_after, + revoked, have_private, have_ca]) + + print("Certificates:") + print(tabulate.tabulate(data, headers)) + +def show_crl(name=None, pem=False): + headers = ['CA Name', 'Updated', 'Revokes'] + data = [] + certs = get_config_ca_certificate() + if certs: + for cert_name, cert_dict in certs.items(): + if name and name != cert_name: + continue + if 'crl' not in cert_dict: + continue + + crls = cert_dict['crl'] + if isinstance(crls, str): + crls = [crls] + + for crl_data in cert_dict['crl']: + crl = load_crl(crl_data) + + if not crl: + continue + + if name and pem: + print(encode_certificate(crl)) + continue + + certs = get_revoked_by_serial_numbers([revoked.serial_number for revoked in crl]) + data.append([cert_name, crl.last_update, ", ".join(certs)]) + + if name and pem: + return + + print("Certificate Revocation Lists:") + print(tabulate.tabulate(data, headers)) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='PKI action', required=True) + + # X509 + parser.add_argument('--ca', help='Certificate Authority', required=False) + parser.add_argument('--certificate', help='Certificate', required=False) + parser.add_argument('--crl', help='Certificate Revocation List', required=False) + parser.add_argument('--sign', help='Sign certificate with specified CA', required=False) + parser.add_argument('--self-sign', help='Self-sign the certificate', action='store_true') + parser.add_argument('--pem', help='Output using PEM encoding', action='store_true') + parser.add_argument('--fingerprint', help='Show fingerprint and exit', action='store') + + # SSH + parser.add_argument('--ssh', help='SSH Key', required=False) + + # DH + parser.add_argument('--dh', help='DH Parameters', required=False) + + # Key pair + parser.add_argument('--keypair', help='Key pair', required=False) + + # OpenVPN + parser.add_argument('--openvpn', help='OpenVPN TLS key', required=False) + + # WireGuard + parser.add_argument('--wireguard', help='Wireguard', action='store_true') + group = parser.add_mutually_exclusive_group() + group.add_argument('--key', help='Wireguard key pair', action='store_true', required=False) + group.add_argument('--psk', help='Wireguard pre shared key', action='store_true', required=False) + parser.add_argument('--interface', help='Install generated keys into running-config for named interface', action='store') + parser.add_argument('--peer', help='Install generated keys into running-config for peer', action='store') + + # Global + parser.add_argument('--file', help='Write generated keys into specified filename', action='store_true') + parser.add_argument('--install', help='Install generated keys into running-config', action='store_true') + + parser.add_argument('--filename', help='Write certificate into specified filename', action='store') + parser.add_argument('--key-filename', help='Write key into specified filename', action='store') + + parser.add_argument('--no-prompt', action='store_true', help='Perform action non-interactively') + parser.add_argument('--passphrase', help='A passphrase to decrypt the private key') + + args = parser.parse_args() + + try: + if args.action == 'generate': + if args.ca: + if args.sign: + generate_ca_certificate_sign(args.ca, args.sign, install=args.install, file=args.file) + else: + generate_ca_certificate(args.ca, install=args.install, file=args.file) + elif args.certificate: + if args.sign: + generate_certificate_sign(args.certificate, args.sign, install=args.install, file=args.file) + elif args.self_sign: + generate_certificate_selfsign(args.certificate, install=args.install, file=args.file) + else: + generate_certificate_request(name=args.certificate, install=args.install, file=args.file) + + elif args.crl: + generate_certificate_revocation_list(args.crl, install=args.install, file=args.file) + + elif args.ssh: + generate_ssh_keypair(args.ssh, install=args.install, file=args.file) + + elif args.dh: + generate_dh_parameters(args.dh, install=args.install, file=args.file) + + elif args.keypair: + generate_keypair(args.keypair, install=args.install, file=args.file) + + elif args.openvpn: + generate_openvpn_key(args.openvpn, install=args.install, file=args.file) + + elif args.wireguard: + # WireGuard supports writing key directly into the CLI, but this + # requires the vyos_libexec_dir environment variable to be set + os.environ["vyos_libexec_dir"] = "/usr/libexec/vyos" + + if args.key: + generate_wireguard_key(args.interface, install=args.install) + if args.psk: + generate_wireguard_psk(args.interface, peer=args.peer, install=args.install) + elif args.action == 'import': + if args.ca: + import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) + elif args.certificate: + import_certificate(args.certificate, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) + elif args.crl: + import_crl(args.crl, args.filename) + elif args.dh: + import_dh_parameters(args.dh, args.filename) + elif args.keypair: + import_keypair(args.keypair, path=args.filename, key_path=args.key_filename, + no_prompt=args.no_prompt, passphrase=args.passphrase) + elif args.openvpn: + import_openvpn_secret(args.openvpn, args.filename) + elif args.action == 'show': + if args.ca: + ca_name = None if args.ca == 'all' else args.ca + if ca_name: + if not conf.exists(['pki', 'ca', ca_name]): + print(f'CA "{ca_name}" does not exist!') + exit(1) + show_certificate_authority(ca_name, args.pem) + elif args.certificate: + cert_name = None if args.certificate == 'all' else args.certificate + if cert_name: + if not conf.exists(['pki', 'certificate', cert_name]): + print(f'Certificate "{cert_name}" does not exist!') + exit(1) + if args.fingerprint is None: + show_certificate(None if args.certificate == 'all' else args.certificate, args.pem) + else: + show_certificate(args.certificate, fingerprint_hash=args.fingerprint) + elif args.crl: + show_crl(None if args.crl == 'all' else args.crl, args.pem) + else: + show_certificate_authority() + print('\n') + show_certificate() + print('\n') + show_crl() + except KeyboardInterrupt: + print("Aborted") + sys.exit(0) diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py new file mode 100644 index 0000000..d124650 --- /dev/null +++ b/src/op_mode/policy_route.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# +# 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 +# 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 argparse +import re +import tabulate + +from vyos.config import Config +from vyos.utils.process import cmd + +def get_config_policy(conf, name=None, ipv6=False): + config_path = ['policy'] + if name: + config_path += ['route6' if ipv6 else 'route', name] + + policy = conf.get_config_dict(config_path, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return policy + +def get_nftables_details(name, ipv6=False): + suffix = '6' if ipv6 else '' + command = f'sudo nft list chain ip{suffix} mangle VYOS_PBR{suffix}_{name}' + try: + results = cmd(command) + except: + return {} + + out = {} + for line in results.split('\n'): + comment_search = re.search(rf'{name}[\- ](\d+|default-action)', line) + if not comment_search: + continue + + rule = {} + rule_id = comment_search[1] + 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[rule_id] = rule + return out + +def output_policy_route(name, route_conf, ipv6=False, single_rule_id=None): + ip_str = 'IPv6' if ipv6 else 'IPv4' + print(f'\n---------------------------------\n{ip_str} Policy Route "{name}"\n') + + if route_conf.get('interface'): + print('Active on: {0}\n'.format(" ".join(route_conf['interface']))) + else: + print('Inactive - Not applied to any interfaces\n') + + details = get_nftables_details(name, ipv6) + rows = [] + + if 'rule' in route_conf: + for rule_id, rule_conf in route_conf['rule'].items(): + if single_rule_id and rule_id != single_rule_id: + continue + + if 'disable' in rule_conf: + continue + + action = rule_conf['action'] if 'action' in rule_conf else 'set' + protocol = rule_conf['protocol'] if 'protocol' in rule_conf else 'all' + + row = [rule_id, action, protocol] + if rule_id in details: + rule_details = details[rule_id] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + row.append(rule_details['conditions']) + rows.append(row) + + if 'default_action' in route_conf and not single_rule_id: + row = ['default', route_conf['default_action'], 'all'] + if 'default-action' in details: + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + rows.append(row) + + if rows: + header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] + print(tabulate.tabulate(rows, header) + '\n') + +def show_policy(ipv6=False): + print('Ruleset Information') + + conf = Config() + policy = get_config_policy(conf) + + if not policy: + return + + if not ipv6 and 'route' in policy: + for route, route_conf in policy['route'].items(): + output_policy_route(route, route_conf, ipv6=False) + + if ipv6 and 'route6' in policy: + for route, route_conf in policy['route6'].items(): + output_policy_route(route, route_conf, ipv6=True) + +def show_policy_name(name, ipv6=False): + print('Ruleset Information') + + conf = Config() + policy = get_config_policy(conf, name, ipv6) + if policy: + output_policy_route(name, policy, ipv6) + +def show_policy_rule(name, rule_id, ipv6=False): + print('Rule Information') + + conf = Config() + policy = get_config_policy(conf, name, ipv6) + if policy: + output_policy_route(name, policy, ipv6, rule_id) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Action', required=False) + parser.add_argument('--name', help='Policy name', required=False, action='store', nargs='?', default='') + parser.add_argument('--rule', help='Policy Rule ID', required=False) + parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true') + + args = parser.parse_args() + + if args.action == 'show': + if not args.rule: + show_policy_name(args.name, args.ipv6) + else: + show_policy_rule(args.name, args.rule, args.ipv6) + elif args.action == 'show_all': + show_policy(args.ipv6) diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py new file mode 100644 index 0000000..c32a2be --- /dev/null +++ b/src/op_mode/powerctrl.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import os +import re + +from argparse import ArgumentParser +from datetime import datetime +from sys import exit +from time import time + +from vyos.utils.io import ask_yes_no +from vyos.utils.process import call +from vyos.utils.process import run +from vyos.utils.process import STDOUT + +systemd_sched_file = "/run/systemd/shutdown/scheduled" + +def utc2local(datetime): + now = time() + offs = datetime.fromtimestamp(now) - datetime.utcfromtimestamp(now) + return datetime + offs + +def parse_time(s): + try: + if re.match(r'^\d{1,9999}$', s): + if (int(s) > 59) and (int(s) < 1440): + s = str(int(s)//60) + ":" + str(int(s)%60) + return datetime.strptime(s, "%H:%M").time() + if (int(s) >= 1440): + return s.split() + else: + return datetime.strptime(s, "%M").time() + else: + return datetime.strptime(s, "%H:%M").time() + except ValueError: + return None + + +def parse_date(s): + for fmt in ["%d%m%Y", "%d/%m/%Y", "%d.%m.%Y", "%d:%m:%Y", "%Y-%m-%d"]: + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + # If nothing matched... + return None + + +def get_shutdown_status(): + if os.path.exists(systemd_sched_file): + # Get scheduled from systemd file + with open(systemd_sched_file, 'r') as f: + data = f.read().rstrip('\n') + r_data = {} + for line in data.splitlines(): + tmp_split = line.split("=") + if tmp_split[0] == "USEC": + # Convert USEC to human readable format + r_data['DATETIME'] = datetime.utcfromtimestamp( + int(tmp_split[1])/1000000).strftime('%Y-%m-%d %H:%M:%S') + else: + r_data[tmp_split[0]] = tmp_split[1] + return r_data + return None + + +def check_shutdown(): + output = get_shutdown_status() + if output and 'MODE' in output: + dt = datetime.strptime(output['DATETIME'], '%Y-%m-%d %H:%M:%S') + if output['MODE'] == 'reboot': + print("Reboot is scheduled", utc2local(dt)) + elif output['MODE'] == 'poweroff': + print("Poweroff is scheduled", utc2local(dt)) + else: + print("Reboot or poweroff is not scheduled") + + +def cancel_shutdown(): + output = get_shutdown_status() + if output and 'MODE' in output: + timenow = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + try: + run('/sbin/shutdown -c --no-wall') + except OSError as e: + exit(f'Could not cancel a reboot or poweroff: {e}') + + mode = output['MODE'] + message = f'Scheduled {mode} has been cancelled {timenow}' + run(f'wall {message} > /dev/null 2>&1') + else: + print("Reboot or poweroff is not scheduled") + +def check_unsaved_config(): + from vyos.config_mgmt import unsaved_commits + from vyos.utils.boot import boot_configuration_success + + if unsaved_commits(allow_missing_config=True) and boot_configuration_success(): + print("Warning: there are unsaved configuration changes!") + print("Run 'save' command if you do not want to lose those changes after reboot/shutdown.") + else: + pass + +def execute_shutdown(time, reboot=True, ask=True): + from vyos.utils.process import cmd + + check_unsaved_config() + + host = cmd("hostname --fqdn") + + action = "reboot" if reboot else "poweroff" + if not ask: + if not ask_yes_no(f"Are you sure you want to {action} this system ({host})?"): + exit(0) + action_cmd = "-r" if reboot else "-P" + + if len(time) == 0: + # T870 legacy reboot job support + chk_vyatta_based_reboots() + ### + + out = cmd(f'/sbin/shutdown {action_cmd} now', stderr=STDOUT) + print(out.split(",", 1)[0]) + return + elif len(time) == 1: + # Assume the argument is just time + ts = parse_time(time[0]) + if ts: + cmd(f'/sbin/shutdown {action_cmd} {time[0]}', stderr=STDOUT) + # Inform all other logged in users about the reboot/shutdown + wall_msg = f'System {action} is scheduled {time[0]}' + cmd(f'/usr/bin/wall "{wall_msg}"') + else: + exit(f'Invalid time "{time[0]}". The valid format is HH:MM') + elif len(time) == 2: + # Assume it's date and time + ts = parse_time(time[0]) + ds = parse_date(time[1]) + if ts and ds: + t = datetime.combine(ds, ts) + td = t - datetime.now() + t2 = 1 + int(td.total_seconds())//60 # Get total minutes + + cmd(f'/sbin/shutdown {action_cmd} {t2}', stderr=STDOUT) + # Inform all other logged in users about the reboot/shutdown + wall_msg = f'System {action} is scheduled {time[1]} {time[0]}' + cmd(f'/usr/bin/wall "{wall_msg}"') + else: + if not ts: + exit(f'Invalid time "{time[0]}". Uses 24 Hour Clock format') + else: + exit(f'Invalid date "{time[1]}". A valid format is YYYY-MM-DD [HH:MM]') + else: + exit('Could not decode date and time. Valids formats are HH:MM or YYYY-MM-DD HH:MM') + check_shutdown() + + +def chk_vyatta_based_reboots(): + # T870 commit-confirm is still using the vyatta code base, once gone, the code below can be removed + # legacy scheduled reboot s are using at and store the is as /var/run/<name>.job + # name is the node of scheduled the job, commit-confirm checks for that + + f = r'/var/run/confirm.job' + if os.path.exists(f): + jid = open(f).read().strip() + if jid != 0: + call(f'sudo atrm {jid}') + os.remove(f) + + +def main(): + parser = ArgumentParser() + parser.add_argument("--yes", "-y", + help="Do not ask for confirmation", + action="store_true", + dest="yes") + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--reboot", "-r", + help="Reboot the system", + nargs="*", + metavar="HH:MM") + + action.add_argument("--reboot-in", "-i", + help="Reboot the system", + nargs="*", + metavar="Minutes") + + action.add_argument("--poweroff", "-p", + help="Poweroff the system", + nargs="*", + metavar="Minutes|HH:MM") + + action.add_argument("--cancel", "-c", + help="Cancel pending shutdown", + action="store_true") + + action.add_argument("--check", + help="Check pending shutdown", + action="store_true") + args = parser.parse_args() + + try: + if args.reboot is not None: + for r in args.reboot: + if ':' not in r and '/' not in r and '.' not in r: + print("Incorrect format! Use HH:MM") + exit(1) + execute_shutdown(args.reboot, reboot=True, ask=args.yes) + if args.reboot_in is not None: + for i in args.reboot_in: + if ':' in i: + print("Incorrect format! Use Minutes") + exit(1) + execute_shutdown(args.reboot_in, reboot=True, ask=args.yes) + if args.poweroff is not None: + execute_shutdown(args.poweroff, reboot=False, ask=args.yes) + if args.cancel: + cancel_shutdown() + if args.check: + check_shutdown() + except KeyboardInterrupt: + exit("Interrupted") + +if __name__ == "__main__": + main() diff --git a/src/op_mode/ppp-server-ctrl.py b/src/op_mode/ppp-server-ctrl.py new file mode 100644 index 0000000..2bae5b3 --- /dev/null +++ b/src/op_mode/ppp-server-ctrl.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import argparse + +from vyos.config import Config +from vyos.utils.process import popen +from vyos.utils.process import DEVNULL + +cmd_dict = { + 'cmd_base' : '/usr/bin/accel-cmd -p {} ', + 'vpn_types' : { + 'pppoe' : 2001, + 'pptp' : 2003, + 'l2tp' : 2004, + 'sstp' : 2005 + }, + 'conf_proto' : { + 'pppoe' : 'service pppoe-server', + 'pptp' : 'vpn pptp remote-access', + 'l2tp' : 'vpn l2tp remote-access', + 'sstp' : 'vpn sstp' + } +} + +def is_service_configured(proto): + if not Config().exists_effective(cmd_dict['conf_proto'][proto]): + print("Service {} is not configured".format(proto)) + sys.exit(1) + +def main(): + #parese args + parser = argparse.ArgumentParser() + parser.add_argument('--proto', help='Possible protocols pppoe|pptp|l2tp|sstp', required=True) + parser.add_argument('--action', help='Action command', required=True) + args = parser.parse_args() + + if args.proto in cmd_dict['vpn_types'] and args.action: + # Check is service configured + is_service_configured(args.proto) + + if args.action == "show sessions": + ses_pattern = " ifname,username,ip,ip6,ip6-dp,calling-sid,rate-limit,state,uptime,rx-bytes,tx-bytes" + else: + ses_pattern = "" + + output, err = popen(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][args.proto]) + args.action + ses_pattern, stderr=DEVNULL, decode='utf-8') + if not err: + try: + print(f' {output}') + except: + sys.exit(0) + else: + print("{} server is not running".format(args.proto)) + + else: + print("Param --proto and --action required") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/src/op_mode/qos.py b/src/op_mode/qos.py new file mode 100644 index 0000000..b8ca149 --- /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/raid.py b/src/op_mode/raid.py new file mode 100644 index 0000000..fed8ae2 --- /dev/null +++ b/src/op_mode/raid.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# +import sys + +import vyos.opmode +from vyos.raid import add_raid_member +from vyos.raid import delete_raid_member + +def add(raid_set_name: str, member: str, by_id: bool = False): + try: + add_raid_member(raid_set_name, member, by_id) + except ValueError as e: + raise vyos.opmode.IncorrectValue(str(e)) + +def delete(raid_set_name: str, member: str, by_id: bool = False): + try: + delete_raid_member(raid_set_name, member, by_id) + except ValueError as e: + raise vyos.opmode.IncorrectValue(str(e)) + +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/reset_openvpn.py b/src/op_mode/reset_openvpn.py new file mode 100644 index 0000000..cef5299 --- /dev/null +++ b/src/op_mode/reset_openvpn.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +from sys import argv, exit +from vyos.utils.process import call +from vyos.utils.commit import commit_in_progress + +if __name__ == '__main__': + if (len(argv) < 1): + print('Must specify OpenVPN interface name!') + exit(1) + + interface = argv[1] + if os.path.isfile(f'/run/openvpn/{interface}.conf'): + if commit_in_progress(): + print('Cannot restart OpenVPN while a commit is in progress') + exit(1) + call(f'systemctl restart openvpn@{interface}.service') + else: + print(f'OpenVPN interface "{interface}" does not exist!') + exit(1) diff --git a/src/op_mode/reset_vpn.py b/src/op_mode/reset_vpn.py new file mode 100644 index 0000000..61d7c8c --- /dev/null +++ b/src/op_mode/reset_vpn.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import typing + +from vyos.utils.process import run + +import vyos.opmode + +cmd_dict = { + 'cmd_base': '/usr/bin/accel-cmd -p {} terminate {} {}', + 'vpn_types': { + 'pptp': 2003, + 'l2tp': 2004, + 'sstp': 2005 + } +} + +def reset_conn(protocol: str, username: typing.Optional[str] = None, + interface: typing.Optional[str] = None): + if protocol in cmd_dict['vpn_types']: + # Reset by Interface + if interface: + run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol], + 'if', interface)) + return + # Reset by username + if username: + run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol], + 'username', username)) + # Reset all + else: + run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol], + 'all', + '')) + else: + vyos.opmode.IncorrectValue('Unknown VPN Protocol, aborting') + + +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/restart.py b/src/op_mode/restart.py new file mode 100644 index 0000000..a83c8b9 --- /dev/null +++ b/src/op_mode/restart.py @@ -0,0 +1,149 @@ +#!/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 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 vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import call +from vyos.utils.commit import commit_in_progress + +config = ConfigTreeQuery() + +service_map = { + 'dhcp': { + 'systemd_service': 'kea-dhcp4-server', + 'path': ['service', 'dhcp-server'], + }, + 'dhcpv6': { + 'systemd_service': 'kea-dhcp6-server', + 'path': ['service', 'dhcpv6-server'], + }, + 'dns_dynamic': { + 'systemd_service': 'ddclient', + 'path': ['service', 'dns', 'dynamic'], + }, + 'dns_forwarding': { + 'systemd_service': 'pdns-recursor', + 'path': ['service', 'dns', 'forwarding'], + }, + 'igmp_proxy': { + 'systemd_service': 'igmpproxy', + 'path': ['protocols', 'igmp-proxy'], + }, + 'ipsec': { + 'systemd_service': 'strongswan', + 'path': ['vpn', 'ipsec'], + }, + 'mdns_repeater': { + 'systemd_service': 'avahi-daemon', + 'path': ['service', 'mdns', 'repeater'], + }, + 'reverse_proxy': { + 'systemd_service': 'haproxy', + 'path': ['load-balancing', 'reverse-proxy'], + }, + 'router_advert': { + 'systemd_service': 'radvd', + 'path': ['service', 'router-advert'], + }, + 'snmp': { + 'systemd_service': 'snmpd', + }, + 'ssh': { + 'systemd_service': 'ssh', + }, + 'suricata': { + 'systemd_service': 'suricata', + }, + 'vrrp': { + 'systemd_service': 'keepalived', + 'path': ['high-availability', 'vrrp'], + }, + 'webproxy': { + 'systemd_service': 'squid', + }, +} +services = typing.Literal[ + 'dhcp', + 'dhcpv6', + 'dns_dynamic', + 'dns_forwarding', + 'igmp_proxy', + 'ipsec', + 'mdns_repeater', + 'reverse_proxy', + 'router_advert', + 'snmp', + 'ssh', + 'suricata', + 'vrrp', + 'webproxy', +] + + +def _verify(func): + """Decorator checks if DHCP(v6) config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + name = kwargs.get('name') + human_name = name.replace('_', '-') + + if commit_in_progress(): + print(f'Cannot restart {human_name} service while a commit is in progress') + sys.exit(1) + + # Get optional CLI path from service_mapping dict + # otherwise use "service name" CLI path + path = ['service', name] + if 'path' in service_map[name]: + path = service_map[name]['path'] + + # Check if config does not exist + if not config.exists(path): + raise vyos.opmode.UnconfiguredSubsystem( + f'Service {human_name} is not configured!' + ) + if config.exists(path + ['disable']): + raise vyos.opmode.UnconfiguredSubsystem( + f'Service {human_name} is disabled!' + ) + return func(*args, **kwargs) + + return _wrapper + + +@_verify +def restart_service(raw: bool, name: services, vrf: typing.Optional[str]): + systemd_service = service_map[name]['systemd_service'] + if vrf: + call(f'systemctl restart "{systemd_service}@{vrf}.service"') + else: + call(f'systemctl restart "{systemd_service}.service"') + + +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/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py new file mode 100644 index 0000000..42626ca --- /dev/null +++ b/src/op_mode/restart_dhcp_relay.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-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/>. + +# File: restart_dhcp_relay.py +# Purpose: +# Restart IPv4 and IPv6 DHCP relay instances of dhcrelay service + +import sys +import argparse + +import vyos.config +from vyos.utils.process import call +from vyos.utils.commit import commit_in_progress + + +parser = argparse.ArgumentParser() +parser.add_argument("--ipv4", action="store_true", help="Restart IPv4 DHCP relay") +parser.add_argument("--ipv6", action="store_true", help="Restart IPv6 DHCP relay") + +if __name__ == '__main__': + args = parser.parse_args() + c = vyos.config.Config() + + if args.ipv4: + # Do nothing if service is not configured + if not c.exists_effective('service dhcp-relay'): + print("DHCP relay service not configured") + else: + if commit_in_progress(): + print('Cannot restart DHCP relay while a commit is in progress') + exit(1) + call('systemctl restart isc-dhcp-relay.service') + + sys.exit(0) + elif args.ipv6: + # Do nothing if service is not configured + if not c.exists_effective('service dhcpv6-relay'): + print("DHCPv6 relay service not configured") + else: + if commit_in_progress(): + print('Cannot restart DHCPv6 relay while commit is in progress') + exit(1) + call('systemctl restart isc-dhcp-relay6.service') + + sys.exit(0) + else: + parser.print_help() + sys.exit(1) diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py new file mode 100644 index 0000000..83146f5 --- /dev/null +++ b/src/op_mode/restart_frr.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import argparse +import logging +import psutil + +from logging.handlers import SysLogHandler +from shutil import rmtree + +from vyos.base import Warning +from vyos.utils.io import ask_yes_no +from vyos.utils.file import makedir +from vyos.utils.process import call +from vyos.utils.process import process_named_running + +# some default values +watchfrr = '/usr/lib/frr/watchfrr.sh' +vtysh = '/usr/bin/vtysh' +frrconfig_tmp = '/tmp/frr_restart' + +# configure logging +logger = logging.getLogger(__name__) +logs_handler = SysLogHandler('/dev/log') +logs_handler.setFormatter(logging.Formatter('%(filename)s: %(message)s')) +logger.addHandler(logs_handler) +logger.setLevel(logging.INFO) + +# check if it is safe to restart FRR +def _check_safety(): + try: + # print warning + if not ask_yes_no('WARNING: This is a potentially unsafe function!\n' \ + 'You may lose the connection to the router or active configuration after\n' \ + 'running this command. Use it at your own risk!\n\n' + 'Continue?'): + return False + + # check if another restart process already running + if len([process for process in psutil.process_iter(attrs=['pid', 'name', 'cmdline']) if 'python' in process.info['name'] and 'restart_frr.py' in process.info['cmdline'][1]]) > 1: + message = 'Another restart_frr.py process is already running!' + logger.error(message) + if not ask_yes_no(f'\n{message} It is unsafe to continue.\n\n' \ + 'Do you want to process anyway?'): + return False + + # check if watchfrr.sh is running + tmp = os.path.basename(watchfrr) + if process_named_running(tmp): + message = f'Another {tmp} process is already running.' + logger.error(message) + if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \ + 'Do you want to process anyway?'): + return False + + # check if vtysh is running + if process_named_running('vtysh'): + message = 'vtysh process is executed by another task.' + logger.error(message) + if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \ + 'Do you want to process anyway?'): + return False + + # check if temporary directory exists + if os.path.exists(frrconfig_tmp): + message = f'Temporary directory "{frrconfig_tmp}" already exists!' + logger.error(message) + if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \ + 'Do you want to process anyway?'): + return False + + except: + logger.error("Something goes wrong in _check_safety()") + return False + + # return True if all check was passed or user confirmed to ignore they results + return True + +# write active config to file +def _write_config(): + # create temporary directory + makedir(frrconfig_tmp) + # save frr.conf to it + command = f'{vtysh} -n -w --config_dir {frrconfig_tmp} 2> /dev/null' + return_code = call(command) + if return_code != 0: + logger.error(f'Failed to save active config: "{command}" returned exit code: {return_code}') + return False + logger.info(f'Active config saved to {frrconfig_tmp}') + return True + +# clear and remove temporary directory +def _cleanup(): + if os.path.isdir(frrconfig_tmp): + rmtree(frrconfig_tmp) + +# restart daemon +def _daemon_restart(daemon): + command = f'{watchfrr} restart {daemon}' + return_code = call(command) + if not return_code == 0: + logger.error(f'Failed to restart daemon "{daemon}"!') + return False + + # return True if restarted successfully + logger.info(f'Daemon "{daemon}" restarted!') + return True + +# reload old config +def _reload_config(daemon): + if daemon != '': + command = f'{vtysh} -n -b --config_dir {frrconfig_tmp} -d {daemon} 2> /dev/null' + else: + command = f'{vtysh} -n -b --config_dir {frrconfig_tmp} 2> /dev/null' + + return_code = call(command) + if not return_code == 0: + logger.error('Failed to re-install configuration!') + return False + + # return True if restarted successfully + logger.info('Configuration re-installed successfully!') + return True + +# define program arguments +cmd_args_parser = argparse.ArgumentParser(description='restart frr daemons') +cmd_args_parser.add_argument('--action', choices=['restart'], required=True, help='action to frr daemons') +cmd_args_parser.add_argument('--daemon', choices=['zebra', 'staticd', 'bgpd', 'eigrpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', 'isisd', 'pimd', 'pim6d', 'ldpd', 'babeld', 'bfdd', 'fabricd'], required=False, nargs='*', help='select single or multiple daemons') +# parse arguments +cmd_args = cmd_args_parser.parse_args() + +# main logic +# restart daemon +if cmd_args.action == 'restart': + # check if it is safe to restart FRR + if not _check_safety(): + print("\nOne of the safety checks was failed or user aborted command. Exiting.") + exit(1) + + if not _write_config(): + print("Failed to save active config") + _cleanup() + exit(1) + + # a little trick to make further commands more clear + if not cmd_args.daemon: + cmd_args.daemon = [''] + + # check all daemons if they are running + if cmd_args.daemon != ['']: + for daemon in cmd_args.daemon: + if not process_named_running(daemon): + Warning('some of listed daemons are not running!') + + # run command to restart daemon + for daemon in cmd_args.daemon: + if not _daemon_restart(daemon): + print('Failed to restart daemon: {daemon}') + _cleanup() + exit(1) + # reinstall old configuration + _reload_config(daemon) + + # cleanup after all actions + _cleanup() + +exit(0) diff --git a/src/op_mode/reverseproxy.py b/src/op_mode/reverseproxy.py new file mode 100644 index 0000000..1970418 --- /dev/null +++ b/src/op_mode/reverseproxy.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import json +import socket +import sys + +from tabulate import tabulate +from vyos.configquery import ConfigTreeQuery + +import vyos.opmode + +socket_path = '/run/haproxy/admin.sock' +timeout = 5 + + +def _execute_haproxy_command(command): + """Execute a command on the HAProxy UNIX socket and retrieve the response. + + Args: + command (str): The command to be executed. + + Returns: + str: The response received from the HAProxy UNIX socket. + + Raises: + socket.error: If there is an error while connecting or communicating with the socket. + + Finally: + Closes the socket connection. + + Notes: + - HAProxy expects a newline character at the end of the command. + - The socket connection is established using the HAProxy UNIX socket. + - The response from the socket is received and decoded. + + Example: + response = _execute_haproxy_command('show stat') + print(response) + """ + try: + # HAProxy expects new line for command + command = f'{command}\n' + + # Connect to the HAProxy UNIX socket + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + + # Set the socket timeout + sock.settimeout(timeout) + + # Send the command + sock.sendall(command.encode()) + + # Receive and decode the response + response = b'' + while True: + data = sock.recv(4096) + if not data: + break + response += data + response = response.decode() + + return (response) + + except socket.error as e: + print(f"Error: {e}") + + finally: + # Close the socket + sock.close() + + +def _convert_seconds(seconds): + """Convert seconds to days, hours, minutes, and seconds. + + Args: + seconds (int): The number of seconds to convert. + + Returns: + tuple: A tuple containing the number of days, hours, minutes, and seconds. + """ + minutes = seconds // 60 + hours = minutes // 60 + days = hours // 24 + + return days, hours % 24, minutes % 60, seconds % 60 + + +def _last_change_format(seconds): + """Format the time components into a string representation. + + Args: + seconds (int): The total number of seconds. + + Returns: + str: The formatted time string with days, hours, minutes, and seconds. + + Examples: + >>> _last_change_format(1434) + '23m54s' + >>> _last_change_format(93734) + '1d0h23m54s' + >>> _last_change_format(85434) + '23h23m54s' + """ + days, hours, minutes, seconds = _convert_seconds(seconds) + time_format = "" + + if days: + time_format += f"{days}d" + if hours: + time_format += f"{hours}h" + if minutes: + time_format += f"{minutes}m" + if seconds: + time_format += f"{seconds}s" + + return time_format + + +def _get_json_data(): + """Get haproxy data format JSON""" + return _execute_haproxy_command('show stat json') + + +def _get_raw_data(): + """Retrieve raw data from JSON and organize it into a dictionary. + + Returns: + dict: A dictionary containing the organized data categorized + into frontend, backend, and server. + """ + + data = json.loads(_get_json_data()) + lb_dict = {'frontend': [], 'backend': [], 'server': []} + + for key in data: + frontend = [] + backend = [] + server = [] + for entry in key: + obj_type = entry['objType'].lower() + position = entry['field']['pos'] + name = entry['field']['name'] + value = entry['value']['value'] + + dict_entry = {'pos': position, 'name': {name: value}} + + if obj_type == 'frontend': + frontend.append(dict_entry) + elif obj_type == 'backend': + backend.append(dict_entry) + elif obj_type == 'server': + server.append(dict_entry) + + if len(frontend) > 0: + lb_dict['frontend'].append(frontend) + if len(backend) > 0: + lb_dict['backend'].append(backend) + if len(server) > 0: + lb_dict['server'].append(server) + + return lb_dict + + +def _get_formatted_output(data): + """ + Format the data into a tabulated output. + + Args: + data (dict): The data to be formatted. + + Returns: + str: The tabulated output representing the formatted data. + """ + table = [] + headers = [ + "Proxy name", "Role", "Status", "Req rate", "Resp time", "Last change" + ] + + for key in data: + for item in data[key]: + row = [None] * len(headers) + + for element in item: + if 'pxname' in element['name']: + row[0] = element['name']['pxname'] + elif 'svname' in element['name']: + row[1] = element['name']['svname'] + elif 'status' in element['name']: + row[2] = element['name']['status'] + elif 'req_rate' in element['name']: + row[3] = element['name']['req_rate'] + elif 'rtime' in element['name']: + row[4] = f"{element['name']['rtime']} ms" + elif 'lastchg' in element['name']: + row[5] = _last_change_format(element['name']['lastchg']) + table.append(row) + + out = tabulate(table, headers, numalign="left") + return out + + +def show(raw: bool): + config = ConfigTreeQuery() + if not config.exists('load-balancing reverse-proxy'): + raise vyos.opmode.UnconfiguredSubsystem('Reverse-proxy is not configured') + + data = _get_raw_data() + if raw: + return data + else: + return _get_formatted_output(data) + + +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/route.py b/src/op_mode/route.py new file mode 100644 index 0000000..4aa57db --- /dev/null +++ b/src/op_mode/route.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Purpose: +# Displays routing table information. +# Used by the "run <ip|ipv6> route *" commands. + +import re +import sys +import typing + +from jinja2 import Template + +import vyos.opmode + +frr_command_template = Template(""" +{% if family == "inet" %} + show ip route +{% else %} + show ipv6 route +{% endif %} + +{% if table %} + table {{table}} +{% endif %} + +{% if vrf %} + vrf {{table}} +{% endif %} + +{% if tag %} + tag {{tag}} +{% elif net %} + {{net}} +{% elif protocol %} + {{protocol}} +{% endif %} + +{% if raw %} + json +{% endif %} +""") + +ArgFamily = typing.Literal['inet', 'inet6'] + +def show_summary(raw: bool, family: ArgFamily, table: typing.Optional[int], vrf: typing.Optional[str]): + from vyos.utils.process import cmd + + if family == 'inet': + family_cmd = 'ip' + elif family == 'inet6': + family_cmd = 'ipv6' + else: + raise ValueError(f"Unsupported address family {family}") + + if (table is not None) and (vrf is not None): + raise ValueError("table and vrf options are mutually exclusive") + + # Replace with Jinja if it ever starts growing + if table: + table_cmd = f"table {table}" + else: + table_cmd = "" + + if vrf: + vrf_cmd = f"vrf {vrf}" + else: + vrf_cmd = "" + + if raw: + from json import loads + + output = cmd(f"vtysh -c 'show {family_cmd} route {vrf_cmd} summary {table_cmd} json'").strip() + + # If there are no routes in a table, its "JSON" output is an empty string, + # as of FRR 8.4.1 + if output: + return loads(output) + else: + return {} + else: + output = cmd(f"vtysh -c 'show {family_cmd} route {vrf_cmd} summary {table_cmd}'") + return output + +def show(raw: bool, + family: ArgFamily, + net: typing.Optional[str], + table: typing.Optional[int], + protocol: typing.Optional[str], + vrf: typing.Optional[str], + tag: typing.Optional[str]): + if net and protocol: + raise ValueError("net and protocol are mutually exclusive") + elif table and vrf: + raise ValueError("table and vrf are mutually exclusive") + elif (family == 'inet6') and (protocol == 'rip'): + raise ValueError("rip is not a valid protocol for family inet6") + elif (family == 'inet') and (protocol == 'ripng'): + raise ValueError("rip is not a valid protocol for family inet6") + else: + if (family == 'inet6') and (protocol == 'ospf'): + protocol = 'ospf6' + + kwargs = dict(locals()) + + frr_command = frr_command_template.render(kwargs) + frr_command = re.sub(r'\s+', ' ', frr_command) + + from vyos.utils.process import cmd + output = cmd(f"vtysh -c '{frr_command}'") + + if raw: + from json import loads + d = loads(output) + collect = [] + for k,_ in d.items(): + for l in d[k]: + collect.append(l) + return collect + else: + return output + +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/secure_boot.py b/src/op_mode/secure_boot.py new file mode 100644 index 0000000..5f6390a --- /dev/null +++ b/src/op_mode/secure_boot.py @@ -0,0 +1,50 @@ +#!/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/>. + +import sys +import vyos.opmode + +from vyos.utils.boot import is_uefi_system +from vyos.utils.system import get_secure_boot_state + +def _get_raw_data(name=None): + sb_data = { + 'state' : get_secure_boot_state(), + 'uefi' : is_uefi_system() + } + return sb_data + +def _get_formatted_output(raw_data): + if not raw_data['uefi']: + print('System run in legacy BIOS mode!') + state = 'enabled' if raw_data['state'] else 'disabled' + return f'SecureBoot {state}' + +def show(raw: bool): + sb_data = _get_raw_data() + if raw: + return sb_data + else: + return _get_formatted_output(sb_data) + +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/serial.py b/src/op_mode/serial.py new file mode 100644 index 0000000..a586487 --- /dev/null +++ b/src/op_mode/serial.py @@ -0,0 +1,38 @@ +#!/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/>. + +import sys, typing + +import vyos.opmode +from vyos.utils.serial import restart_login_consoles as _restart_login_consoles + +def restart_console(device_name: typing.Optional[str]): + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message and a prompt + # to continue, verifying that the user acknowledges possible interruptions. + if device_name: + _restart_login_consoles(prompt_user=True, quiet=False, devices=[device_name]) + else: + _restart_login_consoles(prompt_user=True, quiet=False) + +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/sflow.py b/src/op_mode/sflow.py new file mode 100644 index 0000000..0f3feb3 --- /dev/null +++ b/src/op_mode/sflow.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import dbus +import sys + +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery + +import vyos.opmode + + +def _get_raw_sflow(): + bus = dbus.SystemBus() + config = ConfigTreeQuery() + + interfaces = config.values('system sflow interface') + servers = config.list_nodes('system sflow server') + + sflow = bus.get_object('net.sflow.hsflowd', '/net/sflow/hsflowd') + sflow_telemetry = dbus.Interface( + sflow, dbus_interface='net.sflow.hsflowd.telemetry') + agent_address = sflow_telemetry.GetAgent() + samples_dropped = int(sflow_telemetry.Get('dropped_samples')) + packet_drop_sent = int(sflow_telemetry.Get('event_samples')) + samples_packet_sent = int(sflow_telemetry.Get('flow_samples')) + samples_counter_sent = int(sflow_telemetry.Get('counter_samples')) + datagrams_sent = int(sflow_telemetry.Get('datagrams')) + rtmetric_samples = int(sflow_telemetry.Get('rtmetric_samples')) + event_samples_suppressed = int(sflow_telemetry.Get('event_samples_suppressed')) + samples_suppressed = int(sflow_telemetry.Get('flow_samples_suppressed')) + counter_samples_suppressed = int( + sflow_telemetry.Get("counter_samples_suppressed")) + version = sflow_telemetry.GetVersion() + + sflow_dict = { + 'agent_address': agent_address, + 'sflow_interfaces': interfaces, + 'sflow_servers': servers, + 'counter_samples_sent': samples_counter_sent, + 'datagrams_sent': datagrams_sent, + 'packet_drop_sent': packet_drop_sent, + 'packet_samples_dropped': samples_dropped, + 'packet_samples_sent': samples_packet_sent, + 'rtmetric_samples': rtmetric_samples, + 'event_samples_suppressed': event_samples_suppressed, + 'flow_samples_suppressed': samples_suppressed, + 'counter_samples_suppressed': counter_samples_suppressed, + 'hsflowd_version': version + } + return sflow_dict + + +def _get_formatted_sflow(data): + table = [ + ['Agent address', f'{data.get("agent_address")}'], + ['sFlow interfaces', f'{data.get("sflow_interfaces", "n/a")}'], + ['sFlow servers', f'{data.get("sflow_servers", "n/a")}'], + ['Counter samples sent', f'{data.get("counter_samples_sent")}'], + ['Datagrams sent', f'{data.get("datagrams_sent")}'], + ['Packet samples sent', f'{data.get("packet_samples_sent")}'], + ['Packet samples dropped', f'{data.get("packet_samples_dropped")}'], + ['Packet drops sent', f'{data.get("packet_drop_sent")}'], + ['Packet drops suppressed', f'{data.get("event_samples_suppressed")}'], + ['Flow samples suppressed', f'{data.get("flow_samples_suppressed")}'], + ['Counter samples suppressed', f'{data.get("counter_samples_suppressed")}'] + ] + + return tabulate(table) + + +def show(raw: bool): + + config = ConfigTreeQuery() + if not config.exists('system sflow'): + raise vyos.opmode.UnconfiguredSubsystem( + '"system sflow" is not configured!') + + sflow_data = _get_raw_sflow() + if raw: + return sflow_data + else: + return _get_formatted_sflow(sflow_data) + + +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-bond.py b/src/op_mode/show-bond.py new file mode 100644 index 0000000..f676e08 --- /dev/null +++ b/src/op_mode/show-bond.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import jinja2 + +from argparse import ArgumentParser +from vyos.ifconfig import Section +from vyos.ifconfig import BondIf +from vyos.utils.file import read_file + +from sys import exit + +parser = ArgumentParser() +parser.add_argument("--slaves", action="store_true", help="Show LLDP neighbors on all interfaces") +parser.add_argument("--interface", action="store", help="Show LLDP neighbors on specific interface") + +args = parser.parse_args() + +all_bonds = Section.interfaces('bonding') +# we are not interested in any bond vlan interface +all_bonds = [x for x in all_bonds if '.' not in x] + +TMPL_BRIEF = """Interface Mode State Link Slaves +{% for interface in data %} +{{ "%-12s" | format(interface.ifname) }} {{ "%-22s" | format(interface.mode) }} {{ "%-8s" | format(interface.admin_state) }} {{ "%-6s" | format(interface.oper_state) }} {{ interface.members | join(' ') }} +{% endfor %} +""" + +TMPL_INDIVIDUAL_BOND = """Interface RX: bytes packets TX: bytes packets +{{ "%-16s" | format(data.ifname) }} {{ "%-10s" | format(data.rx_bytes) }} {{ "%-11s" | format(data.rx_packets) }} {{ "%-10s" | format(data.tx_bytes) }} {{ data.tx_packets }} +{% for member in data.members if data.members is defined %} + {{ "%-12s" | format(member.ifname) }} {{ "%-10s" | format(member.rx_bytes) }} {{ "%-11s" | format(member.rx_packets) }} {{ "%-10s" | format(member.tx_bytes) }} {{ member.tx_packets }} +{% endfor %} +""" + +if args.slaves and args.interface: + exit('Can not use both --slaves and --interfaces option at the same time') + parser.print_help() + +elif args.slaves: + data = [] + template = TMPL_BRIEF + for bond in all_bonds: + tmp = BondIf(bond) + cfg_dict = {} + cfg_dict['ifname'] = bond + 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() + data.append(cfg_dict) + +elif args.interface: + template = TMPL_INDIVIDUAL_BOND + data = {} + data['ifname'] = args.interface + data['rx_bytes'] = read_file(f'/sys/class/net/{args.interface}/statistics/rx_bytes') + data['rx_packets'] = read_file(f'/sys/class/net/{args.interface}/statistics/rx_packets') + data['tx_bytes'] = read_file(f'/sys/class/net/{args.interface}/statistics/tx_bytes') + data['tx_packets'] = read_file(f'/sys/class/net/{args.interface}/statistics/tx_packets') + + # each bond member interface has its own statistics + data['members'] = [] + for member in BondIf(args.interface).get_slaves(): + tmp = {} + tmp['ifname'] = member + tmp['rx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/rx_bytes') + tmp['rx_packets'] = read_file(f'/sys/class/net/{member}/statistics/rx_packets') + tmp['tx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/tx_bytes') + tmp['tx_packets'] = read_file(f'/sys/class/net/{member}/statistics/tx_packets') + data['members'].append(tmp) + +else: + parser.print_help() + exit(1) + +tmpl = jinja2.Template(template, trim_blocks=True) +config_text = tmpl.render(data=data) +print(config_text) diff --git a/src/op_mode/show_acceleration.py b/src/op_mode/show_acceleration.py new file mode 100644 index 0000000..1c4831f --- /dev/null +++ b/src/op_mode/show_acceleration.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import os +import re +import argparse + +from vyos.config import Config +from vyos.utils.process import call +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) + # QAT devices found + if data: + return + print("\t No QAT device found") + sys.exit(1) + +def show_qat_status(): + detect_qat_dev() + + # Check QAT service + if not os.path.exists('/etc/init.d/qat_service'): + print("\t QAT service not installed") + sys.exit(1) + + # Show QAT service + call('/etc/init.d/qat_service status') + +# Return QAT devices +def get_qat_devices(): + data_st, err = popen('/etc/init.d/qat_service status', decode='utf-8') + if not err: + elm_lst = re.findall('qat_dev\d', data_st) + print('\n'.join(elm_lst)) + +# Return QAT path in sysfs +def get_qat_proc_path(qat_dev): + q_type = "" + q_bsf = "" + output, err = popen('/etc/init.d/qat_service status', decode='utf-8') + if not err: + # Parse QAT service output + data_st = output.split("\n") + for elm_str in range(len(data_st)): + if re.search(qat_dev, data_st[elm_str]): + elm_list = data_st[elm_str].split(", ") + for elm in range(len(elm_list)): + if re.search('type', elm_list[elm]): + q_list = elm_list[elm].split(": ") + q_type=q_list[1] + elif re.search('bsf', elm_list[elm]): + q_list = elm_list[elm].split(": ") + q_bsf = q_list[1] + return "/sys/kernel/debug/qat_"+q_type+"_"+q_bsf+"/" + +# Check if QAT service confgured +def check_qat_if_conf(): + if not Config().exists_effective('system acceleration qat'): + print("\t system acceleration qat is not configured") + sys.exit(1) + +parser = argparse.ArgumentParser() +group = parser.add_mutually_exclusive_group() +group.add_argument("--hw", action="store_true", help="Show Intel QAT HW") +group.add_argument("--dev_list", action="store_true", help="Return Intel QAT devices") +group.add_argument("--flow", action="store_true", help="Show Intel QAT flows") +group.add_argument("--interrupts", action="store_true", help="Show Intel QAT interrupts") +group.add_argument("--status", action="store_true", help="Show Intel QAT status") +group.add_argument("--conf", action="store_true", help="Show Intel QAT configuration") + +parser.add_argument("--dev", type=str, help="Selected QAT device") + +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\'') +elif args.flow and args.dev: + check_qat_if_conf() + call('cat '+get_qat_proc_path(args.dev)+"fw_counters") +elif args.interrupts: + check_qat_if_conf() + # Delete _dev from args.dev + call('cat /proc/interrupts | grep qat') +elif args.status: + check_qat_if_conf() + show_qat_status() +elif args.conf and args.dev: + check_qat_if_conf() + call('cat '+get_qat_proc_path(args.dev)+"dev_cfg") +elif args.dev_list: + get_qat_devices() +else: + parser.print_help() + sys.exit(1) diff --git a/src/op_mode/show_configuration_files.sh b/src/op_mode/show_configuration_files.sh new file mode 100644 index 0000000..ad8e074 --- /dev/null +++ b/src/op_mode/show_configuration_files.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Wrapper script for the show configuration files command +find ${vyatta_sysconfdir}/config/ \ + -type f \ + -not -name ".*" \ + -not -name "config.boot.*" \ + -printf "%f\t(%Tc)\t%T@\n" \ + | sort -r -k3 \ + | awk -F"\t" '{printf ("%-20s\t%s\n", $1,$2) ;}' diff --git a/src/op_mode/show_configuration_json.py b/src/op_mode/show_configuration_json.py new file mode 100644 index 0000000..fdece53 --- /dev/null +++ b/src/op_mode/show_configuration_json.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import json + +from vyos.configquery import ConfigTreeQuery + + +config = ConfigTreeQuery() +c = config.get_config_dict() + +parser = argparse.ArgumentParser() +parser.add_argument("-p", "--pretty", action="store_true", help="Show pretty configuration in JSON format") + + +if __name__ == '__main__': + args = parser.parse_args() + + if args.pretty: + print(json.dumps(c, indent=4)) + else: + print(json.dumps(c)) diff --git a/src/op_mode/show_current_user.sh b/src/op_mode/show_current_user.sh new file mode 100644 index 0000000..93e6efa --- /dev/null +++ b/src/op_mode/show_current_user.sh @@ -0,0 +1,18 @@ +#! /bin/bash + +echo -n "login : " ; who -m + +if [ -n "$VYATTA_USER_LEVEL_DIR" ] +then + echo -n "level : " + basename $VYATTA_USER_LEVEL_DIR +fi + +echo -n "user : " ; id -un +echo -n "groups : " ; id -Gn + +if id -Z >/dev/null 2>&1 +then + echo -n "context : " + id -Z +fi diff --git a/src/op_mode/show_disk_format.sh b/src/op_mode/show_disk_format.sh new file mode 100644 index 0000000..61b15a5 --- /dev/null +++ b/src/op_mode/show_disk_format.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +disk_dev="/dev/$1" +if [ ! -b "$disk_dev" ];then + echo "$3 is not a disk device" + exit 1 +fi +sudo /sbin/fdisk -l "$disk_dev" diff --git a/src/op_mode/show_ntp.sh b/src/op_mode/show_ntp.sh new file mode 100644 index 0000000..4b59b80 --- /dev/null +++ b/src/op_mode/show_ntp.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +sourcestats=0 +tracking=0 + +while [[ "$#" -gt 0 ]]; do + case $1 in + --sourcestats) sourcestats=1 ;; + --tracking) tracking=1 ;; + *) echo "Unknown parameter passed: $1" ;; + esac + shift +done + +if ! ps -C chronyd &>/dev/null; then + echo NTP daemon disabled + exit 1 +fi + +PID=$(pgrep chronyd | head -n1) +VRF_NAME=$(ip vrf identify ${PID}) + +if [ ! -z ${VRF_NAME} ]; then + VRF_CMD="sudo ip vrf exec ${VRF_NAME}" +fi + +if [ $sourcestats -eq 1 ]; then + $VRF_CMD chronyc sourcestats -v +elif [ $tracking -eq 1 ]; then + $VRF_CMD chronyc tracking -v +else + echo "Unknown option" +fi + diff --git a/src/op_mode/show_openconnect_otp.py b/src/op_mode/show_openconnect_otp.py new file mode 100644 index 0000000..3771fb3 --- /dev/null +++ b/src/op_mode/show_openconnect_otp.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os +from base64 import b32encode + +from vyos.config import Config +from vyos.utils.dict import dict_search_args +from vyos.utils.process import popen + +otp_file = '/run/ocserv/users.oath' + +def check_uname_otp(username): + """ + Check if "username" exists and have an OTP key + """ + config = Config() + base_key = ['vpn', 'openconnect', 'authentication', 'local-users', 'username', username, 'otp', 'key'] + if not config.exists(base_key): + return False + return True + +def get_otp_ocserv(username): + config = Config() + base = ['vpn', 'openconnect'] + if not config.exists(base): + return None + + ocserv = config.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + user_path = ['authentication', 'local_users', 'username'] + users = dict_search_args(ocserv, *user_path) + + if users is None: + return None + + # function is called conditionally, if check_uname_otp true, so username + # exists + result = users[username] + + return result + +def display_otp_ocserv(username, params, info): + hostname = os.uname()[1] + key_hex = params['otp']['key'] + otp_length = params['otp']['otp_length'] + interval = params['otp']['interval'] + token_type = params['otp']['token_type'] + if token_type == 'hotp-time': + token_type_acrn = 'totp' + key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + otp_url = ''.join(["otpauth://",token_type_acrn,"/",username,"@",hostname,"?secret=",key_base32,"&digits=",otp_length,"&period=",interval]) + qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + + if info == 'full': + print("# You can share it with the user, he just needs to scan the QR in his OTP app") + print("# username: ", username) + print("# OTP KEY: ", key_base32) + print("# OTP URL: ", otp_url) + print(qrcode) + print('# To add this OTP key to configuration, run the following commands:') + print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'") + if interval != "30": + print(f"set vpn openconnect authentication local-users username {username} otp interval '{interval}'") + if otp_length != "6": + print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{otp_length}'") + elif info == 'key-hex': + print("# OTP key in hexadecimal: ") + print(key_hex) + elif info == 'key-b32': + print("# OTP key in Base32: ") + print(key_base32) + elif info == 'qrcode': + print(f"# QR code for OpenConnect user '{username}'") + print(qrcode) + elif info == 'uri': + print(f"# URI for OpenConnect user '{username}'") + print(otp_url) + +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') + + args = parser.parse_args() + if check_uname_otp(args.user): + user_otp_params = get_otp_ocserv(args.user) + display_otp_ocserv(args.user, user_otp_params, args.info) + else: + print(f'There is no such user ("{args.user}") with an OTP key configured') diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py new file mode 100644 index 0000000..6abafc8 --- /dev/null +++ b/src/op_mode/show_openvpn.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import jinja2 +import argparse + +from sys import exit +from vyos.config import Config + +outp_tmpl = """ +{% if clients %} +OpenVPN status on {{ intf }} + +Client CN Remote Host Tunnel IP Local Host TX bytes RX bytes Connected Since +--------- ----------- --------- ---------- -------- -------- --------------- +{% for c in clients %} +{{ "%-15s"|format(c.name) }} {{ "%-21s"|format(c.remote) }} {{ "%-15s"|format(c.tunnel) }} {{ "%-21s"|format(local) }} {{ "%-9s"|format(c.tx_bytes) }} {{ "%-9s"|format(c.rx_bytes) }} {{ c.online_since }} +{% endfor %} +{% endif %} +""" + +def bytes2HR(size): + # we need to operate in integers + size = int(size) + + suff = ['B', 'KB', 'MB', 'GB', 'TB'] + suffIdx = 0 + + while size > 1024: + # incr. suffix index + suffIdx += 1 + # divide + size = size/1024.0 + + output="{0:.1f} {1}".format(size, suff[suffIdx]) + return output + +def get_vpn_tunnel_address(peer, interface): + lst = [] + status_file = '/var/run/openvpn/{}.status'.format(interface) + + with open(status_file, 'r') as f: + lines = f.readlines() + for line in lines: + if peer in line: + lst.append(line) + + # filter out subnet entries + lst = [l for l in lst[1:] if '/' not in l.split(',')[0]] + + if lst: + tunnel_ip = lst[0].split(',')[0] + return tunnel_ip + + return 'n/a' + +def get_status(mode, interface): + status_file = '/var/run/openvpn/{}.status'.format(interface) + # this is an empirical value - I assume we have no more then 999999 + # current OpenVPN connections + routing_table_line = 999999 + + data = { + 'mode': mode, + 'intf': interface, + 'local': 'N/A', + 'date': '', + 'clients': [], + } + + if not os.path.exists(status_file): + return data + + with open(status_file, 'r') as f: + lines = f.readlines() + for line_no, line in enumerate(lines): + # remove trailing newline character first + line = line.rstrip('\n') + + # check first line header + if line_no == 0: + if mode == 'server': + if not line == 'OpenVPN CLIENT LIST': + raise NameError('Expected "OpenVPN CLIENT LIST"') + else: + if not line == 'OpenVPN STATISTICS': + raise NameError('Expected "OpenVPN STATISTICS"') + + continue + + # second line informs us when the status file has been last updated + if line_no == 1: + data['date'] = line.lstrip('Updated,').rstrip('\n') + continue + + if mode == 'server': + # followed by line3 giving output information and the actual output data + # + # Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since + # client1,172.18.202.10:55904,2880587,2882653,Fri Aug 23 16:25:48 2019 + # client3,172.18.204.10:41328,2850832,2869729,Fri Aug 23 16:25:43 2019 + # client2,172.18.203.10:48987,2856153,2871022,Fri Aug 23 16:25:45 2019 + if (line_no >= 3) and (line_no < routing_table_line): + # indicator that there are no more clients and we will continue with the + # routing table + if line == 'ROUTING TABLE': + routing_table_line = line_no + continue + + client = { + 'name': line.split(',')[0], + 'remote': line.split(',')[1], + 'rx_bytes': bytes2HR(line.split(',')[2]), + 'tx_bytes': bytes2HR(line.split(',')[3]), + 'online_since': line.split(',')[4] + } + client["tunnel"] = get_vpn_tunnel_address(client['remote'], interface) + data['clients'].append(client) + continue + else: + if line_no == 2: + client = { + 'name': 'N/A', + 'remote': 'N/A', + 'rx_bytes': bytes2HR(line.split(',')[1]), + 'tx_bytes': '', + 'online_since': 'N/A' + } + continue + + if line_no == 3: + client['tx_bytes'] = bytes2HR(line.split(',')[1]) + data['clients'].append(client) + break + + return data + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('-m', '--mode', help='OpenVPN operation mode (server, client, site-2-site)', required=True) + + args = parser.parse_args() + + # Do nothing if service is not configured + config = Config() + if len(config.list_effective_nodes('interfaces openvpn')) == 0: + print("No OpenVPN interfaces configured") + exit(0) + + # search all OpenVPN interfaces and add those with a matching mode to our + # interfaces list + interfaces = [] + for intf in config.list_effective_nodes('interfaces openvpn'): + # get interface type (server, client, site-to-site) + mode = config.return_effective_value('interfaces openvpn {} mode'.format(intf)) + if args.mode == mode: + interfaces.append(intf) + + for intf in interfaces: + data = get_status(args.mode, intf) + local_host = config.return_effective_value('interfaces openvpn {} local-host'.format(intf)) + local_port = config.return_effective_value('interfaces openvpn {} local-port'.format(intf)) + if local_host and local_port: + data['local'] = local_host + ':' + local_port + + if args.mode in ['client', 'site-to-site']: + for client in data['clients']: + if config.exists_effective('interfaces openvpn {} shared-secret-key-file'.format(intf)): + client['name'] = "None (PSK)" + + remote_host = config.return_effective_values('interfaces openvpn {} remote-host'.format(intf)) + remote_port = config.return_effective_value('interfaces openvpn {} remote-port'.format(intf)) + + if not remote_port: + remote_port = '1194' + + if len(remote_host) >= 1: + client['remote'] = str(remote_host[0]) + ':' + remote_port + + client['tunnel'] = 'N/A' + + tmpl = jinja2.Template(outp_tmpl) + print(tmpl.render(data)) diff --git a/src/op_mode/show_openvpn_mfa.py b/src/op_mode/show_openvpn_mfa.py new file mode 100644 index 0000000..100c421 --- /dev/null +++ b/src/op_mode/show_openvpn_mfa.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# +# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import re +import socket +import urllib.parse +import argparse + +from vyos.utils.process import popen + +otp_file = '/config/auth/openvpn/{interface}-otp-secrets' + +def get_mfa_secret(interface, client): + try: + with open(otp_file.format(interface=interface), "r") as f: + users = f.readlines() + for user in users: + if re.search('^' + client + ' ', user): + return user.split(':')[3] + except: + pass + +def get_mfa_uri(client, secret): + hostname = socket.gethostname() + fqdn = socket.getfqdn() + uri = 'otpauth://totp/{hostname}:{client}@{fqdn}?secret={secret}' + + return urllib.parse.quote(uri.format(hostname=hostname, client=client, fqdn=fqdn, secret=secret), safe='/:@?=') + +if __name__ == '__main__': + parser = argparse.ArgumentParser(add_help=False, description='Show two-factor authentication information') + parser.add_argument('--intf', action="store", type=str, default='', help='only show the specified interface') + parser.add_argument('--user', action="store", type=str, default='', help='only show the specified users') + parser.add_argument('--action', action="store", type=str, default='show', help='action to perform') + + args = parser.parse_args() + secret = get_mfa_secret(args.intf, args.user) + + if args.action == "secret" and secret: + print(secret) + + if args.action == "uri" and secret: + uri = get_mfa_uri(args.user, secret) + print(uri) + + if args.action == "qrcode" and secret: + uri = get_mfa_uri(args.user, secret) + qrcode,err = popen('qrencode -t ansiutf8', input=uri) + print(qrcode) + diff --git a/src/op_mode/show_raid.sh b/src/op_mode/show_raid.sh new file mode 100644 index 0000000..ab5d4d5 --- /dev/null +++ b/src/op_mode/show_raid.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +if [ "$EUID" -ne 0 ]; then + # This should work without sudo because we have read + # access to the dev, but for some reason mdadm must be + # run as root in order to succeed. + echo "Please run as root" + exit 1 +fi + +raid_set_name=$1 +raid_sets=`cat /proc/partitions | grep md | awk '{ print $4 }'` +valid_set=`echo $raid_sets | grep $raid_set_name` +if [ -z $valid_set ]; then + echo "$raid_set_name is not a RAID set" +else + if [ -r /dev/${raid_set_name} ]; then + # This should work without sudo because we have read + # access to the dev, but for some reason mdadm must be + # run as root in order to succeed. + mdadm --detail /dev/${raid_set_name} + else + echo "Must be administrator or root to display RAID status" + fi +fi diff --git a/src/op_mode/show_sensors.py b/src/op_mode/show_sensors.py new file mode 100644 index 0000000..5e3084f --- /dev/null +++ b/src/op_mode/show_sensors.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# +# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import re +import sys +from vyos.utils.process import popen +from vyos.utils.process import 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) + 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) + if retcode == 0: + print (output) + sys.exit(0) + + +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 new file mode 100644 index 0000000..32cf677 --- /dev/null +++ b/src/op_mode/show_techsupport_report.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-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/>. + +import os +import sys +from typing import List +from vyos.ifconfig import Section +from vyos.ifconfig import Interface +from vyos.utils.process import rc_cmd + + +def print_header(command: str) -> None: + """Prints a command with headers '-'. + + Example: + + % print_header('Example command') + + --------------- + Example command + --------------- + """ + header_length = len(command) * '-' + print(f"\n{header_length}\n{command}\n{header_length}") + + +def execute_command(command: str, header_text: str) -> None: + """Executes a command and prints the output with a header. + + 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 + + """ + 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 op(cmd: str) -> str: + """Returns a command with the VyOS operational mode wrapper.""" + return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' + + +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: + """Prints the physical interface statistics.""" + execute_command('/usr/bin/true', 'Physical Interface statistics') + for iface in get_ethernet_interfaces(): + # Exclude vlans + if '.' in iface: + 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') + + +def show_bridge() -> None: + """Show bridge interfaces.""" + execute_command(op('show bridge'), 'Show bridge') + + +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 show_route() -> None: + """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", + ] + 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') + + +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_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') + + +def show_softnet_statistics() -> None: + """Prints softnet statistics.""" + execute_command('cat /proc/net/softnet_stat', 'cat /proc/net/softnet_stat') + + +def show_running_processes() -> None: + """Prints current running processes""" + execute_command('ps -ef', 'Running Processes') + + +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') + + +def list_disks(): + disks = set() + with open('/proc/partitions') as partitions_file: + for line in partitions_file: + fields = line.strip().split() + if len(fields) == 4 and fields[3].isalpha() and fields[3] != 'name': + disks.add(fields[3]) + return disks + + +def show_storage() -> None: + """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() diff --git a/src/op_mode/show_usb_serial.py b/src/op_mode/show_usb_serial.py new file mode 100644 index 0000000..973bf19 --- /dev/null +++ b/src/op_mode/show_usb_serial.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from jinja2 import Template +from pyudev import Context, Devices +from sys import exit + +OUT_TMPL_SRC = """Device Model Vendor +------ ------ ------ +{% for d in devices %} +{{ "%-16s" | format(d.device) }} {{ "%-19s" | format(d.model)}} {{d.vendor}} +{% endfor %} + +""" + +data = { + 'devices': [] +} + + +base_directory = '/dev/serial/by-bus' +if not os.path.isdir(base_directory): + print("No USB to serial converter connected") + exit(0) + +context = Context() +for root, dirs, files in os.walk(base_directory): + for basename in files: + os.path.join(root, basename) + device = Devices.from_device_file(context, os.path.join(root, basename)) + tmp = { + 'device': basename, + 'model': device.properties.get('ID_MODEL'), + 'vendor': device.properties.get('ID_VENDOR_FROM_DATABASE') + } + data['devices'].append(tmp) + +data['devices'] = sorted(data['devices'], key = lambda i: i['device']) +tmpl = Template(OUT_TMPL_SRC) +print(tmpl.render(data)) + +exit(0) diff --git a/src/op_mode/show_users.py b/src/op_mode/show_users.py new file mode 100644 index 0000000..82bd585 --- /dev/null +++ b/src/op_mode/show_users.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import argparse +import pwd +import struct +import sys +from time import ctime + +from tabulate import tabulate +from vyos.config import Config + + +class UserInfo: + def __init__(self, uid, name, user_type, is_locked, login_time, tty, host): + self.uid = uid + self.name = name + self.user_type = user_type + self.is_locked = is_locked + self.login_time = login_time + self.tty = tty + self.host = host + + +filters = { + 'default': lambda user: not user.is_locked, # Default is everything but locked accounts + 'vyos': lambda user: user.user_type == 'vyos', + 'other': lambda user: user.user_type != 'vyos', + 'locked': lambda user: user.is_locked, + 'all': lambda user: True +} + + +def is_locked(user_name: str) -> bool: + """Check if a given user has password in shadow db""" + + try: + import warnings + with warnings.catch_warnings(): + warnings.filterwarnings("ignore",category=DeprecationWarning) + import spwd + encrypted_password = spwd.getspnam(user_name)[1] + return encrypted_password == '*' or encrypted_password.startswith('!') + except (KeyError, PermissionError): + print('Cannot access shadow database, ensure this script is run with sufficient permissions') + sys.exit(1) + + +def decode_lastlog(lastlog_file, uid: int): + """Decode last login info of a given user uid from the lastlog file""" + + struct_fmt = '=L32s256s' + recordsize = struct.calcsize(struct_fmt) + lastlog_file.seek(recordsize * uid) + buf = lastlog_file.read(recordsize) + if len(buf) < recordsize: + return None + (time, tty, host) = struct.unpack(struct_fmt, buf) + time = 'never logged in' if time == 0 else ctime(time) + tty = tty.strip(b'\x00') + host = host.strip(b'\x00') + return time, tty, host + + +def list_users(): + cfg = Config() + 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(): + lastlog_info = decode_lastlog(lastlog_file, uid) + if lastlog_info is None: + continue + user_info = UserInfo( + uid, name, + user_type='vyos' if name in vyos_users else 'other', + is_locked=is_locked(name), + login_time=lastlog_info[0], + tty=lastlog_info[1], + host=lastlog_info[2]) + users.append(user_info) + return users + + +def main(): + parser = argparse.ArgumentParser(prog=sys.argv[0], add_help=False) + parser.add_argument('type', nargs='?', choices=['all', 'vyos', 'other', 'locked']) + args = parser.parse_args() + + filter_type = args.type if args.type is not None else 'default' + filter_expr = filters[filter_type] + + headers = ['Username', 'Type', 'Locked', 'Tty', 'From', 'Last login'] + table_data = [] + for user in list_users(): + if filter_expr(user): + table_data.append([user.name, user.user_type, user.is_locked, user.tty, user.host, user.login_time]) + print(tabulate(table_data, headers, tablefmt='simple')) + + +if __name__ == '__main__': + main() diff --git a/src/op_mode/show_virtual_server.py b/src/op_mode/show_virtual_server.py new file mode 100644 index 0000000..7880edc --- /dev/null +++ b/src/op_mode/show_virtual_server.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import call + +def is_configured(): + """ Check if high-availability virtual-server is configured """ + config = ConfigTreeQuery() + if not config.exists(['high-availability', 'virtual-server']): + return False + return True + +if __name__ == '__main__': + + if is_configured() == False: + print('Virtual server not configured!') + exit(0) + + call('sudo ipvsadm --list --numeric') diff --git a/src/op_mode/show_wwan.py b/src/op_mode/show_wwan.py new file mode 100644 index 0000000..bd97bb0 --- /dev/null +++ b/src/op_mode/show_wwan.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse + +from sys import exit +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd + +parser = argparse.ArgumentParser() +parser.add_argument("--model", help="Get module model", action="store_true") +parser.add_argument("--revision", help="Get module revision", action="store_true") +parser.add_argument("--capabilities", help="Get module capabilities", action="store_true") +parser.add_argument("--imei", help="Get module IMEI/ESN/MEID", action="store_true") +parser.add_argument("--imsi", help="Get module IMSI", action="store_true") +parser.add_argument("--msisdn", help="Get module MSISDN", action="store_true") +parser.add_argument("--sim", help="Get SIM card status", action="store_true") +parser.add_argument("--signal", help="Get current RF signal info", action="store_true") +parser.add_argument("--firmware", help="Get current RF signal info", action="store_true") + +required = parser.add_argument_group('Required arguments') +required.add_argument("--interface", help="WWAN interface name, e.g. wwan0", required=True) + +def qmi_cmd(device, command, silent=False): + try: + tmp = cmd(f'qmicli --device={device} --device-open-proxy {command}') + tmp = tmp.replace(f'[{cdc}] ', '') + if not silent: + # skip first line as this only holds the info headline + for line in tmp.splitlines()[1:]: + print(line.lstrip()) + return tmp + except: + print('Command not supported by Modem') + exit(1) + +if __name__ == '__main__': + args = parser.parse_args() + + tmp = ConfigTreeQuery() + if not tmp.exists(['interfaces', 'wwan', args.interface]): + print(f'Interface "{args.interface}" unconfigured!') + exit(1) + + # remove the WWAN prefix from the interface, required for the CDC interface + if_num = args.interface.replace('wwan','') + cdc = f'/dev/cdc-wdm{if_num}' + + if args.model: + qmi_cmd(cdc, '--dms-get-model') + elif args.capabilities: + qmi_cmd(cdc, '--dms-get-capabilities') + qmi_cmd(cdc, '--dms-get-band-capabilities') + elif args.revision: + qmi_cmd(cdc, '--dms-get-revision') + elif args.imei: + qmi_cmd(cdc, '--dms-get-ids') + elif args.imsi: + qmi_cmd(cdc, '--dms-uim-get-imsi') + elif args.msisdn: + qmi_cmd(cdc, '--dms-get-msisdn') + elif args.sim: + qmi_cmd(cdc, '--uim-get-card-status') + elif args.signal: + qmi_cmd(cdc, '--nas-get-signal-info') + qmi_cmd(cdc, '--nas-get-rf-band-info') + elif args.firmware: + tmp = qmi_cmd(cdc, '--dms-get-manufacturer', silent=True) + if 'Sierra Wireless' in tmp: + qmi_cmd(cdc, '--dms-swi-get-current-firmware') + else: + qmi_cmd(cdc, '--dms-get-software-version') + else: + parser.print_help() + exit(1) diff --git a/src/op_mode/snmp.py b/src/op_mode/snmp.py new file mode 100644 index 0000000..3d6cd22 --- /dev/null +++ b/src/op_mode/snmp.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-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/>. + +import sys +import argparse + +from vyos.config import Config +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.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') + +config = { + 'communities': [], +} + +def read_config(): + with open(config_file_daemon, 'r') as f: + for line in f: + # Only get configured SNMP communitie + if line.startswith('rocommunity') or line.startswith('rwcommunity'): + string = line.split(' ') + # append community to the output list only once + c = string[1] + if c not in config['communities']: + config['communities'].append(c) + +def show_all(): + if len(config['communities']) > 0: + print(' '.join(config['communities'])) + +def show_community(c, h): + print('Status of SNMP community {0} on {1}'.format(c, h), flush=True) + call('/usr/bin/snmpstatus -t1 -v1 -c {0} {1}'.format(c, h)) + +if __name__ == '__main__': + args = parser.parse_args() + + # Do nothing if service is not configured + c = Config() + if not c.exists_effective('service snmp'): + print("SNMP service is not configured") + sys.exit(0) + + read_config() + + if args.allowed: + show_all() + sys.exit(1) + elif args.community: + show_community(args.community, args.host) + sys.exit(1) + else: + parser.print_help() + sys.exit(1) diff --git a/src/op_mode/snmp_ifmib.py b/src/op_mode/snmp_ifmib.py new file mode 100644 index 0000000..c71feba --- /dev/null +++ b/src/op_mode/snmp_ifmib.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 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/>. +# +# File: snmp_ifmib.py +# Purpose: +# Show SNMP MIB information +# Used by the "run show snmp mib" commands. + +import sys +import argparse +import netifaces + +from vyos.config import Config +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('--ifdescr', action='store', nargs='?', const='all', help='Show interface description') + +def show_ifindex(intf): + out, err = popen(f'/bin/ip link show {intf}', decode='utf-8') + index = 'ifIndex = ' + out.split(':')[0] + return index.replace('\n', '') + +def show_ifalias(intf): + out, err = popen(f'/bin/ip link show {intf}', decode='utf-8') + alias = out.split('alias')[1].lstrip() if 'alias' in out else intf + return 'ifAlias = ' + alias.replace('\n', '') + +def show_ifdescr(i): + ven_id = '' + dev_id = '' + + try: + with open(r'/sys/class/net/' + i + '/device/vendor', 'r') as f: + ven_id = f.read().replace('\n', '') + except FileNotFoundError: + pass + + try: + with open(r'/sys/class/net/' + i + '/device/device', 'r') as f: + dev_id = f.read().replace('\n', '') + except FileNotFoundError: + pass + + if ven_id == '' and dev_id == '': + ret = 'ifDescr = {0}'.format(i) + return ret + + device = str(ven_id) + ':' + str(dev_id) + out, err = popen(f'/usr/bin/lspci -mm -d {device}', decode='utf-8') + + vendor = "" + device = "" + + # convert output to string + string = out.split('"') + if len(string) > 3: + vendor = string[3] + + if len(string) > 5: + device = string[5] + + ret = 'ifDescr = {0} {1}'.format(vendor, device) + return ret.replace('\n', '') + +if __name__ == '__main__': + args = parser.parse_args() + + # Do nothing if service is not configured + c = Config() + if not c.exists_effective('service snmp'): + print("SNMP service is not configured") + sys.exit(0) + + if args.ifindex: + if args.ifindex == 'all': + for i in netifaces.interfaces(): + print('{0}: {1}'.format(i, show_ifindex(i))) + else: + print('{0}: {1}'.format(args.ifindex, show_ifindex(args.ifindex))) + + elif args.ifalias: + if args.ifalias == 'all': + for i in netifaces.interfaces(): + print('{0}: {1}'.format(i, show_ifalias(i))) + else: + print('{0}: {1}'.format(args.ifalias, show_ifalias(args.ifalias))) + + elif args.ifdescr: + if args.ifdescr == 'all': + for i in netifaces.interfaces(): + print('{0}: {1}'.format(i, show_ifdescr(i))) + else: + print('{0}: {1}'.format(args.ifdescr, show_ifdescr(args.ifdescr))) + + else: + #eth0: ifIndex = 2 + # ifAlias = NET-MYBLL-MUCI-BACKBONE + # ifDescr = VMware VMXNET3 Ethernet Controller + #lo: ifIndex = 1 + for i in netifaces.interfaces(): + print('{0}:\t{1}'.format(i, show_ifindex(i))) + print('\t{0}'.format(show_ifalias(i))) + print('\t{0}'.format(show_ifdescr(i))) + + sys.exit(1) diff --git a/src/op_mode/snmp_v3.py b/src/op_mode/snmp_v3.py new file mode 100644 index 0000000..abeb524 --- /dev/null +++ b/src/op_mode/snmp_v3.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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/>. +# +# File: snmp_v3.py +# Purpose: +# Show SNMP v3 information +# Used by the "run show snmp v3" commands. + +import sys +import jinja2 +import argparse + +from vyos.config import Config + +parser = argparse.ArgumentParser(description='Retrieve SNMP v3 information') +parser.add_argument('--all', action="store_true", help='Show all available information') +parser.add_argument('--group', action="store_true", help='Show the list of configured groups') +parser.add_argument('--trap', action="store_true", help='Show the list of configured targets') +parser.add_argument('--user', action="store_true", help='Show the list of configured users') +parser.add_argument('--view', action="store_true", help='Show the list of configured views') + +GROUP_OUTP_TMPL_SRC = """ +SNMPv3 Groups: + + Group View + ----- ---- + {% if group %}{% for g in group %} + {{ "%-20s" | format(g.name) }}{{ g.view }}({{ g.mode }}) + {% endfor %}{% endif %} +""" + +TRAPTGT_OUTP_TMPL_SRC = """ +SNMPv3 Trap-targets: + + Tpap-target Port Protocol Auth Priv Type EngineID User + ----------- ---- -------- ---- ---- ---- -------- ---- + {% if trap %}{% for t in trap %} + {{ "%-20s" | format(t.name) }} {{ t.port }} {{ t.proto }} {{ t.auth }} {{ t.priv }} {{ t.type }} {{ "%-32s" | format(t.engID) }} {{ t.user }} + {% endfor %}{% endif %} +""" + +USER_OUTP_TMPL_SRC = """ +SNMPv3 Users: + + User Auth Priv Mode Group + ---- ---- ---- ---- ----- + {% if user %}{% for u in user %} + {{ "%-20s" | format(u.name) }}{{ u.auth }} {{ u.priv }} {{ u.mode }} {{ u.group }} + {% endfor %}{% endif %} +""" + +VIEW_OUTP_TMPL_SRC = """ +SNMPv3 Views: + {% if view %}{% for v in view %} + View : {{ v.name }} + OIDs : .{{ v.oids | join("\n .")}} + {% endfor %}{% endif %} +""" + +if __name__ == '__main__': + args = parser.parse_args() + + # Do nothing if service is not configured + c = Config() + if not c.exists_effective('service snmp v3'): + print("SNMP v3 is not configured") + sys.exit(0) + + data = { + 'group': [], + 'trap': [], + 'user': [], + 'view': [] + } + + if c.exists_effective('service snmp v3 group'): + for g in c.list_effective_nodes('service snmp v3 group'): + group = { + 'name': g, + 'mode': '', + 'view': '' + } + group['mode'] = c.return_effective_value('service snmp v3 group {0} mode'.format(g)) + group['view'] = c.return_effective_value('service snmp v3 group {0} view'.format(g)) + + data['group'].append(group) + + if c.exists_effective('service snmp v3 user'): + for u in c.list_effective_nodes('service snmp v3 user'): + user = { + 'name' : u, + 'mode' : '', + 'auth' : '', + 'priv' : '', + 'group': '' + } + user['mode'] = c.return_effective_value('service snmp v3 user {0} mode'.format(u)) + user['auth'] = c.return_effective_value('service snmp v3 user {0} auth type'.format(u)) + user['priv'] = c.return_effective_value('service snmp v3 user {0} privacy type'.format(u)) + user['group'] = c.return_effective_value('service snmp v3 user {0} group'.format(u)) + + data['user'].append(user) + + if c.exists_effective('service snmp v3 view'): + for v in c.list_effective_nodes('service snmp v3 view'): + view = { + 'name': v, + 'oids': [] + } + view['oids'] = c.list_effective_nodes('service snmp v3 view {0} oid'.format(v)) + + data['view'].append(view) + + if c.exists_effective('service snmp v3 trap-target'): + for t in c.list_effective_nodes('service snmp v3 trap-target'): + trap = { + 'name' : t, + 'port' : '', + 'proto': '', + 'auth' : '', + 'priv' : '', + 'type' : '', + 'engID': '', + 'user' : '' + } + trap['port'] = c.return_effective_value('service snmp v3 trap-target {0} port'.format(t)) + trap['proto'] = c.return_effective_value('service snmp v3 trap-target {0} protocol'.format(t)) + trap['auth'] = c.return_effective_value('service snmp v3 trap-target {0} auth type'.format(t)) + trap['priv'] = c.return_effective_value('service snmp v3 trap-target {0} privacy type'.format(t)) + trap['type'] = c.return_effective_value('service snmp v3 trap-target {0} type'.format(t)) + trap['engID'] = c.return_effective_value('service snmp v3 trap-target {0} engineid'.format(t)) + trap['user'] = c.return_effective_value('service snmp v3 trap-target {0} user'.format(t)) + + data['trap'].append(trap) + + if args.all: + # Special case, print all templates ! + tmpl = jinja2.Template(GROUP_OUTP_TMPL_SRC) + print(tmpl.render(data)) + tmpl = jinja2.Template(TRAPTGT_OUTP_TMPL_SRC) + print(tmpl.render(data)) + tmpl = jinja2.Template(USER_OUTP_TMPL_SRC) + print(tmpl.render(data)) + tmpl = jinja2.Template(VIEW_OUTP_TMPL_SRC) + print(tmpl.render(data)) + + elif args.group: + tmpl = jinja2.Template(GROUP_OUTP_TMPL_SRC) + print(tmpl.render(data)) + + elif args.trap: + tmpl = jinja2.Template(TRAPTGT_OUTP_TMPL_SRC) + print(tmpl.render(data)) + + elif args.user: + tmpl = jinja2.Template(USER_OUTP_TMPL_SRC) + print(tmpl.render(data)) + + elif args.view: + tmpl = jinja2.Template(VIEW_OUTP_TMPL_SRC) + print(tmpl.render(data)) + + else: + parser.print_help() + + sys.exit(1) diff --git a/src/op_mode/snmp_v3_showcerts.sh b/src/op_mode/snmp_v3_showcerts.sh new file mode 100644 index 0000000..015b2e6 --- /dev/null +++ b/src/op_mode/snmp_v3_showcerts.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +files=`sudo ls /etc/snmp/tls/certs/ 2> /dev/null`; +if [ -n "$files" ]; then + sudo /usr/bin/net-snmp-cert showcerts --subject --fingerprint +else + echo "You don't have any certificates. Put it in '/etc/snmp/tls/certs/' folder." +fi diff --git a/src/op_mode/ssh.py b/src/op_mode/ssh.py new file mode 100644 index 0000000..0c51576 --- /dev/null +++ b/src/op_mode/ssh.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# +# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import json +import sys +import glob +import vyos.opmode +from vyos.utils.process import cmd +from vyos.configquery import ConfigTreeQuery +from tabulate import tabulate + +def show_fingerprints(raw: bool, ascii: bool): + config = ConfigTreeQuery() + if not config.exists("service ssh"): + raise vyos.opmode.UnconfiguredSubsystem("SSH server is not enabled.") + + publickeys = glob.glob("/etc/ssh/*.pub") + + if publickeys: + keys = [] + for keyfile in publickeys: + try: + if ascii: + keydata = cmd("ssh-keygen -l -v -E sha256 -f " + keyfile).splitlines() + else: + keydata = cmd("ssh-keygen -l -E sha256 -f " + keyfile).splitlines() + type = keydata[0].split(None)[-1].strip("()") + key_size = keydata[0].split(None)[0] + fingerprint = keydata[0].split(None)[1] + comment = keydata[0].split(None)[2:-1][0] + if ascii: + ascii_art = "\n".join(keydata[1:]) + keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment, "ascii_art": ascii_art}) + else: + keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment}) + except: + # Ignore invalid public keys + pass + if raw: + return keys + else: + headers = {"type": "Type", "key_size": "Key Size", "fingerprint": "Fingerprint", "comment": "Comment", "ascii_art": "ASCII Art"} + output = "SSH server public key fingerprints:\n\n" + tabulate(keys, headers=headers, tablefmt="simple") + return output + else: + if raw: + return [] + else: + return "No SSH server public keys are found." + +def show_dynamic_protection(raw: bool): + config = ConfigTreeQuery() + if not config.exists(['service', 'ssh', 'dynamic-protection']): + raise vyos.opmode.UnconfiguredObject("SSH server dynamic-protection is not enabled.") + + attackers = [] + try: + # IPv4 + attackers = attackers + json.loads(cmd("nft -j list set ip sshguard attackers"))["nftables"][1]["set"]["elem"] + except: + pass + try: + # IPv6 + attackers = attackers + json.loads(cmd("nft -j list set ip6 sshguard attackers"))["nftables"][1]["set"]["elem"] + except: + pass + if attackers: + if raw: + return attackers + else: + output = "Blocked attackers:\n" + "\n".join(attackers) + return output + else: + if raw: + return [] + else: + return "No blocked attackers." + +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/storage.py b/src/op_mode/storage.py new file mode 100644 index 0000000..8fd2ffe --- /dev/null +++ b/src/op_mode/storage.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. +# + +import sys + +import vyos.opmode + +from jinja2 import Template + +output_tmpl = """ +Filesystem: {{filesystem}} +Size: {{size}} +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 + + if raw: + res = get_persistent_storage_stats(human_units=False) + if res is None: + raise vyos.opmode.DataUnavailable("Storage statistics are not available") + else: + return res + else: + data = get_persistent_storage_stats(human_units=True) + if data is None: + return "Storage statistics are not available" + else: + data["avail_percentage"] = 100 - int(data["use_percentage"]) + tmpl = Template(output_tmpl) + return tmpl.render(data).strip() + + return output + +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 new file mode 100644 index 0000000..854b4b6 --- /dev/null +++ b/src/op_mode/system.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-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/>. + +import jmespath +import sys + +from vyos.configquery import ConfigTreeQuery + +import vyos.opmode +import vyos.version + +config = ConfigTreeQuery() +base = ['system', 'update-check'] + + +def _compare_version_raw(): + url = config.value(base + ['url']) + local_data = vyos.version.get_full_version_data() + remote_data = vyos.version.get_remote_version(url) + if not remote_data: + return {"error": True, + "reason": "Unable to get remote version"} + if local_data.get('version') and remote_data: + local_version = local_data.get('version') + remote_version = jmespath.search('[0].version', remote_data) + image_url = jmespath.search('[0].url', remote_data) + if local_data.get('version') != remote_version: + return {"error": False, + "update_available": True, + "local_version": local_version, + "remote_version": remote_version, + "url": image_url} + return {"update_available": False, + "local_version": local_version, + "remote_version": remote_version} + + +def _formatted_compare_version(data): + local_version = data.get('local_version') + remote_version = data.get('remote_version') + url = data.get('url') + if {'update_available','local_version', 'remote_version', 'url'} <= set(data): + return f'Current version: {local_version}\n\nUpdate available: {remote_version}\nUpdate URL: {url}' + elif local_version == remote_version and remote_version is not None: + return f'No available updates for your system \n' \ + f'current version: {local_version}\nremote version: {remote_version}' + else: + return 'Update not found' + + +def _verify(): + if not config.exists(base): + return False + return True + + +def show_update(raw: bool): + if not _verify(): + raise vyos.opmode.UnconfiguredSubsystem("system update-check not configured") + data = _compare_version_raw() + if raw: + return data + else: + return _formatted_compare_version(data) + + +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/tcpdump.py b/src/op_mode/tcpdump.py new file mode 100644 index 0000000..607b596 --- /dev/null +++ b/src/op_mode/tcpdump.py @@ -0,0 +1,165 @@ +#! /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/>. + +import sys + +from vyos.utils.process import call + +options = { + 'dump': { + 'cmd': '{command} -A', + 'type': 'noarg', + 'help': 'Print each packet (minus its link level header) in ASCII.' + }, + 'hexdump': { + 'cmd': '{command} -X', + 'type': 'noarg', + 'help': 'Print each packet (minus its link level header) in both hex and ASCII.' + }, + 'filter': { + 'cmd': '{command} \'{value}\'', + 'type': '<pcap-filter>', + 'help': 'Match traffic for capture and display with a pcap-filter expression.' + }, + 'numeric': { + 'cmd': '{command} -nn', + 'type': 'noarg', + 'help': 'Do not attempt to resolve addresses, protocols or services to names.' + }, + 'save': { + 'cmd': '{command} -w {value}', + 'type': '<file>', + 'help': 'Write captured raw packets to <file> rather than parsing or printing them out.' + }, + 'verbose': { + 'cmd': '{command} -vvv -ne', + 'type': 'noarg', + 'help': 'Parse packets with increased detail output, including link-level headers and extended decoding protocol sanity checks.' + }, +} + +tcpdump = 'sudo /usr/bin/tcpdump' + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expansion_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expansion_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['cmd'].format( + command=command, value='') + elif not args: + sys.exit(f'monitor traffic: missing argument for {longname} option') + else: + command = options[longname]['cmd'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + ifname = args.first() + + # Slightly simplified & tweaked version of the code from mtr.py - it may be + # worthwhile to combine and centralise this in a common module. + if ifname == '--get-options-nested': + args.first() # pop monitor + args.first() # pop traffic + args.first() # pop interface + args.first() # pop <ifname> + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + if not matched: + sys.stdout.write('<nocomps>') + else: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + # Run helpfunction to get list of possible values + if 'helpfunction' in options[matched[0]]: + result = options[matched[0]]['helpfunction']() + if result: + helplines = '\n' + ' '.join(result) + sys.stdout.write(helplines) + sys.exit(0) + + command = convert(tcpdump, args) + call(f'{command} -i {ifname}') diff --git a/src/op_mode/tech_support.py b/src/op_mode/tech_support.py new file mode 100644 index 0000000..f60bb87 --- /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 <http://www.gnu.org/licenses/>. + +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) diff --git a/src/op_mode/toggle_help_binding.sh b/src/op_mode/toggle_help_binding.sh new file mode 100644 index 0000000..a8708f3 --- /dev/null +++ b/src/op_mode/toggle_help_binding.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# Copyright (C) 2019 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/>. + +# Script for [un-]binding the question mark key for getting help +if [ "$1" == 'disable' ]; then + sed -i "/^bind '\"?\": .* # vyatta key binding$/d" $HOME/.bashrc + echo "bind '\"?\": self-insert' # vyatta key binding" >> $HOME/.bashrc + bind '"?": self-insert' +else + sed -i "/^bind '\"?\": .* # vyatta key binding$/d" $HOME/.bashrc + bind '"?": possible-completions' +fi diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py new file mode 100644 index 0000000..d2bac3f --- /dev/null +++ b/src/op_mode/traceroute.py @@ -0,0 +1,238 @@ +#! /usr/bin/env python3 + +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import socket +import ipaddress + +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call + +options = { + 'backward-hops': { + 'traceroute': '{command} --back', + 'type': 'noarg', + 'help': 'Display number of backward hops when they different from the forwarded path' + }, + 'bypass': { + 'traceroute': '{command} -r', + 'type': 'noarg', + 'help': 'Bypass the normal routing tables and send directly to a host on an attached network' + }, + 'do-not-fragment': { + 'traceroute': '{command} -F', + 'type': 'noarg', + 'help': 'Do not fragment probe packets.' + }, + 'first-ttl': { + 'traceroute': '{command} -f {value}', + 'type': '<ttl>', + 'help': 'Specifies with what TTL to start. Defaults to 1.' + }, + 'icmp': { + 'traceroute': '{command} -I', + 'type': 'noarg', + 'help': 'Use ICMP ECHO for tracerouting' + }, + 'interface': { + 'traceroute': '{command} -i {value}', + 'type': '<interface>', + 'helpfunction': interface_list, + 'help': 'Source interface' + }, + 'lookup-as': { + 'traceroute': '{command} -A', + 'type': 'noarg', + 'help': 'Perform AS path lookups' + }, + 'mark': { + 'traceroute': '{command} --fwmark={value}', + 'type': '<fwmark>', + 'help': 'Set the firewall mark for outgoing packets' + }, + 'no-resolve': { + 'traceroute': '{command} -n', + 'type': 'noarg', + 'help': 'Do not resolve hostnames' + }, + 'port': { + 'traceroute': '{command} -p {value}', + 'type': '<port>', + 'help': 'Destination port' + }, + 'source-address': { + 'traceroute': '{command} -s {value}', + 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', + 'help': 'Specify source IP v4/v6 address' + }, + 'tcp': { + 'traceroute': '{command} -T', + 'type': 'noarg', + 'help': 'Use TCP SYN for tracerouting (default port is 80)' + }, + 'tos': { + 'traceroute': '{commad} -t {value}', + 'type': '<tos>', + 'help': 'Mark packets with specified TOS' + }, + 'ttl': { + 'traceroute': '{command} -m {value}', + 'type': '<ttl>', + 'help': 'Maximum number of hops' + }, + 'udp': { + 'traceroute': '{command} -U', + 'type': 'noarg', + 'help': 'Use UDP to particular port for tracerouting (default port is 53)' + }, + 'vrf': { + 'traceroute': 'sudo ip vrf exec {value} {command}', + 'type': '<vrf>', + 'help': 'Use specified VRF table', + 'helpfunction': vrf_list, + 'dflt': 'default'} +} + +traceroute = { + 4: '/bin/traceroute -4', + 6: '/bin/traceroute -6', +} + + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expension_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expension_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['traceroute'].format( + command=command, value='') + elif not args: + sys.exit(f'traceroute: missing argument for {longname} option') + else: + command = options[longname]['traceroute'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + host = args.first() + + if not host: + sys.exit("traceroute: Missing host") + + if host == '--get-options': + args.first() # pop ping + args.first() # pop IP + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + # Run helpfunction to get list of possible values + if 'helpfunction' in options[matched[0]]: + result = options[matched[0]]['helpfunction']() + if result: + helplines = '\n' + ' '.join(result) + sys.stdout.write(helplines) + sys.exit(0) + + for name, option in options.items(): + if 'dflt' in option and name not in args: + args.append(name) + args.append(option['dflt']) + + try: + ip = socket.gethostbyname(host) + except UnicodeError: + sys.exit(f'tracroute: Unknown host: {host}') + except socket.gaierror: + ip = host + + try: + version = ipaddress.ip_address(ip).version + except ValueError: + sys.exit(f'traceroute: Unknown host: {host}') + + command = convert(traceroute[version], args) + call(f'{command} {host}') diff --git a/src/op_mode/uptime.py b/src/op_mode/uptime.py new file mode 100644 index 0000000..1c1a149 --- /dev/null +++ b/src/op_mode/uptime.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# +# 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 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys + +import vyos.opmode + +def _get_raw_data(): + from vyos.utils.system import get_uptime_seconds, get_load_averages + from vyos.utils.convert import seconds_to_human + + res = {} + uptime_seconds = get_uptime_seconds() + res["uptime"] = seconds_to_human(uptime_seconds, separator=' ') + res["load_average"] = get_load_averages() + + return res + +def _get_formatted_output(data): + out = "Uptime: {}\n\n".format(data["uptime"]) + avgs = data["load_average"] + out += "Load averages:\n" + out += "1 minute: {:.01f}%\n".format(avgs[1]*100) + out += "5 minutes: {:.01f}%\n".format(avgs[5]*100) + out += "15 minutes: {:.01f}%\n".format(avgs[15]*100) + + return out + +def show(raw: bool): + uptime_data = _get_raw_data() + + if raw: + return uptime_data + else: + return _get_formatted_output(uptime_data) + +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/version.py b/src/op_mode/version.py new file mode 100644 index 0000000..71a40dd --- /dev/null +++ b/src/op_mode/version.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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/>. +# +# Purpose: +# Displays image version and system information. +# Used by the "run show version" command. + +import sys +import typing + +import vyos.opmode +import vyos.version +import vyos.limericks + +from vyos.utils.boot import is_uefi_system +from vyos.utils.system import get_secure_boot_state + +from jinja2 import Template + +version_output_tmpl = """ +Version: VyOS {{version}} +Release train: {{release_train}} +Release flavor: {{flavor}} + +Built by: {{built_by}} +Built on: {{built_on}} +Build UUID: {{build_uuid}} +Build commit ID: {{build_git}} +{%- if build_comment %} +Build comment: {{build_comment}} +{% endif %} + +Architecture: {{system_arch}} +Boot via: {{boot_via}} +System type: {{system_type}} +Secure Boot: {{secure_boot}} + +Hardware vendor: {{hardware_vendor}} +Hardware model: {{hardware_model}} +Hardware S/N: {{hardware_serial}} +Hardware UUID: {{hardware_uuid}} + +Copyright: VyOS maintainers and contributors +{%- if limerick %} +{{limerick}} +{% endif -%} +""" + +def _get_raw_data(funny=False): + version_data = vyos.version.get_full_version_data() + version_data["secure_boot"] = "n/a (BIOS)" + if is_uefi_system(): + version_data["secure_boot"] = "disabled" + if get_secure_boot_state(): + version_data["secure_boot"] = "enabled" + + if funny: + version_data["limerick"] = vyos.limericks.get_random() + + return version_data + +def _get_formatted_output(version_data): + tmpl = Template(version_output_tmpl) + return tmpl.render(version_data).strip() + +def show(raw: bool, funny: typing.Optional[bool]): + """ Display neighbor table contents """ + version_data = _get_raw_data(funny=funny) + + if raw: + return version_data + else: + return _get_formatted_output(version_data) + + +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/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py new file mode 100644 index 0000000..9385bcd --- /dev/null +++ b/src/op_mode/vpn_ike_sa.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# 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 +# 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 argparse +import sys +import vici + +from vyos.utils.process import process_named_running + +ike_sa_peer_prefix = """\ +Peer ID / IP Local ID / IP +------------ -------------""" + +ike_sa_tunnel_prefix = """ + + State IKEVer Encrypt Hash D-H Group NAT-T A-Time L-Time + ----- ------ ------- ---- --------- ----- ------ ------""" + +def s(byte_string): + return str(byte_string, 'utf-8') + +def ike_sa(peer, nat): + session = vici.Session() + sas = session.list_sas() + peers = [] + for conn in sas: + for name, sa in conn.items(): + if peer and s(sa['remote-host']) != peer: + continue + if name.startswith('peer_') and name in peers: + continue + if nat and 'nat-local' not in sa: + continue + peers.append(name) + remote_str = f'{s(sa["remote-host"])} {s(sa["remote-id"])}' if s(sa['remote-id']) != '%any' else s(sa["remote-host"]) + local_str = f'{s(sa["local-host"])} {s(sa["local-id"])}' if s(sa['local-id']) != '%any' else s(sa["local-host"]) + print(ike_sa_peer_prefix) + print('%-39s %-39s' % (remote_str, local_str)) + state = 'up' if 'state' in sa and s(sa['state']) == 'ESTABLISHED' else 'down' + version = 'IKEv' + s(sa['version']) + encryption = f'{s(sa["encr-alg"])}' if 'encr-alg' in sa else 'n/a' + if 'encr-keysize' in sa: + encryption += '_' + s(sa["encr-keysize"]) + integrity = s(sa['integ-alg']) if 'integ-alg' in sa else 'n/a' + dh_group = s(sa['dh-group']) if 'dh-group' in sa else 'n/a' + natt = 'yes' if 'nat-local' in sa and s(sa['nat-local']) == 'yes' else 'no' + atime = s(sa['established']) if 'established' in sa else '0' + ltime = s(sa['rekey-time']) if 'rekey-time' in sa else '0' + print(ike_sa_tunnel_prefix) + print(' %-6s %-6s %-12s %-13s %-14s %-6s %-7s %-7s\n' % (state, version, encryption, integrity, dh_group, natt, atime, ltime)) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--peer', help='Peer name', required=False) + parser.add_argument('--nat', help='NAT Traversal', required=False) + + args = parser.parse_args() + + if not process_named_running('charon-systemd'): + print("IPsec Process NOT Running") + sys.exit(0) + + ike_sa(args.peer, args.nat) diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py new file mode 100644 index 0000000..ef89e60 --- /dev/null +++ b/src/op_mode/vpn_ipsec.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +import argparse + +from vyos.utils.process import call + +SWANCTL_CONF = '/etc/swanctl/swanctl.conf' + + +def get_peer_connections(peer, tunnel, return_all = False): + search = rf'^[\s]*(peer_{peer}_(tunnel_[\d]+|vti)).*' + 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 + if return_all or (result[2] == suffix): + matches.append(result[1]) + return matches + + +def debug_peer(peer, tunnel): + peer = peer.replace(':', '-') + if not peer or peer == "all": + debug_commands = [ + "ipsec statusall", + "swanctl -L", + "swanctl -l", + "swanctl -P", + "ip x sa show", + "ip x policy show", + "ip tunnel show", + "ip address", + "ip rule show", + "ip route | head -100", + "ip route show table 220" + ] + for debug_cmd in debug_commands: + print(f'\n### {debug_cmd} ###') + call(debug_cmd) + return + + if not tunnel or tunnel == 'all': + tunnel = '' + + conns = get_peer_connections(peer, tunnel, return_all = (tunnel == '' or tunnel == 'all')) + + if not conns: + print('Peer not found, aborting') + return + + for conn in conns: + call(f'/usr/sbin/ipsec statusall | grep {conn}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Control action', required=True) + parser.add_argument('--name', help='Name for peer reset', required=False) + parser.add_argument('--tunnel', help='Specific tunnel of peer', required=False) + + args = parser.parse_args() + + + if args.action == "vpn-debug": + debug_peer(args.name, args.tunnel) diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py new file mode 100644 index 0000000..51032a4 --- /dev/null +++ b/src/op_mode/vrf.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import json +import jmespath +import sys +import typing + +from tabulate import tabulate +from vyos.utils.network import get_vrf_members +from vyos.utils.process import cmd + +import vyos.opmode + +def _get_raw_data(name=None): + """ + If vrf name is not set - get all VRFs + If vrf name is set - get only this name data + If vrf name set and not found - return [] + """ + output = cmd('ip --json --brief link show type vrf') + data = json.loads(output) + if not data: + return [] + if name: + is_vrf_exists = True if [vrf for vrf in data if vrf.get('ifname') == name] else False + if is_vrf_exists: + output = cmd(f'ip --json --brief link show dev {name}') + data = json.loads(output) + return data + return [] + return data + + +def _get_formatted_output(raw_data): + data_entries = [] + for vrf in raw_data: + name = vrf.get('ifname') + state = vrf.get('operstate').lower() + hw_address = vrf.get('address') + flags = ','.join(vrf.get('flags')).lower() + tmp = get_vrf_members(name) + if tmp: members = ','.join(get_vrf_members(name)) + else: members = 'n/a' + data_entries.append([name, state, hw_address, flags, members]) + + headers = ["Name", "State", "MAC address", "Flags", "Interfaces"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def show(raw: bool, name: typing.Optional[str]): + vrf_data = _get_raw_data(name=name) + if not jmespath.search('[*].ifname', vrf_data): + return "VRF is not configured" + if raw: + return vrf_data + else: + return _get_formatted_output(vrf_data) + + +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/vrrp.py b/src/op_mode/vrrp.py new file mode 100644 index 0000000..60be860 --- /dev/null +++ b/src/op_mode/vrrp.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-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/>. + +import sys +import argparse + +from vyos.configquery import ConfigTreeQuery +from vyos.ifconfig.vrrp import VRRP +from vyos.ifconfig.vrrp import VRRPNoData + +parser = argparse.ArgumentParser() +group = parser.add_mutually_exclusive_group() +group.add_argument("-s", "--summary", action="store_true", help="Print VRRP summary") +group.add_argument("-t", "--statistics", action="store_true", help="Print VRRP statistics") +group.add_argument("-d", "--data", action="store_true", help="Print detailed VRRP data") + +args = parser.parse_args() + +def is_configured(): + """ Check if VRRP is configured """ + config = ConfigTreeQuery() + if not config.exists(['high-availability', 'vrrp', 'group']): + return False + return True + +# Exit early if VRRP is dead or not configured +if is_configured() == False: + print('VRRP not configured!') + exit(0) +if not VRRP.is_running(): + print('VRRP is not running') + sys.exit(0) + +try: + if args.summary: + print(VRRP.format(VRRP.collect('json'))) + elif args.statistics: + print(VRRP.collect('stats')) + elif args.data: + print(VRRP.collect('state')) + else: + parser.print_help() + sys.exit(1) +except VRRPNoData as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/vtysh_wrapper.sh b/src/op_mode/vtysh_wrapper.sh new file mode 100644 index 0000000..25d09ce --- /dev/null +++ b/src/op_mode/vtysh_wrapper.sh @@ -0,0 +1,6 @@ +#!/bin/sh +declare -a tmp +# FRR uses ospf6 where we use ospfv3, and we use reset over clear for BGP, +# thus alter the commands +tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/") +vtysh -c "$tmp" diff --git a/src/op_mode/vyos-op-cmd-wrapper.sh b/src/op_mode/vyos-op-cmd-wrapper.sh new file mode 100644 index 0000000..a89211b --- /dev/null +++ b/src/op_mode/vyos-op-cmd-wrapper.sh @@ -0,0 +1,6 @@ +#!/bin/vbash +shopt -s expand_aliases +source /etc/default/vyatta +source /etc/bash_completion.d/vyatta-op +_vyatta_op_init +_vyatta_op_run "$@" diff --git a/src/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh new file mode 100644 index 0000000..05ea86f --- /dev/null +++ b/src/op_mode/webproxy_update_blacklist.sh @@ -0,0 +1,138 @@ +#!/bin/sh +# +# Copyright (C) 2020 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/>. + +blacklist_url='ftp://ftp.univ-tlse1.fr/pub/reseau/cache/squidguard_contrib/blacklists.tar.gz' +data_dir="/opt/vyatta/etc/config/url-filtering" +archive="${data_dir}/squidguard/archive" +db_dir="${data_dir}/squidguard/db" +conf_file="/etc/squidguard/squidGuard.conf" +tmp_conf_file="/tmp/sg_update_db.conf" + +#$1-category +#$2-type +#$3-list +create_sg_db () +{ + FILE=$db_dir/$1/$2 + if test -f "$FILE"; then + rm -f ${tmp_conf_file} + printf "dbhome $db_dir\ndest $1 {\n $3 $1/$2\n}\nacl {\n default {\n pass any\n }\n}" >> ${tmp_conf_file} + /usr/bin/squidGuard -b -c ${tmp_conf_file} -C $FILE + rm -f ${tmp_conf_file} + fi + +} + +while [ $# -gt 0 ] +do + case $1 in + --update-blacklist) + update="yes" + ;; + --auto-update-blacklist) + auto="yes" + ;; + --vrf) + vrf="yes" + ;; + (-*) echo "$0: error - unrecognized option $1" 1>&2; exit 1;; + (*) break;; + esac + shift +done + +if [ ! -d ${db_dir} ]; then + mkdir -p ${db_dir} + getent passwd proxy 2> /dev/null + if [ $? -ne 0 ]; then + echo "proxy system user does not exist" + exit 1 + fi + getent group proxy 2> /dev/null + if [ $? -ne 0 ]; then + echo "proxy system group does not exist" + exit 1 + fi + chown proxy:proxy ${db_dir} +fi + +free_space=$(expr $(df ${db_dir} | grep -v Filesystem | awk '{print $4}') \* 1024) +mb_size="100" +required_space=$(expr $mb_size \* 1024 \* 1024) # 100 MB +if [ ${free_space} -le ${required_space} ]; then + echo "Error: not enough disk space, required ${mb_size} MiB" + exit 1 +fi + +if [[ -n $update ]] && [[ $update -eq "yes" ]]; then + tmp_blacklists='/tmp/blacklists.gz' + if [[ -n $vrf ]] && [[ $vrf -eq "yes" ]]; then + sudo ip vrf exec $1 curl -o $tmp_blacklists $blacklist_url + else + curl -o $tmp_blacklists $blacklist_url + fi + if [ $? -ne 0 ]; then + echo "Unable to download [$blacklist_url]!" + exit 1 + fi + echo "Uncompressing blacklist..." + tar --directory /tmp -xf $tmp_blacklists + if [ $? -ne 0 ]; then + echo "Unable to uncompress [$blacklist_url]!" + fi + + if [ ! -d ${archive} ]; then + mkdir -p ${archive} + fi + + rm -rf ${archive}/* + count_before=$(find ${db_dir} -type f \( -name domains -o -name urls \) | xargs wc -l | tail -n 1 | awk '{print $1}') + mv ${db_dir}/* ${archive} 2> /dev/null + mv /tmp/blacklists/* ${db_dir} + if [ $? -ne 0 ]; then + echo "Unable to install [$blacklist_url]" + exit 1 + fi + mv ${archive}/local-* ${db_dir} 2> /dev/null + rm -rf /tmp/blacklists $tmp_blacklists 2> /dev/null + count_after=$(find ${db_dir} -type f \( -name domains -o -name urls \) | xargs wc -l | tail -n 1 | awk '{print $1}') + + # fix permissions + chown -R proxy:proxy ${db_dir} + + #create db + category_list=(`find $db_dir -type d -exec basename {} \; `) + for category in ${category_list[@]} + do + create_sg_db $category "domains" "domainlist" + create_sg_db $category "urls" "urllist" + create_sg_db $category "expressions" "expressionlist" + done + chown -R proxy:proxy ${db_dir} + chmod 755 ${db_dir} + + logger --priority WARNING "webproxy blacklist entries updated (${count_before}/${count_after})" + +else + echo "SquidGuard blacklist updater" + echo "" + echo "Usage:" + echo "--update-blacklist Download latest version of the SquidGuard blacklist" + echo "--auto-update-blacklist Automatically update" + echo "" + exit 1 +fi + diff --git a/src/op_mode/wireguard_client.py b/src/op_mode/wireguard_client.py new file mode 100644 index 0000000..04d8ce2 --- /dev/null +++ b/src/op_mode/wireguard_client.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os + +from jinja2 import Template +from ipaddress import ip_interface + +from vyos.ifconfig import Section +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 +from vyos.utils.process import cmd +from vyos.utils.process import popen + +if os.geteuid() != 0: + exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") + +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 === + +{% for addr in address if address is defined %} +set interfaces wireguard {{ interface }} peer {{ name }} allowed-ips '{{ addr }}' +{% endfor %} +set interfaces wireguard {{ interface }} peer {{ name }} public-key '{{ pubkey }}' + +=== RoadWarrior (client) configuration === +""" + +client_config = """ + +[Interface] +PrivateKey = {{ privkey }} +{% if address is defined and address|length > 0 %} +Address = {{ address | join(', ')}} +{% endif %} +DNS = 1.1.1.1 + +[Peer] +PublicKey = {{ system_pubkey }} +Endpoint = {{ server }}:{{ port }} +AllowedIPs = 0.0.0.0/0, ::/0 + +""" + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("-n", "--name", type=str, help='WireGuard peer name', required=True) + parser.add_argument("-i", "--interface", type=str, help='WireGuard interface the client is connecting to', required=True) + parser.add_argument("-s", "--server", type=str, help='WireGuard server IPv4/IPv6 address or FQDN', required=True) + parser.add_argument("-a", "--address", type=str, help='WireGuard client IPv4/IPv6 address', action='append') + args = parser.parse_args() + + interface = args.interface + if interface not in Section.interfaces('wireguard'): + exit(f'WireGuard interface "{interface}" does not exist!') + + wg_pubkey = cmd(f'wg show {interface} | grep "public key"').split(':')[-1].lstrip() + wg_port = cmd(f'wg show {interface} | grep "listening port"').split(':')[-1].lstrip() + + # Generate WireGuard private key + privkey,_ = popen('wg genkey') + # Generate public key portion from given private key + pubkey,_ = popen('wg pubkey', input=privkey) + + config = { + 'name' : args.name, + 'interface' : interface, + 'system_pubkey' : wg_pubkey, + 'privkey': privkey, + 'pubkey' : pubkey, + 'server' : args.server, + 'port' : wg_port, + 'address' : [], + } + + if args.address: + v4_addr = 0 + v6_addr = 0 + for tmp in args.address: + try: + ip = str(ip_interface(tmp).ip) + if is_ipv4(tmp): + config['address'].append(f'{ip}/32') + v4_addr += 1 + elif is_ipv6(tmp): + config['address'].append(f'{ip}/128') + v6_addr += 1 + except: + print(tmp) + exit('Client IP address invalid!') + + if (v4_addr > 1) or (v6_addr > 1): + exit('Client can only have one IPv4 and one IPv6 address.') + + # Clear out terminal first + print('\x1b[2J\x1b[H') + server = Template(server_config, trim_blocks=True).render(config) + print(server) + client = Template(client_config, trim_blocks=True).render(config) + print(client) + qrcode,err = popen('qrencode -t ansiutf8', input=client) + print(qrcode) diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py new file mode 100644 index 0000000..49fecdf --- /dev/null +++ b/src/op_mode/zone.py @@ -0,0 +1,215 @@ +#!/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/>. +import typing +import sys +import vyos.opmode + +import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search + + +def get_config_zone(conf, name=None): + config_path = ['firewall', 'zone'] + if name: + config_path += [name] + + zone_policy = conf.get_config_dict(config_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + return zone_policy + + +def _convert_one_zone_data(zone: str, zone_config: dict) -> dict: + """ + Convert config dictionary of one zone to API dictionary + :param zone: Zone name + :type zone: str + :param zone_config: config dictionary + :type zone_config: dict + :return: AP dictionary + :rtype: dict + """ + list_of_rules = [] + intrazone_dict = {} + if dict_search('from', zone_config): + for from_zone, from_zone_config in zone_config['from'].items(): + from_zone_dict = {'name': from_zone} + if dict_search('firewall.name', from_zone_config): + from_zone_dict['firewall'] = dict_search('firewall.name', + from_zone_config) + if dict_search('firewall.ipv6_name', from_zone_config): + from_zone_dict['firewall_v6'] = dict_search( + 'firewall.ipv6_name', from_zone_config) + list_of_rules.append(from_zone_dict) + + zone_dict = { + 'name': zone, + 'interface': dict_search('interface', zone_config), + 'type': 'LOCAL' if dict_search('local_zone', + zone_config) is not None else None, + } + if list_of_rules: + zone_dict['from'] = list_of_rules + if dict_search('intra_zone_filtering.firewall.name', zone_config): + intrazone_dict['firewall'] = dict_search( + 'intra_zone_filtering.firewall.name', zone_config) + if dict_search('intra_zone_filtering.firewall.ipv6_name', zone_config): + intrazone_dict['firewall_v6'] = dict_search( + 'intra_zone_filtering.firewall.ipv6_name', zone_config) + if intrazone_dict: + zone_dict['intrazone'] = intrazone_dict + return zone_dict + + +def _convert_zones_data(zone_policies: dict) -> list: + """ + Convert all config dictionary to API list of zone dictionaries + :param zone_policies: config dictionary + :type zone_policies: dict + :return: API list + :rtype: list + """ + zone_list = [] + for zone, zone_config in zone_policies.items(): + zone_list.append(_convert_one_zone_data(zone, zone_config)) + return zone_list + + +def _convert_config(zones_config: dict, zone: str = None) -> list: + """ + convert config to API list + :param zones_config: zones config + :type zones_config: + :param zone: zone name + :type zone: str + :return: API list + :rtype: list + """ + if zone: + if zones_config: + output = [_convert_one_zone_data(zone, zones_config)] + else: + raise vyos.opmode.UnconfiguredObject(f'Zone {zone} not found') + else: + if zones_config: + output = _convert_zones_data(zones_config) + else: + raise vyos.opmode.UnconfiguredSubsystem( + 'Zone entries are not configured') + return output + + +def output_zone_list(zone_conf: dict) -> list: + """ + Format one zone row + :param zone_conf: zone config + :type zone_conf: dict + :return: formatted list of zones + :rtype: list + """ + zone_info = [zone_conf['name']] + if zone_conf['type'] == 'LOCAL': + zone_info.append('LOCAL') + else: + zone_info.append("\n".join(zone_conf['interface'])) + + from_zone = [] + firewall = [] + firewall_v6 = [] + if 'intrazone' in zone_conf: + from_zone.append(zone_conf['name']) + + v4_name = dict_search_args(zone_conf['intrazone'], 'firewall') + v6_name = dict_search_args(zone_conf['intrazone'], 'firewall_v6') + if v4_name: + firewall.append(v4_name) + else: + firewall.append('') + if v6_name: + firewall_v6.append(v6_name) + else: + firewall_v6.append('') + + if 'from' in zone_conf: + for from_conf in zone_conf['from']: + from_zone.append(from_conf['name']) + + v4_name = dict_search_args(from_conf, 'firewall') + v6_name = dict_search_args(from_conf, 'firewall_v6') + if v4_name: + firewall.append(v4_name) + else: + firewall.append('') + if v6_name: + firewall_v6.append(v6_name) + else: + firewall_v6.append('') + + zone_info.append("\n".join(from_zone)) + zone_info.append("\n".join(firewall)) + zone_info.append("\n".join(firewall_v6)) + return zone_info + + +def get_formatted_output(zone_policy: list) -> str: + """ + Formatted output of all zones + :param zone_policy: list of zones + :type zone_policy: list + :return: formatted table with zones + :rtype: str + """ + headers = ["Zone", + "Interfaces", + "From Zone", + "Firewall IPv4", + "Firewall IPv6" + ] + formatted_list = [] + for zone_conf in zone_policy: + formatted_list.append(output_zone_list(zone_conf)) + tabulate.PRESERVE_WHITESPACE = True + output = tabulate.tabulate(formatted_list, headers, numalign="left") + return output + + +def show(raw: bool, zone: typing.Optional[str]): + """ + Show zone-policy command + :param raw: if API + :type raw: bool + :param zone: zone name + :type zone: str + """ + conf: ConfigTreeQuery = ConfigTreeQuery() + zones_config: dict = get_config_zone(conf, zone) + zone_policy_api: list = _convert_config(zones_config, zone) + if raw: + return zone_policy_api + else: + return get_formatted_output(zone_policy_api) + + +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) |
