diff options
Diffstat (limited to 'src')
94 files changed, 3353 insertions, 1312 deletions
| diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index ac3dc536b..8efeaed54 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -40,20 +40,7 @@ airbag.enable()  config_containers_registry = '/etc/containers/registries.conf'  config_containers_storage = '/etc/containers/storage.conf' - -def _run_rerun(container_cmd): -    counter = 0 -    while True: -        if counter >= 10: -            break -        try: -            _cmd(container_cmd) -            break -        except: -            counter = counter +1 -            sleep(0.5) - -    return None +systemd_unit_path = '/run/systemd/system'  def _cmd(command):      if os.path.exists('/tmp/vyos.container.debug'): @@ -122,7 +109,7 @@ def verify(container):              # of image upgrade and deletion.              image = container_config['image']              if run(f'podman image exists {image}') != 0: -                Warning(f'Image "{image}" used in contianer "{name}" does not exist '\ +                Warning(f'Image "{image}" used in container "{name}" does not exist '\                          f'locally. Please use "add container image {image}" to add it '\                          f'to the system! Container "{name}" will not be started!') @@ -136,9 +123,6 @@ def verify(container):                      raise ConfigError(f'Container network "{network_name}" does not exist!')                  if 'address' in container_config['network'][network_name]: -                    if 'network' not in container_config: -                        raise ConfigError(f'Can not use "address" without "network" for container "{name}"!') -                      address = container_config['network'][network_name]['address']                      network = None                      if is_ipv4(address): @@ -220,6 +204,72 @@ def verify(container):      return None +def generate_run_arguments(name, container_config): +    image = container_config['image'] +    memory = container_config['memory'] +    shared_memory = container_config['shared_memory'] +    restart = container_config['restart'] + +    # Add capability options. Should be in uppercase +    cap_add = '' +    if 'cap_add' in container_config: +        for c in container_config['cap_add']: +            c = c.upper() +            c = c.replace('-', '_') +            cap_add += f' --cap-add={c}' + +    # Add a host device to the container /dev/x:/dev/x +    device = '' +    if 'device' in container_config: +        for dev, dev_config in container_config['device'].items(): +            source_dev = dev_config['source'] +            dest_dev = dev_config['destination'] +            device += f' --device={source_dev}:{dest_dev}' + +    # Check/set environment options "-e foo=bar" +    env_opt = '' +    if 'environment' in container_config: +        for k, v in container_config['environment'].items(): +            env_opt += f" -e \"{k}={v['value']}\"" + +    # Publish ports +    port = '' +    if 'port' in container_config: +        protocol = '' +        for portmap in container_config['port']: +            if 'protocol' in container_config['port'][portmap]: +                protocol = container_config['port'][portmap]['protocol'] +                protocol = f'/{protocol}' +            else: +                protocol = '/tcp' +            sport = container_config['port'][portmap]['source'] +            dport = container_config['port'][portmap]['destination'] +            port += f' -p {sport}:{dport}{protocol}' + +    # Bind volume +    volume = '' +    if 'volume' in container_config: +        for vol, vol_config in container_config['volume'].items(): +            svol = vol_config['source'] +            dvol = vol_config['destination'] +            volume += f' -v {svol}:{dvol}' + +    container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \ +                         f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ +                         f'--name {name} {device} {port} {volume} {env_opt}' + +    if 'allow_host_networks' in container_config: +        return f'{container_base_cmd} --net host {image}' + +    ip_param = '' +    networks = ",".join(container_config['network']) +    for network in container_config['network']: +        if 'address' in container_config['network'][network]: +            address = container_config['network'][network]['address'] +            ip_param = f'--ip {address}' + +    return f'{container_base_cmd} --net {networks} {ip_param} {image}' +  def generate(container):      # bail out early - looks like removal from running config      if not container: @@ -263,6 +313,15 @@ def generate(container):      render(config_containers_registry, 'container/registries.conf.j2', container)      render(config_containers_storage, 'container/storage.conf.j2', container) +    if 'name' in container: +        for name, container_config in container['name'].items(): +            if 'disable' in container_config: +                continue + +            file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') +            run_args = generate_run_arguments(name, container_config) +            render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args}) +      return None  def apply(container): @@ -270,8 +329,12 @@ def apply(container):      # Option "--force" allows to delete containers with any status      if 'container_remove' in container:          for name in container['container_remove']: -            call(f'podman stop --time 3 {name}') -            call(f'podman rm --force {name}') +            file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') +            call(f'systemctl stop vyos-container-{name}.service') +            if os.path.exists(file_path): +                os.unlink(file_path) + +    call('systemctl daemon-reload')      # Delete old networks if needed      if 'network_remove' in container: @@ -282,6 +345,7 @@ def apply(container):                  os.unlink(tmp)      # Add container +    disabled_new = False      if 'name' in container:          for name, container_config in container['name'].items():              image = container_config['image'] @@ -295,70 +359,17 @@ def apply(container):                  # check if there is a container by that name running                  tmp = _cmd('podman ps -a --format "{{.Names}}"')                  if name in tmp: -                    _cmd(f'podman stop --time 3 {name}') -                    _cmd(f'podman rm --force {name}') +                    file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') +                    call(f'systemctl stop vyos-container-{name}.service') +                    if os.path.exists(file_path): +                        disabled_new = True +                        os.unlink(file_path)                  continue -            memory = container_config['memory'] -            restart = container_config['restart'] - -            # Add capability options. Should be in uppercase -            cap_add = '' -            if 'cap_add' in container_config: -                for c in container_config['cap_add']: -                    c = c.upper() -                    c = c.replace('-', '_') -                    cap_add += f' --cap-add={c}' - -            # Add a host device to the container /dev/x:/dev/x -            device = '' -            if 'device' in container_config: -                for dev, dev_config in container_config['device'].items(): -                    source_dev = dev_config['source'] -                    dest_dev = dev_config['destination'] -                    device += f' --device={source_dev}:{dest_dev}' - -            # Check/set environment options "-e foo=bar" -            env_opt = '' -            if 'environment' in container_config: -                for k, v in container_config['environment'].items(): -                    env_opt += f" -e \"{k}={v['value']}\"" - -            # Publish ports -            port = '' -            if 'port' in container_config: -                protocol = '' -                for portmap in container_config['port']: -                    if 'protocol' in container_config['port'][portmap]: -                        protocol = container_config['port'][portmap]['protocol'] -                        protocol = f'/{protocol}' -                    else: -                        protocol = '/tcp' -                    sport = container_config['port'][portmap]['source'] -                    dport = container_config['port'][portmap]['destination'] -                    port += f' -p {sport}:{dport}{protocol}' - -            # Bind volume -            volume = '' -            if 'volume' in container_config: -                for vol, vol_config in container_config['volume'].items(): -                    svol = vol_config['source'] -                    dvol = vol_config['destination'] -                    volume += f' -v {svol}:{dvol}' - -            container_base_cmd = f'podman run --detach --interactive --tty --replace {cap_add} ' \ -                                 f'--memory {memory}m --memory-swap 0 --restart {restart} ' \ -                                 f'--name {name} {device} {port} {volume} {env_opt}' -            if 'allow_host_networks' in container_config: -                _run_rerun(f'{container_base_cmd} --net host {image}') -            else: -                for network in container_config['network']: -                    ipparam = '' -                    if 'address' in container_config['network'][network]: -                        address = container_config['network'][network]['address'] -                        ipparam = f'--ip {address}' +            cmd(f'systemctl restart vyos-container-{name}.service') -                    _run_rerun(f'{container_base_cmd} --net {network} {ipparam} {image}') +    if disabled_new: +        call('systemctl daemon-reload')      return None diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index cbd9cbe90..9fee20358 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -26,13 +26,10 @@ from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import node_changed  from vyos.configdiff import get_config_diff, Diff +from vyos.configdep import set_dependent, call_dependents  # from vyos.configverify import verify_interface_exists +from vyos.firewall import fqdn_config_parse  from vyos.firewall import geoip_update -from vyos.firewall import get_ips_domains_dict -from vyos.firewall import nft_add_set_elements -from vyos.firewall import nft_flush_set -from vyos.firewall import nft_init_set -from vyos.firewall import nft_update_set_elements  from vyos.template import render  from vyos.util import call  from vyos.util import cmd @@ -45,7 +42,8 @@ from vyos import ConfigError  from vyos import airbag  airbag.enable() -policy_route_conf_script = '/usr/libexec/vyos/conf_mode/policy-route.py' +nat_conf_script = 'nat.py' +policy_route_conf_script = 'policy-route.py'  nftables_conf = '/run/nftables.conf' @@ -162,7 +160,13 @@ def get_config(config=None):          for zone in firewall['zone']:              firewall['zone'][zone] = dict_merge(default_values, firewall['zone'][zone]) -    firewall['policy_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) +    firewall['group_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) +    if firewall['group_resync']: +        # Update nat as firewall groups were updated +        set_dependent(nat_conf_script, conf) +        # Update policy route as firewall groups were updated +        set_dependent(policy_route_conf_script, conf) +      if 'config_trap' in firewall and firewall['config_trap'] == 'enable':          diff = get_config_diff(conf) @@ -173,6 +177,8 @@ def get_config(config=None):      firewall['geoip_updated'] = geoip_updated(conf, firewall) +    fqdn_config_parse(firewall) +      return firewall  def verify_rule(firewall, rule_conf, ipv6): @@ -232,29 +238,28 @@ def verify_rule(firewall, rule_conf, ipv6):          if side in rule_conf:              side_conf = rule_conf[side] -            if dict_search_args(side_conf, 'geoip', 'country_code'): -                if 'address' in side_conf: -                    raise ConfigError('Address and GeoIP cannot both be defined') - -                if dict_search_args(side_conf, 'group', 'address_group'): -                    raise ConfigError('Address-group and GeoIP cannot both be defined') - -                if dict_search_args(side_conf, 'group', 'network_group'): -                    raise ConfigError('Network-group and GeoIP cannot both be defined') +            if len({'address', 'fqdn', 'geoip'} & set(side_conf)) > 1: +                raise ConfigError('Only one of address, fqdn or geoip can be specified')              if 'group' in side_conf: -                if {'address_group', 'network_group'} <= set(side_conf['group']): -                    raise ConfigError('Only one address-group or network-group can be specified') +                if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: +                    raise ConfigError('Only one address-group, network-group or domain-group can be specified')                  for group in valid_groups:                      if group in side_conf['group']:                          group_name = side_conf['group'][group] +                        fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group +                        error_group = fw_group.replace("_", "-") + +                        if group in ['address_group', 'network_group', 'domain_group']: +                            types = [t for t in ['address', 'fqdn', 'geoip'] if t in side_conf] +                            if types: +                                raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') +                          if group_name and group_name[0] == '!':                              group_name = group_name[1:] -                        fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group -                        error_group = fw_group.replace("_", "-")                          group_obj = dict_search_args(firewall, 'group', fw_group, group_name)                          if group_obj is None: @@ -466,42 +471,23 @@ def post_apply_trap(firewall):                  cmd(base_cmd + ' '.join(objects)) -def resync_policy_route(): -    # Update policy route as firewall groups were updated -    tmp, out = rc_cmd(policy_route_conf_script) -    if tmp > 0: -        Warning(f'Failed to re-apply policy route configuration! {out}') -  def apply(firewall):      install_result, output = rc_cmd(f'nft -f {nftables_conf}')      if install_result == 1:          raise ConfigError(f'Failed to apply firewall: {output}') -    # set firewall group domain-group xxx -    if 'group' in firewall: -        if 'domain_group' in firewall['group']: -            # T970 Enable a resolver (systemd daemon) that checks -            # domain-group addresses and update entries for domains by timeout -            # If router loaded without internet connection or for synchronization -            call('systemctl restart vyos-domain-group-resolve.service') -            for group, group_config in firewall['group']['domain_group'].items(): -                domains = [] -                if group_config.get('address') is not None: -                    for address in group_config.get('address'): -                        domains.append(address) -                # Add elements to domain-group, try to resolve domain => ip -                # and add elements to nft set -                ip_dict = get_ips_domains_dict(domains) -                elements = sum(ip_dict.values(), []) -                nft_init_set(f'D_{group}') -                nft_add_set_elements(f'D_{group}', elements) -        else: -            call('systemctl stop vyos-domain-group-resolve.service') -      apply_sysfs(firewall) -    if firewall['policy_resync']: -        resync_policy_route() +    if firewall['group_resync']: +        call_dependents() + +    # T970 Enable a resolver (systemd daemon) that checks +    # domain-group/fqdn addresses and update entries for domains by timeout +    # If router loaded without internet connection or for synchronization +    domain_action = 'stop' +    if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'] or firewall['ip6_fqdn']: +        domain_action = 'restart' +    call(f'systemctl {domain_action} vyos-domain-resolver.service')      if firewall['geoip_updated']:          # Call helper script to Update set contents diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 04113fc09..be80613c6 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -24,9 +24,11 @@ from copy import deepcopy  import vyos.defaults  from vyos.config import Config +from vyos.configdict import dict_merge  from vyos.template import render  from vyos.util import cmd  from vyos.util import call +from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -36,6 +38,15 @@ systemd_service = '/run/systemd/system/vyos-http-api.service'  vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] +def _translate_values_to_boolean(d: dict) -> dict: +    for k in list(d): +        if d[k] == {}: +            d[k] = True +        elif isinstance(d[k], dict): +            _translate_values_to_boolean(d[k]) +        else: +            pass +  def get_config(config=None):      http_api = deepcopy(vyos.defaults.api_data)      x = http_api.get('api_keys') @@ -54,48 +65,40 @@ def get_config(config=None):      if not conf.exists(base):          return None +    api_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) + +    # One needs to 'flatten' the keys dict from the config into the +    # http-api.conf format for api_keys: +    if 'keys' in api_dict: +        api_dict['api_keys'] = [] +        for el in list(api_dict['keys']['id']): +            key = api_dict['keys']['id'][el]['key'] +            api_dict['api_keys'].append({'id': el, 'key': key}) +        del api_dict['keys'] +      # Do we run inside a VRF context?      vrf_path = ['service', 'https', 'vrf']      if conf.exists(vrf_path):          http_api['vrf'] = conf.return_value(vrf_path) -    conf.set_level('service https api') -    if conf.exists('strict'): -        http_api['strict'] = True - -    if conf.exists('debug'): -        http_api['debug'] = True +    if 'api_keys' in api_dict: +        keys_added = True -    if conf.exists('gql'): -        http_api['gql'] = True -        if conf.exists('gql introspection'): -            http_api['introspection'] = True +    if 'graphql' in api_dict: +        api_dict = dict_merge(defaults(base), api_dict) -    if conf.exists('socket'): -        http_api['socket'] = True - -    if conf.exists('port'): -        port = conf.return_value('port') -        http_api['port'] = port - -    if conf.exists('cors'): -        http_api['cors'] = {} -        if conf.exists('cors allow-origin'): -            origins = conf.return_values('cors allow-origin') -            http_api['cors']['origins'] = origins[:] - -    if conf.exists('keys'): -        for name in conf.list_nodes('keys id'): -            if conf.exists('keys id {0} key'.format(name)): -                key = conf.return_value('keys id {0} key'.format(name)) -                new_key = { 'id': name, 'key': key } -                http_api['api_keys'].append(new_key) -                keys_added = True +    http_api.update(api_dict)      if keys_added and default_key:          if default_key in http_api['api_keys']:              http_api['api_keys'].remove(default_key) +    # Finally, translate entries in http_api into boolean settings for +    # backwards compatability of JSON http-api.conf file +    _translate_values_to_boolean(http_api) +      return http_api  def verify(http_api): diff --git a/src/conf_mode/interfaces-virtual-ethernet.py b/src/conf_mode/interfaces-virtual-ethernet.py new file mode 100755 index 000000000..b1819233c --- /dev/null +++ b/src/conf_mode/interfaces-virtual-ethernet.py @@ -0,0 +1,98 @@ +#!/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 netifaces import interfaces +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_vrf +from vyos.ifconfig import VethIf + +airbag.enable() + +def get_config(config=None): +    """ +    Retrive CLI config as dictionary. Dictionary can never be empty, as at +    least the interface name will be added or a deleted flag +    """ +    if config: +        conf = config +    else: +        conf = Config() +    base = ['interfaces', 'virtual-ethernet'] +    ifname, veth = get_interface_dict(conf, base) + +    # We need to know all other veth related interfaces as veth requires a 1:1 +    # mapping for the peer-names. The Linux kernel automatically creates both +    # interfaces, the local one and the peer-name, but VyOS also needs a peer +    # interfaces configrued on the CLI so we can assign proper IP addresses etc. +    veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), +                                     get_first_key=True, no_tag_node_value_mangle=True) + +    return veth + + +def verify(veth): +    if 'deleted' in veth: +        verify_bridge_delete(veth) +        return None + +    verify_vrf(veth) +    verify_address(veth) + +    if 'peer_name' not in veth: +        raise ConfigError(f'Remote peer name must be set for "{veth["ifname"]}"!') + +    if veth['peer_name'] not in veth['other_interfaces']: +        peer_name = veth['peer_name'] +        ifname = veth['ifname'] +        raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ +                          'is not configured!') + +    return None + + +def generate(peth): +    return None + +def apply(veth): +    # Check if the Veth interface already exists +    if 'rebuild_required' in veth or 'deleted' in veth: +        if veth['ifname'] in interfaces(): +            p = VethIf(veth['ifname']) +            p.remove() + +    if 'deleted' not in veth: +        p = VethIf(**veth) +        p.update(veth) + +    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/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 8d738f55e..762bad94f 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -87,6 +87,8 @@ def verify(wireguard):                                 'cannot be used for the interface!')      # run checks on individual configured WireGuard peer +    public_keys = [] +      for tmp in wireguard['peer']:          peer = wireguard['peer'][tmp] @@ -100,6 +102,11 @@ def verify(wireguard):              raise ConfigError('Both Wireguard port and address must be defined '                                f'for peer "{tmp}" if either one of them is set!') +        if peer['public_key'] in public_keys: +            raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + +        public_keys.append(peer['public_key']) +  def apply(wireguard):      tmp = WireGuardIf(wireguard['ifname'])      if 'deleted' in wireguard: diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index 97b3a6396..a14a992ae 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -116,7 +116,7 @@ def generate(wwan):      # disconnect - e.g. happens during RF signal loss. The script watches every      # WWAN interface - so there is only one instance.      if not os.path.exists(cron_script): -        write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py') +        write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n')      return None diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 8b1a5a720..9f8221514 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -32,6 +32,7 @@ from vyos.util import cmd  from vyos.util import run  from vyos.util import check_kmod  from vyos.util import dict_search +from vyos.util import dict_search_args  from vyos.validate import is_addr_assigned  from vyos.xml import defaults  from vyos import ConfigError @@ -47,6 +48,13 @@ else:  nftables_nat_config = '/run/nftables_nat.conf'  nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' +valid_groups = [ +    'address_group', +    'domain_group', +    'network_group', +    'port_group' +] +  def get_handler(json, chain, target):      """ Get nftable rule handler number of given chain/target combination.      Handler is required when adding NAT/Conntrack helper targets """ @@ -60,7 +68,7 @@ def get_handler(json, chain, target):      return None -def verify_rule(config, err_msg): +def verify_rule(config, err_msg, groups_dict):      """ Common verify steps used for both source and destination NAT """      if (dict_search('translation.port', config) != None or @@ -78,6 +86,45 @@ def verify_rule(config, err_msg):                               'statically maps a whole network of addresses onto another\n' \                               'network of addresses') +    for side in ['destination', 'source']: +        if side in config: +            side_conf = config[side] + +            if len({'address', 'fqdn'} & set(side_conf)) > 1: +                raise ConfigError('Only one of address, fqdn or geoip can be specified') + +            if 'group' in side_conf: +                if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: +                    raise ConfigError('Only one address-group, network-group or domain-group can be specified') + +                for group in valid_groups: +                    if group in side_conf['group']: +                        group_name = side_conf['group'][group] +                        error_group = group.replace("_", "-") + +                        if group in ['address_group', 'network_group', 'domain_group']: +                            types = [t for t in ['address', 'fqdn'] if t in side_conf] +                            if types: +                                raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') + +                        if group_name and group_name[0] == '!': +                            group_name = group_name[1:] + +                        group_obj = dict_search_args(groups_dict, group, group_name) + +                        if group_obj is None: +                            raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule') + +                        if not group_obj: +                            Warning(f'{error_group} "{group_name}" has no members!') + +            if dict_search_args(side_conf, 'group', 'port_group'): +                if 'protocol' not in config: +                    raise ConfigError('Protocol must be defined if specifying a port-group') + +                if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: +                    raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') +  def get_config(config=None):      if config:          conf = config @@ -105,16 +152,20 @@ def get_config(config=None):      condensed_json = jmespath.search(pattern, nftable_json)      if not conf.exists(base): -        nat['helper_functions'] = 'remove' - -        # Retrieve current table handler positions -        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER') -        nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') -        nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_HELPER') -        nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') +        if get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER'): +            nat['helper_functions'] = 'remove' + +            # Retrieve current table handler positions +            nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER') +            nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') +            nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_HELPER') +            nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK')          nat['deleted'] = ''          return nat +    nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, +                                    no_tag_node_value_mangle=True) +      # check if NAT connection tracking helpers need to be set up - this has to      # be done only once      if not get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK'): @@ -146,6 +197,10 @@ def verify(nat):              if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces():                  Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') +            if not dict_search('translation.address', config) and not dict_search('translation.port', config): +                if 'exclude' not in config: +                    raise ConfigError(f'{err_msg} translation requires address and/or port') +              addr = dict_search('translation.address', config)              if addr != None and addr != 'masquerade' and not is_ip_network(addr):                  for ip in addr.split('-'): @@ -153,7 +208,7 @@ def verify(nat):                          Warning(f'IP address {ip} does not exist on the system!')              # common rule verification -            verify_rule(config, err_msg) +            verify_rule(config, err_msg, nat['firewall_group'])      if dict_search('destination.rule', nat): @@ -166,8 +221,12 @@ def verify(nat):              elif config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces():                  Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') +            if not dict_search('translation.address', config) and not dict_search('translation.port', config): +                if 'exclude' not in config: +                    raise ConfigError(f'{err_msg} translation requires address and/or port') +              # common rule verification -            verify_rule(config, err_msg) +            verify_rule(config, err_msg, nat['firewall_group'])      if dict_search('static.rule', nat):          for rule, config in dict_search('static.rule', nat).items(): @@ -178,7 +237,7 @@ def verify(nat):                                    'inbound-interface not specified')              # common rule verification -            verify_rule(config, err_msg) +            verify_rule(config, err_msg, nat['firewall_group'])      return None @@ -204,6 +263,10 @@ def apply(nat):      cmd(f'nft -f {nftables_nat_config}')      cmd(f'nft -f {nftables_static_nat_conf}') +    if not nat or 'deleted' in nat: +        os.unlink(nftables_nat_config) +        os.unlink(nftables_static_nat_conf) +      return None  if __name__ == '__main__': diff --git a/src/conf_mode/policy-route-interface.py b/src/conf_mode/policy-route-interface.py deleted file mode 100755 index 58c5fd93d..000000000 --- a/src/conf_mode/policy-route-interface.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import os -import re - -from sys import argv -from sys import exit - -from vyos.config import Config -from vyos.ifconfig import Section -from vyos.template import render -from vyos.util import cmd -from vyos.util import run -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): -    if config: -        conf = config -    else: -        conf = Config() - -    ifname = argv[1] -    ifpath = Section.get_config_path(ifname) -    if_policy_path = f'interfaces {ifpath} policy' - -    if_policy = conf.get_config_dict(if_policy_path, key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) - -    if_policy['ifname'] = ifname -    if_policy['policy'] = conf.get_config_dict(['policy'], key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) - -    return if_policy - -def verify_chain(table, chain): -    # Verify policy route applied -    code = run(f'nft list chain {table} {chain}') -    return code == 0 - -def verify(if_policy): -    # bail out early - looks like removal from running config -    if not if_policy: -        return None - -    for route in ['route', 'route6']: -        if route in if_policy: -            if route not in if_policy['policy']: -                raise ConfigError('Policy route not configured') - -            route_name = if_policy[route] - -            if route_name not in if_policy['policy'][route]: -                raise ConfigError(f'Invalid policy route name "{name}"') - -            nft_prefix = 'VYOS_PBR6_' if route == 'route6' else 'VYOS_PBR_' -            nft_table = 'ip6 mangle' if route == 'route6' else 'ip mangle' - -            if not verify_chain(nft_table, nft_prefix + route_name): -                raise ConfigError('Policy route did not apply') - -    return None - -def generate(if_policy): -    return None - -def cleanup_rule(table, chain, ifname, new_name=None): -    results = cmd(f'nft -a list chain {table} {chain}').split("\n") -    retval = None -    for line in results: -        if f'ifname "{ifname}"' in line: -            if new_name and f'jump {new_name}' in line: -                # new_name is used to clear rules for any previously referenced chains -                # returns true when rule exists and doesn't need to be created -                retval = True -                continue - -            handle_search = re.search('handle (\d+)', line) -            if handle_search: -                cmd(f'nft delete rule {table} {chain} handle {handle_search[1]}') -    return retval - -def apply(if_policy): -    ifname = if_policy['ifname'] - -    route_chain = 'VYOS_PBR_PREROUTING' -    ipv6_route_chain = 'VYOS_PBR6_PREROUTING' - -    if 'route' in if_policy: -        name = 'VYOS_PBR_' + if_policy['route'] -        rule_exists = cleanup_rule('ip mangle', route_chain, ifname, name) - -        if not rule_exists: -            cmd(f'nft insert rule ip mangle {route_chain} iifname {ifname} counter jump {name}') -    else: -        cleanup_rule('ip mangle', route_chain, ifname) - -    if 'route6' in if_policy: -        name = 'VYOS_PBR6_' + if_policy['route6'] -        rule_exists = cleanup_rule('ip6 mangle', ipv6_route_chain, ifname, name) - -        if not rule_exists: -            cmd(f'nft insert rule ip6 mangle {ipv6_route_chain} iifname {ifname} counter jump {name}') -    else: -        cleanup_rule('ip6 mangle', ipv6_route_chain, ifname) - -    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/policy-route.py b/src/conf_mode/policy-route.py index 00539b9c7..1d016695e 100755 --- a/src/conf_mode/policy-route.py +++ b/src/conf_mode/policy-route.py @@ -15,7 +15,6 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os -import re  from json import loads  from sys import exit @@ -25,7 +24,6 @@ from vyos.config import Config  from vyos.template import render  from vyos.util import cmd  from vyos.util import dict_search_args -from vyos.util import dict_search_recursive  from vyos.util import run  from vyos import ConfigError  from vyos import airbag @@ -34,48 +32,13 @@ airbag.enable()  mark_offset = 0x7FFFFFFF  nftables_conf = '/run/nftables_policy.conf' -ROUTE_PREFIX = 'VYOS_PBR_' -ROUTE6_PREFIX = 'VYOS_PBR6_' - -preserve_chains = [ -    'VYOS_PBR_PREROUTING', -    'VYOS_PBR_POSTROUTING', -    'VYOS_PBR6_PREROUTING', -    'VYOS_PBR6_POSTROUTING' -] -  valid_groups = [      'address_group', +    'domain_group',      'network_group',      'port_group'  ] -group_set_prefix = { -    'A_': 'address_group', -    'A6_': 'ipv6_address_group', -#    'D_': 'domain_group', -    'M_': 'mac_group', -    'N_': 'network_group', -    'N6_': 'ipv6_network_group', -    'P_': 'port_group' -} - -def get_policy_interfaces(conf): -    out = {} -    interfaces = conf.get_config_dict(['interfaces'], key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) -    def find_interfaces(iftype_conf, output={}, prefix=''): -        for ifname, if_conf in iftype_conf.items(): -            if 'policy' in if_conf: -                output[prefix + ifname] = if_conf['policy'] -            for vif in ['vif', 'vif_s', 'vif_c']: -                if vif in if_conf: -                    output.update(find_interfaces(if_conf[vif], output, f'{prefix}{ifname}.')) -        return output -    for iftype, iftype_conf in interfaces.items(): -        out.update(find_interfaces(iftype_conf)) -    return out -  def get_config(config=None):      if config:          conf = config @@ -88,7 +51,6 @@ def get_config(config=None):      policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True,                                      no_tag_node_value_mangle=True) -    policy['interfaces'] = get_policy_interfaces(conf)      return policy @@ -132,8 +94,8 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id):              side_conf = rule_conf[side]              if 'group' in side_conf: -                if {'address_group', 'network_group'} <= set(side_conf['group']): -                    raise ConfigError('Only one address-group or network-group can be specified') +                if len({'address_group', 'domain_group', 'network_group'} & set(side_conf['group'])) > 1: +                    raise ConfigError('Only one address-group, domain-group or network-group can be specified')                  for group in valid_groups:                      if group in side_conf['group']: @@ -168,73 +130,11 @@ def verify(policy):                      for rule_id, rule_conf in pol_conf['rule'].items():                          verify_rule(policy, name, rule_conf, ipv6, rule_id) -    for ifname, if_policy in policy['interfaces'].items(): -        name = dict_search_args(if_policy, 'route') -        ipv6_name = dict_search_args(if_policy, 'route6') - -        if name and not dict_search_args(policy, 'route', name): -            raise ConfigError(f'Policy route "{name}" is still referenced on interface {ifname}') - -        if ipv6_name and not dict_search_args(policy, 'route6', ipv6_name): -            raise ConfigError(f'Policy route6 "{ipv6_name}" is still referenced on interface {ifname}') -      return None -def cleanup_commands(policy): -    commands = [] -    commands_chains = [] -    commands_sets = [] -    for table in ['ip mangle', 'ip6 mangle']: -        route_node = 'route' if table == 'ip mangle' else 'route6' -        chain_prefix = ROUTE_PREFIX if table == 'ip mangle' else ROUTE6_PREFIX - -        json_str = cmd(f'nft -t -j list table {table}') -        obj = loads(json_str) -        if 'nftables' not in obj: -            continue -        for item in obj['nftables']: -            if 'chain' in item: -                chain = item['chain']['name'] -                if chain in preserve_chains or not chain.startswith("VYOS_PBR"): -                    continue - -                if dict_search_args(policy, route_node, chain.replace(chain_prefix, "", 1)) != None: -                    commands.append(f'flush chain {table} {chain}') -                else: -                    commands_chains.append(f'delete chain {table} {chain}') - -            if 'rule' in item: -                rule = item['rule'] -                chain = rule['chain'] -                handle = rule['handle'] - -                if chain not in preserve_chains: -                    continue - -                target, _ = next(dict_search_recursive(rule['expr'], 'target')) - -                if target.startswith(chain_prefix): -                    if dict_search_args(policy, route_node, target.replace(chain_prefix, "", 1)) == None: -                        commands.append(f'delete rule {table} {chain} handle {handle}') - -            if 'set' in item: -                set_name = item['set']['name'] - -                for prefix, group_type in group_set_prefix.items(): -                    if set_name.startswith(prefix): -                        group_name = set_name.replace(prefix, "", 1) -                        if dict_search_args(policy, 'firewall_group', group_type, group_name) != None: -                            commands_sets.append(f'flush set {table} {set_name}') -                        else: -                            commands_sets.append(f'delete set {table} {set_name}') - -    return commands + commands_chains + commands_sets -  def generate(policy):      if not os.path.exists(nftables_conf):          policy['first_install'] = True -    else: -        policy['cleanup_commands'] = cleanup_commands(policy)      render(nftables_conf, 'firewall/nftables-policy.j2', policy)      return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index 3008a20e0..331194fec 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -23,8 +23,42 @@ from vyos.util import dict_search  from vyos import ConfigError  from vyos import frr  from vyos import airbag +  airbag.enable() + +def community_action_compatibility(actions: dict) -> bool: +    """ +    Check compatibility of values in community and large community sections +    :param actions: dictionary with community +    :type actions: dict +    :return: true if compatible, false if not +    :rtype: bool +    """ +    if ('none' in actions) and ('replace' in actions or 'add' in actions): +        return False +    if 'replace' in actions and 'add' in actions: +        return False +    if ('delete' in actions) and ('none' in actions or 'replace' in actions): +        return False +    return True + + +def extcommunity_action_compatibility(actions: dict) -> bool: +    """ +    Check compatibility of values in extended community sections +    :param actions: dictionary with community +    :type actions: dict +    :return: true if compatible, false if not +    :rtype: bool +    """ +    if ('none' in actions) and ( +            'rt' in actions or 'soo' in actions or 'bandwidth' in actions or 'bandwidth_non_transitive' in actions): +        return False +    if ('bandwidth_non_transitive' in actions) and ('bandwidth' not in actions): +        return False +    return True +  def routing_policy_find(key, dictionary):      # Recursively traverse a dictionary and extract the value assigned to      # a given key as generator object. This is made for routing policies, @@ -46,6 +80,7 @@ def routing_policy_find(key, dictionary):                      for result in routing_policy_find(key, d):                          yield result +  def get_config(config=None):      if config:          conf = config @@ -53,7 +88,8 @@ def get_config(config=None):          conf = Config()      base = ['policy'] -    policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +    policy = conf.get_config_dict(base, key_mangling=('-', '_'), +                                  get_first_key=True,                                    no_tag_node_value_mangle=True)      # We also need some additional information from the config, prefix-lists @@ -67,12 +103,14 @@ def get_config(config=None):      policy = dict_merge(tmp, policy)      return policy +  def verify(policy):      if not policy:          return None      for policy_type in ['access_list', 'access_list6', 'as_path_list', -                        'community_list', 'extcommunity_list', 'large_community_list', +                        'community_list', 'extcommunity_list', +                        'large_community_list',                          'prefix_list', 'prefix_list6', 'route_map']:          # Bail out early and continue with next policy type          if policy_type not in policy: @@ -97,15 +135,18 @@ def verify(policy):                      if 'source' not in rule_config:                          raise ConfigError(f'A source {mandatory_error}') -                    if int(instance) in range(100, 200) or int(instance) in range(2000, 2700): +                    if int(instance) in range(100, 200) or int( +                            instance) in range(2000, 2700):                          if 'destination' not in rule_config: -                            raise ConfigError(f'A destination {mandatory_error}') +                            raise ConfigError( +                                f'A destination {mandatory_error}')                  if policy_type == 'access_list6':                      if 'source' not in rule_config:                          raise ConfigError(f'A source {mandatory_error}') -                if policy_type in ['as_path_list', 'community_list', 'extcommunity_list', +                if policy_type in ['as_path_list', 'community_list', +                                   'extcommunity_list',                                     'large_community_list']:                      if 'regex' not in rule_config:                          raise ConfigError(f'A regex {mandatory_error}') @@ -115,10 +156,10 @@ def verify(policy):                          raise ConfigError(f'A prefix {mandatory_error}')                      if rule_config in entries: -                        raise ConfigError(f'Rule "{rule}" contains a duplicate prefix definition!') +                        raise ConfigError( +                            f'Rule "{rule}" contains a duplicate prefix definition!')                      entries.append(rule_config) -      # route-maps tend to be a bit more complex so they get their own verify() section      if 'route_map' in policy:          for route_map, route_map_config in policy['route_map'].items(): @@ -126,20 +167,29 @@ def verify(policy):                  continue              for rule, rule_config in route_map_config['rule'].items(): +                # Action 'deny' cannot be used with "continue" +                # FRR does not validate it T4827 +                if rule_config['action'] == 'deny' and 'continue' in rule_config: +                    raise ConfigError(f'rule {rule} "continue" cannot be used with action deny!') +                  # Specified community-list must exist -                tmp = dict_search('match.community.community_list', rule_config) +                tmp = dict_search('match.community.community_list', +                                  rule_config)                  if tmp and tmp not in policy.get('community_list', []):                      raise ConfigError(f'community-list {tmp} does not exist!')                  # Specified extended community-list must exist                  tmp = dict_search('match.extcommunity', rule_config)                  if tmp and tmp not in policy.get('extcommunity_list', []): -                    raise ConfigError(f'extcommunity-list {tmp} does not exist!') +                    raise ConfigError( +                        f'extcommunity-list {tmp} does not exist!')                  # Specified large-community-list must exist -                tmp = dict_search('match.large_community.large_community_list', rule_config) +                tmp = dict_search('match.large_community.large_community_list', +                                  rule_config)                  if tmp and tmp not in policy.get('large_community_list', []): -                    raise ConfigError(f'large-community-list {tmp} does not exist!') +                    raise ConfigError( +                        f'large-community-list {tmp} does not exist!')                  # Specified prefix-list must exist                  tmp = dict_search('match.ip.address.prefix_list', rule_config) @@ -147,49 +197,87 @@ def verify(policy):                      raise ConfigError(f'prefix-list {tmp} does not exist!')                  # Specified prefix-list must exist -                tmp = dict_search('match.ipv6.address.prefix_list', rule_config) +                tmp = dict_search('match.ipv6.address.prefix_list', +                                  rule_config)                  if tmp and tmp not in policy.get('prefix_list6', []):                      raise ConfigError(f'prefix-list6 {tmp} does not exist!') -                     +                  # Specified access_list6 in nexthop must exist -                tmp = dict_search('match.ipv6.nexthop.access_list', rule_config) +                tmp = dict_search('match.ipv6.nexthop.access_list', +                                  rule_config)                  if tmp and tmp not in policy.get('access_list6', []):                      raise ConfigError(f'access_list6 {tmp} does not exist!')                  # Specified prefix-list6 in nexthop must exist -                tmp = dict_search('match.ipv6.nexthop.prefix_list', rule_config) +                tmp = dict_search('match.ipv6.nexthop.prefix_list', +                                  rule_config)                  if tmp and tmp not in policy.get('prefix_list6', []):                      raise ConfigError(f'prefix-list6 {tmp} does not exist!') +                tmp = dict_search('set.community.delete', rule_config) +                if tmp and tmp not in policy.get('community_list', []): +                    raise ConfigError(f'community-list {tmp} does not exist!') + +                tmp = dict_search('set.large_community.delete', +                                  rule_config) +                if tmp and tmp not in policy.get('large_community_list', []): +                    raise ConfigError( +                        f'large-community-list {tmp} does not exist!') + +                if 'set' in rule_config: +                    rule_action = rule_config['set'] +                    if 'community' in rule_action: +                        if not community_action_compatibility( +                                rule_action['community']): +                            raise ConfigError( +                                f'Unexpected combination between action replace, add, delete or none in community') +                    if 'large_community' in rule_action: +                        if not community_action_compatibility( +                                rule_action['large_community']): +                            raise ConfigError( +                                f'Unexpected combination between action replace, add, delete or none in large-community') +                    if 'extcommunity' in rule_action: +                        if not extcommunity_action_compatibility( +                                rule_action['extcommunity']): +                            raise ConfigError( +                                f'Unexpected combination between none, rt, soo, bandwidth, bandwidth-non-transitive in extended-community')      # When routing protocols are active some use prefix-lists, route-maps etc.      # to apply the systems routing policy to the learned or redistributed routes.      # When the "routing policy" changes and policies, route-maps etc. are deleted,      # it is our responsibility to verify that the policy can not be deleted if it      # is used by any routing protocol      if 'protocols' in policy: -        for policy_type in ['access_list', 'access_list6', 'as_path_list', 'community_list', -                            'extcommunity_list', 'large_community_list', 'prefix_list', 'route_map']: +        for policy_type in ['access_list', 'access_list6', 'as_path_list', +                            'community_list', +                            'extcommunity_list', 'large_community_list', +                            'prefix_list', 'route_map']:              if policy_type in policy: -                for policy_name in list(set(routing_policy_find(policy_type, policy['protocols']))): +                for policy_name in list(set(routing_policy_find(policy_type, +                                                                policy[ +                                                                    'protocols']))):                      found = False                      if policy_name in policy[policy_type]:                          found = True                      # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related                      # list - we need to go the extra mile here and check both prefix-lists -                    if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in policy['prefix_list6']: +                    if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \ +                            policy['prefix_list6']:                          found = True                      if not found: -                        tmp = policy_type.replace('_','-') -                        raise ConfigError(f'Can not delete {tmp} "{policy_name}", still in use!') +                        tmp = policy_type.replace('_', '-') +                        raise ConfigError( +                            f'Can not delete {tmp} "{policy_name}", still in use!')      return None +  def generate(policy):      if not policy:          return None      policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy)      return None +  def apply(policy):      bgp_daemon = 'bgpd'      zebra_daemon = 'zebra' @@ -203,7 +291,8 @@ def apply(policy):      frr_cfg.modify_section(r'^bgp community-list .*')      frr_cfg.modify_section(r'^bgp extcommunity-list .*')      frr_cfg.modify_section(r'^bgp large-community-list .*') -    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', +                           remove_stop_mark=True)      if 'new_frr_config' in policy:          frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])      frr_cfg.commit_configuration(bgp_daemon) @@ -214,13 +303,15 @@ def apply(policy):      frr_cfg.modify_section(r'^ipv6 access-list .*')      frr_cfg.modify_section(r'^ip prefix-list .*')      frr_cfg.modify_section(r'^ipv6 prefix-list .*') -    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', +                           remove_stop_mark=True)      if 'new_frr_config' in policy:          frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])      frr_cfg.commit_configuration(zebra_daemon)      return None +  if __name__ == '__main__':      try:          c = get_config() diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 87456f00b..ff568d470 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -159,6 +159,11 @@ def verify(bgp):              if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config:                  raise ConfigError('You can not set both ebgp-multihop and ttl-security hops') +            # interface and ebgp-multihop can't be used in the same configration +            if 'ebgp_multihop' in peer_config and 'interface' in peer_config: +                raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\ +                                  f'neighbor "{peer}"') +              # Check if neighbor has both override capability and strict capability match              # configured at the same time.              if 'override_capability' in peer_config and 'strict_capability_match' in peer_config: diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 5b4874ba2..0582d32be 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -198,6 +198,58 @@ def verify(ospf):                  if 'master' not in tmp or tmp['master'] != vrf:                      raise ConfigError(f'Interface {interface} is not a member of VRF {vrf}!') +    # Segment routing checks +    if dict_search('segment_routing.global_block', ospf): +        g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf) +        g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf) + +        # If segment routing global block high or low value is blank, throw error +        if not (g_low_label_value or g_high_label_value): +            raise ConfigError('Segment routing global-block requires both low and high value!') + +        # If segment routing global block low value is higher than the high value, throw error +        if int(g_low_label_value) > int(g_high_label_value): +            raise ConfigError('Segment routing global-block low value must be lower than high value') + +    if dict_search('segment_routing.local_block', ospf): +        if dict_search('segment_routing.global_block', ospf) == None: +            raise ConfigError('Segment routing local-block requires global-block to be configured!') + +        l_high_label_value = dict_search('segment_routing.local_block.high_label_value', ospf) +        l_low_label_value = dict_search('segment_routing.local_block.low_label_value', ospf) + +        # If segment routing local-block high or low value is blank, throw error +        if not (l_low_label_value or l_high_label_value): +            raise ConfigError('Segment routing local-block requires both high and low value!') + +        # If segment routing local-block low value is higher than the high value, throw error +        if int(l_low_label_value) > int(l_high_label_value): +            raise ConfigError('Segment routing local-block low value must be lower than high value') + +        # local-block most live outside global block +        global_range = range(int(g_low_label_value), int(g_high_label_value) +1) +        local_range  = range(int(l_low_label_value), int(l_high_label_value) +1) + +        # Check for overlapping ranges +        if list(set(global_range) & set(local_range)): +            raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ +                              f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') +         +    # Check for a blank or invalid value per prefix +    if dict_search('segment_routing.prefix', ospf): +        for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): +            if 'index' in prefix_config: +                if prefix_config['index'].get('value') is None: +                    raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.') + +    # Check for explicit-null and no-php-flag configured at the same time per prefix +    if dict_search('segment_routing.prefix', ospf): +        for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): +            if 'index' in prefix_config: +                if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): +                    raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ +                                      f'and no-php-flag configured at the same time.') +      return None  def generate(ospf): diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index 427cb6911..aafece47a 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -42,7 +42,11 @@ systemd_override = '/etc/systemd/system/telegraf.service.d/10-override.conf'  def get_nft_filter_chains():      """ Get nft chains for table filter """ -    nft = cmd('nft --json list table ip vyos_filter') +    try: +        nft = cmd('nft --json list table ip vyos_filter') +    except Exception: +        print('nft table ip vyos_filter not found') +        return []      nft = json.loads(nft)      chain_list = [] diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 2bbd7142a..8746cc701 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -73,6 +73,9 @@ def verify(ssh):      if not ssh:          return None +    if 'rekey' in ssh and 'data' not in ssh['rekey']: +        raise ConfigError(f'Rekey data is required!') +      verify_vrf(ssh)      return None diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index dbd346fe4..e26b81e3d 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -257,6 +257,15 @@ def apply(login):              except Exception as e:                  raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') +            # Generate 2FA/MFA One-Time-Pad configuration +            if dict_search('authentication.otp.key', user_config): +                render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', +                       user_config, permission=0o400, user=user, group='users') +            else: +                # delete configuration as it's not enabled for the user +                if os.path.exists(f'{home_dir}/.google_authenticator'): +                    os.remove(f'{home_dir}/.google_authenticator') +      if 'rm_users' in login:          for user in login['rm_users']:              try: diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 77a425f8b..b79e9847a 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -22,6 +22,7 @@ from sys import exit  from time import sleep  from time import time +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import leaf_node_changed  from vyos.configverify import verify_interface_exists @@ -117,13 +118,26 @@ def get_config(config=None):                      ipsec['ike_group'][group]['proposal'][proposal] = dict_merge(default_values,                          ipsec['ike_group'][group]['proposal'][proposal]) -    if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']: +    # XXX: T2665: we can not safely rely on the defaults() when there are +    # tagNodes in place, it is better to blend in the defaults manually. +    if dict_search('remote_access.connection', ipsec):          default_values = defaults(base + ['remote-access', 'connection'])          for rw in ipsec['remote_access']['connection']:              ipsec['remote_access']['connection'][rw] = dict_merge(default_values,                ipsec['remote_access']['connection'][rw]) -    if 'remote_access' in ipsec and 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: +    # XXX: T2665: we can not safely rely on the defaults() when there are +    # tagNodes in place, it is better to blend in the defaults manually. +    if dict_search('remote_access.radius.server', ipsec): +        # Fist handle the "base" stuff like RADIUS timeout +        default_values = defaults(base + ['remote-access', 'radius']) +        if 'server' in default_values: +            del default_values['server'] +        ipsec['remote_access']['radius'] = dict_merge(default_values, +                                                      ipsec['remote_access']['radius']) + +        # Take care about individual RADIUS servers implemented as tagNodes - this +        # requires special treatment          default_values = defaults(base + ['remote-access', 'radius', 'server'])          for server in ipsec['remote_access']['radius']['server']:              ipsec['remote_access']['radius']['server'][server] = dict_merge(default_values, @@ -425,6 +439,10 @@ def verify(ipsec):                  if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf:                      raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") +                if dict_search('options.disable_route_autoinstall', +                               ipsec) == None: +                    Warning('It\'s recommended to use ipsec vty with the next command\n[set vpn ipsec option disable-route-autoinstall]') +                  if 'bind' in peer_conf['vti']:                      vti_interface = peer_conf['vti']['bind']                      if not os.path.exists(f'/sys/class/net/{vti_interface}'): diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf index b1902b585..518abeaec 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf +++ b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf @@ -33,8 +33,8 @@ if /usr/bin/systemctl -q is-active vyos-hostsd; then          if [ -n "$new_dhcp6_name_servers" ]; then              logmsg info "Deleting nameservers with tag \"dhcpv6-$interface\" via vyos-hostsd-client"              $hostsd_client --delete-name-servers --tag "dhcpv6-$interface" -            logmsg info "Adding nameservers \"$new_dhcpv6_name_servers\" with tag \"dhcpv6-$interface\" via vyos-hostsd-client" -            $hostsd_client --add-name-servers $new_dhcpv6_name_servers --tag "dhcpv6-$interface" +            logmsg info "Adding nameservers \"$new_dhcp6_name_servers\" with tag \"dhcpv6-$interface\" via vyos-hostsd-client" +            $hostsd_client --add-name-servers $new_dhcp6_name_servers --tag "dhcpv6-$interface"              hostsd_changes=y          fi diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup index ad6a1d5eb..da1bda137 100644 --- a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup +++ b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup @@ -8,7 +8,7 @@ hostsd_changes=  /usr/bin/systemctl -q is-active vyos-hostsd  hostsd_status=$? -if [[ $reason =~ (EXPIRE|FAIL|RELEASE|STOP) ]]; then +if [[ $reason =~ ^(EXPIRE|FAIL|RELEASE|STOP)$ ]]; then      if [[ $hostsd_status -eq 0 ]]; then          # delete search domains and nameservers via vyos-hostsd          logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client" @@ -96,7 +96,7 @@ if [[ $reason =~ (EXPIRE|FAIL|RELEASE|STOP) ]]; then      fi  fi -if [[ $reason =~ (EXPIRE6|RELEASE6|STOP6) ]]; then +if [[ $reason =~ ^(EXPIRE6|RELEASE6|STOP6)$ ]]; then      if [[ $hostsd_status -eq 0 ]]; then          # delete search domains and nameservers via vyos-hostsd          logmsg info "Deleting search domains with tag \"dhcpv6-$interface\" via vyos-hostsd-client" diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/vyatta-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/vyatta-dhclient-hook index eeb8b0782..49bb18372 100644 --- a/src/etc/dhcp/dhclient-exit-hooks.d/vyatta-dhclient-hook +++ b/src/etc/dhcp/dhclient-exit-hooks.d/vyatta-dhclient-hook @@ -8,12 +8,12 @@  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 as  # published by the Free Software Foundation. -#  +#  # This program is distributed in the hope that it will be useful, but  # WITHOUT ANY WARRANTY; without even the implied warranty of  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU  # General Public License for more details. -#  +#  # This code was originally developed by Vyatta, Inc.  # Portions created by Vyatta are Copyright (C) 2006, 2007, 2008 Vyatta, Inc.  # All Rights Reserved. @@ -23,7 +23,7 @@  RUN="yes"  proto="" -if [[ $reason =~ (REBOOT6|INIT6|EXPIRE6|RELEASE6|STOP6|INFORM6|BOUND6|REBIND6|DELEGATED6) ]]; then +if [[ $reason =~ ^(REBOOT6|INIT6|EXPIRE6|RELEASE6|STOP6|INFORM6|BOUND6|REBIND6|DELEGATED6)$ ]]; then          proto="v6"  fi diff --git a/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers new file mode 100755 index 000000000..222c75f21 --- /dev/null +++ b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers @@ -0,0 +1,15 @@ +#!/bin/bash +### Autogenerated by interfaces-pppoe.py ### + +interface=$6 +if [ -z "$interface" ]; then +    exit +fi + +if ! /usr/bin/systemctl -q is-active vyos-hostsd; then +    exit  # vyos-hostsd is not running +fi + +hostsd_client="/usr/bin/vyos-hostsd-client" +$hostsd_client --delete-name-servers --tag "dhcp-$interface" +$hostsd_client --apply diff --git a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers new file mode 100755 index 000000000..0fcedbedc --- /dev/null +++ b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers @@ -0,0 +1,24 @@ +#!/bin/bash +### Autogenerated by interfaces-pppoe.py ### + +interface=$6 +if [ -z "$interface" ]; then +    exit +fi + +if ! /usr/bin/systemctl -q is-active vyos-hostsd; then +    exit  # vyos-hostsd is not running +fi + +hostsd_client="/usr/bin/vyos-hostsd-client" + +$hostsd_client --delete-name-servers --tag "dhcp-$interface" + +if [ "$USEPEERDNS" ] && [ -n "$DNS1" ]; then +$hostsd_client --add-name-servers "$DNS1" --tag "dhcp-$interface" +fi +if [ "$USEPEERDNS" ] && [ -n "$DNS2" ]; then +$hostsd_client --add-name-servers "$DNS2" --tag "dhcp-$interface" +fi + +$hostsd_client --apply diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos index f760b417f..e0fd8cb0b 100644 --- a/src/etc/sudoers.d/vyos +++ b/src/etc/sudoers.d/vyos @@ -40,10 +40,13 @@ Cmnd_Alias PCAPTURE = /usr/bin/tcpdump  Cmnd_Alias HWINFO   = /usr/bin/lspci  Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \                             /usr/share/heartbeat/hb_standby +Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *,       \ +                         /bin/ip vrf exec * /bin/traceroute *, \ +                         /usr/libexec/vyos/op_mode/*  %operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \  			PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \                          DMIDECODE, DISK, CONNTRACK, IP6TABLES,  \ -                        FORCE_CLUSTER +                        FORCE_CLUSTER, DIAGNOSTICS  # Allow any user to run files in sudo-users  %users ALL=NOPASSWD: /opt/vyatta/bin/sudo-users/ diff --git a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py index cbc2bfe6b..d7eca5894 100755 --- a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py +++ b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py @@ -11,7 +11,10 @@ def get_nft_filter_chains():      """      Get list of nft chains for table filter      """ -    nft = cmd('/usr/sbin/nft --json list table ip vyos_filter') +    try: +        nft = cmd('/usr/sbin/nft --json list table ip vyos_filter') +    except Exception: +        return []      nft = json.loads(nft)      chain_list = [] diff --git a/src/helpers/system-versions-foot.py b/src/helpers/system-versions-foot.py index 2aa687221..9614f0d28 100755 --- a/src/helpers/system-versions-foot.py +++ b/src/helpers/system-versions-foot.py @@ -1,6 +1,6 @@  #!/usr/bin/python3 -# Copyright 2019 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 @@ -16,24 +16,13 @@  # along with this library.  If not, see <http://www.gnu.org/licenses/>.  import sys -import vyos.formatversions as formatversions -import vyos.systemversions as systemversions  import vyos.defaults -import vyos.version - -sys_versions = systemversions.get_system_component_version() - -component_string = formatversions.format_versions_string(sys_versions) - -os_version_string = vyos.version.get_version() +from vyos.component_version import write_system_footer  sys.stdout.write("\n\n")  if vyos.defaults.cfg_vintage == 'vyos': -    formatversions.write_vyos_versions_foot(None, component_string, -                                            os_version_string) +    write_system_footer(None, vintage='vyos')  elif vyos.defaults.cfg_vintage == 'vyatta': -    formatversions.write_vyatta_versions_foot(None, component_string, -                                              os_version_string) +    write_system_footer(None, vintage='vyatta')  else: -    formatversions.write_vyatta_versions_foot(None, component_string, -                                              os_version_string) +    write_system_footer(None, vintage='vyos') diff --git a/src/helpers/vyos-domain-group-resolve.py b/src/helpers/vyos-domain-group-resolve.py deleted file mode 100755 index 6b677670b..000000000 --- a/src/helpers/vyos-domain-group-resolve.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/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 time - -from vyos.configquery import ConfigTreeQuery -from vyos.firewall import get_ips_domains_dict -from vyos.firewall import nft_add_set_elements -from vyos.firewall import nft_flush_set -from vyos.firewall import nft_init_set -from vyos.firewall import nft_update_set_elements -from vyos.util import call - - -base = ['firewall', 'group', 'domain-group'] -check_required = True -# count_failed = 0 -# Timeout in sec between checks -timeout = 300 - -domain_state = {} - -if __name__ == '__main__': - -    while check_required: -        config = ConfigTreeQuery() -        if config.exists(base): -            domain_groups = config.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) -            for set_name, domain_config in domain_groups.items(): -                list_domains = domain_config['address'] -                elements = [] -                ip_dict = get_ips_domains_dict(list_domains) - -                for domain in list_domains: -                    # Resolution succeeded, update domain state -                    if domain in ip_dict: -                        domain_state[domain] = ip_dict[domain] -                        elements += ip_dict[domain] -                    # Resolution failed, use previous domain state -                    elif domain in domain_state: -                        elements += domain_state[domain] - -                # Resolve successful -                if elements: -                    nft_update_set_elements(f'D_{set_name}', elements) -        time.sleep(timeout) diff --git a/src/helpers/vyos-domain-resolver.py b/src/helpers/vyos-domain-resolver.py new file mode 100755 index 000000000..e31d9238e --- /dev/null +++ b/src/helpers/vyos-domain-resolver.py @@ -0,0 +1,183 @@ +#!/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 os +import time + +from vyos.configdict import dict_merge +from vyos.configquery import ConfigTreeQuery +from vyos.firewall import fqdn_config_parse +from vyos.firewall import fqdn_resolve +from vyos.util import cmd +from vyos.util import commit_in_progress +from vyos.util import dict_search_args +from vyos.util import run +from vyos.xml import defaults + +base = ['firewall'] +timeout = 300 +cache = False + +domain_state = {} + +ipv4_tables = { +    'ip vyos_mangle', +    'ip vyos_filter', +    'ip vyos_nat' +} + +ipv6_tables = { +    'ip6 vyos_mangle', +    'ip6 vyos_filter' +} + +def get_config(conf): +    firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +                                    no_tag_node_value_mangle=True) + +    default_values = defaults(base) +    for tmp in ['name', 'ipv6_name']: +        if tmp in default_values: +            del default_values[tmp] + +    if 'zone' in default_values: +        del default_values['zone'] + +    firewall = dict_merge(default_values, firewall) + +    global timeout, cache + +    if 'resolver_interval' in firewall: +        timeout = int(firewall['resolver_interval']) + +    if 'resolver_cache' in firewall: +        cache = True + +    fqdn_config_parse(firewall) + +    return firewall + +def resolve(domains, ipv6=False): +    global domain_state + +    ip_list = set() + +    for domain in domains: +        resolved = fqdn_resolve(domain, ipv6=ipv6) + +        if resolved and cache: +            domain_state[domain] = resolved +        elif not resolved: +            if domain not in domain_state: +                continue +            resolved = domain_state[domain] + +        ip_list = ip_list | resolved +    return ip_list + +def nft_output(table, set_name, ip_list): +    output = [f'flush set {table} {set_name}'] +    if ip_list: +        ip_str = ','.join(ip_list) +        output.append(f'add element {table} {set_name} {{ {ip_str} }}') +    return output + +def nft_valid_sets(): +    try: +        valid_sets = [] +        sets_json = cmd('nft -j list sets') +        sets_obj = json.loads(sets_json) + +        for obj in sets_obj['nftables']: +            if 'set' in obj: +                family = obj['set']['family'] +                table = obj['set']['table'] +                name = obj['set']['name'] +                valid_sets.append((f'{family} {table}', name)) + +        return valid_sets +    except: +        return [] + +def update(firewall): +    conf_lines = [] +    count = 0 + +    valid_sets = nft_valid_sets() + +    domain_groups = dict_search_args(firewall, 'group', 'domain_group') +    if domain_groups: +        for set_name, domain_config in domain_groups.items(): +            if 'address' not in domain_config: +                continue + +            nft_set_name = f'D_{set_name}' +            domains = domain_config['address'] + +            ip_list = resolve(domains, ipv6=False) +            for table in ipv4_tables: +                if (table, nft_set_name) in valid_sets: +                    conf_lines += nft_output(table, nft_set_name, ip_list) + +            ip6_list = resolve(domains, ipv6=True) +            for table in ipv6_tables: +                if (table, nft_set_name) in valid_sets: +                    conf_lines += nft_output(table, nft_set_name, ip6_list) +            count += 1 + +    for set_name, domain in firewall['ip_fqdn'].items(): +        table = 'ip vyos_filter' +        nft_set_name = f'FQDN_{set_name}' + +        ip_list = resolve([domain], ipv6=False) + +        if (table, nft_set_name) in valid_sets: +            conf_lines += nft_output(table, nft_set_name, ip_list) +        count += 1 + +    for set_name, domain in firewall['ip6_fqdn'].items(): +        table = 'ip6 vyos_filter' +        nft_set_name = f'FQDN_{set_name}' + +        ip_list = resolve([domain], ipv6=True) +        if (table, nft_set_name) in valid_sets: +            conf_lines += nft_output(table, nft_set_name, ip_list) +        count += 1 + +    nft_conf_str = "\n".join(conf_lines) + "\n" +    code = run(f'nft -f -', input=nft_conf_str) + +    print(f'Updated {count} sets - result: {code}') + +if __name__ == '__main__': +    print(f'VyOS domain resolver') + +    count = 1 +    while commit_in_progress(): +        if ( count % 60 == 0 ): +            print(f'Commit still in progress after {count}s - waiting') +        count += 1 +        time.sleep(1) + +    conf = ConfigTreeQuery() +    firewall = get_config(conf) + +    print(f'interval: {timeout}s - cache: {cache}') + +    while True: +        update(firewall) +        time.sleep(timeout) diff --git a/src/migration-scripts/https/3-to-4 b/src/migration-scripts/https/3-to-4 new file mode 100755 index 000000000..5ee528b31 --- /dev/null +++ b/src/migration-scripts/https/3-to-4 @@ -0,0 +1,53 @@ +#!/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/>. + +# T4768 rename node 'gql' to 'graphql'. + +import sys + +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 2): +    print("Must specify file name!") +    sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +old_base = ['service', 'https', 'api', 'gql'] +if not config.exists(old_base): +    # Nothing to do +    sys.exit(0) + +new_base = ['service', 'https', 'api', 'graphql'] +config.set(new_base) + +nodes = config.list_nodes(old_base) +for node in nodes: +    config.copy(old_base + [node], new_base + [node]) + +config.delete(old_base) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    sys.exit(1) diff --git a/src/migration-scripts/isis/1-to-2 b/src/migration-scripts/isis/1-to-2 new file mode 100755 index 000000000..f914ea995 --- /dev/null +++ b/src/migration-scripts/isis/1-to-2 @@ -0,0 +1,46 @@ +#!/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/>. + +# T4739 refactor, and remove "on" from segment routing from the configuration + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +# Check if ISIS segment routing is configured. Then check if segment routing "on" exists, then delete the "on" as it is no longer needed. This is for global configuration. +if config.exists(['protocols', 'isis']): +    if config.exists(['protocols', 'isis', 'segment-routing']): +        if config.exists(['protocols', 'isis', 'segment-routing', 'enable']): +            config.delete(['protocols', 'isis', 'segment-routing', 'enable']) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/policy/3-to-4 b/src/migration-scripts/policy/3-to-4 new file mode 100755 index 000000000..bae30cffc --- /dev/null +++ b/src/migration-scripts/policy/3-to-4 @@ -0,0 +1,162 @@ +#!/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/>. + +# T4660: change cli +#     from: set policy route-map FOO rule 10 set community 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set community replace <community> +#     Multiple value +#     to: set policy route-map FOO rule 10 set community add <community> +#     to: set policy route-map FOO rule 10 set community none +# +#     from: set policy route-map FOO rule 10 set large-community 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set large-community replace <community> +#     Multiple value +#     to: set policy route-map FOO rule 10 set large-community add <community> +#     to: set policy route-map FOO rule 10 set large-community none +# +#     from: set policy route-map FOO rule 10 set extecommunity [rt|soo] 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set extcommunity [rt|soo] <community> + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + + +# Migration function for large and regular communities +def community_migrate(config: ConfigTree, rule: list[str]) -> bool: +    """ + +    :param config: configuration object +    :type config: ConfigTree +    :param rule: Path to variable +    :type rule: list[str] +    :return: True if additive presents in community string +    :rtype: bool +    """ +    community_list = list((config.return_value(rule)).split(" ")) +    config.delete(rule) +    if 'none' in community_list: +        config.set(rule + ['none']) +        return False +    else: +        community_action: str = 'replace' +        if 'additive' in community_list: +            community_action = 'add' +            community_list.remove('additive') +        for community in community_list: +            config.set(rule + [community_action], value=community, +                       replace=False) +        if community_action == 'replace': +            return False +        else: +            return True + + +# Migration function for extcommunities +def extcommunity_migrate(config: ConfigTree, rule: list[str]) -> None: +    """ + +    :param config: configuration object +    :type config: ConfigTree +    :param rule: Path to variable +    :type rule: list[str] +    """ +    # if config.exists(rule + ['bandwidth']): +    #     bandwidth: str = config.return_value(rule + ['bandwidth']) +    #     config.delete(rule + ['bandwidth']) +    #     config.set(rule + ['bandwidth'], value=bandwidth) + +    if config.exists(rule + ['rt']): +        community_list = list((config.return_value(rule + ['rt'])).split(" ")) +        config.delete(rule + ['rt']) +        for community in community_list: +            config.set(rule + ['rt'], value=community, replace=False) + +    if config.exists(rule + ['soo']): +        community_list = list((config.return_value(rule + ['soo'])).split(" ")) +        config.delete(rule + ['soo']) +        for community in community_list: +            config.set(rule + ['soo'], value=community, replace=False) + + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name: str = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base: list[str] = ['policy', 'route-map'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +for route_map in config.list_nodes(base): +    if not config.exists(base + [route_map, 'rule']): +        continue +    for rule in config.list_nodes(base + [route_map, 'rule']): +        base_rule: list[str] = base + [route_map, 'rule', rule, 'set'] + +        # IF additive presents in coummunity then comm-list is redundant +        isAdditive: bool = True +        #### Change Set community ######## +        if config.exists(base_rule + ['community']): +            isAdditive = community_migrate(config, +                                           base_rule + ['community']) + +        #### Change Set community-list delete migrate ######## +        if config.exists(base_rule + ['comm-list', 'comm-list']): +            if isAdditive: +                tmp = config.return_value( +                    base_rule + ['comm-list', 'comm-list']) +                config.delete(base_rule + ['comm-list']) +                config.set(base_rule + ['community', 'delete'], value=tmp) +            else: +                config.delete(base_rule + ['comm-list']) + +        isAdditive = False +        #### Change Set large-community ######## +        if config.exists(base_rule + ['large-community']): +            isAdditive = community_migrate(config, +                                           base_rule + ['large-community']) + +        #### Change Set large-community delete by List ######## +        if config.exists(base_rule + ['large-comm-list-delete']): +            if isAdditive: +                tmp = config.return_value( +                    base_rule + ['large-comm-list-delete']) +                config.delete(base_rule + ['large-comm-list-delete']) +                config.set(base_rule + ['large-community', 'delete'], +                           value=tmp) +            else: +                config.delete(base_rule + ['large-comm-list-delete']) + +        #### Change Set extcommunity ######## +        extcommunity_migrate(config, base_rule + ['extcommunity']) +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5 new file mode 100755 index 000000000..33c9e6ade --- /dev/null +++ b/src/migration-scripts/policy/4-to-5 @@ -0,0 +1,92 @@ +#!/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/>. + +# T2199: Migrate interface policy nodes to policy route <name> interface <ifname> + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base4 = ['policy', 'route'] +base6 = ['policy', 'route6'] +config = ConfigTree(config_file) + +if not config.exists(base4) and not config.exists(base6): +    # Nothing to do +    exit(0) + +def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None): +    if_path = ['interfaces', iftype, ifname] +    ifname_full = ifname + +    if vif: +        if_path += ['vif', vif] +        ifname_full = f'{ifname}.{vif}' +    elif vifs: +        if_path += ['vif-s', vifs] +        ifname_full = f'{ifname}.{vifs}' +        if vifc: +            if_path += ['vif-c', vifc] +            ifname_full = f'{ifname}.{vifs}.{vifc}' + +    if not config.exists(if_path + ['policy']): +        return + +    if config.exists(if_path + ['policy', 'route']): +        route_name = config.return_value(if_path + ['policy', 'route']) +        config.set(base4 + [route_name, 'interface'], value=ifname_full, replace=False) + +    if config.exists(if_path + ['policy', 'route6']): +        route_name = config.return_value(if_path + ['policy', 'route6']) +        config.set(base6 + [route_name, 'interface'], value=ifname_full, replace=False) + +    config.delete(if_path + ['policy']) + +for iftype in config.list_nodes(['interfaces']): +    for ifname in config.list_nodes(['interfaces', iftype]): +        migrate_interface(config, iftype, ifname) + +        if config.exists(['interfaces', iftype, ifname, 'vif']): +            for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']): +                migrate_interface(config, iftype, ifname, vif=vif) + +        if config.exists(['interfaces', iftype, ifname, 'vif-s']): +            for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']): +                migrate_interface(config, iftype, ifname, vifs=vifs) + +                if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): +                    for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): +                        migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    exit(1) diff --git a/src/op_mode/accelppp.py b/src/op_mode/accelppp.py new file mode 100755 index 000000000..2fd045dc3 --- /dev/null +++ b/src/op_mode/accelppp.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# + +import sys + +import vyos.accel_ppp +import vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.util import rc_cmd + + +accel_dict = { +    'ipoe': { +        'port': 2002, +        'path': 'service ipoe-server' +    }, +    'pppoe': { +        'port': 2001, +        'path': 'service pppoe-server' +    }, +    'pptp': { +        'port': 2003, +        'path': 'vpn pptp' +    }, +    'l2tp': { +        'port': 2004, +        'path': 'vpn l2tp' +    }, +    'sstp': { +        'port': 2005, +        'path': 'vpn sstp' +    } +} + + +def _get_raw_statistics(accel_output, pattern): +    return vyos.accel_ppp.get_server_statistics(accel_output, pattern, sep=':') + + +def _get_raw_sessions(port): +    cmd_options = 'show sessions ifname,username,ip,ip6,ip6-dp,type,state,' \ +                  'uptime-raw,calling-sid,called-sid,sid,comp,rx-bytes-raw,' \ +                  'tx-bytes-raw,rx-pkts,tx-pkts' +    output = vyos.accel_ppp.accel_cmd(port, cmd_options) +    parsed_data: list[dict[str, str]] = vyos.accel_ppp.accel_out_parse( +        output.splitlines()) +    return parsed_data + + +def _verify(func): +    """Decorator checks if accel-ppp protocol +    ipoe/pppoe/pptp/l2tp/sstp is configured + +    for example: +        service ipoe-server +        vpn sstp +    """ +    from functools import wraps + +    @wraps(func) +    def _wrapper(*args, **kwargs): +        config = ConfigTreeQuery() +        protocol_list = accel_dict.keys() +        protocol = kwargs.get('protocol') +        # unknown or incorrect protocol query +        if protocol not in protocol_list: +            unconf_message = f'unknown protocol "{protocol}"' +            raise vyos.opmode.UnconfiguredSubsystem(unconf_message) +        # Check if config does not exist +        config_protocol_path = accel_dict[protocol]['path'] +        if not config.exists(config_protocol_path): +            unconf_message = f'"{config_protocol_path}" is not configured' +            raise vyos.opmode.UnconfiguredSubsystem(unconf_message) +        return func(*args, **kwargs) + +    return _wrapper + + +@_verify +def show_statistics(raw: bool, protocol: str): +    """show accel-cmd statistics +    CPU utilization and amount of sessions + +    protocol: ipoe/pppoe/ppptp/l2tp/sstp +    """ +    pattern = f'{protocol}:' +    port = accel_dict[protocol]['port'] +    rc, output = rc_cmd(f'/usr/bin/accel-cmd -p {port} show stat') + +    if raw: +        return _get_raw_statistics(output, pattern) + +    return output + + +@_verify +def show_sessions(raw: bool, protocol: str): +    """show accel-cmd sessions + +    protocol: ipoe/pppoe/ppptp/l2tp/sstp +    """ +    port = accel_dict[protocol]['port'] +    if raw: +        return _get_raw_sessions(port) + +    return vyos.accel_ppp.accel_cmd(port, +                                    'show sessions ifname,username,ip,ip6,ip6-dp,' +                                    'calling-sid,rate-limit,state,uptime,rx-bytes,tx-bytes') + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/bgp.py b/src/op_mode/bgp.py new file mode 100755 index 000000000..23001a9d7 --- /dev/null +++ b/src/op_mode/bgp.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# Purpose: +#    Displays bgp neighbors information. +#    Used by the "show bgp (vrf <tag>) ipv4|ipv6 neighbors" commands. + +import re +import sys +import typing + +import jmespath +from jinja2 import Template +from humps import decamelize + +from vyos.configquery import ConfigTreeQuery + +import vyos.opmode + + +frr_command_template = Template(""" +{% if family %} +    show bgp +        {{ 'vrf ' ~ vrf if vrf else '' }} +        {{ 'ipv6' if family == 'inet6' else 'ipv4'}} +        {{ 'neighbor ' ~ peer if peer else 'summary' }} +{% endif %} + +{% if raw %} +    json +{% endif %} +""") + + +def _verify(func): +    """Decorator checks if BGP config exists +    BGP configuration can be present under vrf <tag> +    If we do npt get arg 'peer' then it can be 'bgp summary' +    """ +    from functools import wraps + +    @wraps(func) +    def _wrapper(*args, **kwargs): +        config = ConfigTreeQuery() +        afi = 'ipv6' if kwargs.get('family') == 'inet6' else 'ipv4' +        global_vrfs = ['all', 'default'] +        peer = kwargs.get('peer') +        vrf = kwargs.get('vrf') +        unconf_message = f'BGP or neighbor is not configured' +        # Add option to check the specific neighbor if we have arg 'peer' +        peer_opt = f'neighbor {peer} address-family {afi}-unicast' if peer else '' +        vrf_opt = '' +        if vrf and vrf not in global_vrfs: +            vrf_opt = f'vrf name {vrf}' +        # Check if config does not exist +        if not config.exists(f'{vrf_opt} protocols bgp {peer_opt}'): +            raise vyos.opmode.UnconfiguredSubsystem(unconf_message) +        return func(*args, **kwargs) + +    return _wrapper + + +@_verify +def show_neighbors(raw: bool, +                   family: str, +                   peer: typing.Optional[str], +                   vrf: typing.Optional[str]): +    kwargs = dict(locals()) +    frr_command = frr_command_template.render(kwargs) +    frr_command = re.sub(r'\s+', ' ', frr_command) + +    from vyos.util import cmd +    output = cmd(f"vtysh -c '{frr_command}'") + +    if raw: +        from json import loads +        data = loads(output) +        # Get list of the peers +        peers = jmespath.search('*.peers | [0]', data) +        if peers: +            # Create new dict, delete old key 'peers' +            # add key 'peers' neighbors to the list +            list_peers = [] +            new_dict = jmespath.search('* | [0]', data) +            if 'peers' in new_dict: +                new_dict.pop('peers') + +                for neighbor, neighbor_options in peers.items(): +                    neighbor_options['neighbor'] = neighbor +                    list_peers.append(neighbor_options) +                new_dict['peers'] = list_peers +            return decamelize(new_dict) +        data = jmespath.search('* | [0]', data) +        return decamelize(data) + +    else: +        return output + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py index 5a821a287..d6098c158 100755 --- a/src/op_mode/bridge.py +++ b/src/op_mode/bridge.py @@ -32,7 +32,7 @@ def _get_json_data():      """      Get bridge data format JSON      """ -    return cmd(f'sudo bridge --json link show') +    return cmd(f'bridge --json link show')  def _get_raw_data_summary(): @@ -48,7 +48,7 @@ def _get_raw_data_vlan():      """      :returns dict      """ -    json_data = cmd('sudo bridge --json --compressvlans vlan show') +    json_data = cmd('bridge --json --compressvlans vlan show')      data_dict = json.loads(json_data)      return data_dict @@ -57,7 +57,7 @@ def _get_raw_data_fdb(bridge):      """Get MAC-address for the bridge brX      :returns list      """ -    code, json_data = rc_cmd(f'sudo bridge --json fdb show br {bridge}') +    code, json_data = rc_cmd(f'bridge --json fdb show br {bridge}')      # From iproute2 fdb.c, fdb_show() will only exit(-1) in case of      # non-existent bridge device; raise error.      if code == 255: diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py index b27aa6060..fff537936 100755 --- a/src/op_mode/conntrack.py +++ b/src/op_mode/conntrack.py @@ -48,6 +48,14 @@ def _get_raw_data(family):      Return: dictionary      """      xml = _get_xml_data(family) +    if len(xml) == 0: +        output = {'conntrack': +            { +                'error': True, +                'reason': 'entries not found' +            } +        } +        return output      return _xml_to_dict(xml) @@ -72,7 +80,8 @@ def get_formatted_output(dict_data):      :return: formatted output      """      data_entries = [] -    #dict_data = _get_raw_data(family) +    if 'error' in dict_data['conntrack']: +        return 'Entries not found'      for entry in dict_data['conntrack']['flow']:          orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {}          reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {} diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py new file mode 100755 index 000000000..07e9b7d6c --- /dev/null +++ b/src/op_mode/dhcp.py @@ -0,0 +1,278 @@ +#!/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 ipaddress import ip_address +import typing + +from datetime import datetime +from sys import exit +from tabulate import tabulate +from isc_dhcp_leases import IscDhcpLeases + +from vyos.base import Warning +from vyos.configquery import ConfigTreeQuery + +from vyos.util import cmd +from vyos.util import dict_search +from vyos.util import is_systemd_service_running + +import vyos.opmode + + +config = ConfigTreeQuery() +pool_key = "shared-networkname" + + +def _in_pool(lease, pool): +    if pool_key in lease.sets: +        if lease.sets[pool_key] == pool: +            return True +    return False + + +def _utc_to_local(utc_dt): +    return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds()) + + +def _format_hex_string(in_str): +    out_str = "" +    # if input is divisible by 2, add : every 2 chars +    if len(in_str) > 0 and len(in_str) % 2 == 0: +        out_str = ':'.join(a+b for a,b in zip(in_str[::2], in_str[1::2])) +    else: +        out_str = in_str + +    return out_str + + +def _find_list_of_dict_index(lst, key='ip', value='') -> int: +    """ +    Find the index entry of list of dict matching the dict value +    Exampe: +        % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}] +        % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2') +        % 1 +    """ +    idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) +    return idx + + +def _get_raw_server_leases(family, pool=None) -> list: +    """ +    Get DHCP server leases +    :return list +    """ +    lease_file = '/config/dhcpdv6.leases' if family == 'inet6' else '/config/dhcpd.leases' +    data = [] +    leases = IscDhcpLeases(lease_file).get() +    if pool is not None: +        if config.exists(f'service dhcp-server shared-network-name {pool}'): +            leases = list(filter(lambda x: _in_pool(x, pool), leases)) +    for lease in leases: +        data_lease = {} +        data_lease['ip'] = lease.ip +        data_lease['state'] = lease.binding_state +        data_lease['pool'] = lease.sets.get('shared-networkname', '') +        data_lease['end'] = lease.end.timestamp() + +        if family == 'inet': +            data_lease['hardware'] = lease.ethernet +            data_lease['start'] = lease.start.timestamp() +            data_lease['hostname'] = lease.hostname + +        if family == 'inet6': +            data_lease['last_communication'] = lease.last_communication.timestamp() +            data_lease['iaid_duid'] = _format_hex_string(lease.host_identifier_string) +            lease_types_long = {'na': 'non-temporary', 'ta': 'temporary', 'pd': 'prefix delegation'} +            data_lease['type'] = lease_types_long[lease.type] + +        data_lease['remaining'] = lease.end - datetime.utcnow() + +        if data_lease['remaining'].days >= 0: +            # substraction gives us a timedelta object which can't be formatted with strftime +            # so we use str(), split gets rid of the microseconds +            data_lease['remaining'] = str(data_lease["remaining"]).split('.')[0] +        else: +            data_lease['remaining'] = '' + +        # Do not add old leases +        if data_lease['remaining'] != '': +            data.append(data_lease) + +        # deduplicate +        checked = [] +        for entry in data: +            addr = entry.get('ip') +            if addr not in checked: +                checked.append(addr) +            else: +                idx = _find_list_of_dict_index(data, key='ip', value=addr) +                data.pop(idx) + +    return data + + +def _get_formatted_server_leases(raw_data, family): +    data_entries = [] +    if family == 'inet': +        for lease in raw_data: +            ipaddr = lease.get('ip') +            hw_addr = lease.get('hardware') +            state = lease.get('state') +            start = lease.get('start') +            start =  _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') +            end = lease.get('end') +            end =  _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') +            remain = lease.get('remaining') +            pool = lease.get('pool') +            hostname = lease.get('hostname') +            data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname]) + +        headers = ['IP Address', 'Hardware address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool', +                   'Hostname'] + +    if family == 'inet6': +        for lease in raw_data: +            ipaddr = lease.get('ip') +            state = lease.get('state') +            start = lease.get('last_communication') +            start =  _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') +            end = lease.get('end') +            end =  _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') +            remain = lease.get('remaining') +            lease_type = lease.get('type') +            pool = lease.get('pool') +            host_identifier = lease.get('iaid_duid') +            data_entries.append([ipaddr, state, start, end, remain, lease_type, pool, host_identifier]) + +        headers = ['IPv6 address', 'State', 'Last communication', 'Lease expiration', 'Remaining', 'Type', 'Pool', +                   'IAID_DUID'] + +    output = tabulate(data_entries, headers, numalign='left') +    return output + + +def _get_dhcp_pools(family='inet') -> list: +    v = 'v6' if family == 'inet6' else '' +    pools = config.list_nodes(f'service dhcp{v}-server shared-network-name') +    return pools + + +def _get_pool_size(pool, family='inet'): +    v = 'v6' if family == 'inet6' else '' +    base = f'service dhcp{v}-server shared-network-name {pool}' +    size = 0 +    subnets = config.list_nodes(f'{base} subnet') +    for subnet in subnets: +        if family == 'inet6': +            ranges = config.list_nodes(f'{base} subnet {subnet} address-range start') +        else: +            ranges = config.list_nodes(f'{base} subnet {subnet} range') +        for range in ranges: +            if family == 'inet6': +                start = config.list_nodes(f'{base} subnet {subnet} address-range start')[0] +                stop = config.value(f'{base} subnet {subnet} address-range start {start} stop') +            else: +                start = config.value(f'{base} subnet {subnet} range {range} start') +                stop = config.value(f'{base} subnet {subnet} range {range} stop') +            # Add +1 because both range boundaries are inclusive +            size += int(ip_address(stop)) - int(ip_address(start)) + 1 +    return size + + +def _get_raw_pool_statistics(family='inet', pool=None): +    if pool is None: +        pool = _get_dhcp_pools(family=family) +    else: +        pool = [pool] + +    v = 'v6' if family == 'inet6' else '' +    stats = [] +    for p in pool: +        subnet = config.list_nodes(f'service dhcp{v}-server shared-network-name {p} subnet') +        size = _get_pool_size(family=family, pool=p) +        leases = len(_get_raw_server_leases(family=family, pool=p)) +        use_percentage = round(leases / size * 100) if size != 0 else 0 +        pool_stats = {'pool': p, 'size': size, 'leases': leases, +                      'available': (size - leases), 'use_percentage': use_percentage, 'subnet': subnet} +        stats.append(pool_stats) +    return stats + + +def _get_formatted_pool_statistics(pool_data, family='inet'): +    data_entries = [] +    for entry in pool_data: +        pool = entry.get('pool') +        size = entry.get('size') +        leases = entry.get('leases') +        available = entry.get('available') +        use_percentage = entry.get('use_percentage') +        use_percentage = f'{use_percentage}%' +        data_entries.append([pool, size, leases, available, use_percentage]) + +    headers = ['Pool', 'Size','Leases', 'Available', 'Usage'] +    output = tabulate(data_entries, headers, numalign='left') +    return output + + +def _verify(func): +    """Decorator checks if DHCP(v6) config exists""" +    from functools import wraps + +    @wraps(func) +    def _wrapper(*args, **kwargs): +        config = ConfigTreeQuery() +        family = kwargs.get('family') +        v = 'v6' if family == 'inet6' else '' +        unconf_message = f'DHCP{v} server is not configured' +        # Check if config does not exist +        if not config.exists(f'service dhcp{v}-server'): +            raise vyos.opmode.UnconfiguredSubsystem(unconf_message) +        return func(*args, **kwargs) +    return _wrapper + + +@_verify +def show_pool_statistics(raw: bool, family: str, pool: typing.Optional[str]): +    pool_data = _get_raw_pool_statistics(family=family, pool=pool) +    if raw: +        return pool_data +    else: +        return _get_formatted_pool_statistics(pool_data, family=family) + + +@_verify +def show_server_leases(raw: bool, family: str): +    # if dhcp server is down, inactive leases may still be shown as active, so warn the user. +    if not is_systemd_service_running('isc-dhcp-server.service'): +        Warning('DHCP server is configured but not started. Data may be stale.') + +    leases = _get_raw_server_leases(family) +    if raw: +        return leases +    else: +        return _get_formatted_server_leases(leases, family) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py 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/firewall.py b/src/op_mode/firewall.py index 950feb625..46bda5f7e 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -63,7 +63,7 @@ def get_config_firewall(conf, name=None, ipv6=False, interfaces=True):                                  get_first_key=True, no_tag_node_value_mangle=True)      if firewall and interfaces:          if name: -            firewall['interface'] = [] +            firewall['interface'] = {}          else:              if 'name' in firewall:                  for fw_name, name_conf in firewall['name'].items(): diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index a4d1b4cb1..e0d204a0a 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -14,13 +14,16 @@  # You should have received a copy of the GNU General Public License  # along with this program.  If not, see <http://www.gnu.org/licenses/>. +import os  import re  import sys +import 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 @@ -43,7 +46,10 @@ def _alphanum_key(key):  def _get_vici_sas():      from vici import Session as vici_session -    session = vici_session() +    try: +        session = vici_session() +    except Exception: +        raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized")      sas = list(session.list_sas())      return sas @@ -132,42 +138,305 @@ def _get_formatted_output_sas(sas):      return output -def get_peer_connections(peer, tunnel, return_all = False): -    peer = peer.replace(':', '-') -    search = rf'^[\s]*(peer_{peer}_(tunnel_[\d]+|vti)).*' +# 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): -    if not peer: -        print('Invalid peer, aborting') -        return - -    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: -        print('Tunnel(s) not found, aborting') -        return +        raise vyos.opmode.IncorrectValue('Peer or tunnel(s) not found, aborting') -    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 - +            raise vyos.opmode.InternalError(f'Timed out while resetting {conn}') -    print('Peer reset result: ' + ('success' if result else 'failed')) +    print('Peer reset result: success')  def show_sa(raw: bool): @@ -177,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/log.py b/src/op_mode/log.py new file mode 100755 index 000000000..b0abd6191 --- /dev/null +++ b/src/op_mode/log.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import json +import re +import sys +import typing + +from jinja2 import Template + +from vyos.util import rc_cmd + +import vyos.opmode + +journalctl_command_template = Template(""" +--no-hostname +--quiet + +{% if boot %} +  --boot +{% endif %} + +{% if count %} +  --lines={{ count }} +{% endif %} + +{% if reverse %} +  --reverse +{% endif %} + +{% if since %} +  --since={{ since }} +{% endif %} + +{% if unit %} +  --unit={{ unit }} +{% endif %} + +{% if utc %} +  --utc +{% endif %} + +{% if raw %} +{# By default show 100 only lines for raw option if count does not set #} +{# Protection from parsing the full log by default #} +{%    if not boot %} +  --lines={{ '' ~ count if count else '100' }} +{%    endif %} +  --no-pager +  --output=json +{% endif %} +""") + + +def show(raw: bool, +         boot: typing.Optional[bool], +         count: typing.Optional[int], +         facility: typing.Optional[str], +         reverse: typing.Optional[bool], +         utc: typing.Optional[bool], +         unit: typing.Optional[str]): +    kwargs = dict(locals()) + +    journalctl_options = journalctl_command_template.render(kwargs) +    journalctl_options = re.sub(r'\s+', ' ', journalctl_options) +    rc, output = rc_cmd(f'journalctl {journalctl_options}') +    if raw: +        # Each 'journalctl --output json' line is a separate JSON object +        # So we should return list of dict +        return [json.loads(line) for line in output.split('\n')] +    return output + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/memory.py b/src/op_mode/memory.py index 178544be4..7666de646 100755 --- a/src/op_mode/memory.py +++ b/src/op_mode/memory.py @@ -20,7 +20,7 @@ import sys  import vyos.opmode -def _get_system_memory(): +def _get_raw_data():      from re import search as re_search      def find_value(keyword, mem_data): @@ -38,7 +38,7 @@ def _get_system_memory():      used = total - available -    res = { +    mem_data = {        "total":   total,        "free":    available,        "used":    used, @@ -46,24 +46,21 @@ def _get_system_memory():        "cached":  cached      } -    return res - -def _get_system_memory_human(): -    from vyos.util import bytes_to_human - -    mem = _get_system_memory() - -    for key in mem: +    for key in mem_data:          # The Linux kernel exposes memory values in kilobytes,          # so we need to normalize them -        mem[key] = bytes_to_human(mem[key], initial_exponent=10) +        mem_data[key] = mem_data[key] * 1024 -    return mem - -def _get_raw_data(): -    return _get_system_memory_human() +    return mem_data  def _get_formatted_output(mem): +    from vyos.util import bytes_to_human + +    # For human-readable outputs, we convert bytes to more convenient units +    # (100M, 1.3G...) +    for key in mem: +        mem[key] = bytes_to_human(mem[key]) +      out = "Total: {}\n".format(mem["total"])      out += "Free:  {}\n".format(mem["free"])      out += "Used:  {}".format(mem["used"]) diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py index 845dbbb2c..f899eb3dc 100755 --- a/src/op_mode/nat.py +++ b/src/op_mode/nat.py @@ -22,12 +22,18 @@ import xmltodict  from sys import exit  from tabulate import tabulate +from vyos.configquery import ConfigTreeQuery +  from vyos.util import cmd  from vyos.util import dict_search  import vyos.opmode +base = 'nat' +unconf_message = 'NAT is not configured' + +  def _get_xml_translation(direction, family):      """      Get conntrack XML output --src-nat|--dst-nat @@ -277,6 +283,20 @@ def _get_formatted_translation(dict_data, nat_direction, family):      return output +def _verify(func): +    """Decorator checks if NAT config exists""" +    from functools import wraps + +    @wraps(func) +    def _wrapper(*args, **kwargs): +        config = ConfigTreeQuery() +        if not config.exists(base): +            raise vyos.opmode.UnconfiguredSubsystem(unconf_message) +        return func(*args, **kwargs) +    return _wrapper + + +@_verify  def show_rules(raw: bool, direction: str, family: str):      nat_rules = _get_raw_data_rules(direction, family)      if raw: @@ -285,6 +305,7 @@ def show_rules(raw: bool, direction: str, family: str):          return _get_formatted_output_rules(nat_rules, direction, family) +@_verify  def show_statistics(raw: bool, direction: str, family: str):      nat_statistics = _get_raw_data_rules(direction, family)      if raw: @@ -293,6 +314,7 @@ def show_statistics(raw: bool, direction: str, family: str):          return _get_formatted_output_statistics(nat_statistics, direction) +@_verify  def show_translations(raw: bool, direction: str, family: str):      family = 'ipv6' if family == 'inet6' else 'ipv4'      nat_translation = _get_raw_translation(direction, family) diff --git a/src/op_mode/ping.py b/src/op_mode/ping.py index 60bbc0c78..610e63cb3 100755 --- a/src/op_mode/ping.py +++ b/src/op_mode/ping.py @@ -18,6 +18,25 @@ import os  import sys  import socket  import ipaddress +from vyos.util import get_all_vrfs +from vyos.ifconfig import Section + + +def interface_list() -> list: +    """ +    Get list of interfaces in system +    :rtype: list +    """ +    return Section.interfaces() + + +def vrf_list() -> list: +    """ +    Get list of VRFs in system +    :rtype: list +    """ +    return list(get_all_vrfs().keys()) +  options = {      'audible': { @@ -63,6 +82,7 @@ options = {      'interface': {          'ping': '{command} -I {value}',          'type': '<interface>', +        'helpfunction': interface_list,          'help': 'Source interface'      },      'interval': { @@ -128,6 +148,7 @@ options = {          'ping': 'sudo ip vrf exec {value} {command}',          'type': '<vrf>',          'help': 'Use specified VRF table', +        'helpfunction': vrf_list,          'dflt': 'default',      },      'verbose': { @@ -142,20 +163,33 @@ ping = {  } -class List (list): -    def first (self): +class List(list): +    def first(self):          return self.pop(0) if self else ''      def last(self):          return self.pop() if self else '' -    def prepend(self,value): -        self.insert(0,value) +    def prepend(self, value): +        self.insert(0, value) + + +def completion_failure(option: str) -> None: +    """ +    Shows failure message after TAB when option is wrong +    :param option: failure option +    :type str: +    """ +    sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) +    sys.stdout.write('<nocomps>') +    sys.exit(1)  def expension_failure(option, completions):      reason = 'Ambiguous' if completions else 'Invalid' -    sys.stderr.write('\n\n  {} command: {} [{}]\n\n'.format(reason,' '.join(sys.argv), option)) +    sys.stderr.write( +        '\n\n  {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), +                                               option))      if completions:          sys.stderr.write('  Possible completions:\n   ')          sys.stderr.write('\n   '.join(completions)) @@ -196,28 +230,44 @@ if __name__ == '__main__':      if host == '--get-options':          args.first()  # pop ping          args.first()  # pop IP +        usedoptionslist = []          while args: -            option = args.first() - -            matched = complete(option) +            option = args.first()  # pop option +            matched = complete(option)  # get option parameters +            usedoptionslist.append(option)  # list of used options +            # Select options              if not args: +                # remove from Possible completions used options +                for o in usedoptionslist: +                    if o in matched: +                        matched.remove(o)                  sys.stdout.write(' '.join(matched))                  sys.exit(0) -            if len(matched) > 1 : +            if len(matched) > 1:                  sys.stdout.write(' '.join(matched))                  sys.exit(0) +            # If option doesn't have value +            if matched: +                if options[matched[0]]['type'] == 'noarg': +                    continue +            else: +                # Unexpected option +                completion_failure(option) -            if options[matched[0]]['type'] == 'noarg': -                continue - -            value = args.first() +            value = args.first()  # pop option's value              if not args:                  matched = complete(option) -                sys.stdout.write(options[matched[0]]['type']) +                helplines = options[matched[0]]['type'] +                # Run helpfunction to get list of possible values +                if 'helpfunction' in options[matched[0]]: +                    result = options[matched[0]]['helpfunction']() +                    if result: +                        helplines = '\n' + ' '.join(result) +                sys.stdout.write(helplines)                  sys.exit(0) -    for name,option in options.items(): +    for name, option in options.items():          if 'dflt' in option and name not in args:              args.append(name)              args.append(option['dflt']) @@ -234,8 +284,7 @@ if __name__ == '__main__':      except ValueError:          sys.exit(f'ping: Unknown host: {host}') -    command = convert(ping[version],args) +    command = convert(ping[version], args)      # print(f'{command} {host}')      os.system(f'{command} {host}') - diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py index 5be40082f..5953786f3 100755 --- a/src/op_mode/policy_route.py +++ b/src/op_mode/policy_route.py @@ -22,53 +22,13 @@ from vyos.config import Config  from vyos.util import cmd  from vyos.util import dict_search_args -def get_policy_interfaces(conf, policy, name=None, ipv6=False): -    interfaces = conf.get_config_dict(['interfaces'], key_mangling=('-', '_'), -                                      get_first_key=True, no_tag_node_value_mangle=True) - -    routes = ['route', 'route6'] - -    def parse_if(ifname, if_conf): -        if 'policy' in if_conf: -            for route in routes: -                if route in if_conf['policy']: -                    route_name = if_conf['policy'][route] -                    name_str = f'({ifname},{route})' - -                    if not name: -                        policy[route][route_name]['interface'].append(name_str) -                    elif not ipv6 and name == route_name: -                        policy['interface'].append(name_str) - -        for iftype in ['vif', 'vif_s', 'vif_c']: -            if iftype in if_conf: -                for vifname, vif_conf in if_conf[iftype].items(): -                    parse_if(f'{ifname}.{vifname}', vif_conf) - -    for iftype, iftype_conf in interfaces.items(): -        for ifname, if_conf in iftype_conf.items(): -            parse_if(ifname, if_conf) - -def get_config_policy(conf, name=None, ipv6=False, interfaces=True): +def get_config_policy(conf, name=None, ipv6=False):      config_path = ['policy']      if name:          config_path += ['route6' if ipv6 else 'route', name]      policy = conf.get_config_dict(config_path, key_mangling=('-', '_'),                                  get_first_key=True, no_tag_node_value_mangle=True) -    if policy and interfaces: -        if name: -            policy['interface'] = [] -        else: -            if 'route' in policy: -                for route_name, route_conf in policy['route'].items(): -                    route_conf['interface'] = [] - -            if 'route6' in policy: -                for route_name, route_conf in policy['route6'].items(): -                    route_conf['interface'] = [] - -        get_policy_interfaces(conf, policy, name, ipv6)      return policy diff --git a/src/op_mode/route.py b/src/op_mode/route.py index e1eee5bbf..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], @@ -83,7 +95,12 @@ def show(raw: bool,          if raw:              from json import loads -            return loads(output) +            d = loads(output) +            collect = [] +            for k,_ in d.items(): +                for l in d[k]: +                    collect.append(l) +            return collect          else:              return output diff --git a/src/op_mode/storage.py b/src/op_mode/storage.py index 75964c493..d16e271bd 100755 --- a/src/op_mode/storage.py +++ b/src/op_mode/storage.py @@ -20,6 +20,16 @@ import sys  import vyos.opmode  from vyos.util import cmd +# FIY: As of coreutils from Debian Buster and Bullseye, +# the outpt looks like this: +# +# $ df -h -t ext4 --output=source,size,used,avail,pcent +# Filesystem      Size  Used Avail Use% +# /dev/sda1        16G  7.6G  7.3G  51% +# +# Those field names are automatically normalized by vyos.opmode.run, +# so we don't touch them here, +# and only normalize values.  def _get_system_storage(only_persistent=False):      if not only_persistent: @@ -32,11 +42,19 @@ def _get_system_storage(only_persistent=False):      return res  def _get_raw_data(): +    from re import sub as re_sub +    from vyos.util import human_to_bytes +      out =  _get_system_storage(only_persistent=True)      lines = out.splitlines()      lists = [l.split() for l in lines]      res = {lists[0][i]: lists[1][i] for i in range(len(lists[0]))} +    res["Size"] = human_to_bytes(res["Size"]) +    res["Used"] = human_to_bytes(res["Used"]) +    res["Avail"] = human_to_bytes(res["Avail"]) +    res["Use%"] = re_sub(r'%', '', res["Use%"]) +      return res  def _get_formatted_output(): diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py index 4299d6e5f..6c7030ea0 100755 --- a/src/op_mode/traceroute.py +++ b/src/op_mode/traceroute.py @@ -18,6 +18,25 @@ import os  import sys  import socket  import ipaddress +from vyos.util import get_all_vrfs +from vyos.ifconfig import Section + + +def interface_list() -> list: +    """ +    Get list of interfaces in system +    :rtype: list +    """ +    return Section.interfaces() + + +def vrf_list() -> list: +    """ +    Get list of VRFs in system +    :rtype: list +    """ +    return list(get_all_vrfs().keys()) +  options = {      'backward-hops': { @@ -48,6 +67,7 @@ options = {      'interface': {          'traceroute': '{command} -i {value}',          'type': '<interface>', +        'helpfunction': interface_list,          'help': 'Source interface'      },      'lookup-as': { @@ -99,6 +119,7 @@ options = {          'traceroute': 'sudo ip vrf exec {value} {command}',          'type': '<vrf>',          'help': 'Use specified VRF table', +        'helpfunction': vrf_list,          'dflt': 'default'}  } @@ -108,20 +129,33 @@ traceroute = {  } -class List (list): -    def first (self): +class List(list): +    def first(self):          return self.pop(0) if self else ''      def last(self):          return self.pop() if self else '' -    def prepend(self,value): -        self.insert(0,value) +    def prepend(self, value): +        self.insert(0, value) + + +def completion_failure(option: str) -> None: +    """ +    Shows failure message after TAB when option is wrong +    :param option: failure option +    :type str: +    """ +    sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) +    sys.stdout.write('<nocomps>') +    sys.exit(1)  def expension_failure(option, completions):      reason = 'Ambiguous' if completions else 'Invalid' -    sys.stderr.write('\n\n  {} command: {} [{}]\n\n'.format(reason,' '.join(sys.argv), option)) +    sys.stderr.write( +        '\n\n  {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), +                                               option))      if completions:          sys.stderr.write('  Possible completions:\n   ')          sys.stderr.write('\n   '.join(completions)) @@ -160,30 +194,46 @@ if __name__ == '__main__':          sys.exit("traceroute: Missing host")      if host == '--get-options': -        args.first()  # pop traceroute +        args.first()  # pop ping          args.first()  # pop IP +        usedoptionslist = []          while args: -            option = args.first() - -            matched = complete(option) +            option = args.first()  # pop option +            matched = complete(option)  # get option parameters +            usedoptionslist.append(option)  # list of used options +            # Select options              if not args: +                # remove from Possible completions used options +                for o in usedoptionslist: +                    if o in matched: +                        matched.remove(o)                  sys.stdout.write(' '.join(matched))                  sys.exit(0) -            if len(matched) > 1 : +            if len(matched) > 1:                  sys.stdout.write(' '.join(matched))                  sys.exit(0) +            # If option doesn't have value +            if matched: +                if options[matched[0]]['type'] == 'noarg': +                    continue +            else: +                # Unexpected option +                completion_failure(option) -            if options[matched[0]]['type'] == 'noarg': -                continue - -            value = args.first() +            value = args.first()  # pop option's value              if not args:                  matched = complete(option) -                sys.stdout.write(options[matched[0]]['type']) +                helplines = options[matched[0]]['type'] +                # Run helpfunction to get list of possible values +                if 'helpfunction' in options[matched[0]]: +                    result = options[matched[0]]['helpfunction']() +                    if result: +                        helplines = '\n' + ' '.join(result) +                sys.stdout.write(helplines)                  sys.exit(0) -    for name,option in options.items(): +    for name, option in options.items():          if 'dflt' in option and name not in args:              args.append(name)              args.append(option['dflt']) @@ -200,8 +250,7 @@ if __name__ == '__main__':      except ValueError:          sys.exit(f'traceroute: Unknown host: {host}') -    command = convert(traceroute[version],args) +    command = convert(traceroute[version], args)      # print(f'{command} {host}')      os.system(f'{command} {host}') - diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py index aeb50fe6e..a9a416761 100755 --- a/src/op_mode/vrf.py +++ b/src/op_mode/vrf.py @@ -31,14 +31,14 @@ def _get_raw_data(name=None):      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') +    output = cmd('ip --json --brief link show type vrf')      data = json.loads(output)      if not data:          return []      if name:          is_vrf_exists = True if [vrf for vrf in data if vrf.get('ifname') == name] else False          if is_vrf_exists: -            output = cmd(f'sudo ip --json --brief link show dev {name}') +            output = cmd(f'ip --json --brief link show dev {name}')              data = json.loads(output)              return data          return [] @@ -51,7 +51,7 @@ def _get_vrf_members(vrf: str) -> list:      :param vrf: str      :return: list      """ -    output = cmd(f'sudo ip --json --brief link show master {vrf}') +    output = cmd(f'ip --json --brief link show master {vrf}')      answer = json.loads(output)      interfaces = []      for data in answer: diff --git a/src/services/api/graphql/__init__.py b/src/services/api/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/services/api/graphql/__init__.py diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 0b1260912..aa1ba0eb0 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -18,16 +18,26 @@ 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 . graphql.auth_token_mutation import auth_token_mutation +from . generate.schema_from_op_mode import generate_op_mode_definitions +from . generate.schema_from_config_session import generate_config_session_definitions +from . generate.schema_from_composite import generate_composite_definitions +from . libs.token_auth import init_secret +from . import state  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() +    generate_config_session_definitions() +    generate_composite_definitions() + +    if state.settings['app'].state.vyos_auth_type == 'token': +        init_secret()      type_defs = load_schema_from_path(api_schema_dir) -    schema = make_executable_schema(type_defs, query, op_mode_error, mutation, snake_case_fallback_resolvers, directives=directives_dict) +    schema = make_executable_schema(type_defs, query, op_mode_error, mutation, auth_token_mutation, snake_case_fallback_resolvers, directives=directives_dict)      return schema diff --git a/src/services/api/graphql/generate/composite_function.py b/src/services/api/graphql/generate/composite_function.py new file mode 100644 index 000000000..bc9d80fbb --- /dev/null +++ b/src/services/api/graphql/generate/composite_function.py @@ -0,0 +1,11 @@ +# typing information for composite functions: those that invoke several +# elementary requests, and return the result as a single dict +import typing + +def system_status(): +    pass + +queries = {'system_status': system_status} + +mutations = {} + diff --git a/src/services/api/graphql/generate/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py new file mode 100644 index 000000000..fc0dd7a87 --- /dev/null +++ b/src/services/api/graphql/generate/config_session_function.py @@ -0,0 +1,28 @@ +# typing information for native configsession functions; used to generate +# schema definition files +import typing + +def show_config(path: list[str], configFormat: typing.Optional[str]): +    pass + +def show(path: list[str]): +    pass + +queries = {'show_config': show_config, +           'show': show} + +def save_config_file(fileName: typing.Optional[str]): +    pass +def load_config_file(fileName: str): +    pass +def add_system_image(location: str): +    pass +def delete_system_image(name: str): +    pass + +mutations = {'save_config_file': save_config_file, +             'load_config_file': load_config_file, +             'add_system_image': add_system_image, +             'delete_system_image': delete_system_image} + + diff --git a/src/services/api/graphql/generate/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py new file mode 100755 index 000000000..61a08cb2f --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_composite.py @@ -0,0 +1,165 @@ +#!/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 typing information of +# composite functions comprising several requests. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': +    sys.path.append("/usr/libexec/vyos/services/api") +    from graphql.libs.op_mode import snake_to_pascal_case, map_type_name +    from composite_function import queries, mutations +    from vyos.config import Config +    from vyos.configdict import dict_merge +    from vyos.xml import defaults +else: +    from .. libs.op_mode import snake_to_pascal_case, map_type_name +    from . composite_function import queries, mutations +    from .. import state + +SCHEMA_PATH = directories['api_schema'] + +if __package__ is None or __package__ == '': +    # allow running stand-alone +    conf = Config() +    base = ['service', 'https', 'api'] +    graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) +    if 'graphql' not in graphql_dict: +        exit("graphql is not configured") + +    graphql_dict = dict_merge(defaults(base), graphql_dict) +    auth_type = graphql_dict['graphql']['authentication']['type'] +else: +    auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, +                     'schema_name': '', +                     'schema_fields': []} + +query_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Query { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositequery +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @compositequery +{%- endif %} +} +""" + +mutation_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositemutation +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @compositemutation +{%- endif %} +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = snake_to_pascal_case(func_name) +    schema_data['schema_fields'] = schema_fields + +    j2_template = Template(template) +    res = j2_template.render(schema_data) + +    return res + +def generate_composite_definitions(): +    results = [] +    for name,func in queries.items(): +        res = create_schema(name, func, query_template) +        results.append(res) + +    for name,func in mutations.items(): +        res = create_schema(name, func, mutation_template) +        results.append(res) + +    out = '\n'.join(results) +    with open(f'{SCHEMA_PATH}/composite.graphql', 'w') as f: +        f.write(out) + +if __name__ == '__main__': +    generate_composite_definitions() diff --git a/src/services/api/graphql/generate/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py new file mode 100755 index 000000000..49bf2440e --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -0,0 +1,165 @@ +#!/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 typing information of +# (wrappers of) native configsession functions. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': +    sys.path.append("/usr/libexec/vyos/services/api") +    from graphql.libs.op_mode import snake_to_pascal_case, map_type_name +    from config_session_function import queries, mutations +    from vyos.config import Config +    from vyos.configdict import dict_merge +    from vyos.xml import defaults +else: +    from .. libs.op_mode import snake_to_pascal_case, map_type_name +    from . config_session_function import queries, mutations +    from .. import state + +SCHEMA_PATH = directories['api_schema'] + +if __package__ is None or __package__ == '': +    # allow running stand-alone +    conf = Config() +    base = ['service', 'https', 'api'] +    graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) +    if 'graphql' not in graphql_dict: +        exit("graphql is not configured") + +    graphql_dict = dict_merge(defaults(base), graphql_dict) +    auth_type = graphql_dict['graphql']['authentication']['type'] +else: +    auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, +                     'schema_name': '', +                     'schema_fields': []} + +query_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Query { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionquery +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @configsessionquery +{%- endif %} +} +""" + +mutation_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionmutation +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @configsessionmutation +{%- endif %} +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = snake_to_pascal_case(func_name) +    schema_data['schema_fields'] = schema_fields + +    j2_template = Template(template) +    res = j2_template.render(schema_data) + +    return res + +def generate_config_session_definitions(): +    results = [] +    for name,func in queries.items(): +        res = create_schema(name, func, query_template) +        results.append(res) + +    for name,func in mutations.items(): +        res = create_schema(name, func, mutation_template) +        results.append(res) + +    out = '\n'.join(results) +    with open(f'{SCHEMA_PATH}/configsession.graphql', 'w') as f: +        f.write(out) + +if __name__ == '__main__': +    generate_config_session_definitions() diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py index 379d15250..fc63b0100 100755 --- a/src/services/api/graphql/utils/schema_from_op_mode.py +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -19,16 +19,24 @@  # scripts.  import os +import sys  import json -import typing  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__ == '': -    from util import load_as_module, is_op_mode_function_name, is_show_function_name +    sys.path.append("/usr/libexec/vyos/services/api") +    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 . util 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  OP_MODE_PATH = directories['op_mode']  SCHEMA_PATH = directories['api_schema'] @@ -37,16 +45,40 @@ 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': '', +if __package__ is None or __package__ == '': +    # allow running stand-alone +    conf = Config() +    base = ['service', 'https', 'api'] +    graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) +    if 'graphql' not in graphql_dict: +        exit("graphql is not configured") + +    graphql_dict = dict_merge(defaults(base), graphql_dict) +    auth_type = graphql_dict['graphql']['authentication']['type'] +else: +    auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, +                     'schema_name': '',                       'schema_fields': []}  query_template  = """ +{%- if auth_type == 'key' %}  input {{ schema_name }}Input {      key: String!      {%- for field_entry in schema_fields %}      {{ field_entry }}      {%- endfor %}  } +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %}  type {{ schema_name }} {      result: Generic @@ -60,17 +92,29 @@ type {{ schema_name }}Result {  }  extend type Query { +{%- if auth_type == 'key' or schema_fields %}      {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopquery +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @genopquery +{%- endif %}  }  """  mutation_template  = """ +{%- if auth_type == 'key' %}  input {{ schema_name }}Input {      key: String!      {%- for field_entry in schema_fields %}      {{ field_entry }}      {%- endfor %}  } +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %}  type {{ schema_name }} {      result: Generic @@ -84,7 +128,11 @@ type {{ schema_name }}Result {  }  extend type Mutation { +{%- if auth_type == 'key' or schema_fields %}      {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @genopquery +{%- endif %}  }  """ @@ -103,35 +151,12 @@ type {{ name }} implements OpModeError {  {%- endfor %}  """ -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) +        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. @@ -142,7 +167,7 @@ def create_schema(func_name: str, base_name: str, func: callable) -> str:      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_name'] = snake_to_pascal_case(func_name + '_' + base_name)      schema_data['schema_fields'] = schema_fields      if is_show_function_name(func_name): diff --git a/src/services/api/graphql/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py new file mode 100644 index 000000000..21ac40094 --- /dev/null +++ b/src/services/api/graphql/graphql/auth_token_mutation.py @@ -0,0 +1,49 @@ +# 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 jwt +import datetime +from typing import Any, Dict +from ariadne import ObjectType, UnionType +from graphql import GraphQLResolveInfo + +from .. libs.token_auth import generate_token +from .. import state + +auth_token_mutation = ObjectType("Mutation") + +@auth_token_mutation.field('AuthToken') +def auth_token_resolver(obj: Any, info: GraphQLResolveInfo, data: Dict): +    # non-nullable fields +    user = data['username'] +    passwd = data['password'] + +    secret = state.settings['secret'] +    exp_interval = int(state.settings['app'].state.vyos_token_exp) +    expiration = (datetime.datetime.now(tz=datetime.timezone.utc) + +                  datetime.timedelta(seconds=exp_interval)) + +    res = generate_token(user, passwd, secret, expiration) +    if res: +        data['result'] = res +        return { +            "success": True, +            "data": data +        } + +    return { +        "success": False, +        "errors": ['token generation failed'] +    } diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index d8ceefae6..a7919854a 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -31,76 +31,57 @@ class VyosDirective(SchemaDirectiveVisitor):          field.resolve = func          return field - -class ConfigureDirective(VyosDirective): +class ConfigSessionQueryDirective(VyosDirective):      """ -    Class providing implementation of 'configure' directive in schema. +    Class providing implementation of 'configsessionquery' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_configure_resolver) +                                       make_resolver=make_config_session_query_resolver) -class ShowConfigDirective(VyosDirective): -    """ -    Class providing implementation of 'show' directive in schema. +class ConfigSessionMutationDirective(VyosDirective):      """ -    def visit_field_definition(self, field, object_type): -        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. +    Class providing implementation of 'configsessionmutation' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_system_status_resolver) +                                       make_resolver=make_config_session_mutation_resolver) -class ConfigFileDirective(VyosDirective): -    """ -    Class providing implementation of 'configfile' directive in schema. -    """ -    def visit_field_definition(self, field, object_type): -        super().visit_field_definition(field, object_type, -                                       make_resolver=make_config_file_resolver) - -class ShowDirective(VyosDirective): +class GenOpQueryDirective(VyosDirective):      """ -    Class providing implementation of 'show' directive in schema. +    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_show_resolver) +                                       make_resolver=make_gen_op_query_resolver) -class ImageDirective(VyosDirective): +class GenOpMutationDirective(VyosDirective):      """ -    Class providing implementation of 'image' directive in schema. +    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_image_resolver) +                                       make_resolver=make_gen_op_mutation_resolver) -class GenOpQueryDirective(VyosDirective): +class CompositeQueryDirective(VyosDirective):      """ -    Class providing implementation of 'genopquery' directive in schema. +    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_gen_op_query_resolver) +                                       make_resolver=make_composite_query_resolver) -class GenOpMutationDirective(VyosDirective): +class CompositeMutationDirective(VyosDirective):      """ -    Class providing implementation of 'genopmutation' directive in schema. +    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_gen_op_mutation_resolver) +                                       make_resolver=make_composite_mutation_resolver) -directives_dict = {"configure": ConfigureDirective, -                   "showconfig": ShowConfigDirective, -                   "systemstatus": SystemStatusDirective, -                   "configfile": ConfigFileDirective, -                   "show": ShowDirective, -                   "image": ImageDirective, +directives_dict = {"configsessionquery": ConfigSessionQueryDirective, +                   "configsessionmutation": ConfigSessionMutationDirective,                     "genopquery": GenOpQueryDirective, -                   "genopmutation": GenOpMutationDirective} +                   "genopmutation": GenOpMutationDirective, +                   "compositequery": CompositeQueryDirective, +                   "compositemutation": CompositeMutationDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 5ccc9b0b6..87ea59c43 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -14,13 +14,13 @@  # 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  from .. import state -from .. import key_auth +from .. libs 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 @@ -42,32 +42,52 @@ 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      @with_signature(func_sig, func_name=resolver_name)      async def func_impl(*args, **kwargs):          try: -            if 'data' not in kwargs: -                return { -                    "success": False, -                    "errors": ['missing data'] -                } - -            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'] +            auth_type = state.settings['app'].state.vyos_auth_type + +            if auth_type == 'key': +                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'] + +            elif auth_type == 'token': +                data = kwargs['data'] +                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'] +                    } +            else: +                # AtrributeError will have already been raised if no +                # vyos_auth_type; validation and defaultValue ensure it is +                # one of the previous cases, so this is never reached. +                pass              session = state.settings['app'].state.vyos_session @@ -106,24 +126,13 @@ def make_mutation_resolver(mutation_name, class_name, session_func):      return func_impl -def make_prefix_resolver(mutation_name, prefix=[]): -    for pre in prefix: -        Pre = pre.capitalize() -        if Pre in mutation_name: -            class_name = mutation_name.replace(Pre, '', 1) -            return make_mutation_resolver(mutation_name, class_name, pre) -    raise Exception - -def make_configure_resolver(mutation_name): -    class_name = mutation_name -    return make_mutation_resolver(mutation_name, class_name, 'configure') - -def make_config_file_resolver(mutation_name): -    return make_prefix_resolver(mutation_name, prefix=['save', 'load']) - -def make_image_resolver(mutation_name): -    return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) +def make_config_session_mutation_resolver(mutation_name): +    return make_mutation_resolver(mutation_name, mutation_name, +                                  convert_camel_case_to_snake(mutation_name))  def make_gen_op_mutation_resolver(mutation_name): -    class_name = mutation_name -    return make_mutation_resolver(mutation_name, class_name, 'gen_op_mutation') +    return make_mutation_resolver(mutation_name, mutation_name, 'gen_op_mutation') + +def make_composite_mutation_resolver(mutation_name): +    return make_mutation_resolver(mutation_name, mutation_name, +                                  convert_camel_case_to_snake(mutation_name)) diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index b46914dcc..1ad586428 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -14,13 +14,13 @@  # 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  from .. import state -from .. import key_auth +from .. libs 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 @@ -42,32 +42,52 @@ 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      @with_signature(func_sig, func_name=resolver_name)      async def func_impl(*args, **kwargs):          try: -            if 'data' not in kwargs: -                return { -                    "success": False, -                    "errors": ['missing data'] -                } - -            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'] +            auth_type = state.settings['app'].state.vyos_auth_type + +            if auth_type == 'key': +                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'] + +            elif auth_type == 'token': +                data = kwargs['data'] +                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'] +                    } +            else: +                # AtrributeError will have already been raised if no +                # vyos_auth_type; validation and defaultValue ensure it is +                # one of the previous cases, so this is never reached. +                pass              session = state.settings['app'].state.vyos_session @@ -106,18 +126,13 @@ def make_query_resolver(query_name, class_name, session_func):      return func_impl -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_config_session_query_resolver(query_name): +    return make_query_resolver(query_name, query_name, +                               convert_camel_case_to_snake(query_name))  def make_gen_op_query_resolver(query_name): -    class_name = query_name -    return make_query_resolver(query_name, class_name, 'gen_op_query') +    return make_query_resolver(query_name, query_name, 'gen_op_query') + +def make_composite_query_resolver(query_name): +    return make_query_resolver(query_name, query_name, +                               convert_camel_case_to_snake(query_name)) diff --git a/src/services/api/graphql/graphql/schema/auth_token.graphql b/src/services/api/graphql/graphql/schema/auth_token.graphql new file mode 100644 index 000000000..af53a293a --- /dev/null +++ b/src/services/api/graphql/graphql/schema/auth_token.graphql @@ -0,0 +1,19 @@ + +input AuthTokenInput { +    username: String! +    password: String! +} + +type AuthToken { +    result: Generic +} + +type AuthTokenResult { +    data: AuthToken +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +    AuthToken(data: AuthTokenInput) : AuthTokenResult +} diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql deleted file mode 100644 index a7263114b..000000000 --- a/src/services/api/graphql/graphql/schema/config_file.graphql +++ /dev/null @@ -1,29 +0,0 @@ -input SaveConfigFileInput { -    key: String! -    fileName: String -} - -type SaveConfigFile { -    fileName: String -} - -type SaveConfigFileResult { -    data: SaveConfigFile -    success: Boolean! -    errors: [String] -} - -input LoadConfigFileInput { -    key: String! -    fileName: String! -} - -type LoadConfigFile { -    fileName: String! -} - -type LoadConfigFileResult { -    data: LoadConfigFile -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql deleted file mode 100644 index 345c349ac..000000000 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ /dev/null @@ -1,36 +0,0 @@ -input DhcpServerConfigInput { -    key: String! -    sharedNetworkName: String -    subnet: String -    defaultRouter: String -    nameServer: String -    domainName: String -    lease: Int -    range: Int -    start: String -    stop: String -    dnsForwardingAllowFrom: String -    dnsForwardingCacheSize: Int -    dnsForwardingListenAddress: String -} - -type DhcpServerConfig { -    sharedNetworkName: String -    subnet: String -    defaultRouter: String -    nameServer: String -    domainName: String -    lease: Int -    range: Int -    start: String -    stop: String -    dnsForwardingAllowFrom: String -    dnsForwardingCacheSize: Int -    dnsForwardingListenAddress: String -} - -type CreateDhcpServerResult { -    data: DhcpServerConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql deleted file mode 100644 index 9454d2997..000000000 --- a/src/services/api/graphql/graphql/schema/firewall_group.graphql +++ /dev/null @@ -1,101 +0,0 @@ -input CreateFirewallAddressGroupInput { -    key: String! -    name: String! -    address: [String] -} - -type CreateFirewallAddressGroup { -    name: String! -    address: [String] -} - -type CreateFirewallAddressGroupResult { -    data: CreateFirewallAddressGroup -    success: Boolean! -    errors: [String] -} - -input UpdateFirewallAddressGroupMembersInput { -    key: String! -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressGroupMembers { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressGroupMembersResult { -    data: UpdateFirewallAddressGroupMembers -    success: Boolean! -    errors: [String] -} - -input RemoveFirewallAddressGroupMembersInput { -    key: String! -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressGroupMembers { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressGroupMembersResult { -    data: RemoveFirewallAddressGroupMembers -    success: Boolean! -    errors: [String] -} - -input CreateFirewallAddressIpv6GroupInput { -    key: String! -    name: String! -    address: [String] -} - -type CreateFirewallAddressIpv6Group { -    name: String! -    address: [String] -} - -type CreateFirewallAddressIpv6GroupResult { -    data: CreateFirewallAddressIpv6Group -    success: Boolean! -    errors: [String] -} - -input UpdateFirewallAddressIpv6GroupMembersInput { -    key: String! -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressIpv6GroupMembers { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressIpv6GroupMembersResult { -    data: UpdateFirewallAddressIpv6GroupMembers -    success: Boolean! -    errors: [String] -} - -input RemoveFirewallAddressIpv6GroupMembersInput { -    key: String! -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressIpv6GroupMembers { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressIpv6GroupMembersResult { -    data: RemoveFirewallAddressIpv6GroupMembers -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/image.graphql b/src/services/api/graphql/graphql/schema/image.graphql deleted file mode 100644 index 485033875..000000000 --- a/src/services/api/graphql/graphql/schema/image.graphql +++ /dev/null @@ -1,31 +0,0 @@ -input AddSystemImageInput { -    key: String! -    location: String! -} - -type AddSystemImage { -    location: String -    result: String -} - -type AddSystemImageResult { -    data: AddSystemImage -    success: Boolean! -    errors: [String] -} - -input DeleteSystemImageInput { -    key: String! -    name: String! -} - -type DeleteSystemImage { -    name: String -    result: String -} - -type DeleteSystemImageResult { -    data: DeleteSystemImage -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql deleted file mode 100644 index 8a17d919f..000000000 --- a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql +++ /dev/null @@ -1,19 +0,0 @@ -input InterfaceEthernetConfigInput { -    key: String! -    interface: String -    address: String -    replace: Boolean = true -    description: String -} - -type InterfaceEthernetConfig { -    interface: String -    address: String -    description: String -} - -type CreateInterfaceEthernetResult { -    data: InterfaceEthernetConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 624be2620..62b0d30bb 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -3,34 +3,14 @@ schema {      mutation: Mutation  } -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 @compositequery on FIELD_DEFINITION +directive @compositemutation on FIELD_DEFINITION +directive @configsessionquery on FIELD_DEFINITION +directive @configsessionmutation 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 { -    CreateDhcpServer(data: DhcpServerConfigInput) : CreateDhcpServerResult @configure -    CreateInterfaceEthernet(data: InterfaceEthernetConfigInput) : CreateInterfaceEthernetResult @configure -    CreateFirewallAddressGroup(data: CreateFirewallAddressGroupInput) : CreateFirewallAddressGroupResult @configure -    UpdateFirewallAddressGroupMembers(data: UpdateFirewallAddressGroupMembersInput) : UpdateFirewallAddressGroupMembersResult @configure -    RemoveFirewallAddressGroupMembers(data: RemoveFirewallAddressGroupMembersInput) : RemoveFirewallAddressGroupMembersResult @configure -    CreateFirewallAddressIpv6Group(data: CreateFirewallAddressIpv6GroupInput) : CreateFirewallAddressIpv6GroupResult @configure -    UpdateFirewallAddressIpv6GroupMembers(data: UpdateFirewallAddressIpv6GroupMembersInput) : UpdateFirewallAddressIpv6GroupMembersResult @configure -    RemoveFirewallAddressIpv6GroupMembers(data: RemoveFirewallAddressIpv6GroupMembersInput) : RemoveFirewallAddressIpv6GroupMembersResult @configure -    SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configfile -    LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configfile -    AddSystemImage(data: AddSystemImageInput) : AddSystemImageResult @image -    DeleteSystemImage(data: DeleteSystemImageInput) : DeleteSystemImageResult @image -} +type Query +type Mutation diff --git a/src/services/api/graphql/graphql/schema/show.graphql b/src/services/api/graphql/graphql/schema/show.graphql deleted file mode 100644 index 278ed536b..000000000 --- a/src/services/api/graphql/graphql/schema/show.graphql +++ /dev/null @@ -1,15 +0,0 @@ -input ShowInput { -    key: String! -    path: [String!]! -} - -type Show { -    path: [String] -    result: String -} - -type ShowResult { -    data: Show -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/show_config.graphql b/src/services/api/graphql/graphql/schema/show_config.graphql deleted file mode 100644 index 5a1fe43da..000000000 --- a/src/services/api/graphql/graphql/schema/show_config.graphql +++ /dev/null @@ -1,21 +0,0 @@ -""" -Use 'scalar Generic' for show config output, to avoid attempts to -JSON-serialize in case of JSON output. -""" - -input ShowConfigInput { -    key: String! -    path: [String!]! -    configFormat: String -} - -type ShowConfig { -    path: [String] -    result: Generic -} - -type ShowConfigResult { -    data: ShowConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/system_status.graphql b/src/services/api/graphql/graphql/schema/system_status.graphql deleted file mode 100644 index be8d87535..000000000 --- a/src/services/api/graphql/graphql/schema/system_status.graphql +++ /dev/null @@ -1,18 +0,0 @@ -""" -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/libs/key_auth.py index f756ed6d8..2db0f7d48 100644 --- a/src/services/api/graphql/key_auth.py +++ b/src/services/api/graphql/libs/key_auth.py @@ -1,5 +1,5 @@ -from . import state +from .. import state  def check_auth(key_list, key):      if not key_list: diff --git a/src/services/api/graphql/utils/util.py b/src/services/api/graphql/libs/op_mode.py index 073126853..6939ed5d6 100644 --- a/src/services/api/graphql/utils/util.py +++ b/src/services/api/graphql/libs/op_mode.py @@ -15,15 +15,14 @@  import os  import re +import typing  import importlib.util +from typing import Union +from humps import decamelize  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 +from vyos.util import load_as_module +from vyos.opmode import _normalize_field_names  def load_op_mode_as_module(name: str):      path = os.path.join(directories['op_mode'], name) @@ -74,3 +73,29 @@ def split_compound_op_mode_name(name: str, files: list):              pair = (pair[0], f[0])              return pair      return (name, '') + +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 normalize_output(result: Union[dict, list]) -> Union[dict, list]: +    return _normalize_field_names(decamelize(result)) diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py new file mode 100644 index 000000000..2100eba7f --- /dev/null +++ b/src/services/api/graphql/libs/token_auth.py @@ -0,0 +1,71 @@ +import jwt +import uuid +import pam +from secrets import token_hex + +from .. import state + +def _check_passwd_pam(username: str, passwd: str) -> bool: +    if pam.authenticate(username, passwd): +        return True +    return False + +def init_secret(): +    length = int(state.settings['app'].state.vyos_secret_len) +    secret = token_hex(length) +    state.settings['secret'] = secret + +def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict: +    if user is None or passwd is None: +        return {} +    if _check_passwd_pam(user, passwd): +        app = state.settings['app'] +        try: +            users = app.state.vyos_token_users +        except AttributeError: +            app.state.vyos_token_users = {} +            users = app.state.vyos_token_users +        user_id = uuid.uuid1().hex +        payload_data = {'iss': user, 'sub': user_id, 'exp': exp} +        secret = state.settings.get('secret') +        if secret is None: +            return { +                    "success": False, +                    "errors": ['failed secret generation'] +                   } +        token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256") + +        users |= {user_id: user} +        return {'token': token} + +def get_user_context(request): +    context = {} +    context['request'] = request +    context['user'] = None +    if 'Authorization' in request.headers: +        auth = request.headers['Authorization'] +        scheme, token = auth.split() +        if scheme.lower() != 'bearer': +            return context + +        try: +            secret = state.settings.get('secret') +            payload = jwt.decode(token, secret, algorithms=["HS256"]) +            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: +            users = state.settings['app'].state.vyos_token_users +        except AttributeError: +            return context + +        user = users.get(user_id) +        if user is not None: +            context['user'] = user + +    return context diff --git a/src/services/api/graphql/session/composite/system_status.py b/src/services/api/graphql/session/composite/system_status.py index 3c1a3d45b..d809f32e3 100755 --- a/src/services/api/graphql/session/composite/system_status.py +++ b/src/services/api/graphql/session/composite/system_status.py @@ -23,7 +23,7 @@ import importlib.util  from vyos.defaults import directories -from api.graphql.utils.util import load_op_mode_as_module +from api.graphql.libs.op_mode import load_op_mode_as_module  def get_system_version() -> dict:      show_version = load_op_mode_as_module('version.py') diff --git a/src/services/api/graphql/session/errors/op_mode_errors.py b/src/services/api/graphql/session/errors/op_mode_errors.py index 7ba75455d..7bc1d1d81 100644 --- a/src/services/api/graphql/session/errors/op_mode_errors.py +++ b/src/services/api/graphql/session/errors/op_mode_errors.py @@ -3,11 +3,13 @@  op_mode_err_msg = {      "UnconfiguredSubsystem": "subsystem is not configured or not running",      "DataUnavailable": "data currently unavailable", -    "PermissionDenied": "client does not have permission" +    "PermissionDenied": "client does not have permission", +    "IncorrectValue": "argument value is incorrect"  }  op_mode_err_code = {      "UnconfiguredSubsystem": 2000,      "DataUnavailable": 2001, -    "PermissionDenied": 1003 +    "PermissionDenied": 1003, +    "IncorrectValue": 1002  } diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py index 93e1c328e..0b77b1433 100644 --- a/src/services/api/graphql/session/session.py +++ b/src/services/api/graphql/session/session.py @@ -24,7 +24,8 @@ 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 +from api.graphql.libs.op_mode import load_op_mode_as_module, split_compound_op_mode_name +from api.graphql.libs.op_mode import normalize_output  op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') @@ -45,40 +46,6 @@ class Session:          except Exception:              self._op_mode_list = None -    def configure(self): -        session = self._session -        data = self._data -        func_base_name = self._name - -        tmpl_file = f'{func_base_name}.tmpl' -        cmd_file = f'/tmp/{func_base_name}.cmds' -        tmpl_dir = directories['api_templates'] - -        try: -            render(cmd_file, tmpl_file, data, location=tmpl_dir) -            commands = [] -            with open(cmd_file) as f: -                lines = f.readlines() -            for line in lines: -                commands.append(line.split()) -            for cmd in commands: -                if cmd[0] == 'set': -                    session.set(cmd[1:]) -                elif cmd[0] == 'delete': -                    session.delete(cmd[1:]) -                else: -                    raise ValueError('Operation must be "set" or "delete"') -            session.commit() -        except Exception as error: -            raise error - -    def delete_path_if_childless(self, path): -        session = self._session -        config = Config(session.get_session_env()) -        if not config.list_nodes(path): -            session.delete(path) -            session.commit() -      def show_config(self):          session = self._session          data = self._data @@ -87,14 +54,14 @@ class Session:          try:              out = session.show_config(data['path'])              if data.get('config_format', '') == 'json': -                config_tree = vyos.configtree.ConfigTree(out) +                config_tree = ConfigTree(out)                  out = json.loads(config_tree.to_json())          except Exception as error:              raise error          return out -    def save(self): +    def save_config_file(self):          session = self._session          data = self._data          if 'file_name' not in data or not data['file_name']: @@ -105,7 +72,7 @@ class Session:          except Exception as error:              raise error -    def load(self): +    def load_config_file(self):          session = self._session          data = self._data @@ -127,7 +94,7 @@ class Session:          return out -    def add(self): +    def add_system_image(self):          session = self._session          data = self._data @@ -138,7 +105,7 @@ class Session:          return res -    def delete(self): +    def delete_system_image(self):          session = self._session          data = self._data @@ -183,6 +150,8 @@ class Session:          except OpModeError as e:              raise e +        res = normalize_output(res) +          return res      def gen_op_mutation(self): diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd index 9ae7b1ea9..a380f2e66 100755 --- a/src/services/vyos-hostsd +++ b/src/services/vyos-hostsd @@ -406,8 +406,7 @@ def validate_schema(data):  def pdns_rec_control(command): -    # pdns-r process name is NOT equal to the name shown in ps -    if not process_named_running('pdns-r/worker'): +    if not process_named_running('pdns_recursor'):          logger.info(f'pdns_recursor not running, not sending "{command}"')          return diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 190f3409d..60ea9a5ee 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -647,21 +647,30 @@ def reset_op(data: ResetModel):  ###  def graphql_init(fast_api_app): -    from api.graphql.bindings import generate_schema - +    from api.graphql.libs.token_auth import get_user_context      api.graphql.state.init()      api.graphql.state.settings['app'] = app +    # import after initializaion of state +    from api.graphql.bindings import generate_schema      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, introspection=in_spec), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) +        app.add_route('/graphql', CORSMiddleware(GraphQL(schema, +                                                         context_value=get_user_context, +                                                         debug=True, +                                                         introspection=in_spec), +                                                 allow_origins=origins, +                                                 allow_methods=("GET", "POST", "OPTIONS"), +                                                 allow_headers=("Authorization",)))      else: -        app.add_route('/graphql', GraphQL(schema, debug=True, introspection=in_spec)) - +        app.add_route('/graphql', GraphQL(schema, +                                          context_value=get_user_context, +                                          debug=True, +                                          introspection=in_spec))  ###  if __name__ == '__main__': @@ -686,12 +695,23 @@ if __name__ == '__main__':      app.state.vyos_keys = server_config['api_keys']      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', []) +    app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', []) +    if 'graphql' in server_config: +        app.state.vyos_graphql = True +        if isinstance(server_config['graphql'], dict): +            if 'introspection' in server_config['graphql']: +                app.state.vyos_introspection = True +            else: +                app.state.vyos_introspection = False +            # default value is merged in conf_mode http-api.py, if not set +            app.state.vyos_auth_type = server_config['graphql']['authentication']['type'] +            app.state.vyos_token_exp = server_config['graphql']['authentication']['expiration'] +            app.state.vyos_secret_len = server_config['graphql']['authentication']['secret_length'] +    else: +        app.state.vyos_graphql = False -    if app.state.vyos_gql: +    if app.state.vyos_graphql:          graphql_init(app)      try: diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index a0fccd1d0..864ee8419 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -67,13 +67,13 @@ class KeepalivedFifo:          # For VRRP configuration to be read, the commit must be finished          count = 1          while commit_in_progress(): -            if ( count <= 40 ): -                logger.debug(f'commit in progress try: {count}') +            if ( count <= 20 ): +                logger.debug(f'Attempt to load keepalived configuration aborted due to a commit in progress (attempt {count}/20)')              else: -                logger.error(f'commit still in progress after {count} continuing anyway') +                logger.error(f'Forced keepalived configuration loading despite a commit in progress ({count} wait time expired, not waiting further)')                  break              count += 1 -            time.sleep(0.5) +            time.sleep(1)          try:              base = ['high-availability', 'vrrp'] diff --git a/src/systemd/vyos-domain-group-resolve.service b/src/systemd/vyos-domain-group-resolve.service deleted file mode 100644 index 29628fddb..000000000 --- a/src/systemd/vyos-domain-group-resolve.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=VyOS firewall domain-group resolver -After=vyos-router.service - -[Service] -Type=simple -Restart=always -ExecStart=/usr/bin/python3 /usr/libexec/vyos/vyos-domain-group-resolve.py - -[Install] -WantedBy=multi-user.target diff --git a/src/systemd/vyos-domain-resolver.service b/src/systemd/vyos-domain-resolver.service new file mode 100644 index 000000000..c56b51f0c --- /dev/null +++ b/src/systemd/vyos-domain-resolver.service @@ -0,0 +1,13 @@ +[Unit] +Description=VyOS firewall domain resolver +After=vyos-router.service + +[Service] +Type=simple +Restart=always +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/vyos-domain-resolver.py +StandardError=journal +StandardOutput=journal + +[Install] +WantedBy=multi-user.target diff --git a/src/tests/test_op_mode.py b/src/tests/test_op_mode.py new file mode 100644 index 000000000..90963b3c5 --- /dev/null +++ b/src/tests/test_op_mode.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 unittest import TestCase + +import vyos.opmode + +class TestVyOSOpMode(TestCase): +    def test_field_name_normalization(self): +        from vyos.opmode import _normalize_field_name + +        self.assertEqual(_normalize_field_name(" foo bar "), "foo_bar") +        self.assertEqual(_normalize_field_name("foo-bar"), "foo_bar") +        self.assertEqual(_normalize_field_name("foo (bar) baz"), "foo_bar_baz") +        self.assertEqual(_normalize_field_name("load%"), "load_percentage") + +    def test_dict_fields_normalization_non_unique(self): +        from vyos.opmode import _normalize_field_names + +        # Space and dot are both replaced by an underscore, +        # so dicts like this cannor be normalized uniquely +        data = {"foo bar": True, "foo.bar": False} + +        with self.assertRaises(vyos.opmode.InternalError): +            _normalize_field_names(data) + +    def test_dict_fields_normalization_simple_dict(self): +        from vyos.opmode import _normalize_field_names + +        data = {"foo bar": True, "Bar-Baz": False} +        self.assertEqual(_normalize_field_names(data), {"foo_bar": True, "bar_baz": False}) + +    def test_dict_fields_normalization_nested_dict(self): +        from vyos.opmode import _normalize_field_names + +        data = {"foo bar": True, "bar-baz": {"baz-quux": {"quux-xyzzy": False}}} +        self.assertEqual(_normalize_field_names(data), +          {"foo_bar": True, "bar_baz": {"baz_quux": {"quux_xyzzy": False}}}) + +    def test_dict_fields_normalization_mixed(self): +        from vyos.opmode import _normalize_field_names + +        data = [{"foo bar": True, "bar-baz": [{"baz-quux": {"quux-xyzzy": [False]}}]}] +        self.assertEqual(_normalize_field_names(data), +          [{"foo_bar": True, "bar_baz": [{"baz_quux": {"quux_xyzzy": [False]}}]}]) + +    def test_dict_fields_normalization_primitive(self): +        from vyos.opmode import _normalize_field_names + +        data = [1, False, "foo"] +        self.assertEqual(_normalize_field_names(data), [1, False, "foo"]) + diff --git a/src/tests/test_util.py b/src/tests/test_util.py index 8ac9a500a..d8b2b7940 100644 --- a/src/tests/test_util.py +++ b/src/tests/test_util.py @@ -26,3 +26,17 @@ class TestVyOSUtil(TestCase):      def test_sysctl_read(self):          self.assertEqual(sysctl_read('net.ipv4.conf.lo.forwarding'), '1') + +    def test_camel_to_snake_case(self): +        self.assertEqual(camel_to_snake_case('ConnectionTimeout'), +                                             'connection_timeout') +        self.assertEqual(camel_to_snake_case('connectionTimeout'), +                                             'connection_timeout') +        self.assertEqual(camel_to_snake_case('TCPConnectionTimeout'), +                                             'tcp_connection_timeout') +        self.assertEqual(camel_to_snake_case('TCPPort'), +                                             'tcp_port') +        self.assertEqual(camel_to_snake_case('UseHTTPProxy'), +                                             'use_http_proxy') +        self.assertEqual(camel_to_snake_case('CustomerID'), +                                             'customer_id') diff --git a/src/validators/accel-radius-dictionary b/src/validators/accel-radius-dictionary new file mode 100755 index 000000000..05287e770 --- /dev/null +++ b/src/validators/accel-radius-dictionary @@ -0,0 +1,13 @@ +#!/bin/sh + +DICT_PATH=/usr/share/accel-ppp/radius +NAME=$1 + +if [ -n "$NAME" -a -e $DICT_PATH/dictionary.$NAME ]; then +    exit 0 +else +    echo "$NAME is not a valid RADIUS dictionary name" +    echo "Please make sure that $DICT_PATH/dictionary.$NAME file exists" +    exit 1 +fi + diff --git a/src/validators/allowed-vlan b/src/validators/allowed-vlan deleted file mode 100755 index 11389390b..000000000 --- a/src/validators/allowed-vlan +++ /dev/null @@ -1,19 +0,0 @@ -#! /usr/bin/python3 - -import sys -import re - -if __name__ == '__main__': -    if len(sys.argv)>1: -        allowed_vlan = sys.argv[1] -        if re.search('[0-9]{1,4}-[0-9]{1,4}', allowed_vlan): -            for tmp in allowed_vlan.split('-'): -                if int(tmp) not in range(1, 4095): -                    sys.exit(1) -        else: -            if int(allowed_vlan) not in range(1, 4095): -                sys.exit(1) -    else: -        sys.exit(2) -     -    sys.exit(0) diff --git a/src/validators/bgp-extended-community b/src/validators/bgp-extended-community new file mode 100755 index 000000000..b69ae3449 --- /dev/null +++ b/src/validators/bgp-extended-community @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +# 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 +# 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/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_2_OCTET: int = 65535 +COMM_MAX_4_OCTET: int = 4294967295 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 1: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_left: str = community.split(':')[0] +        comm_right: int = int(community.split(':')[1]) + +        # check if left part is an IPv4 address +        if is_ipv4(comm_left) and 0 <= comm_right <= COMM_MAX_2_OCTET: +            exit() +        # check if a left part is a number +        if 0 <= int(comm_left) <= COMM_MAX_2_OCTET \ +                and 0 <= comm_right <= COMM_MAX_4_OCTET: +            exit() + +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/bgp-large-community b/src/validators/bgp-large-community new file mode 100755 index 000000000..386398308 --- /dev/null +++ b/src/validators/bgp-large-community @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +# 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 +# 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/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_4_OCTET: int = 4294967295 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 2: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_part1: int = int(community.split(':')[0]) +        comm_part2: int = int(community.split(':')[1]) +        comm_part3: int = int(community.split(':')[2]) + +        # check compatibilities of left and right parts +        if 0 <= comm_part1 <= COMM_MAX_4_OCTET \ +                and 0 <= comm_part2 <= COMM_MAX_4_OCTET \ +                and 0 <= comm_part3 <= COMM_MAX_4_OCTET: +            exit(0) + +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/bgp-regular-community b/src/validators/bgp-regular-community new file mode 100755 index 000000000..d43a71eae --- /dev/null +++ b/src/validators/bgp-regular-community @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +# 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 +# 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/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_2_OCTET: int = 65535 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 1: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_left: int = int(community.split(':')[0]) +        comm_right: int = int(community.split(':')[1]) + +        # check compatibilities of left and right parts +        if 0 <= comm_left <= COMM_MAX_2_OCTET \ +                and 0 <= comm_right <= COMM_MAX_2_OCTET: +            exit(0) +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/dotted-decimal b/src/validators/dotted-decimal deleted file mode 100755 index 652110346..000000000 --- a/src/validators/dotted-decimal +++ /dev/null @@ -1,33 +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 re -import sys - -area = sys.argv[1] - -res = re.match(r'^(\d+)\.(\d+)\.(\d+)\.(\d+)$', area) -if not res: -    print("\'{0}\' is not a valid dotted decimal value".format(area)) -    sys.exit(1) -else: -    components = res.groups() -    for n in range(0, 4): -        if (int(components[n]) > 255): -            print("Invalid component of a dotted decimal value: {0} exceeds 255".format(components[n])) -            sys.exit(1) - -sys.exit(0) diff --git a/src/validators/fqdn b/src/validators/fqdn index a4027e4ca..a65d2d5d4 100755 --- a/src/validators/fqdn +++ b/src/validators/fqdn @@ -1,27 +1,2 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2021 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import re -import sys - -pattern = '[A-Za-z0-9][-.A-Za-z0-9]*' - -if __name__ == '__main__': -    if len(sys.argv) != 2: -        sys.exit(1) -    if not re.match(pattern, sys.argv[1]): -        sys.exit(1) -    sys.exit(0) +#!/usr/bin/env sh +${vyos_libexec_dir}/validate-value --regex "[A-Za-z0-9][-.A-Za-z0-9]*" --value "$1" diff --git a/src/validators/mac-address b/src/validators/mac-address index 7d020f387..bb859a603 100755 --- a/src/validators/mac-address +++ b/src/validators/mac-address @@ -1,27 +1,2 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import re -import sys - -pattern = "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$" - -if __name__ == '__main__': -    if len(sys.argv) != 2: -        sys.exit(1) -    if not re.match(pattern, sys.argv[1]): -        sys.exit(1) -    sys.exit(0) +#!/usr/bin/env sh +${vyos_libexec_dir}/validate-value --regex "([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})" --value "$1" diff --git a/src/validators/mac-address-exclude b/src/validators/mac-address-exclude new file mode 100755 index 000000000..c44913023 --- /dev/null +++ b/src/validators/mac-address-exclude @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +${vyos_libexec_dir}/validate-value --regex "!([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})" --value "$1" diff --git a/src/validators/mac-address-firewall b/src/validators/mac-address-firewall deleted file mode 100755 index 70551f86d..000000000 --- a/src/validators/mac-address-firewall +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -# 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 -# 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 - -pattern = "^!?([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$" - -if __name__ == '__main__': -    if len(sys.argv) != 2: -        sys.exit(1) -    if not re.match(pattern, sys.argv[1]): -        sys.exit(1) -    sys.exit(0) diff --git a/src/validators/tcp-flag b/src/validators/tcp-flag deleted file mode 100755 index 1496b904a..000000000 --- a/src/validators/tcp-flag +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/python3 - -import sys -import re - -if __name__ == '__main__': -    if len(sys.argv)>1: -        flag = sys.argv[1] -        if flag and flag[0] == '!': -            flag = flag[1:] -        if flag not in ['syn', 'ack', 'rst', 'fin', 'urg', 'psh', 'ecn', 'cwr']: -            print(f'Error: {flag} is not a valid TCP flag') -            sys.exit(1) -    else: -        sys.exit(2) - -    sys.exit(0) diff --git a/src/xdp/common/common.mk b/src/xdp/common/common.mk index ebe23a9ed..ffb86a65c 100644 --- a/src/xdp/common/common.mk +++ b/src/xdp/common/common.mk @@ -39,7 +39,7 @@ KERN_USER_H ?= $(wildcard common_kern_user.h)  CFLAGS ?= -g -I../include/  BPF_CFLAGS ?= -I../include/ -LIBS = -l:libbpf.a -lelf $(USER_LIBS) +LIBS = -lbpf -lelf $(USER_LIBS)  all: llvm-check $(USER_TARGETS) $(XDP_OBJ) $(COPY_LOADER) $(COPY_STATS) diff --git a/src/xdp/common/common_user_bpf_xdp.c b/src/xdp/common/common_user_bpf_xdp.c index e7ef77174..faf7f4f91 100644 --- a/src/xdp/common/common_user_bpf_xdp.c +++ b/src/xdp/common/common_user_bpf_xdp.c @@ -274,7 +274,7 @@ struct bpf_object *load_bpf_and_xdp_attach(struct config *cfg)  		exit(EXIT_FAIL_BPF);  	} -	strncpy(cfg->progsec, bpf_program__title(bpf_prog, false), sizeof(cfg->progsec)); +	strncpy(cfg->progsec, bpf_program__section_name(bpf_prog), sizeof(cfg->progsec));  	prog_fd = bpf_program__fd(bpf_prog);  	if (prog_fd <= 0) { | 
