diff options
-rw-r--r-- | data/op-mode-standardized.json | 1 | ||||
-rw-r--r-- | data/templates/ipsec/swanctl/peer.j2 | 2 | ||||
-rw-r--r-- | data/templates/snmp/etc.snmpd.conf.j2 | 2 | ||||
-rw-r--r-- | interface-definitions/container.xml.in | 4 | ||||
-rw-r--r-- | op-mode-definitions/vpn-ipsec.xml.in | 6 | ||||
-rw-r--r-- | python/vyos/ifconfig/macvlan.py | 9 | ||||
-rwxr-xr-x | src/op_mode/dns.py | 4 | ||||
-rwxr-xr-x | src/op_mode/ipsec.py | 299 | ||||
-rwxr-xr-x | src/op_mode/route.py | 12 | ||||
-rwxr-xr-x | src/services/api/graphql/generate/schema_from_op_mode.py | 5 | ||||
-rw-r--r-- | src/services/api/graphql/graphql/mutations.py | 22 | ||||
-rw-r--r-- | src/services/api/graphql/graphql/queries.py | 22 | ||||
-rw-r--r-- | src/services/api/graphql/libs/op_mode.py | 7 | ||||
-rw-r--r-- | src/services/api/graphql/libs/token_auth.py | 3 |
14 files changed, 356 insertions, 42 deletions
diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json index b162f4097..1509975b4 100644 --- a/data/op-mode-standardized.json +++ b/data/op-mode-standardized.json @@ -6,6 +6,7 @@ "container.py", "cpu.py", "dhcp.py", +"dns.py", "log.py", "memory.py", "nat.py", diff --git a/data/templates/ipsec/swanctl/peer.j2 b/data/templates/ipsec/swanctl/peer.j2 index d097a04fc..837fa263c 100644 --- a/data/templates/ipsec/swanctl/peer.j2 +++ b/data/templates/ipsec/swanctl/peer.j2 @@ -124,7 +124,7 @@ {% endif %} {% elif tunnel_esp.mode == 'transport' %} local_ts = {{ peer_conf.local_address }}{{ local_suffix }} - remote_ts = {{ peer }}{{ remote_suffix }} + remote_ts = {{ peer_conf.remote_address | join(",") }}{{ remote_suffix }} {% endif %} ipcomp = {{ 'yes' if tunnel_esp.compression is vyos_defined else 'no' }} mode = {{ tunnel_esp.mode }} diff --git a/data/templates/snmp/etc.snmpd.conf.j2 b/data/templates/snmp/etc.snmpd.conf.j2 index d7dc0ba5d..57ad704c0 100644 --- a/data/templates/snmp/etc.snmpd.conf.j2 +++ b/data/templates/snmp/etc.snmpd.conf.j2 @@ -69,7 +69,7 @@ agentaddress unix:/run/snmpd.socket{{ ',' ~ options | join(',') if options is vy {% for network in comm_config.network %} {% if network | is_ipv4 %} {{ comm_config.authorization }}community {{ comm }} {{ network }} -{% elif client | is_ipv6 %} +{% elif network | is_ipv6 %} {{ comm_config.authorization }}community6 {{ comm }} {{ network }} {% endif %} {% endfor %} diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index f84c94a40..d50039665 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -272,6 +272,10 @@ <tagNode name="network"> <properties> <help>Network name</help> + <constraint> + <regex>[-_a-zA-Z0-9]{1,11}</regex> + </constraint> + <constraintErrorMessage>Network name cannot be longer than 11 characters</constraintErrorMessage> </properties> <children> <leafNode name="description"> diff --git a/op-mode-definitions/vpn-ipsec.xml.in b/op-mode-definitions/vpn-ipsec.xml.in index f1af65fcb..803ce4cc2 100644 --- a/op-mode-definitions/vpn-ipsec.xml.in +++ b/op-mode-definitions/vpn-ipsec.xml.in @@ -137,6 +137,12 @@ <help>Show Internet Protocol Security (IPsec) information</help> </properties> <children> + <node name="connections"> + <properties> + <help>Show VPN connections</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/ipsec.py show_connections</command> + </node> <node name="policy"> <properties> <help>Show the in-kernel crypto policies</help> diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index 776014bc3..2266879ec 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-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 @@ -30,10 +30,17 @@ class MACVLANIf(Interface): } def _create(self): + """ + Create MACvlan interface in OS kernel. Interface is administrative + down by default. + """ # please do not change the order when assembling the command cmd = 'ip link add {ifname} link {source_interface} type {type} mode {mode}' self._cmd(cmd.format(**self.config)) + # interface is always A/D down. It needs to be enabled explicitly + self.set_admin_state('down') + def set_mode(self, mode): ifname = self.config['ifname'] cmd = f'ip link set dev {ifname} type macvlan mode {mode}' diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py index 9e5b1040c..a0e47d7ad 100755 --- a/src/op_mode/dns.py +++ b/src/op_mode/dns.py @@ -54,10 +54,10 @@ def _data_to_dict(data, sep="\t") -> dict: def _get_raw_forwarding_statistics() -> dict: - command = cmd('sudo /usr/bin/rec_control --socket-dir=/run/powerdns get-all') + command = cmd('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 ) + cmd('rec_control --socket-dir=/run/powerdns get cache-bytes')) / 1024 ) return data diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 83e4241d7..e0d204a0a 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -17,11 +17,13 @@ import os import re import sys +import typing from collections import OrderedDict from hurry import filesize from re import split as re_split from tabulate import tabulate +from subprocess import TimeoutExpired from vyos.util import call from vyos.util import convert_data @@ -136,23 +138,293 @@ def _get_formatted_output_sas(sas): return output -def get_peer_connections(peer, tunnel, return_all = False): +# Connections block +def _get_vici_connections(): + from vici import Session as vici_session + + try: + session = vici_session() + except Exception: + raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized") + connections = list(session.list_conns()) + return connections + + +def _get_convert_data_connections(): + get_connections = _get_vici_connections() + connections = convert_data(get_connections) + return connections + + +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(): + return {} + 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 + """ + if not data: + return 'down' + for sa in data: + # check if parent SA exist + if connection_name not in sa.keys(): + return 'down' + if sa[connection_name]['state'].lower() == 'established': + return 'up' + else: + return 'down' + + +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` + """ + if not data: + return 'down' + for sa in data: + # check if parent SA exist + if connection_name not in sa.keys(): + return 'down' + 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 'down' + + +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(): + return {} + 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 + data_entries = '' + connections = [] + for entry in data: + tunnels = [] + 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_peer_connections(peer, tunnel): search = rf'^[\s]*({peer}-(tunnel-[\d]+|vti)).*' matches = [] if not os.path.exists(SWANCTL_CONF): raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized") + suffix = None if tunnel is None else (f'tunnel-{tunnel}' if + tunnel.isnumeric() else tunnel) 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): + if tunnel is None: matches.append(result[1]) + else: + if result[2] == suffix: + matches.append(result[1]) return matches -def reset_peer(peer: str, tunnel:str): - conns = get_peer_connections(peer, tunnel, return_all = (not tunnel or tunnel == 'all')) +def reset_peer(peer: str, tunnel:typing.Optional[str]): + conns = get_peer_connections(peer, tunnel) if not conns: raise vyos.opmode.IncorrectValue('Peer or tunnel(s) not found, aborting') @@ -174,6 +446,23 @@ def show_sa(raw: bool): return _get_formatted_output_sas(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) + + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/route.py b/src/op_mode/route.py index d11b00ba0..d07a34180 100755 --- a/src/op_mode/route.py +++ b/src/op_mode/route.py @@ -54,6 +54,18 @@ frr_command_template = Template(""" {% endif %} """) +def show_summary(raw: bool): + from vyos.util import cmd + + if raw: + from json import loads + + output = cmd(f"vtysh -c 'show ip route summary json'") + return loads(output) + else: + output = cmd(f"vtysh -c 'show ip route summary'") + return output + def show(raw: bool, family: str, net: typing.Optional[str], diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py index 1fd198a37..fc63b0100 100755 --- a/src/services/api/graphql/generate/schema_from_op_mode.py +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -25,15 +25,16 @@ from inspect import signature, getmembers, isfunction, isclass, getmro from jinja2 import Template from vyos.defaults import directories +from vyos.util import load_as_module if __package__ is None or __package__ == '': sys.path.append("/usr/libexec/vyos/services/api") - from graphql.libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from graphql.libs.op_mode import is_op_mode_function_name, is_show_function_name from graphql.libs.op_mode import snake_to_pascal_case, map_type_name from vyos.config import Config from vyos.configdict import dict_merge from vyos.xml import defaults else: - from .. libs.op_mode import load_as_module, is_op_mode_function_name, is_show_function_name + from .. libs.op_mode import is_op_mode_function_name, is_show_function_name from .. libs.op_mode import snake_to_pascal_case, map_type_name from .. import state diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 2778feb69..87ea59c43 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -14,7 +14,7 @@ # along with this library. If not, see <http://www.gnu.org/licenses/>. from importlib import import_module -from typing import Any, Dict +from typing import Any, Dict, Optional from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake from graphql import GraphQLResolveInfo from makefun import with_signature @@ -42,7 +42,7 @@ def make_mutation_resolver(mutation_name, class_name, session_func): func_base_name = convert_camel_case_to_snake(class_name) resolver_name = f'resolve_{func_base_name}' - func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict = {})' + func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)' @mutation.field(mutation_name) @convert_kwargs_to_snake_case @@ -67,20 +67,18 @@ def make_mutation_resolver(mutation_name, class_name, session_func): del data['key'] elif auth_type == 'token': - # there is a subtlety here: with the removal of the key entry, - # some requests will now have empty input, hence no data arg, so - # make it optional in the func_sig. However, it can not be None, - # as the makefun package provides accurate TypeError exceptions; - # hence set it to {}, but now it is a mutable default argument, - # so clear the key 'result', which is added at the end of - # this function. data = kwargs['data'] - if 'result' in data: - del data['result'] - + if data is None: + data = {} info = kwargs['info'] user = info.context.get('user') if user is None: + error = info.context.get('error') + if error is not None: + return { + "success": False, + "errors": [error] + } return { "success": False, "errors": ['not authenticated'] diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 9c8a4f064..1ad586428 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -14,7 +14,7 @@ # along with this library. If not, see <http://www.gnu.org/licenses/>. from importlib import import_module -from typing import Any, Dict +from typing import Any, Dict, Optional from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake from graphql import GraphQLResolveInfo from makefun import with_signature @@ -42,7 +42,7 @@ def make_query_resolver(query_name, class_name, session_func): func_base_name = convert_camel_case_to_snake(class_name) resolver_name = f'resolve_{func_base_name}' - func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict = {})' + func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)' @query.field(query_name) @convert_kwargs_to_snake_case @@ -67,20 +67,18 @@ def make_query_resolver(query_name, class_name, session_func): del data['key'] elif auth_type == 'token': - # there is a subtlety here: with the removal of the key entry, - # some requests will now have empty input, hence no data arg, so - # make it optional in the func_sig. However, it can not be None, - # as the makefun package provides accurate TypeError exceptions; - # hence set it to {}, but now it is a mutable default argument, - # so clear the key 'result', which is added at the end of - # this function. data = kwargs['data'] - if 'result' in data: - del data['result'] - + if data is None: + data = {} info = kwargs['info'] user = info.context.get('user') if user is None: + error = info.context.get('error') + if error is not None: + return { + "success": False, + "errors": [error] + } return { "success": False, "errors": ['not authenticated'] diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py index 97a26520e..6939ed5d6 100644 --- a/src/services/api/graphql/libs/op_mode.py +++ b/src/services/api/graphql/libs/op_mode.py @@ -21,14 +21,9 @@ from typing import Union from humps import decamelize from vyos.defaults import directories +from vyos.util import load_as_module from vyos.opmode import _normalize_field_names -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('-', '_') diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py index 3ecd8b855..2100eba7f 100644 --- a/src/services/api/graphql/libs/token_auth.py +++ b/src/services/api/graphql/libs/token_auth.py @@ -54,6 +54,9 @@ def get_user_context(request): user_id: str = payload.get('sub') if user_id is None: return context + except jwt.exceptions.ExpiredSignatureError: + context['error'] = 'expired token' + return context except jwt.PyJWTError: return context try: |