diff options
Diffstat (limited to 'src')
45 files changed, 1510 insertions, 267 deletions
diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 7750c1247..7e16235c1 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -192,6 +192,11 @@ def verify(flow_config): raise ConfigError("All sFlow servers must use the same IP protocol") else: sflow_collector_ipver = ip_address(server).version + + # check if vrf is defined for Sflow + sflow_vrf = None + if 'vrf' in flow_config: + sflow_vrf = flow_config['vrf'] # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa for server in flow_config['sflow']['server']: @@ -203,12 +208,12 @@ def verify(flow_config): if 'agent_address' in flow_config['sflow']: tmp = flow_config['sflow']['agent_address'] - if not is_addr_assigned(tmp): + if not is_addr_assigned(tmp, sflow_vrf): raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') # Check if configured netflow source-address exist in the system if 'source_address' in flow_config['sflow']: - if not is_addr_assigned(flow_config['sflow']['source_address']): + if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf): tmp = flow_config['sflow']['source_address'] raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 4a7906c17..04113fc09 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -66,14 +66,10 @@ def get_config(config=None): if conf.exists('debug'): http_api['debug'] = True - # this node is not available by CLI by default, and is reserved for - # the graphql tools. One can enable it for testing, with the warning - # that this will open an unauthenticated server. To do so - # mkdir /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql - # touch /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql/node.def - # and configure; editing the config alone is insufficient. if conf.exists('gql'): http_api['gql'] = True + if conf.exists('gql introspection'): + http_api['introspection'] = True if conf.exists('socket'): http_api['socket'] = True diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index cd0d9003b..b961408db 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -61,7 +61,7 @@ def get_config(config=None): else: bridge.update({'member' : {'interface_remove' : tmp }}) - if dict_search('member.interface', bridge): + if dict_search('member.interface', bridge) != None: # XXX: T2665: we need a copy of the dict keys for iteration, else we will get: # RuntimeError: dictionary changed size during iteration for interface in list(bridge['member']['interface']): @@ -103,6 +103,14 @@ def get_config(config=None): if 'enable_vlan' in bridge and tmp: bridge['member']['interface'][interface].update({'has_vlan' : ''}) + # delete empty dictionary keys - no need to run code paths if nothing is there to do + if 'member' in bridge: + if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: + del bridge['member']['interface'] + + if len(bridge['member']) == 0: + del bridge['member'] + return bridge def verify(bridge): diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index 03a010086..870049a88 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -22,6 +22,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete @@ -56,7 +57,7 @@ def get_config(config=None): # Check if interface has been removed if 'deleted' in macsec: - source_interface = conf.return_effective_value(['source-interface']) + source_interface = conf.return_effective_value(base + [ifname, 'source-interface']) macsec.update({'source_interface': source_interface}) if is_node_changed(conf, base + [ifname, 'security']): @@ -65,6 +66,10 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'source_interface']): macsec.update({'shutdown_required': {}}) + if 'source_interface' in macsec: + tmp = is_source_interface(conf, macsec['source_interface'], 'macsec') + if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + return macsec @@ -87,6 +92,22 @@ def verify(macsec): if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') + cak_len = len(dict_search('security.mka.cak', macsec)) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != 32: + # gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit + raise ConfigError('gcm-aes-128 requires a 128bit long key!') + + elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != 64: + # gcm-aes-128 requires a 128bit long key - 64 characters (string) = 32byte = 256bit + raise ConfigError('gcm-aes-128 requires a 256bit long key!') + + if 'is_source_interface' in macsec: + tmp = macsec['is_source_interface'] + src_ifname = macsec['source_interface'] + raise ConfigError(f'Can not use source-interface "{src_ifname}", it already ' \ + f'belongs to interface "{tmp}"!') + if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad # and 802.1q) - we need to check the underlaying MTU if our configured diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index bf0f6840d..af2d0588d 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -118,6 +118,11 @@ def verify(vxlan): # in use. vxlan_overhead += 20 + # If source_address is not used - check IPv6 'remote' list + elif 'remote' in vxlan: + if any(is_ipv6(a) for a in vxlan['remote']): + vxlan_overhead += 20 + lower_mtu = Interface(vxlan['source_interface']).get_mtu() if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index e275ace84..97b3a6396 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -76,7 +76,7 @@ def get_config(config=None): # We need to know the amount of other WWAN interfaces as ModemManager needs # to be started or stopped. conf.set_level(base) - _, wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), + wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) diff --git a/src/conf_mode/load-balancing-wan.py b/src/conf_mode/load-balancing-wan.py new file mode 100755 index 000000000..11840249f --- /dev/null +++ b/src/conf_mode/load-balancing-wan.py @@ -0,0 +1,65 @@ +#!/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 sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.util import call +from vyos import ConfigError +from pprint import pprint +from vyos import airbag +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, get_first_key=True, + no_tag_node_value_mangle=True) + + pprint(lb) + return lb + +def verify(lb): + return None + + +def generate(lb): + if not lb: + return None + + return None + + +def apply(lb): + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index e85438c00..0f8f1af42 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -213,6 +213,12 @@ def verify(bgp): if 'source_interface' in peer_config['interface']: raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"') + # Local-AS allowed only for EBGP peers + if 'local_as' in peer_config: + remote_as = verify_remote_as(peer_config, bgp) + if remote_as == bgp['local_as']: + raise ConfigError(f'local-as configured for "{peer}", allowed only for eBGP peers!') + for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec', 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', 'l2vpn_evpn']: diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index ae7e582ec..c58f8db9a 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -19,14 +19,17 @@ import os from sys import exit from vyos.config import Config -from vyos import ConfigError -from vyos.util import call +from vyos.configdict import dict_merge from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = r'/etc/fastnetmon.conf' -networks_list = r'/etc/networks_list' +config_file = r'/run/fastnetmon/fastnetmon.conf' +networks_list = r'/run/fastnetmon/networks_list' +excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' def get_config(config=None): if config: @@ -34,50 +37,55 @@ def get_config(config=None): else: conf = Config() base = ['service', 'ids', 'ddos-protection'] + if not conf.exists(base): + return None + fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + fastnetmon = dict_merge(default_values, fastnetmon) + return fastnetmon def verify(fastnetmon): if not fastnetmon: return None - if not "mode" in fastnetmon: - raise ConfigError('ddos-protection mode is mandatory!') - - if not "network" in fastnetmon: - raise ConfigError('Required define network!') + if 'mode' not in fastnetmon: + raise ConfigError('Specify operating mode!') - if not "listen_interface" in fastnetmon: - raise ConfigError('Define listen-interface is mandatory!') + if 'listen_interface' not in fastnetmon: + raise ConfigError('Specify interface(s) for traffic capture') - if "alert_script" in fastnetmon: - if os.path.isfile(fastnetmon["alert_script"]): + if 'alert_script' in fastnetmon: + if os.path.isfile(fastnetmon['alert_script']): # Check script permissions - if not os.access(fastnetmon["alert_script"], os.X_OK): - raise ConfigError('Script {0} does not have permissions for execution'.format(fastnetmon["alert_script"])) + if not os.access(fastnetmon['alert_script'], os.X_OK): + raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) else: - raise ConfigError('File {0} does not exists!'.format(fastnetmon["alert_script"])) + raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) def generate(fastnetmon): if not fastnetmon: - if os.path.isfile(config_file): - os.unlink(config_file) - if os.path.isfile(networks_list): - os.unlink(networks_list) + for file in [config_file, networks_list]: + if os.path.isfile(file): + os.unlink(file) - return + return None render(config_file, 'ids/fastnetmon.j2', fastnetmon) render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) - + render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) return None def apply(fastnetmon): + systemd_service = 'fastnetmon.service' if not fastnetmon: # Stop fastnetmon service if removed - call('systemctl stop fastnetmon.service') + call(f'systemctl stop {systemd_service}') else: - call('systemctl restart fastnetmon.service') + call(f'systemctl reload-or-restart {systemd_service}') return None diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index ff7caaa84..1b8377a4a 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -90,8 +90,8 @@ def verify(rtradv): if preferred_lifetime == 'infinity': preferred_lifetime = 4294967295 - if not (int(valid_lifetime) > int(preferred_lifetime)): - raise ConfigError('Prefix valid-lifetime must be greater then preferred-lifetime') + if not (int(valid_lifetime) >= int(preferred_lifetime)): + raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime') if 'name_server_lifetime' in interface_config: # man page states: diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py index f7487ee5f..8274e6564 100755 --- a/src/etc/opennhrp/opennhrp-script.py +++ b/src/etc/opennhrp/opennhrp-script.py @@ -14,16 +14,17 @@ # 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 pprint import pprint import os import re import sys import vici +from json import loads from vyos.util import cmd from vyos.util import process_named_running -NHRP_CONFIG="/run/opennhrp/opennhrp.conf" +NHRP_CONFIG = "/run/opennhrp/opennhrp.conf" + def parse_type_ipsec(interface): with open(NHRP_CONFIG, 'r') as f: @@ -35,6 +36,50 @@ def parse_type_ipsec(interface): return m[1], m[2] return None, None + +def add_peer_route(nbma_src: str, nbma_dst: str, mtu: str) -> None: + """Add a route to a NBMA peer + + Args: + nmba_src (str): a local IP address + nbma_dst (str): a remote IP address + mtu (str): a MTU for a route + """ + # Find routes to a peer + route_get_cmd = f'sudo ip -j route get {nbma_dst} from {nbma_src}' + try: + route_info_data = loads(cmd(route_get_cmd)) + except Exception as err: + print(f'Unable to find a route to {nbma_dst}: {err}') + + # Check if an output has an expected format + if not isinstance(route_info_data, list): + print(f'Garbage returned from the "{route_get_cmd}" command: \ + {route_info_data}') + return + + # Add static routes to a peer + for route_item in route_info_data: + route_dev = route_item.get('dev') + route_dst = route_item.get('dst') + route_gateway = route_item.get('gateway') + # Prepare a command to add a route + route_add_cmd = 'sudo ip route add' + if route_dst: + route_add_cmd = f'{route_add_cmd} {route_dst}' + if route_gateway: + route_add_cmd = f'{route_add_cmd} via {route_gateway}' + if route_dev: + route_add_cmd = f'{route_add_cmd} dev {route_dev}' + route_add_cmd = f'{route_add_cmd} proto 42 mtu {mtu}' + # Add a route + try: + cmd(route_add_cmd) + except Exception as err: + print(f'Unable to add a route using command "{route_add_cmd}": \ + {err}') + + def vici_initiate(conn, child_sa, src_addr, dest_addr): try: session = vici.Session() @@ -52,6 +97,7 @@ def vici_initiate(conn, child_sa, src_addr, dest_addr): except: return None + def vici_terminate(conn, child_sa, src_addr, dest_addr): try: session = vici.Session() @@ -69,25 +115,27 @@ def vici_terminate(conn, child_sa, src_addr, dest_addr): except: return None + def iface_up(interface): cmd(f'sudo ip route flush proto 42 dev {interface}') cmd(f'sudo ip neigh flush dev {interface}') + def peer_up(dmvpn_type, conn): - src_addr = os.getenv('NHRP_SRCADDR') + # src_addr = os.getenv('NHRP_SRCADDR') src_nbma = os.getenv('NHRP_SRCNBMA') - dest_addr = os.getenv('NHRP_DESTADDR') + # dest_addr = os.getenv('NHRP_DESTADDR') dest_nbma = os.getenv('NHRP_DESTNBMA') dest_mtu = os.getenv('NHRP_DESTMTU') if dest_mtu: - args = cmd(f'sudo ip route get {dest_nbma} from {src_nbma}') - cmd(f'sudo ip route add {args} proto 42 mtu {dest_mtu}') + add_peer_route(src_nbma, dest_nbma, dest_mtu) if conn and dmvpn_type == 'spoke' and process_named_running('charon'): vici_terminate(conn, 'dmvpn', src_nbma, dest_nbma) vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma) + def peer_down(dmvpn_type, conn): src_nbma = os.getenv('NHRP_SRCNBMA') dest_nbma = os.getenv('NHRP_DESTNBMA') @@ -97,14 +145,17 @@ def peer_down(dmvpn_type, conn): cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') + def route_up(interface): dest_addr = os.getenv('NHRP_DESTADDR') dest_prefix = os.getenv('NHRP_DESTPREFIX') next_hop = os.getenv('NHRP_NEXTHOP') - cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 via {next_hop} dev {interface}') + cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 \ + via {next_hop} dev {interface}') cmd('sudo ip route flush cache') + def route_down(interface): dest_addr = os.getenv('NHRP_DESTADDR') dest_prefix = os.getenv('NHRP_DESTPREFIX') @@ -112,6 +163,7 @@ def route_down(interface): cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42') cmd('sudo ip route flush cache') + if __name__ == '__main__': action = sys.argv[1] interface = os.getenv('NHRP_INTERFACE') diff --git a/src/etc/systemd/system/fastnetmon.service.d/override.conf b/src/etc/systemd/system/fastnetmon.service.d/override.conf new file mode 100644 index 000000000..841666070 --- /dev/null +++ b/src/etc/systemd/system/fastnetmon.service.d/override.conf @@ -0,0 +1,12 @@ +[Unit] +RequiresMountsFor=/run +ConditionPathExists=/run/fastnetmon/fastnetmon.conf +After= +After=vyos-router.service + +[Service] +Type=simple +WorkingDirectory=/run/fastnetmon +PIDFile=/run/fastnetmon.pid +ExecStart= +ExecStart=/usr/sbin/fastnetmon --configuration_file /run/fastnetmon/fastnetmon.conf diff --git a/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf index a895e675f..5cffb7987 100644 --- a/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf +++ b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf @@ -7,4 +7,5 @@ WorkingDirectory= WorkingDirectory=/run/wpa_supplicant PIDFile=/run/wpa_supplicant/%I.pid ExecStart= -ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dnl80211,wext -i%I +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dnl80211,wext -P/run/wpa_supplicant/%I.pid -i%I +ExecReload=/bin/kill -HUP $MAINPID diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py new file mode 100755 index 000000000..411aa06d1 --- /dev/null +++ b/src/op_mode/bridge.py @@ -0,0 +1,202 @@ +#!/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 typing + +from sys import exit +from tabulate import tabulate + +from vyos.util import cmd +from vyos.util import dict_search + +import vyos.opmode + + +def _get_json_data(): + """ + Get bridge data format JSON + """ + return cmd(f'sudo 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(): + """ + :returns dict + """ + json_data = cmd('sudo bridge --json --compressvlans vlan 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 + """ + json_data = cmd(f'sudo bridge --json fdb show br {bridge}') + 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 = ', '.join(vlan_entry.get('flags')).lower() + data_entries.append([interface, vlan, flags]) + + headers = ["Interface", "Vlan", "Flags"] + 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 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): + bridge_vlan = _get_raw_data_vlan() + if raw: + return bridge_vlan + else: + return _get_formatted_output_vlan(bridge_vlan) + + +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) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/show_conntrack.py b/src/op_mode/conntrack.py index 089a3e454..1441d110f 100755 --- a/src/op_mode/show_conntrack.py +++ b/src/op_mode/conntrack.py @@ -14,17 +14,21 @@ # 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 xmltodict from tabulate import tabulate from vyos.util import cmd +from vyos.util import run +import vyos.opmode -def _get_raw_data(): + +def _get_xml_data(family): """ Get conntrack XML output """ - return cmd(f'sudo conntrack --dump --output xml') + return cmd(f'sudo conntrack --dump --family {family} --output xml') def _xml_to_dict(xml): @@ -32,26 +36,34 @@ def _xml_to_dict(xml): Convert XML to dictionary Return: dictionary """ - parse = xmltodict.parse(xml) + 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_formatted_output(xml): +def _get_raw_data(family): + """ + Return: dictionary + """ + xml = _get_xml_data(family) + return _xml_to_dict(xml) + + +def get_formatted_output(dict_data): """ :param xml: :return: formatted output """ data_entries = [] - dict_data = _xml_to_dict(xml) + #dict_data = _get_raw_data(family) 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'] + direction = meta['direction'] if direction in ['original']: if 'layer3' in meta: orig_src = meta['layer3']['src'] @@ -61,7 +73,7 @@ def _get_formatted_output(xml): orig_sport = meta['layer4']['sport'] if meta.get('layer4').get('dport'): orig_dport = meta['layer4']['dport'] - proto = meta['layer4']['@protoname'] + proto = meta['layer4']['protoname'] if direction in ['reply']: if 'layer3' in meta: reply_src = meta['layer3']['src'] @@ -71,7 +83,7 @@ def _get_formatted_output(xml): reply_sport = meta['layer4']['sport'] if meta.get('layer4').get('dport'): reply_dport = meta['layer4']['dport'] - proto = meta['layer4']['@protoname'] + proto = meta['layer4']['protoname'] if direction == 'independent': conn_id = meta['id'] timeout = meta['timeout'] @@ -90,13 +102,20 @@ def _get_formatted_output(xml): return output -def show(raw: bool): - conntrack_data = _get_raw_data() +def show(raw: bool, family: str): + 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) + return get_formatted_output(conntrack_data) if __name__ == '__main__': - print(show(raw=False)) + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/container.py b/src/op_mode/container.py new file mode 100755 index 000000000..78d42f800 --- /dev/null +++ b/src/op_mode/container.py @@ -0,0 +1,85 @@ +#!/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 sys + +from sys import exit + +from vyos.util import 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 show_container(raw: bool): + command = 'sudo podman ps --all' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + + +def show_image(raw: bool): + command = 'sudo podman image ls' + container_data = _get_raw_data('sudo podman image ls') + if raw: + return container_data + else: + return cmd(command) + + +def show_network(raw: bool): + command = 'sudo podman network ls' + container_data = _get_raw_data(command) + if raw: + return container_data + else: + return cmd(command) + + +def restart(name: str): + from vyos.util import rc_cmd + + rc, output = rc_cmd(f'sudo podman restart {name}') + if rc != 0: + print(output) + return None + print(f'Container name "{name}" restarted!') + return output + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py new file mode 100755 index 000000000..717652b9b --- /dev/null +++ b/src/op_mode/dns.py @@ -0,0 +1,95 @@ +#!/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 + +from sys import exit +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery +from vyos.util import cmd + +import vyos.opmode + + +def _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_raw_forwarding_statistics() -> dict: + command = cmd('sudo /usr/bin/rec_control --socket-dir=/run/powerdns get-all') + data = _data_to_dict(command) + data['cache-size'] = "{0:.2f}".format( int( + cmd('sudo /usr/bin/rec_control --socket-dir=/run/powerdns get cache-bytes')) / 1024 ) + return data + + +def _get_formatted_forwarding_statistics(data): + cache_entries = data.get('cache-entries') + max_cache_entries = data.get('max-cache-entries') + cache_size = data.get('cache-size') + data_entries = [[cache_entries, max_cache_entries, f'{cache_size} kbytes']] + headers = ["Cache entries", "Max cache entries" , "Cache size"] + output = tabulate(data_entries, headers, numalign="left") + return output + + +def show_forwarding_statistics(raw: bool): + + config = ConfigTreeQuery() + if not config.exists('service dns forwarding'): + print("DNS forwarding is not configured") + exit(0) + + dns_data = _get_raw_forwarding_statistics() + if raw: + return dns_data + else: + return _get_formatted_forwarding_statistics(dns_data) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py new file mode 100755 index 000000000..432856585 --- /dev/null +++ b/src/op_mode/ipsec.py @@ -0,0 +1,71 @@ +#!/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 re +import sys +from vyos.util import call +import vyos.opmode + + +SWANCTL_CONF = '/etc/swanctl/swanctl.conf' + + +def get_peer_connections(peer, tunnel, return_all = False): + peer = peer.replace(':', '-') + 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 reset_peer(peer: str, tunnel:str): + if not peer: + print('Invalid peer, aborting') + return + + conns = get_peer_connections(peer, tunnel, return_all = (not tunnel or tunnel == 'all')) + + if not conns: + print('Tunnel(s) not found, aborting') + return + + result = True + for conn in conns: + try: + call(f'sudo /usr/sbin/ipsec down {conn}{{*}}', timeout = 10) + call(f'sudo /usr/sbin/ipsec up {conn}', timeout = 10) + except TimeoutExpired as e: + print(f'Timed out while resetting {conn}') + result = False + + + print('Peer reset result: ' + ('success' if result else 'failed')) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py new file mode 100755 index 000000000..a98fc4227 --- /dev/null +++ b/src/op_mode/nat.py @@ -0,0 +1,201 @@ +#!/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 + +from sys import exit +from tabulate import tabulate + +from vyos.util import cmd +from vyos.util import dict_search + +import vyos.opmode + + +def _get_json_data(direction): + """ + Get NAT format JSON + """ + if direction == 'source': + chain = 'POSTROUTING' + if direction == 'destination': + chain = 'PREROUTING' + return cmd(f'sudo nft --json list chain ip nat {chain}') + + +def _get_raw_data_rules(direction): + """Get interested rules + :returns dict + """ + data = _get_json_data(direction) + 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_formatted_output_rules(data, direction): + # Add default values before loop + sport, dport, proto = 'any', 'any', 'any' + saddr, daddr = '0.0.0.0/0', '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']: + if 'prefix' in match['right'] or 'set' in match['right']: + # Merge dict src/dst l3_l4 parameters + my_dict = {**match['left']['payload'], **match['right']} + proto = my_dict.get('protocol').upper() + if my_dict['field'] == 'saddr': + saddr = f'{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' + elif my_dict['field'] == 'daddr': + daddr = f'{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' + elif my_dict['field'] == 'sport': + # Port range or single port + if jmespath.search('set[*].range', my_dict): + sport = my_dict['set'][0]['range'] + sport = '-'.join(map(str, sport)) + else: + sport = my_dict.get('set') + sport = ','.join(map(str, sport)) + elif my_dict['field'] == 'dport': + # Port range or single port + if jmespath.search('set[*].range', my_dict): + dport = my_dict["set"][0]["range"] + dport = '-'.join(map(str, dport)) + else: + dport = my_dict.get('set') + dport = ','.join(map(str, dport)) + else: + if jmespath.search('left.payload.field', match) == 'saddr': + saddr = match.get('right') + if jmespath.search('left.payload.field', match) == 'daddr': + daddr = match.get('right') + else: + saddr = '0.0.0.0/0' + daddr = '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 show_rules(raw: bool, direction: str): + nat_rules = _get_raw_data_rules(direction) + if raw: + return nat_rules + else: + return _get_formatted_output_rules(nat_rules, direction) + + +def show_statistics(raw: bool, direction: str): + nat_statistics = _get_raw_data_rules(direction) + if raw: + return nat_statistics + else: + return _get_formatted_output_statistics(nat_statistics, direction) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/show_nat_rules.py b/src/op_mode/show_nat_rules.py deleted file mode 100755 index 60a4bdd13..000000000 --- a/src/op_mode/show_nat_rules.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/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 jmespath -import json - -from argparse import ArgumentParser -from jinja2 import Template -from sys import exit -from vyos.util import cmd -from vyos.util import dict_search - -parser = ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("--source", help="Show statistics for configured source NAT rules", action="store_true") -group.add_argument("--destination", help="Show statistics for configured destination NAT rules", action="store_true") -args = parser.parse_args() - -if args.source or args.destination: - tmp = cmd('sudo nft -j list table ip nat') - tmp = json.loads(tmp) - - format_nat_rule = '{0: <10} {1: <50} {2: <50} {3: <10}' - print(format_nat_rule.format("Rule", "Source" if args.source else "Destination", "Translation", "Outbound Interface" if args.source else "Inbound Interface")) - print(format_nat_rule.format("----", "------" if args.source else "-----------", "-----------", "------------------" if args.source else "-----------------")) - - data_json = jmespath.search('nftables[?rule].rule[?chain]', tmp) - for idx in range(0, len(data_json)): - data = data_json[idx] - - # The following key values must exist - # When the rule JSON does not have some keys, this is not a rule we can work with - continue_rule = False - for key in ['comment', 'chain', 'expr']: - if key not in data: - continue_rule = True - continue - if continue_rule: - continue - - comment = data['comment'] - - # Check the annotation to see if the annotation format is created by VYOS - continue_rule = True - for comment_prefix in ['SRC-NAT-', 'DST-NAT-']: - if comment_prefix in comment: - continue_rule = False - if continue_rule: - continue - - rule = int(''.join(list(filter(str.isdigit, comment)))) - chain = data['chain'] - if not ((args.source and chain == 'POSTROUTING') or (not args.source and chain == 'PREROUTING')): - continue - interface = dict_search('match.right', data['expr'][0]) - srcdest = '' - srcdests = [] - tran_addr = '' - for i in range(1,len(data['expr']) ): - srcdest_json = dict_search('match.right', data['expr'][i]) - if srcdest_json: - if isinstance(srcdest_json,str): - if srcdest != '': - srcdests.append(srcdest) - srcdest = '' - srcdest = srcdest_json + ' ' - elif 'prefix' in srcdest_json: - addr_tmp = dict_search('match.right.prefix.addr', data['expr'][i]) - len_tmp = dict_search('match.right.prefix.len', data['expr'][i]) - if addr_tmp and len_tmp: - srcdest = addr_tmp + '/' + str(len_tmp) + ' ' - elif 'set' in srcdest_json: - if isinstance(srcdest_json['set'][0],int): - srcdest += 'port ' + str(srcdest_json['set'][0]) + ' ' - else: - port_range = srcdest_json['set'][0]['range'] - srcdest += 'port ' + str(port_range[0]) + '-' + str(port_range[1]) + ' ' - - tran_addr_json = dict_search('snat' if args.source else 'dnat', data['expr'][i]) - if tran_addr_json: - if isinstance(tran_addr_json['addr'],str): - tran_addr += tran_addr_json['addr'] + ' ' - elif 'prefix' in tran_addr_json['addr']: - addr_tmp = dict_search('snat.addr.prefix.addr' if args.source else 'dnat.addr.prefix.addr', data['expr'][3]) - len_tmp = dict_search('snat.addr.prefix.len' if args.source else 'dnat.addr.prefix.len', data['expr'][3]) - if addr_tmp and len_tmp: - tran_addr += addr_tmp + '/' + str(len_tmp) + ' ' - - if tran_addr_json.get('port'): - if isinstance(tran_addr_json['port'],int): - tran_addr += 'port ' + str(tran_addr_json['port']) - - else: - if 'masquerade' in data['expr'][i]: - tran_addr = 'masquerade' - elif 'log' in data['expr'][i]: - continue - - if srcdest != '': - srcdests.append(srcdest) - srcdest = '' - else: - srcdests.append('any') - print(format_nat_rule.format(rule, srcdests[0], tran_addr, interface)) - - for i in range(1, len(srcdests)): - print(format_nat_rule.format(' ', srcdests[i], ' ', ' ')) - - exit(0) -else: - parser.print_help() - exit(1) - diff --git a/src/op_mode/show_vrf.py b/src/op_mode/show_vrf.py deleted file mode 100755 index 3c7a90205..000000000 --- a/src/op_mode/show_vrf.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -# 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/>. - -import argparse -import jinja2 -from json import loads - -from vyos.util import cmd - -vrf_out_tmpl = """VRF name state mac address flags interfaces --------- ----- ----------- ----- ---------- -{%- for v in vrf %} -{{"%-16s"|format(v.ifname)}} {{ "%-8s"|format(v.operstate | lower())}} {{"%-17s"|format(v.address | lower())}} {{ v.flags|join(',')|lower()}} {{v.members|join(',')|lower()}} -{%- endfor %} - -""" - -def list_vrfs(): - command = 'ip -j -br link show type vrf' - answer = loads(cmd(command)) - return [_ for _ in answer if _] - -def list_vrf_members(vrf): - command = f'ip -j -br link show master {vrf}' - answer = loads(cmd(command)) - return [_ for _ in answer if _] - -parser = argparse.ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("-e", "--extensive", action="store_true", - help="provide detailed vrf informatio") -parser.add_argument('interface', metavar='I', type=str, nargs='?', - help='interface to display') - -args = parser.parse_args() - -if args.extensive: - data = { 'vrf': [] } - for vrf in list_vrfs(): - name = vrf['ifname'] - if args.interface and name != args.interface: - continue - - vrf['members'] = [] - for member in list_vrf_members(name): - vrf['members'].append(member['ifname']) - data['vrf'].append(vrf) - - tmpl = jinja2.Template(vrf_out_tmpl) - print(tmpl.render(data)) - -else: - print(" ".join([vrf['ifname'] for vrf in list_vrfs()])) diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index 8955e5a59..68dc5bc45 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# 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 @@ -87,6 +87,7 @@ def reset_profile(profile, tunnel): print('Profile reset result: ' + ('success' if result == 0 else 'failed')) def debug_peer(peer, tunnel): + peer = peer.replace(':', '-') if not peer or peer == "all": debug_commands = [ "sudo ipsec statusall", @@ -109,7 +110,7 @@ def debug_peer(peer, tunnel): if not tunnel or tunnel == 'all': tunnel = '' - conn = get_peer_connections(peer, tunnel) + conns = get_peer_connections(peer, tunnel, return_all = (tunnel == '' or tunnel == 'all')) if not conns: print('Peer not found, aborting') diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py new file mode 100755 index 000000000..e3d944d90 --- /dev/null +++ b/src/op_mode/vrf.py @@ -0,0 +1,95 @@ +#!/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 jmespath +import sys +import typing + +from tabulate import tabulate +from vyos.util 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('sudo 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'sudo ip --json --brief link show dev {name}') + data = json.loads(output) + return data + return [] + return data + + +def _get_vrf_members(vrf: str) -> list: + """ + Get list of interface VRF members + :param vrf: str + :return: list + """ + output = cmd(f'sudo ip --json --brief link show master {vrf}') + answer = json.loads(output) + interfaces = [] + for data in answer: + if 'ifname' in data: + interfaces.append(data.get('ifname')) + return interfaces if len(interfaces) > 0 else ['n/a'] + + +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() + members = ','.join(_get_vrf_members(name)) + 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 as e: + print(e) + sys.exit(1) diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 84d719fda..049d59de7 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -17,11 +17,14 @@ import vyos.defaults from . graphql.queries import query from . graphql.mutations import mutation from . graphql.directives import directives_dict +from . utils.schema_from_op_mode import generate_op_mode_definitions from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers def generate_schema(): api_schema_dir = vyos.defaults.directories['api_schema'] + generate_op_mode_definitions() + type_defs = load_schema_from_path(api_schema_dir) schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict) diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 0a9298f55..d8ceefae6 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -48,6 +48,14 @@ class ShowConfigDirective(VyosDirective): super().visit_field_definition(field, object_type, make_resolver=make_show_config_resolver) +class SystemStatusDirective(VyosDirective): + """ + Class providing implementation of 'system_status' directive in schema. + """ + def visit_field_definition(self, field, object_type): + super().visit_field_definition(field, object_type, + make_resolver=make_system_status_resolver) + class ConfigFileDirective(VyosDirective): """ Class providing implementation of 'configfile' directive in schema. @@ -72,8 +80,27 @@ class ImageDirective(VyosDirective): super().visit_field_definition(field, object_type, make_resolver=make_image_resolver) +class GenOpQueryDirective(VyosDirective): + """ + Class providing implementation of 'genopquery' directive in schema. + """ + def visit_field_definition(self, field, object_type): + super().visit_field_definition(field, object_type, + make_resolver=make_gen_op_query_resolver) + +class GenOpMutationDirective(VyosDirective): + """ + Class providing implementation of 'genopmutation' directive in schema. + """ + def visit_field_definition(self, field, object_type): + super().visit_field_definition(field, object_type, + make_resolver=make_gen_op_mutation_resolver) + directives_dict = {"configure": ConfigureDirective, "showconfig": ShowConfigDirective, + "systemstatus": SystemStatusDirective, "configfile": ConfigFileDirective, "show": ShowDirective, - "image": ImageDirective} + "image": ImageDirective, + "genopquery": GenOpQueryDirective, + "genopmutation": GenOpMutationDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 0c3eb702a..3e89fb239 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -20,6 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state +from .. import key_auth from api.graphql.recipes.session import Session mutation = ObjectType("Mutation") @@ -53,6 +54,19 @@ def make_mutation_resolver(mutation_name, class_name, session_func): } data = kwargs['data'] + key = data['key'] + + auth = key_auth.auth_required(key) + if auth is None: + return { + "success": False, + "errors": ['invalid API key'] + } + + # We are finished with the 'key' entry, and may remove so as to + # pass the rest of data (if any) to function. + del data['key'] + session = state.settings['app'].state.vyos_session # one may override the session functions with a local subclass @@ -61,7 +75,7 @@ def make_mutation_resolver(mutation_name, class_name, session_func): klass = getattr(mod, class_name) except ImportError: # otherwise, dynamically generate subclass to invoke subclass - # name based templates + # name based functions klass = type(class_name, (Session,), {}) k = klass(session, data) method = getattr(k, session_func) @@ -97,3 +111,7 @@ def make_config_file_resolver(mutation_name): def make_image_resolver(mutation_name): return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) + +def make_gen_op_mutation_resolver(mutation_name): + class_name = mutation_name + return make_mutation_resolver(mutation_name, class_name, 'gen_op_mutation') diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index e1868091e..f6544709e 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -20,6 +20,7 @@ from graphql import GraphQLResolveInfo from makefun import with_signature from .. import state +from .. import key_auth from api.graphql.recipes.session import Session query = ObjectType("Query") @@ -53,6 +54,19 @@ def make_query_resolver(query_name, class_name, session_func): } data = kwargs['data'] + key = data['key'] + + auth = key_auth.auth_required(key) + if auth is None: + return { + "success": False, + "errors": ['invalid API key'] + } + + # We are finished with the 'key' entry, and may remove so as to + # pass the rest of data (if any) to function. + del data['key'] + session = state.settings['app'].state.vyos_session # one may override the session functions with a local subclass @@ -61,7 +75,7 @@ def make_query_resolver(query_name, class_name, session_func): klass = getattr(mod, class_name) except ImportError: # otherwise, dynamically generate subclass to invoke subclass - # name based templates + # name based functions klass = type(class_name, (Session,), {}) k = klass(session, data) method = getattr(k, session_func) @@ -84,6 +98,14 @@ def make_show_config_resolver(query_name): class_name = query_name return make_query_resolver(query_name, class_name, 'show_config') +def make_system_status_resolver(query_name): + class_name = query_name + return make_query_resolver(query_name, class_name, 'system_status') + def make_show_resolver(query_name): class_name = query_name return make_query_resolver(query_name, class_name, 'show') + +def make_gen_op_query_resolver(query_name): + class_name = query_name + return make_query_resolver(query_name, class_name, 'gen_op_query') diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql index 31ab26b9e..a7263114b 100644 --- a/src/services/api/graphql/graphql/schema/config_file.graphql +++ b/src/services/api/graphql/graphql/schema/config_file.graphql @@ -1,4 +1,5 @@ input SaveConfigFileInput { + key: String! fileName: String } @@ -13,6 +14,7 @@ type SaveConfigFileResult { } input LoadConfigFileInput { + key: String! fileName: String! } diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql index 25f091bfa..345c349ac 100644 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -1,4 +1,5 @@ input DhcpServerConfigInput { + key: String! sharedNetworkName: String subnet: String defaultRouter: String diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql index d89904b9e..9454d2997 100644 --- a/src/services/api/graphql/graphql/schema/firewall_group.graphql +++ b/src/services/api/graphql/graphql/schema/firewall_group.graphql @@ -1,4 +1,5 @@ input CreateFirewallAddressGroupInput { + key: String! name: String! address: [String] } @@ -15,6 +16,7 @@ type CreateFirewallAddressGroupResult { } input UpdateFirewallAddressGroupMembersInput { + key: String! name: String! address: [String!]! } @@ -31,6 +33,7 @@ type UpdateFirewallAddressGroupMembersResult { } input RemoveFirewallAddressGroupMembersInput { + key: String! name: String! address: [String!]! } @@ -47,6 +50,7 @@ type RemoveFirewallAddressGroupMembersResult { } input CreateFirewallAddressIpv6GroupInput { + key: String! name: String! address: [String] } @@ -63,6 +67,7 @@ type CreateFirewallAddressIpv6GroupResult { } input UpdateFirewallAddressIpv6GroupMembersInput { + key: String! name: String! address: [String!]! } @@ -79,6 +84,7 @@ type UpdateFirewallAddressIpv6GroupMembersResult { } input RemoveFirewallAddressIpv6GroupMembersInput { + key: String! name: String! address: [String!]! } diff --git a/src/services/api/graphql/graphql/schema/image.graphql b/src/services/api/graphql/graphql/schema/image.graphql index 7d1b4f9d0..485033875 100644 --- a/src/services/api/graphql/graphql/schema/image.graphql +++ b/src/services/api/graphql/graphql/schema/image.graphql @@ -1,4 +1,5 @@ input AddSystemImageInput { + key: String! location: String! } @@ -14,6 +15,7 @@ type AddSystemImageResult { } input DeleteSystemImageInput { + key: String! name: String! } diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql index 32438b315..8a17d919f 100644 --- a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql +++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql @@ -1,4 +1,5 @@ input InterfaceEthernetConfigInput { + key: String! interface: String address: String replace: Boolean = true diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 952e46f34..624be2620 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -7,11 +7,17 @@ directive @configure on FIELD_DEFINITION directive @configfile on FIELD_DEFINITION directive @show on FIELD_DEFINITION directive @showconfig on FIELD_DEFINITION +directive @systemstatus on FIELD_DEFINITION directive @image on FIELD_DEFINITION +directive @genopquery on FIELD_DEFINITION +directive @genopmutation on FIELD_DEFINITION + +scalar Generic type Query { Show(data: ShowInput) : ShowResult @show ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig + SystemStatus(data: SystemStatusInput) : SystemStatusResult @systemstatus } type Mutation { diff --git a/src/services/api/graphql/graphql/schema/show.graphql b/src/services/api/graphql/graphql/schema/show.graphql index c7709e48b..278ed536b 100644 --- a/src/services/api/graphql/graphql/schema/show.graphql +++ b/src/services/api/graphql/graphql/schema/show.graphql @@ -1,4 +1,5 @@ input ShowInput { + key: String! path: [String!]! } diff --git a/src/services/api/graphql/graphql/schema/show_config.graphql b/src/services/api/graphql/graphql/schema/show_config.graphql index 34afd2aa9..5a1fe43da 100644 --- a/src/services/api/graphql/graphql/schema/show_config.graphql +++ b/src/services/api/graphql/graphql/schema/show_config.graphql @@ -2,9 +2,9 @@ Use 'scalar Generic' for show config output, to avoid attempts to JSON-serialize in case of JSON output. """ -scalar Generic input ShowConfigInput { + key: String! path: [String!]! configFormat: String } diff --git a/src/services/api/graphql/graphql/schema/system_status.graphql b/src/services/api/graphql/graphql/schema/system_status.graphql new file mode 100644 index 000000000..be8d87535 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/system_status.graphql @@ -0,0 +1,18 @@ +""" +Use 'scalar Generic' for system status output, to avoid attempts to +JSON-serialize in case of JSON output. +""" + +input SystemStatusInput { + key: String! +} + +type SystemStatus { + result: Generic +} + +type SystemStatusResult { + data: SystemStatus + success: Boolean! + errors: [String] +} diff --git a/src/services/api/graphql/key_auth.py b/src/services/api/graphql/key_auth.py new file mode 100644 index 000000000..f756ed6d8 --- /dev/null +++ b/src/services/api/graphql/key_auth.py @@ -0,0 +1,18 @@ + +from . import state + +def check_auth(key_list, key): + if not key_list: + return None + key_id = None + for k in key_list: + if k['key'] == key: + key_id = k['id'] + return key_id + +def auth_required(key): + api_keys = None + api_keys = state.settings['app'].state.vyos_keys + key_id = check_auth(api_keys, key) + state.settings['app'].state.vyos_id = key_id + return key_id diff --git a/src/services/api/graphql/recipes/queries/system_status.py b/src/services/api/graphql/recipes/queries/system_status.py new file mode 100755 index 000000000..8dadcc9f3 --- /dev/null +++ b/src/services/api/graphql/recipes/queries/system_status.py @@ -0,0 +1,38 @@ +#!/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 os +import sys +import json +import importlib.util + +from vyos.defaults import directories + +from api.graphql.utils.util import load_op_mode_as_module + +def get_system_version() -> dict: + show_version = load_op_mode_as_module('version.py') + return show_version.show(raw=True, funny=False) + +def get_system_uptime() -> dict: + show_uptime = load_op_mode_as_module('show_uptime.py') + return show_uptime.get_raw_data() + +def get_system_ram_usage() -> dict: + show_ram = load_op_mode_as_module('memory.py') + return show_ram.show(raw=True) diff --git a/src/services/api/graphql/recipes/session.py b/src/services/api/graphql/recipes/session.py index 1f844ff70..ac185beb7 100644 --- a/src/services/api/graphql/recipes/session.py +++ b/src/services/api/graphql/recipes/session.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -13,15 +13,20 @@ # You should have received a copy of the GNU Lesser General Public License # along with this library. If not, see <http://www.gnu.org/licenses/>. +import os import json from ariadne import convert_camel_case_to_snake -import vyos.defaults from vyos.config import Config from vyos.configtree import ConfigTree +from vyos.defaults import directories from vyos.template import render +from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name + +op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') + class Session: """ Wrapper for calling configsession functions based on GraphQL requests. @@ -33,6 +38,12 @@ class Session: self._data = data self._name = convert_camel_case_to_snake(type(self).__name__) + try: + with open(op_mode_include_file) as f: + self._op_mode_list = json.loads(f.read()) + except Exception: + self._op_mode_list = None + def configure(self): session = self._session data = self._data @@ -40,7 +51,7 @@ class Session: tmpl_file = f'{func_base_name}.tmpl' cmd_file = f'/tmp/{func_base_name}.cmds' - tmpl_dir = vyos.defaults.directories['api_templates'] + tmpl_dir = directories['api_templates'] try: render(cmd_file, tmpl_file, data, location=tmpl_dir) @@ -136,3 +147,61 @@ class Session: raise error return res + + def system_status(self): + import api.graphql.recipes.queries.system_status as system_status + + session = self._session + data = self._data + + status = {} + status['host_name'] = session.show(['host', 'name']).strip() + status['version'] = system_status.get_system_version() + status['uptime'] = system_status.get_system_uptime() + status['ram'] = system_status.get_system_ram_usage() + + return status + + def gen_op_query(self): + session = self._session + data = self._data + name = self._name + op_mode_list = self._op_mode_list + + # handle the case that the op-mode file contains underscores: + if op_mode_list is None: + raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") + (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list) + if scriptname == '': + raise FileNotFoundError(f"No op-mode file named in string '{name}'") + + mod = load_op_mode_as_module(f'{scriptname}') + func = getattr(mod, func_name) + if len(list(data)) > 0: + res = func(True, **data) + else: + res = func(True) + + return res + + def gen_op_mutation(self): + session = self._session + data = self._data + name = self._name + op_mode_list = self._op_mode_list + + # handle the case that the op-mode file name contains underscores: + if op_mode_list is None: + raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") + (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list) + if scriptname == '': + raise FileNotFoundError(f"No op-mode file named in string '{name}'") + + mod = load_op_mode_as_module(f'{scriptname}') + func = getattr(mod, func_name) + if len(list(data)) > 0: + res = func(**data) + else: + res = func() + + return res diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/utils/schema_from_op_mode.py new file mode 100755 index 000000000..d27586747 --- /dev/null +++ b/src/services/api/graphql/utils/schema_from_op_mode.py @@ -0,0 +1,161 @@ +#!/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/>. +# +# +# A utility to generate GraphQL schema defintions from standardized op-mode +# scripts. + +import os +import json +import typing +from inspect import signature, getmembers, isfunction +from jinja2 import Template + +from vyos.defaults import directories +from . util import load_as_module, is_op_mode_function_name, is_show_function_name + +OP_MODE_PATH = directories['op_mode'] +SCHEMA_PATH = directories['api_schema'] +DATA_DIR = directories['data'] + +op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json') + +schema_data: dict = {'schema_name': '', + 'schema_fields': []} + +query_template = """ +input {{ schema_name }}Input { + key: String! + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} + +type {{ schema_name }} { + result: Generic +} + +type {{ schema_name }}Result { + data: {{ schema_name }} + success: Boolean! + errors: [String] +} + +extend type Query { + {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopquery +} +""" + +mutation_template = """ +input {{ schema_name }}Input { + key: String! + {%- for field_entry in schema_fields %} + {{ field_entry }} + {%- endfor %} +} + +type {{ schema_name }} { + result: Generic +} + +type {{ schema_name }}Result { + data: {{ schema_name }} + success: Boolean! + errors: [String] +} + +extend type Mutation { + {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +} +""" + +def _snake_to_pascal_case(name: str) -> str: + res = ''.join(map(str.title, name.split('_'))) + return res + +def _map_type_name(type_name: type, optional: bool = False) -> str: + if type_name == str: + return 'String!' if not optional else 'String = null' + if type_name == int: + return 'Int!' if not optional else 'Int = null' + if type_name == bool: + return 'Boolean!' if not optional else 'Boolean = false' + if typing.get_origin(type_name) == list: + if not optional: + return f'[{_map_type_name(typing.get_args(type_name)[0])}]!' + return f'[{_map_type_name(typing.get_args(type_name)[0])}]' + # typing.Optional is typing.Union[_, NoneType] + if (typing.get_origin(type_name) is typing.Union and + typing.get_args(type_name)[1] == type(None)): + return f'{_map_type_name(typing.get_args(type_name)[0], optional=True)}' + + # scalar 'Generic' is defined in schema.graphql + return 'Generic' + +def create_schema(func_name: str, base_name: str, func: callable) -> str: + sig = signature(func) + + field_dict = {} + for k in sig.parameters: + field_dict[sig.parameters[k].name] = _map_type_name(sig.parameters[k].annotation) + + # It is assumed that if one is generating a schema for a 'show_*' + # function, that 'get_raw_data' is present and 'raw' is desired. + if 'raw' in list(field_dict): + del field_dict['raw'] + + schema_fields = [] + for k,v in field_dict.items(): + schema_fields.append(k+': '+v) + + schema_data['schema_name'] = _snake_to_pascal_case(func_name + '_' + base_name) + schema_data['schema_fields'] = schema_fields + + if is_show_function_name(func_name): + j2_template = Template(query_template) + else: + j2_template = Template(mutation_template) + + res = j2_template.render(schema_data) + + return res + +def generate_op_mode_definitions(): + with open(op_mode_include_file) as f: + op_mode_files = json.load(f) + + for file in op_mode_files: + basename = os.path.splitext(file)[0].replace('-', '_') + module = load_as_module(basename, os.path.join(OP_MODE_PATH, file)) + + funcs = getmembers(module, isfunction) + funcs = list(filter(lambda ft: is_op_mode_function_name(ft[0]), funcs)) + + funcs_dict = {} + for (name, thunk) in funcs: + funcs_dict[name] = thunk + + results = [] + for name,func in funcs_dict.items(): + res = create_schema(name, basename, func) + results.append(res) + + out = '\n'.join(results) + with open(f'{SCHEMA_PATH}/{basename}.graphql', 'w') as f: + f.write(out) + +if __name__ == '__main__': + generate_op_mode_definitions() diff --git a/src/services/api/graphql/utils/util.py b/src/services/api/graphql/utils/util.py new file mode 100644 index 000000000..073126853 --- /dev/null +++ b/src/services/api/graphql/utils/util.py @@ -0,0 +1,76 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import importlib.util + +from vyos.defaults import directories + +def load_as_module(name: str, path: str): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +def load_op_mode_as_module(name: str): + path = os.path.join(directories['op_mode'], name) + name = os.path.splitext(name)[0].replace('-', '_') + return load_as_module(name, path) + +def is_op_mode_function_name(name): + if re.match(r"^(show|clear|reset|restart)", name): + return True + return False + +def is_show_function_name(name): + if re.match(r"^show", name): + return True + return False + +def _nth_split(delim: str, n: int, s: str): + groups = s.split(delim) + l = len(groups) + if n > l-1 or n < 1: + return (s, '') + return (delim.join(groups[:n]), delim.join(groups[n:])) + +def _nth_rsplit(delim: str, n: int, s: str): + groups = s.split(delim) + l = len(groups) + if n > l-1 or n < 1: + return (s, '') + return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) + +# Since we have mangled possible hyphens in the file name while constructing +# the snake case of the query/mutation name, we will need to recover the +# file name by searching with mangling: +def _filter_on_mangled(test): + def func(elem): + mangle = os.path.splitext(elem)[0].replace('-', '_') + return test == mangle + return func + +# Find longest name in concatenated string that matches the basename of an +# op-mode script. Should one prefer to concatenate in the reverse order +# (script_name + '_' + function_name), use _nth_rsplit. +def split_compound_op_mode_name(name: str, files: list): + for i in range(1, name.count('_') + 1): + pair = _nth_split('_', i, name) + f = list(filter(_filter_on_mangled(pair[1]), files)) + if f: + pair = (pair[0], f[0]) + return pair + return (name, '') diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index e9b904ba8..af8837e1e 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -654,11 +654,13 @@ def graphql_init(fast_api_app): schema = generate_schema() + in_spec = app.state.vyos_introspection + if app.state.vyos_origins: origins = app.state.vyos_origins - app.add_route('/graphql', CORSMiddleware(GraphQL(schema, debug=True), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) + app.add_route('/graphql', CORSMiddleware(GraphQL(schema, debug=True, introspection=in_spec), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) else: - app.add_route('/graphql', GraphQL(schema, debug=True)) + app.add_route('/graphql', GraphQL(schema, debug=True, introspection=in_spec)) ### @@ -684,6 +686,7 @@ if __name__ == '__main__': app.state.vyos_debug = server_config['debug'] app.state.vyos_gql = server_config['gql'] + app.state.vyos_introspection = server_config['introspection'] app.state.vyos_strict = server_config['strict'] app.state.vyos_origins = server_config.get('cors', {}).get('origins', []) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 7e0bee8e1..ffb4fe32c 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -1,17 +1,18 @@ [Unit] -Description=WPA supplicant daemon (macsec-specific version) +Description=WPA supplicant daemon (MACsec-specific version) Requires=sys-subsystem-net-devices-%i.device ConditionPathExists=/run/wpa_supplicant/%I.conf After=vyos-router.service RequiresMountsFor=/run -# NetworkManager users will probably want the dbus version instead. - [Service] Type=simple WorkingDirectory=/run/wpa_supplicant PIDFile=/run/wpa_supplicant/%I.pid -ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -i%I +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=2 [Install] WantedBy=multi-user.target diff --git a/src/validators/ipv6-address-exclude b/src/validators/ipv6-address-exclude new file mode 100755 index 000000000..be1d3db25 --- /dev/null +++ b/src/validators/ipv6-address-exclude @@ -0,0 +1,7 @@ +#!/bin/sh +arg="$1" +if [ "${arg:0:1}" != "!" ]; then + exit 1 +fi +path=$(dirname "$0") +${path}/ipv6-address "${arg:1}" diff --git a/src/validators/ipv6-prefix-exclude b/src/validators/ipv6-prefix-exclude new file mode 100755 index 000000000..6fa4f1d8d --- /dev/null +++ b/src/validators/ipv6-prefix-exclude @@ -0,0 +1,7 @@ +#!/bin/sh +arg="$1" +if [ "${arg:0:1}" != "!" ]; then + exit 1 +fi +path=$(dirname "$0") +${path}/ipv6-prefix "${arg:1}" |