diff options
Diffstat (limited to 'src')
| -rwxr-xr-x | src/conf_mode/firewall.py | 30 | ||||
| -rwxr-xr-x | src/conf_mode/high-availability.py | 21 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces-pseudo-ethernet.py | 28 | ||||
| -rwxr-xr-x | src/conf_mode/nat.py | 24 | ||||
| -rwxr-xr-x | src/conf_mode/system_console.py | 27 | ||||
| -rwxr-xr-x | src/etc/opennhrp/opennhrp-script.py | 15 | ||||
| -rw-r--r-- | src/services/api/graphql/bindings.py | 3 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/errors.py | 8 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/mutations.py | 13 | ||||
| -rw-r--r-- | src/services/api/graphql/graphql/queries.py | 13 | ||||
| -rw-r--r-- | src/services/api/graphql/session/errors/op_mode_errors.py | 13 | ||||
| -rw-r--r-- | src/services/api/graphql/session/session.py | 13 | ||||
| -rwxr-xr-x | src/services/api/graphql/utils/schema_from_op_mode.py | 43 | 
13 files changed, 199 insertions, 52 deletions
| diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 07eca722f..f0ea1a1e5 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -206,9 +206,31 @@ def get_config(config=None):      firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,                                      no_tag_node_value_mangle=True) +    # We have gathered the dict representation of the CLI, but there are +    # default options which we need to update into the dictionary retrived. +    # XXX: T2665: we currently have no nice way for defaults under tag +    # nodes, thus we load the defaults "by hand"      default_values = defaults(base) +    for tmp in ['name', 'ipv6_name']: +        if tmp in default_values: +            del default_values[tmp] +      firewall = dict_merge(default_values, firewall) +    # Merge in defaults for IPv4 ruleset +    if 'name' in firewall: +        default_values = defaults(base + ['name']) +        for name in firewall['name']: +            firewall['name'][name] = dict_merge(default_values, +                                                firewall['name'][name]) + +    # Merge in defaults for IPv6 ruleset +    if 'ipv6_name' in firewall: +        default_values = defaults(base + ['ipv6-name']) +        for ipv6_name in firewall['ipv6_name']: +            firewall['ipv6_name'][ipv6_name] = dict_merge(default_values, +                                                          firewall['ipv6_name'][ipv6_name]) +      firewall['policy_resync'] = bool('group' in firewall or node_changed(conf, base + ['group']))      firewall['interfaces'] = get_firewall_interfaces(conf)      firewall['zone_policy'] = get_firewall_zones(conf) @@ -315,7 +337,7 @@ def verify_nested_group(group_name, group, groups, seen):          if g in seen:              raise ConfigError(f'Group "{group_name}" has a circular reference') -             +          seen.append(g)          if 'include' in groups[g]: @@ -378,7 +400,7 @@ def cleanup_commands(firewall):          if firewall['geoip_updated']:              geoip_key = 'deleted_ipv6_name' if table == 'ip6 filter' else 'deleted_name'              geoip_list = dict_search_args(firewall, 'geoip_updated', geoip_key) or [] -  +          json_str = cmd(f'nft -t -j list table {table}')          obj = loads(json_str) @@ -420,7 +442,7 @@ def cleanup_commands(firewall):                  if set_name.startswith('GEOIP_CC_') and set_name in geoip_list:                      commands_sets.append(f'delete set {table} {set_name}')                      continue -                 +                  if set_name.startswith("RECENT_"):                      commands_sets.append(f'delete set {table} {set_name}')                      continue @@ -520,7 +542,7 @@ def apply(firewall):      if install_result == 1:          raise ConfigError('Failed to apply firewall') -    # set fireall group domain-group xxx +    # set firewall group domain-group xxx      if 'group' in firewall:          if 'domain_group' in firewall['group']:              # T970 Enable a resolver (systemd daemon) that checks diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index e14050dd3..8a959dc79 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -88,15 +88,12 @@ def verify(ha):                  if not {'password', 'type'} <= set(group_config['authentication']):                      raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') -            # We can not use a VRID once per interface +            # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction +            # We also need to make sure VRID is not used twice on the same interface with the +            # same address family. +              interface = group_config['interface']              vrid = group_config['vrid'] -            tmp = {'interface': interface, 'vrid': vrid} -            if tmp in used_vrid_if: -                raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface}"!') -            used_vrid_if.append(tmp) - -            # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction              # XXX: filter on map object is destructive, so we force it to list.              # Additionally, filter objects always evaluate to True, empty or not, @@ -109,6 +106,11 @@ def verify(ha):                  raise ConfigError(f'VRRP group "{group}" mixes IPv4 and IPv6 virtual addresses, this is not allowed.\n' \                                    'Create individual groups for IPv4 and IPv6!')              if vaddrs4: +                tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv4'} +                if tmp in used_vrid_if: +                    raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv4"!') +                used_vrid_if.append(tmp) +                  if 'hello_source_address' in group_config:                      if is_ipv6(group_config['hello_source_address']):                          raise ConfigError(f'VRRP group "{group}" uses IPv4 but hello-source-address is IPv6!') @@ -118,6 +120,11 @@ def verify(ha):                          raise ConfigError(f'VRRP group "{group}" uses IPv4 but peer-address is IPv6!')              if vaddrs6: +                tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv6'} +                if tmp in used_vrid_if: +                    raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv6"!') +                used_vrid_if.append(tmp) +                  if 'hello_source_address' in group_config:                      if is_ipv4(group_config['hello_source_address']):                          raise ConfigError(f'VRRP group "{group}" uses IPv6 but hello-source-address is IPv4!') diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 20f2b1975..4c65bc0b6 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -15,11 +15,13 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  from sys import exit +from netifaces import interfaces  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.configdict import leaf_node_changed  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete @@ -49,6 +51,9 @@ def get_config(config=None):      mode = is_node_changed(conf, ['mode'])      if mode: peth.update({'shutdown_required' : {}}) +    if leaf_node_changed(conf, base + [ifname, 'mode']): +        peth.update({'rebuild_required': {}}) +      if 'source_interface' in peth:          _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'],                                                 peth['source_interface']) @@ -77,21 +82,18 @@ def generate(peth):      return None  def apply(peth): -    if 'deleted' in peth: -        # delete interface -        MACVLANIf(peth['ifname']).remove() -        return None +    # Check if the MACVLAN interface already exists +    if 'rebuild_required' in peth or 'deleted' in peth: +        if peth['ifname'] in interfaces(): +            p = MACVLANIf(peth['ifname']) +            # MACVLAN is always needs to be recreated, +            # thus we can simply always delete it first. +            p.remove() -    # Check if MACVLAN interface already exists. Parameters like the underlaying -    # source-interface device or mode can not be changed on the fly and the -    # interface needs to be recreated from the bottom. -    if 'mode_old' in peth: -        MACVLANIf(peth['ifname']).remove() +    if 'deleted' not in peth: +        p = MACVLANIf(**peth) +        p.update(peth) -    # It is safe to "re-create" the interface always, there is a sanity check -    # that the interface will only be create if its non existent -    p = MACVLANIf(**peth) -    p.update(peth)      return None  if __name__ == '__main__': diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 85819a77e..e75418ba5 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -44,7 +44,8 @@ if LooseVersion(kernel_version()) > LooseVersion('5.1'):  else:      k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] -nftables_nat_config = '/tmp/vyos-nat-rules.nft' +nftables_nat_config = '/run/nftables_nat.conf' +nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft'  def get_handler(json, chain, target):      """ Get nftable rule handler number of given chain/target combination. @@ -88,7 +89,7 @@ def get_config(config=None):      # T2665: we must add the tagNode defaults individually until this is      # moved to the base class -    for direction in ['source', 'destination']: +    for direction in ['source', 'destination', 'static']:          if direction in nat:              default_values = defaults(base + [direction, 'rule'])              for rule in dict_search(f'{direction}.rule', nat) or []: @@ -178,24 +179,35 @@ def verify(nat):              # common rule verification              verify_rule(config, err_msg) +    if dict_search('static.rule', nat): +        for rule, config in dict_search('static.rule', nat).items(): +            err_msg = f'Static NAT configuration error in rule {rule}:' + +            if 'inbound_interface' not in config: +                raise ConfigError(f'{err_msg}\n' \ +                                  'inbound-interface not specified') + +            # common rule verification +            verify_rule(config, err_msg) +      return None  def generate(nat):      render(nftables_nat_config, 'firewall/nftables-nat.j2', nat) +    render(nftables_static_nat_conf, 'firewall/nftables-static-nat.j2', nat)      # dry-run newly generated configuration      tmp = run(f'nft -c -f {nftables_nat_config}')      if tmp > 0: -        if os.path.exists(nftables_nat_config): -            os.unlink(nftables_nat_config)          raise ConfigError('Configuration file errors encountered!') +    tmp = run(f'nft -c -f {nftables_nat_config}') +      return None  def apply(nat):      cmd(f'nft -f {nftables_nat_config}') -    if os.path.isfile(nftables_nat_config): -        os.unlink(nftables_nat_config) +    cmd(f'nft -f {nftables_static_nat_conf}')      return None diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 86985d765..e922edc4e 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -16,6 +16,7 @@  import os  import re +from pathlib import Path  from vyos.config import Config  from vyos.configdict import dict_merge @@ -68,18 +69,15 @@ def verify(console):              # amount of connected devices. We will resolve the fixed device name              # to its dynamic device file - and create a new dict entry for it.              by_bus_device = f'{by_bus_dir}/{device}' -            if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): -                device = os.path.basename(os.readlink(by_bus_device)) - -        # If the device name still starts with usbXXX no matching tty was found -        # and it can not be used as a serial interface -        if device.startswith('usb'): -            raise ConfigError(f'Device {device} does not support beeing used as tty') +            # If the device name still starts with usbXXX no matching tty was found +            # and it can not be used as a serial interface +            if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device): +                raise ConfigError(f'Device {device} does not support beeing used as tty')      return None  def generate(console): -    base_dir = '/etc/systemd/system' +    base_dir = '/run/systemd/system'      # Remove all serial-getty configuration files in advance      for root, dirs, files in os.walk(base_dir):          for basename in files: @@ -90,7 +88,8 @@ def generate(console):      if not console or 'device' not in console:          return None -    for device, device_config in console['device'].items(): +    # replace keys in the config for ttyUSB items to use them in `apply()` later +    for device in console['device'].copy():          if device.startswith('usb'):              # It is much easiert to work with the native ttyUSBn name when using              # getty, but that name may change across reboots - depending on the @@ -98,9 +97,17 @@ def generate(console):              # to its dynamic device file - and create a new dict entry for it.              by_bus_device = f'{by_bus_dir}/{device}'              if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): -                device = os.path.basename(os.readlink(by_bus_device)) +                device_updated = os.path.basename(os.readlink(by_bus_device)) + +                # replace keys in the config to use them in `apply()` later +                console['device'][device_updated] = console['device'][device] +                del console['device'][device] +            else: +                raise ConfigError(f'Device {device} does not support beeing used as tty') +    for device, device_config in console['device'].items():          config_file = base_dir + f'/serial-getty@{device}.service' +        Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True)          getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service'          render(config_file, 'getty/serial-getty.service.j2', device_config) diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py index a5293c97e..bf25a7331 100755 --- a/src/etc/opennhrp/opennhrp-script.py +++ b/src/etc/opennhrp/opennhrp-script.py @@ -81,7 +81,13 @@ def vici_ike_terminate(list_ikeid: list[str]) -> bool:          session = vici.Session()          for ikeid in list_ikeid:              logger.info(f'Terminating IKE SA with id {ikeid}') -            session.terminate({'ike-id': ikeid, 'timeout': '-1'}) +            session_generator = session.terminate( +                {'ike-id': ikeid, 'timeout': '-1'}) +            # a dummy `for` loop is required because of requirements +            # from vici. Without a full iteration on the output, the +            # command to vici may not be executed completely +            for _ in session_generator: +                pass          return True      except Exception as err:          logger.error(f'Failed to terminate SA for IKE ids {list_ikeid}: {err}') @@ -175,13 +181,18 @@ def vici_initiate(conn: str, child_sa: str, src_addr: str,          f'src_addr: {src_addr}, dst_addr: {dest_addr}')      try:          session = vici.Session() -        session.initiate({ +        session_generator = session.initiate({              'ike': conn,              'child': child_sa,              'timeout': '-1',              'my-host': src_addr,              'other-host': dest_addr          }) +        # a dummy `for` loop is required because of requirements +        # from vici. Without a full iteration on the output, the +        # command to vici may not be executed completely +        for _ in session_generator: +            pass          return True      except Exception as err:          logger.error(f'Unable to initiate connection {err}') diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 049d59de7..0b1260912 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -17,6 +17,7 @@ import vyos.defaults  from . graphql.queries import query  from . graphql.mutations import mutation  from . graphql.directives import directives_dict +from . graphql.errors import op_mode_error  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 @@ -27,6 +28,6 @@ def generate_schema():      type_defs = load_schema_from_path(api_schema_dir) -    schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict) +    schema = make_executable_schema(type_defs, query, op_mode_error, mutation, snake_case_fallback_resolvers, directives=directives_dict)      return schema diff --git a/src/services/api/graphql/graphql/errors.py b/src/services/api/graphql/graphql/errors.py new file mode 100644 index 000000000..1066300e0 --- /dev/null +++ b/src/services/api/graphql/graphql/errors.py @@ -0,0 +1,8 @@ + +from ariadne import InterfaceType + +op_mode_error = InterfaceType("OpModeError") + +@op_mode_error.type_resolver +def resolve_op_mode_error(obj, *_): +    return obj['name'] diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index c8ae0f516..1b77cff87 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -22,6 +22,8 @@ from makefun import with_signature  from .. import state  from .. import key_auth  from api.graphql.session.session import Session +from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code +from vyos.opmode import Error as OpModeError  mutation = ObjectType("Mutation") @@ -86,10 +88,19 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                  "success": True,                  "data": data              } +        except OpModeError as e: +            typename = type(e).__name__ +            return { +                "success": False, +                "errore": ['op_mode_error'], +                "op_mode_error": {"name": f"{typename}", +                                 "message": op_mode_err_msg.get(typename, "Unknown"), +                                 "vyos_code": op_mode_err_code.get(typename, 9999)} +            }          except Exception as error:              return {                  "success": False, -                "errors": [str(error)] +                "errors": [repr(error)]              }      return func_impl diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 921a66274..8ae61b704 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -22,6 +22,8 @@ from makefun import with_signature  from .. import state  from .. import key_auth  from api.graphql.session.session import Session +from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code +from vyos.opmode import Error as OpModeError  query = ObjectType("Query") @@ -86,10 +88,19 @@ def make_query_resolver(query_name, class_name, session_func):                  "success": True,                  "data": data              } +        except OpModeError as e: +            typename = type(e).__name__ +            return { +                "success": False, +                "errors": ['op_mode_error'], +                "op_mode_error": {"name": f"{typename}", +                                 "message": op_mode_err_msg.get(typename, "Unknown"), +                                 "vyos_code": op_mode_err_code.get(typename, 9999)} +            }          except Exception as error:              return {                  "success": False, -                "errors": [str(error)] +                "errors": [repr(error)]              }      return func_impl diff --git a/src/services/api/graphql/session/errors/op_mode_errors.py b/src/services/api/graphql/session/errors/op_mode_errors.py new file mode 100644 index 000000000..7ba75455d --- /dev/null +++ b/src/services/api/graphql/session/errors/op_mode_errors.py @@ -0,0 +1,13 @@ + + +op_mode_err_msg = { +    "UnconfiguredSubsystem": "subsystem is not configured or not running", +    "DataUnavailable": "data currently unavailable", +    "PermissionDenied": "client does not have permission" +} + +op_mode_err_code = { +    "UnconfiguredSubsystem": 2000, +    "DataUnavailable": 2001, +    "PermissionDenied": 1003 +} diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py index 23bc7154c..93e1c328e 100644 --- a/src/services/api/graphql/session/session.py +++ b/src/services/api/graphql/session/session.py @@ -22,6 +22,7 @@ from vyos.config import Config  from vyos.configtree import ConfigTree  from vyos.defaults import directories  from vyos.template import render +from vyos.opmode import Error as OpModeError  from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name @@ -177,10 +178,10 @@ class Session:          mod = load_op_mode_as_module(f'{scriptname}')          func = getattr(mod, func_name) -        if len(list(data)) > 0: +        try:              res = func(True, **data) -        else: -            res = func(True) +        except OpModeError as e: +            raise e          return res @@ -199,9 +200,9 @@ class Session:          mod = load_op_mode_as_module(f'{scriptname}')          func = getattr(mod, func_name) -        if len(list(data)) > 0: +        try:              res = func(**data) -        else: -            res = func() +        except OpModeError as e: +            raise e          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 index f990aae52..379d15250 100755 --- a/src/services/api/graphql/utils/schema_from_op_mode.py +++ b/src/services/api/graphql/utils/schema_from_op_mode.py @@ -21,7 +21,7 @@  import os  import json  import typing -from inspect import signature, getmembers, isfunction +from inspect import signature, getmembers, isfunction, isclass, getmro  from jinja2 import Template  from vyos.defaults import directories @@ -35,6 +35,7 @@ SCHEMA_PATH = directories['api_schema']  DATA_DIR = directories['data']  op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json') +op_mode_error_schema = 'op_mode_error.graphql'  schema_data: dict = {'schema_name': '',                       'schema_fields': []} @@ -53,6 +54,7 @@ type {{ schema_name }} {  type {{ schema_name }}Result {      data: {{ schema_name }} +    op_mode_error: OpModeError      success: Boolean!      errors: [String]  } @@ -76,6 +78,7 @@ type {{ schema_name }} {  type {{ schema_name }}Result {      data: {{ schema_name }} +    op_mode_error: OpModeError      success: Boolean!      errors: [String]  } @@ -85,6 +88,21 @@ extend type Mutation {  }  """ +error_template = """ +interface OpModeError { +    name: String! +    message: String! +    vyos_code: Int! +} +{% for name in error_names %} +type {{ name }} implements OpModeError { +    name: String! +    message: String! +    vyos_code: Int! +} +{%- endfor %} +""" +  def _snake_to_pascal_case(name: str) -> str:      res = ''.join(map(str.title, name.split('_')))      return res @@ -136,7 +154,30 @@ def create_schema(func_name: str, base_name: str, func: callable) -> str:      return res +def create_error_schema(): +    from vyos import opmode + +    e = Exception +    err_types = getmembers(opmode, isclass) +    err_types = [k for k in err_types if issubclass(k[1], e)] +    # drop base class, to be replaced by interface type. Find the class +    # programmatically, in case the base class name changes. +    for i in range(len(err_types)): +        if err_types[i][1] in getmro(err_types[i-1][1]): +            del err_types[i] +            break +    err_names = [k[0] for k in err_types] +    error_data = {'error_names': err_names} +    j2_template = Template(error_template) +    res = j2_template.render(error_data) + +    return res +  def generate_op_mode_definitions(): +    out = create_error_schema() +    with open(f'{SCHEMA_PATH}/{op_mode_error_schema}', 'w') as f: +        f.write(out) +      with open(op_mode_include_file) as f:          op_mode_files = json.load(f) | 
