diff options
| author | Christian Poessinger <christian@poessinger.com> | 2022-12-17 08:29:12 +0100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-17 08:29:12 +0100 | 
| commit | 76cf45917de5ed3a04132029d33a240ebd5877d6 (patch) | |
| tree | 07ffee72afccd941a60508ba56b6e65424d96bd0 /src | |
| parent | 0c51111829dcd7660fc5405ae6ac651a8b6987b8 (diff) | |
| parent | d7a67aa4a7e7bb82a60ad18103abc6b966a2f8b8 (diff) | |
| download | vyos-1x-76cf45917de5ed3a04132029d33a240ebd5877d6.tar.gz vyos-1x-76cf45917de5ed3a04132029d33a240ebd5877d6.zip  | |
Merge branch 'current' into goodnetnick-shloginotp-T4754
Diffstat (limited to 'src')
96 files changed, 3648 insertions, 1199 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..f68acfe02 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_dependents, 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,10 @@ 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 and policy-route as firewall groups were updated +        set_dependents('group_resync', conf)      if 'config_trap' in firewall and firewall['config_trap'] == 'enable':          diff = get_config_diff(conf) @@ -173,6 +174,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 +235,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: @@ -274,6 +276,8 @@ def verify_nested_group(group_name, group, groups, seen):      if 'include' not in group:          return +    seen.append(group_name) +      for g in group['include']:          if g not in groups:              raise ConfigError(f'Nested group "{g}" does not exist') @@ -281,8 +285,6 @@ def verify_nested_group(group_name, group, groups, seen):          if g in seen:              raise ConfigError(f'Group "{group_name}" has a circular reference') -        seen.append(g) -          if 'include' in groups[g]:              verify_nested_group(g, groups[g], groups, seen) @@ -466,42 +468,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 c196e272b..6328294c1 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -25,6 +25,7 @@ import vyos.defaults  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdep import set_dependents, call_dependents  from vyos.template import render  from vyos.util import cmd  from vyos.util import call @@ -61,6 +62,11 @@ def get_config(config=None):      else:          conf = Config() +    # reset on creation/deletion of 'api' node +    https_base = ['service', 'https'] +    if conf.exists(https_base): +        set_dependents("https", conf) +      base = ['service', 'https', 'api']      if not conf.exists(base):          return None @@ -86,7 +92,7 @@ def get_config(config=None):      if 'api_keys' in api_dict:          keys_added = True -    if 'gql' in api_dict: +    if 'graphql' in api_dict:          api_dict = dict_merge(defaults(base), api_dict)      http_api.update(api_dict) @@ -132,7 +138,7 @@ def apply(http_api):      # Let uvicorn settle before restarting Nginx      sleep(1) -    cmd(f'{vyos_conf_scripts_dir}/https.py', raising=ConfigError) +    call_dependents()  if __name__ == '__main__':      try: diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 21cf204fc..b883ebef2 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -116,7 +116,7 @@ def get_config(config=None):      if dict_search('member.interface', bond):          for interface, interface_config in bond['member']['interface'].items():              # Check if member interface is a new member -            if not conf.exists_effective(['member', 'interface', interface]): +            if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]):                  bond['shutdown_required'] = {}              # Check if member interface is disabled diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index e02841831..b49c945cd 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -175,7 +175,7 @@ def generate(ethernet):          loaded_pki_cert = load_certificate(pki_cert['certificate'])          loaded_ca_certs = {load_certificate(c['certificate']) -            for c in ethernet['pki']['ca'].values()} +            for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {}          cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index e2fdc7a42..ee4defa0d 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -23,7 +23,6 @@ from netifaces import interfaces  from vyos.config import Config  from vyos.configdict import get_interface_dict  from vyos.configdict import is_node_changed -from vyos.configdict import leaf_node_changed  from vyos.configdict import get_pppoe_interfaces  from vyos.configverify import verify_authentication  from vyos.configverify import verify_source_interface diff --git a/src/conf_mode/interfaces-sstpc.py b/src/conf_mode/interfaces-sstpc.py new file mode 100755 index 000000000..6b8094c51 --- /dev/null +++ b/src/conf_mode/interfaces-sstpc.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import os +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_authentication +from vyos.configverify import verify_vrf +from vyos.ifconfig import SSTPCIf +from vyos.pki import encode_certificate +from vyos.pki import find_chain +from vyos.pki import load_certificate +from vyos.template import render +from vyos.util import call +from vyos.util import dict_search +from vyos.util import is_systemd_service_running +from vyos.util import write_file +from vyos import ConfigError +from vyos import airbag +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', 'sstpc'] +    ifname, sstpc = get_interface_dict(conf, base) + +    # We should only terminate the SSTP client session if critical parameters +    # change. All parameters that can be changed on-the-fly (like interface +    # description) should not lead to a reconnect! +    for options in ['authentication', 'no_peer_dns', 'no_default_route', +                    'server', 'ssl']: +        if is_node_changed(conf, base + [ifname, options]): +            sstpc.update({'shutdown_required': {}}) +            # bail out early - no need to further process other nodes +            break + +    # Load PKI certificates for later processing +    sstpc['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), +                                        get_first_key=True, +                                        no_tag_node_value_mangle=True) +    return sstpc + +def verify(sstpc): +    if 'deleted' in sstpc: +        return None + +    verify_authentication(sstpc) +    verify_vrf(sstpc) + +    if dict_search('ssl.ca_certificate', sstpc) == None: +        raise ConfigError('Missing mandatory CA certificate!') + +    return None + +def generate(sstpc): +    ifname = sstpc['ifname'] +    config_sstpc = f'/etc/ppp/peers/{ifname}' + +    sstpc['ca_file_path'] = f'/run/sstpc/{ifname}_ca-cert.pem' + +    if 'deleted' in sstpc: +        for file in [sstpc['ca_file_path'], config_sstpc]: +            if os.path.exists(file): +                os.unlink(file) +        return None + +    ca_name = sstpc['ssl']['ca_certificate'] +    pki_ca_cert = sstpc['pki']['ca'][ca_name] + +    loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) +    loaded_ca_certs = {load_certificate(c['certificate']) +            for c in sstpc['pki']['ca'].values()} if 'ca' in sstpc['pki'] else {} + +    ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + +    write_file(sstpc['ca_file_path'], '\n'.join(encode_certificate(c) for c in ca_full_chain)) +    render(config_sstpc, 'sstp-client/peer.j2', sstpc, permission=0o640) + +    return None + +def apply(sstpc): +    ifname = sstpc['ifname'] +    if 'deleted' in sstpc or 'disable' in sstpc: +        if os.path.isdir(f'/sys/class/net/{ifname}'): +            p = SSTPCIf(ifname) +            p.remove() +        call(f'systemctl stop ppp@{ifname}.service') +        return None + +    # reconnect should only be necessary when specific options change, +    # like server, authentication ... (see get_config() for details) +    if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or +        'shutdown_required' in sstpc): + +        # cleanup system (e.g. FRR routes first) +        if os.path.isdir(f'/sys/class/net/{ifname}'): +            p = SSTPCIf(ifname) +            p.remove() + +        call(f'systemctl restart ppp@{ifname}.service') +        # When interface comes "live" a hook is called: +        # /etc/ppp/ip-up.d/96-vyos-sstpc-callback +        # which triggers SSTPCIf.update() +    else: +        if os.path.isdir(f'/sys/class/net/{ifname}'): +            p = SSTPCIf(ifname) +            p.update(sstpc) + +    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-virtual-ethernet.py b/src/conf_mode/interfaces-virtual-ethernet.py new file mode 100755 index 000000000..8efe89c41 --- /dev/null +++ b/src/conf_mode/interfaces-virtual-ethernet.py @@ -0,0 +1,114 @@ +#!/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) +        # Prevent to delete veth interface which used for another "vethX peer-name" +        for iface, iface_config in veth['other_interfaces'].items(): +            if veth['ifname'] in iface_config['peer_name']: +                ifname = veth['ifname'] +                raise ConfigError( +                    f'Cannot delete "{ifname}" used for "interface {iface} peer-name"' +                ) +        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"]}"!') + +    peer_name = veth['peer_name'] +    ifname = veth['ifname'] + +    if veth['peer_name'] not in veth['other_interfaces']: +        raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ +                          'is not configured!') + +    if veth['other_interfaces'][peer_name]['peer_name'] != ifname: +        raise ConfigError( +            f'Configuration mismatch between "{ifname}" and "{peer_name}"!') + +    if peer_name == ifname: +        raise ConfigError( +            f'Peer-name "{peer_name}" cannot be the same as interface "{ifname}"!') + +    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/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/pki.py b/src/conf_mode/pki.py index 29ed7b1b7..e8f3cc87a 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -16,20 +16,16 @@  from sys import exit -import jmespath -  from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents  from vyos.configdict import dict_merge  from vyos.configdict import node_changed  from vyos.pki import is_ca_certificate  from vyos.pki import load_certificate -from vyos.pki import load_certificate_request  from vyos.pki import load_public_key  from vyos.pki import load_private_key  from vyos.pki import load_crl  from vyos.pki import load_dh_parameters -from vyos.util import ask_input -from vyos.util import call  from vyos.util import dict_search_args  from vyos.util import dict_search_recursive  from vyos.xml import defaults @@ -121,6 +117,39 @@ def get_config(config=None):                                           get_first_key=True,                                           no_tag_node_value_mangle=True) +    if 'changed' in pki: +        for search in sync_search: +            for key in search['keys']: +                changed_key = sync_translate[key] + +                if changed_key not in pki['changed']: +                    continue + +                for item_name in pki['changed'][changed_key]: +                    node_present = False +                    if changed_key == 'openvpn': +                        node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) +                    else: +                        node_present = dict_search_args(pki, changed_key, item_name) + +                    if node_present: +                        search_dict = dict_search_args(pki['system'], *search['path']) + +                        if not search_dict: +                            continue + +                        for found_name, found_path in dict_search_recursive(search_dict, key): +                            if found_name == item_name: +                                path = search['path'] +                                path_str = ' '.join(path + found_path) +                                print(f'pki: Updating config: {path_str} {found_name}') + +                                if path[0] == 'interfaces': +                                    ifname = found_path[0] +                                    set_dependents(path[1], conf, ifname) +                                else: +                                    set_dependents(path[1], conf) +      return pki  def is_valid_certificate(raw_data): @@ -259,37 +288,7 @@ def apply(pki):          return None      if 'changed' in pki: -        for search in sync_search: -            for key in search['keys']: -                changed_key = sync_translate[key] - -                if changed_key not in pki['changed']: -                    continue - -                for item_name in pki['changed'][changed_key]: -                    node_present = False -                    if changed_key == 'openvpn': -                        node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) -                    else: -                        node_present = dict_search_args(pki, changed_key, item_name) - -                    if node_present: -                        search_dict = dict_search_args(pki['system'], *search['path']) - -                        if not search_dict: -                            continue - -                        for found_name, found_path in dict_search_recursive(search_dict, key): -                            if found_name == item_name: -                                path_str = ' '.join(search['path'] + found_path) -                                print(f'pki: Updating config: {path_str} {found_name}') - -                                script = search['script'] -                                if found_path[0] == 'interfaces': -                                    ifname = found_path[2] -                                    call(f'VYOS_TAGNODE_VALUE={ifname} {script}') -                                else: -                                    call(script) +        call_dependents()      return None 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 a0d288e91..331194fec 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -167,6 +167,11 @@ 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) diff --git a/src/conf_mode/protocols_failover.py b/src/conf_mode/protocols_failover.py new file mode 100755 index 000000000..048ba7a89 --- /dev/null +++ b/src/conf_mode/protocols_failover.py @@ -0,0 +1,121 @@ +#!/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 + +from pathlib import Path + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +service_name = 'vyos-failover' +service_conf = Path(f'/run/{service_name}.conf') +systemd_service = '/etc/systemd/system/vyos-failover.service' +rt_proto_failover = '/etc/iproute2/rt_protos.d/failover.conf' + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['protocols', 'failover'] +    failover = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + +    # Set default values only if we set config +    if failover.get('route'): +        for route, route_config in failover.get('route').items(): +            for next_hop, next_hop_config in route_config.get('next_hop').items(): +                default_values = defaults(base + ['route']) +                failover['route'][route]['next_hop'][next_hop] = dict_merge( +                    default_values['next_hop'], failover['route'][route]['next_hop'][next_hop]) + +    return failover + +def verify(failover): +    # bail out early - looks like removal from running config +    if not failover: +        return None + +    if 'route' not in failover: +        raise ConfigError(f'Failover "route" is mandatory!') + +    for route, route_config in failover['route'].items(): +        if not route_config.get('next_hop'): +            raise ConfigError(f'Next-hop for "{route}" is mandatory!') + +        for next_hop, next_hop_config in route_config.get('next_hop').items(): +            if 'interface' not in next_hop_config: +                raise ConfigError(f'Interface for route "{route}" next-hop "{next_hop}" is mandatory!') + +            if not next_hop_config.get('check'): +                raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') + +            if 'target' not in next_hop_config['check']: +                raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') + +            check_type = next_hop_config['check']['type'] +            if check_type == 'tcp' and 'port' not in next_hop_config['check']: +                raise ConfigError(f'Check port for next-hop "{next_hop}" and type TCP is mandatory!') + +    return None + +def generate(failover): +    if not failover: +        service_conf.unlink(missing_ok=True) +        return None + +    # Add own rt_proto 'failover' +    # Helps to detect all own routes 'proto failover' +    with open(rt_proto_failover, 'w') as f: +        f.write('111  failover\n') + +    # Write configuration file +    conf_json = json.dumps(failover, indent=4) +    service_conf.write_text(conf_json) +    render(systemd_service, 'protocols/systemd_vyos_failover_service.j2', failover) + +    return None + +def apply(failover): +    if not failover: +        call(f'systemctl stop {service_name}.service') +        call('ip route flush protocol failover') +    else: +        call('systemctl daemon-reload') +        call(f'systemctl restart {service_name}.service') +        call(f'ip route flush protocol failover') + +    return None + +if __name__ == '__main__': +    try: +        c = get_config() +        verify(c) +        generate(c) +        apply(c) +    except ConfigError as e: +        print(e) +        exit(1) diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 5da8e7b06..73af6595b 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -24,6 +24,7 @@ from vyos.template import render_to_string  from vyos.util import dict_search  from vyos.util import read_file  from vyos.util import sysctl_write +from vyos.configverify import verify_interface_exists  from vyos import ConfigError  from vyos import frr  from vyos import airbag @@ -46,6 +47,10 @@ def verify(mpls):      if not mpls:          return None +    if 'interface' in mpls: +        for interface in mpls['interface']: +            verify_interface_exists(interface) +      # Checks to see if LDP is properly configured      if 'ldp' in mpls:          # If router ID not defined diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index ba0249efd..600ba4e92 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -20,6 +20,7 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_accel_dict +from vyos.configdict import is_node_changed  from vyos.configverify import verify_accel_ppp_base_service  from vyos.configverify import verify_interface_exists  from vyos.template import render @@ -43,6 +44,13 @@ def get_config(config=None):      # retrieve common dictionary keys      pppoe = get_accel_dict(conf, base, pppoe_chap_secrets) + +    # reload-or-restart does not implemented in accel-ppp +    # use this workaround until it will be implemented +    # https://phabricator.accel-ppp.org/T3 +    if is_node_changed(conf, base + ['client-ip-pool']) or is_node_changed( +            conf, base + ['client-ipv6-pool']): +        pppoe.update({'restart_required': {}})      return pppoe  def verify(pppoe): @@ -95,7 +103,10 @@ def apply(pppoe):                  os.unlink(file)          return None -    call(f'systemctl reload-or-restart {systemd_service}') +    if 'restart_required' in pppoe: +        call(f'systemctl restart {systemd_service}') +    else: +        call(f'systemctl reload-or-restart {systemd_service}')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/service_webproxy.py b/src/conf_mode/service_webproxy.py index 32af31bde..41a1deaa3 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -28,8 +28,10 @@ from vyos.util import dict_search  from vyos.util import write_file  from vyos.validate import is_addr_assigned  from vyos.xml import defaults +from vyos.base import Warning  from vyos import ConfigError  from vyos import airbag +  airbag.enable()  squid_config_file = '/etc/squid/squid.conf' @@ -37,24 +39,57 @@ squidguard_config_file = '/etc/squidguard/squidGuard.conf'  squidguard_db_dir = '/opt/vyatta/etc/config/url-filtering/squidguard/db'  user_group = 'proxy' -def generate_sg_localdb(category, list_type, role, proxy): + +def check_blacklist_categorydb(config_section): +    if 'block_category' in config_section: +        for category in config_section['block_category']: +            check_categorydb(category) +    if 'allow_category' in config_section: +        for category in config_section['allow_category']: +            check_categorydb(category) + + +def check_categorydb(category: str): +    """ +    Check if category's db exist +    :param category: +    :type str: +    """ +    path_to_cat: str = f'{squidguard_db_dir}/{category}' +    if not os.path.exists(f'{path_to_cat}/domains.db') \ +            and not os.path.exists(f'{path_to_cat}/urls.db') \ +            and not os.path.exists(f'{path_to_cat}/expressions.db'): +        Warning(f'DB of category {category} does not exist.\n ' +                f'Use [update webproxy blacklists] ' +                f'or delete undefined category!') + + +def generate_sg_rule_localdb(category, list_type, role, proxy): +    if not category or not list_type or not role: +        return None +      cat_ = category.replace('-', '_') -    if isinstance(dict_search(f'url_filtering.squidguard.{cat_}', proxy), -                  list): +    if role == 'default': +        path_to_cat = f'{cat_}' +    else: +        path_to_cat = f'rule.{role}.{cat_}' +    if isinstance( +            dict_search(f'url_filtering.squidguard.{path_to_cat}', proxy), +            list):          # local block databases must be generated "on-the-fly"          tmp = { -            'squidguard_db_dir' : squidguard_db_dir, -            'category' : f'{category}-default', -            'list_type' : list_type, -            'rule' : role +            'squidguard_db_dir': squidguard_db_dir, +            'category': f'{category}-{role}', +            'list_type': list_type, +            'rule': role          }          sg_tmp_file = '/tmp/sg.conf' -        db_file = f'{category}-default/{list_type}' -        domains = '\n'.join(dict_search(f'url_filtering.squidguard.{cat_}', proxy)) - +        db_file = f'{category}-{role}/{list_type}' +        domains = '\n'.join( +            dict_search(f'url_filtering.squidguard.{path_to_cat}', proxy))          # local file -        write_file(f'{squidguard_db_dir}/{category}-default/local', '', +        write_file(f'{squidguard_db_dir}/{category}-{role}/local', '',                     user=user_group, group=user_group)          # database input file          write_file(f'{squidguard_db_dir}/{db_file}', domains, @@ -64,17 +99,18 @@ def generate_sg_localdb(category, list_type, role, proxy):          render(sg_tmp_file, 'squid/sg_acl.conf.j2', tmp,                 user=user_group, group=user_group) -        call(f'su - {user_group} -c "squidGuard -d -c {sg_tmp_file} -C {db_file}"') +        call( +            f'su - {user_group} -c "squidGuard -d -c {sg_tmp_file} -C {db_file}"')          if os.path.exists(sg_tmp_file):              os.unlink(sg_tmp_file) -      else:          # if category is not part of our configuration, clean out the          # squidguard lists -        tmp = f'{squidguard_db_dir}/{category}-default' +        tmp = f'{squidguard_db_dir}/{category}-{role}'          if os.path.exists(tmp): -            rmtree(f'{squidguard_db_dir}/{category}-default') +            rmtree(f'{squidguard_db_dir}/{category}-{role}') +  def get_config(config=None):      if config: @@ -85,7 +121,8 @@ def get_config(config=None):      if not conf.exists(base):          return None -    proxy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    proxy = conf.get_config_dict(base, key_mangling=('-', '_'), +                                 get_first_key=True)      # We have gathered the dict representation of the CLI, but there are default      # options which we need to update into the dictionary retrived.      default_values = defaults(base) @@ -110,10 +147,11 @@ def get_config(config=None):          default_values = defaults(base + ['cache-peer'])          for peer in proxy['cache_peer']:              proxy['cache_peer'][peer] = dict_merge(default_values, -                proxy['cache_peer'][peer]) +                                                   proxy['cache_peer'][peer])      return proxy +  def verify(proxy):      if not proxy:          return None @@ -170,17 +208,30 @@ def generate(proxy):      render(squidguard_config_file, 'squid/squidGuard.conf.j2', proxy)      cat_dict = { -        'local-block' : 'domains', -        'local-block-keyword' : 'expressions', -        'local-block-url' : 'urls', -        'local-ok' : 'domains', -        'local-ok-url' : 'urls' +        'local-block': 'domains', +        'local-block-keyword': 'expressions', +        'local-block-url': 'urls', +        'local-ok': 'domains', +        'local-ok-url': 'urls'      } -    for category, list_type in cat_dict.items(): -        generate_sg_localdb(category, list_type, 'default', proxy) +    if dict_search(f'url_filtering.squidguard', proxy) is not None: +        squidgard_config_section = proxy['url_filtering']['squidguard'] + +        for category, list_type in cat_dict.items(): +            generate_sg_rule_localdb(category, list_type, 'default', proxy) +        check_blacklist_categorydb(squidgard_config_section) + +        if 'rule' in squidgard_config_section: +            for rule in squidgard_config_section['rule']: +                rule_config_section = squidgard_config_section['rule'][ +                    rule] +                for category, list_type in cat_dict.items(): +                    generate_sg_rule_localdb(category, list_type, rule, proxy) +                check_blacklist_categorydb(rule_config_section)      return None +  def apply(proxy):      if not proxy:          # proxy is removed in the commit @@ -198,6 +249,7 @@ def apply(proxy):      call('systemctl restart squid.service')      return None +  if __name__ == '__main__':      try:          c = get_config() 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/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index fd5a4acd8..27e78db99 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -26,7 +26,10 @@ from ipaddress import ip_network  from vyos.config import Config  from vyos.template import is_ipv4  from vyos.template import render -from vyos.util import call, get_half_cpus +from vyos.util import call +from vyos.util import get_half_cpus +from vyos.util import check_port_availability +from vyos.util import is_listen_port_bind_service  from vyos import ConfigError  from vyos import airbag @@ -43,6 +46,7 @@ default_config_data = {      'client_ip_pool': None,      'client_ip_subnets': [],      'client_ipv6_pool': [], +    'client_ipv6_pool_configured': False,      'client_ipv6_delegate_prefix': [],      'dnsv4': [],      'dnsv6': [], @@ -64,7 +68,7 @@ default_config_data = {      'radius_source_address': '',      'radius_shaper_attr': '',      'radius_shaper_vendor': '', -    'radius_dynamic_author': '', +    'radius_dynamic_author': {},      'wins': [],      'ip6_column': [],      'thread_cnt': get_half_cpus() @@ -205,21 +209,21 @@ def get_config(config=None):              l2tp['radius_source_address'] = conf.return_value(['source-address'])          # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) -        if conf.exists(['dynamic-author']): +        if conf.exists(['dae-server']):              dae = {                  'port' : '',                  'server' : '',                  'key' : ''              } -            if conf.exists(['dynamic-author', 'server']): -                dae['server'] = conf.return_value(['dynamic-author', 'server']) +            if conf.exists(['dae-server', 'ip-address']): +                dae['server'] = conf.return_value(['dae-server', 'ip-address']) -            if conf.exists(['dynamic-author', 'port']): -                dae['port'] = conf.return_value(['dynamic-author', 'port']) +            if conf.exists(['dae-server', 'port']): +                dae['port'] = conf.return_value(['dae-server', 'port']) -            if conf.exists(['dynamic-author', 'key']): -                dae['key'] = conf.return_value(['dynamic-author', 'key']) +            if conf.exists(['dae-server', 'secret']): +                dae['key'] = conf.return_value(['dae-server', 'secret'])              l2tp['radius_dynamic_author'] = dae @@ -244,6 +248,7 @@ def get_config(config=None):          l2tp['client_ip_subnets'] = conf.return_values(['client-ip-pool', 'subnet'])      if conf.exists(['client-ipv6-pool', 'prefix']): +        l2tp['client_ipv6_pool_configured'] = True          l2tp['ip6_column'].append('ip6')          for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']):              tmp = { @@ -306,6 +311,9 @@ def get_config(config=None):      if conf.exists(['ppp-options', 'lcp-echo-interval']):          l2tp['ppp_echo_interval'] = conf.return_value(['ppp-options', 'lcp-echo-interval']) +    if conf.exists(['ppp-options', 'ipv6']): +        l2tp['ppp_ipv6'] = conf.return_value(['ppp-options', 'ipv6']) +      return l2tp @@ -329,6 +337,19 @@ def verify(l2tp):              if not radius['key']:                  raise ConfigError(f"Missing RADIUS secret for server { radius['key'] }") +        if l2tp['radius_dynamic_author']: +            if not l2tp['radius_dynamic_author']['server']: +                raise ConfigError("Missing ip-address for dae-server") +            if not l2tp['radius_dynamic_author']['key']: +                raise ConfigError("Missing secret for dae-server") +            address = l2tp['radius_dynamic_author']['server'] +            port = l2tp['radius_dynamic_author']['port'] +            proto = 'tcp' +            # check if dae listen port is not used by another service +            if check_port_availability(address, int(port), proto) is not True and \ +                not is_listen_port_bind_service(int(port), 'accel-pppd'): +                raise ConfigError(f'"{proto}" port "{port}" is used by another service') +      # check for the existence of a client ip pool      if not (l2tp['client_ip_pool'] or l2tp['client_ip_subnets']):          raise ConfigError( diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index c050b796b..af3c51efc 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -58,7 +58,7 @@ def get_config():      default_values = defaults(base)      ocserv = dict_merge(default_values, ocserv) -    if "local" in ocserv["authentication"]["mode"]: +    if 'mode' in ocserv["authentication"] and "local" in ocserv["authentication"]["mode"]:          # workaround a "know limitation" - https://phabricator.vyos.net/T2665          del ocserv['authentication']['local_users']['username']['otp']          if not ocserv["authentication"]["local_users"]["username"]: @@ -157,7 +157,7 @@ def verify(ocserv):                  ocserv["network_settings"]["push_route"].remove("0.0.0.0/0")                  ocserv["network_settings"]["push_route"].append("default")          else: -            ocserv["network_settings"]["push_route"] = "default" +            ocserv["network_settings"]["push_route"] = ["default"]      else:          raise ConfigError('openconnect network settings required') @@ -247,7 +247,7 @@ def apply(ocserv):              if os.path.exists(file):                  os.unlink(file)      else: -        call('systemctl restart ocserv.service') +        call('systemctl reload-or-restart ocserv.service')          counter = 0          while True:              # exit early when service runs 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/96-vyos-sstpc-callback b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback new file mode 100755 index 000000000..4e8804f29 --- /dev/null +++ b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback @@ -0,0 +1,49 @@ +#!/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/>. + +# This is a Python hook script which is invoked whenever a SSTP client session +# goes "ip-up". It will call into our vyos.ifconfig library and will then +# execute common tasks for the SSTP interface. The reason we have to "hook" this +# is that we can not create a sstpcX interface in advance in linux and then +# connect pppd to this already existing interface. + +from sys import argv +from sys import exit + +from vyos.configquery import ConfigTreeQuery +from vyos.configdict import get_interface_dict +from vyos.ifconfig import SSTPCIf + +# When the ppp link comes up, this script is called with the following +# parameters +#       $1      the interface name used by pppd (e.g. ppp3) +#       $2      the tty device name +#       $3      the tty device speed +#       $4      the local IP address for the interface +#       $5      the remote IP address +#       $6      the parameter specified by the 'ipparam' option to pppd + +if (len(argv) < 7): +    exit(1) + +interface = argv[6] + +conf = ConfigTreeQuery() +_, sstpc = get_interface_dict(conf.config, ['interfaces', 'sstpc'], interface) + +# Update the config +p = SSTPCIf(interface) +p.update(sstpc) 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/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/helpers/vyos-failover.py b/src/helpers/vyos-failover.py new file mode 100755 index 000000000..1ac193423 --- /dev/null +++ b/src/helpers/vyos-failover.py @@ -0,0 +1,184 @@ +#!/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 argparse +import json +import subprocess +import socket +import time + +from vyos.util import rc_cmd +from pathlib import Path +from systemd import journal + + +my_name = Path(__file__).stem + + +def get_best_route_options(route, debug=False): +    """ +    Return current best route ('gateway, interface, metric) + +    % get_best_route_options('203.0.113.1') +    ('192.168.0.1', 'eth1', 1) + +    % get_best_route_options('203.0.113.254') +    (None, None, None) +    """ +    rc, data = rc_cmd(f'ip --detail --json route show protocol failover {route}') +    if rc == 0: +        data = json.loads(data) +        if len(data) == 0: +            print(f'\nRoute {route} for protocol failover was not found') +            return None, None, None +        # Fake metric 999 by default +        # Search route with the lowest metric +        best_metric = 999 +        for entry in data: +            if debug: print('\n', entry) +            metric = entry.get('metric') +            gateway = entry.get('gateway') +            iface = entry.get('dev') +            if metric < best_metric: +                best_metric = metric +                best_gateway = gateway +                best_interface = iface +        if debug: +            print(f'### Best_route exists: {route}, best_gateway: {best_gateway}, ' +                  f'best_metric: {best_metric}, best_iface: {best_interface}') +        return best_gateway, best_interface, best_metric + +def is_port_open(ip, port): +    """ +    Check connection to remote host and port +    Return True if host alive + +    % is_port_open('example.com', 8080) +    True +    """ +    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) +    s.settimeout(2) +    try: +        s.connect((ip, int(port))) +        s.shutdown(socket.SHUT_RDWR) +        return True +    except: +        return False +    finally: +        s.close() + +def is_target_alive(target=None, iface='', proto='icmp', port=None, debug=False): +    """ +    Host availability check by ICMP, ARP, TCP +    Return True if target checks is successful + +    % is_target_alive('192.0.2.1', 'eth1', proto='arp') +    True +    """ +    if iface != '': +        iface = f'-I {iface}' +    if proto == 'icmp': +        command = f'/usr/bin/ping -q {target} {iface} -n -c 2 -W 1' +        rc, response = rc_cmd(command) +        if debug: print(f'    [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') +        if rc == 0: +            return True +    elif proto == 'arp': +        command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface} {target}' +        rc, response = rc_cmd(command) +        if debug: print(f'    [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') +        if rc == 0: +            return True +    elif proto == 'tcp' and port is not None: +        return True if is_port_open(target, port) else False +    else: +        return False + + +if __name__ == '__main__': +    # Parse command arguments and get config +    parser = argparse.ArgumentParser() +    parser.add_argument('-c', +                        '--config', +                        action='store', +                        help='Path to protocols failover configuration', +                        required=True, +                        type=Path) + +    args = parser.parse_args() +    try: +        config_path = Path(args.config) +        config = json.loads(config_path.read_text()) +    except Exception as err: +        print( +            f'Configuration file "{config_path}" does not exist or malformed: {err}' +        ) +        exit(1) + +    # Useful debug info to console, use debug = True +    # sudo systemctl stop vyos-failover.service +    # sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf +    debug = False + +    while(True): + +        for route, route_config in config.get('route').items(): + +            exists_route = exists_gateway, exists_iface, exists_metric =  get_best_route_options(route, debug=debug) + +            for next_hop, nexthop_config in route_config.get('next_hop').items(): +                conf_iface = nexthop_config.get('interface') +                conf_metric = int(nexthop_config.get('metric')) +                port = nexthop_config.get('check').get('port') +                port_opt = f'port {port}' if port else '' +                proto = nexthop_config.get('check').get('type') +                target = nexthop_config.get('check').get('target') +                timeout = nexthop_config.get('check').get('timeout') + +                # Best route not fonund in the current routing table +                if exists_route == (None, None, None): +                    if debug: print(f"    [NEW_ROUTE_DETECTED] route: [{route}]") +                    # Add route if check-target alive +                    if is_target_alive(target, conf_iface, proto, port, debug=debug): +                        if debug: print(f'    [ ADD ] -- ip route add {route} via {next_hop} dev {conf_iface} ' +                                        f'metric {conf_metric} proto failover\n###') +                        rc, command = rc_cmd(f'ip route add {route} via {next_hop} dev {conf_iface} ' +                                             f'metric {conf_metric} proto failover') +                        # If something is wrong and gateway not added +                        # Example: Error: Next-hop has invalid gateway. +                        if rc !=0: +                            if debug: print(f'{command} -- return-code [RC: {rc}] {next_hop} dev {conf_iface}') +                        else: +                            journal.send(f'ip route add {route} via {next_hop} dev {conf_iface} ' +                                         f'metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) +                    else: +                        if debug: print(f'    [ TARGET_FAIL ] target checks fails for [{target}], do nothing') +                        journal.send(f'Check fail for route {route} target {target} proto {proto} ' +                                     f'{port_opt}', SYSLOG_IDENTIFIER=my_name) + +                # Route was added, check if the target is alive +                # We should delete route if check fails only if route exists in the routing table +                if not is_target_alive(target, conf_iface, proto, port, debug=debug) and \ +                        exists_route != (None, None, None): +                    if debug: +                        print(f'Nexh_hop {next_hop} fail, target not response') +                        print(f'    [ DEL ] -- ip route del {route} via {next_hop} dev {conf_iface} ' +                              f'metric {conf_metric} proto failover [DELETE]') +                    rc_cmd(f'ip route del {route} via {next_hop} dev {conf_iface} metric {conf_metric} proto failover') +                    journal.send(f'ip route del {route} via {next_hop} dev {conf_iface} ' +                                 f'metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) + +                time.sleep(int(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/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/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 936c20bcb..d39e88bf3 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -41,7 +41,7 @@ def check_ppp_running(interface):  def connect(interface):      """ Connect dialer interface """ -    if interface.startswith('ppp'): +    if interface.startswith('pppoe') or interface.startswith('sstpc'):          check_ppp_interface(interface)          # Check if interface is already dialed          if os.path.isdir(f'/sys/class/net/{interface}'): @@ -62,7 +62,7 @@ def connect(interface):  def disconnect(interface):      """ Disconnect dialer interface """ -    if interface.startswith('ppp'): +    if interface.startswith('pppoe') or interface.startswith('sstpc'):          check_ppp_interface(interface)          # Check if interface is already down 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/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py new file mode 100755 index 000000000..1422559a8 --- /dev/null +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +from datetime import datetime +from pathlib import Path +from shutil import rmtree +from socket import gethostname +from sys import exit +from tarfile import open as tar_open +from vyos.util import rc_cmd + +# define a list of commands that needs to be executed +CMD_LIST: list[str] = [ +    'ipsec status', +    'swanctl -L', +    'swanctl -l', +    'swanctl -P', +    'ip x sa show', +    'ip x policy show', +    'ip tunnel show', +    'ip address', +    'ip rule show', +    'ip route | head -100', +    'ip route show table 220' +] +JOURNALCTL_CMD: str = 'journalctl -b -n 10000 /usr/lib/ipsec/charon' + +# execute a command and save the output to a file +def save_stdout(command: str, file: Path) -> None: +    rc, stdout = rc_cmd(command) +    body: str = f'''### {command} ### +Command: {command} +Exit code: {rc} +Stdout: +{stdout} + +''' +    with file.open(mode='a') as f: +        f.write(body) + + +# get local host name +hostname: str = gethostname() +# get current time +time_now: str = datetime.now().isoformat(timespec='seconds') + +# define a temporary directory for logs and collected data +tmp_dir: Path = Path(f'/tmp/ipsec_debug_{time_now}') +# set file paths +ipsec_status_file: Path = Path(f'{tmp_dir}/ipsec_status.txt') +journalctl_charon_file: Path = Path(f'{tmp_dir}/journalctl_charon.txt') +archive_file: str = f'/tmp/ipsec_debug_{time_now}.tar.bz2' + +# create files +tmp_dir.mkdir() +ipsec_status_file.touch() +journalctl_charon_file.touch() + +try: +    # execute all commands +    for command in CMD_LIST: +        save_stdout(command, ipsec_status_file) +    save_stdout(JOURNALCTL_CMD, journalctl_charon_file) + +    # create an archive +    with tar_open(name=archive_file, mode='x:bz2') as tar_file: +        tar_file.add(tmp_dir) + +    # inform user about success +    print(f'Debug file is generated and located in {archive_file}') +except Exception as err: +    print(f'Error during generating a debug file: {err}') +finally: +    # cleanup +    rmtree(tmp_dir) +    exit() diff --git a/src/op_mode/generate_ipsec_debug_archive.sh b/src/op_mode/generate_ipsec_debug_archive.sh deleted file mode 100755 index 53d0a6eaa..000000000 --- a/src/op_mode/generate_ipsec_debug_archive.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -# Collecting IPSec Debug Information - -DATE=`date +%d-%m-%Y` - -a_CMD=( -       "sudo ipsec status" -       "sudo swanctl -L" -       "sudo swanctl -l" -       "sudo swanctl -P" -       "sudo ip x sa show" -       "sudo ip x policy show" -       "sudo ip tunnel show" -       "sudo ip address" -       "sudo ip rule show" -       "sudo ip route" -       "sudo ip route show table 220" -      ) - - -echo "DEBUG: ${DATE} on host \"$(hostname)\"" > /tmp/ipsec-status-${DATE}.txt -date >> /tmp/ipsec-status-${DATE}.txt - -# Execute all DEBUG commands and save it to file -for cmd in "${a_CMD[@]}"; do -    echo -e "\n### ${cmd} ###" >> /tmp/ipsec-status-${DATE}.txt -    ${cmd} >> /tmp/ipsec-status-${DATE}.txt 2>/dev/null -done - -# Collect charon logs, build .tgz archive -sudo journalctl /usr/lib/ipsec/charon > /tmp/journalctl-charon-${DATE}.txt && \ -sudo tar -zcvf /tmp/ipsec-debug-${DATE}.tgz /tmp/journalctl-charon-${DATE}.txt /tmp/ipsec-status-${DATE}.txt >& /dev/null -sudo rm -f /tmp/journalctl-charon-${DATE}.txt /tmp/ipsec-status-${DATE}.txt - -echo "Debug file is generated and located in /tmp/ipsec-debug-${DATE}.tgz" diff --git a/src/op_mode/generate_system_login_user.py b/src/op_mode/generate_system_login_user.py new file mode 100755 index 000000000..8f8827b1b --- /dev/null +++ b/src/op_mode/generate_system_login_user.py @@ -0,0 +1,77 @@ +#!/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 argparse +import os + +from vyos.util import popen +from secrets import token_hex +from base64 import b32encode + +if os.geteuid() != 0: +    exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True) +    parser.add_argument("-l", "--rate_limit", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 3)',  default="3", required=False) +    parser.add_argument("-t", "--rate_time", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 30)', default="30", required=False) +    parser.add_argument("-w", "--window_size", type=str, help='Set window of concurrently valid codes (default: 3)', default="3", required=False) +    parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval',  default="30", required=False) +    parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False) +    args = parser.parse_args() + +    hostname = os.uname()[1] +    username = args.username +    rate_limit = args.rate_limit +    rate_time = args.rate_time +    window_size = args.window_size +    digits = args.digits +    period = args.interval + +    # check variables: +    if int(rate_limit) < 1 or int(rate_limit) > 10: +        print("") +        quit("Number of logins (rate-limit) must be between '1' and '10'") + +    if int(rate_time) < 15 or int(rate_time) > 600: +        print("") +        quit("The rate-time must be between '15' and '600' seconds") + +    if int(window_size) < 1 or int(window_size) > 21: +        print("") +        quit("Window of concurrently valid codes must be between '1' and '21' seconds") + +    # generate OTP key, URL & QR: +    key_hex = token_hex(20) +    key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + +    otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period]) +    qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + +    print("# You can share it with the user, he just needs to scan the QR in his OTP app") +    print("# username: ", username) +    print("# OTP KEY: ", key_base32) +    print("# OTP URL: ", otp_url) +    print(qrcode) +    print('# To add this OTP key to configuration, run the following commands:') +    print(f"set system login user {username} authentication otp key '{key_base32}'") +    if rate_limit != "3": +        print(f"set system login user {username} authentication otp rate-limit '{rate_limit}'") +    if rate_time != "30": +        print(f"set system login user {username} authentication otp rate-time '{rate_time}'") +    if window_size != "3": +        print(f"set system login user {username} authentication otp window-size '{window_size}'") diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 7ec35d7bd..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,41 +138,305 @@ def _get_formatted_output_sas(sas):      return output -def get_peer_connections(peer, tunnel, return_all = False): +# Connections block +def _get_vici_connections(): +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized") +    connections = list(session.list_conns()) +    return connections + + +def _get_convert_data_connections(): +    get_connections = _get_vici_connections() +    connections = convert_data(get_connections) +    return connections + + +def _get_parent_sa_proposal(connection_name: str, data: list) -> dict: +    """Get parent SA proposals by connection name +    if connections not in the 'down' state + +    Args: +        connection_name (str): Connection name +        data (list): List of current SAs from vici + +    Returns: +        str: Parent SA connection proposal +             AES_CBC/256/HMAC_SHA2_256_128/MODP_1024 +    """ +    if not data: +        return {} +    for sa in data: +        # check if parent SA exist +        if connection_name not in sa.keys(): +            return {} +        if 'encr-alg' in sa[connection_name]: +            encr_alg = sa.get(connection_name, '').get('encr-alg') +            cipher = encr_alg.split('_')[0] +            mode = encr_alg.split('_')[1] +            encr_keysize = sa.get(connection_name, '').get('encr-keysize') +            integ_alg = sa.get(connection_name, '').get('integ-alg') +            # prf_alg = sa.get(connection_name, '').get('prf-alg') +            dh_group = sa.get(connection_name, '').get('dh-group') +            proposal = { +                'cipher': cipher, +                'mode': mode, +                'key_size': encr_keysize, +                'hash': integ_alg, +                'dh': dh_group +            } +            return proposal +        return {} + + +def _get_parent_sa_state(connection_name: str, data: list) -> str: +    """Get parent SA state by connection name + +    Args: +        connection_name (str): Connection name +        data (list): List of current SAs from vici + +    Returns: +        Parent SA connection state +    """ +    if not data: +        return 'down' +    for sa in data: +        # check if parent SA exist +        if connection_name not in sa.keys(): +            return 'down' +        if sa[connection_name]['state'].lower() == 'established': +            return 'up' +        else: +            return 'down' + + +def _get_child_sa_state(connection_name: str, tunnel_name: str, +                        data: list) -> str: +    """Get child SA state by connection and tunnel name + +    Args: +        connection_name (str): Connection name +        tunnel_name (str): Tunnel name +        data (list): List of current SAs from vici + +    Returns: +        str: `up` if child SA state is 'installed' otherwise `down` +    """ +    if not data: +        return 'down' +    for sa in data: +        # check if parent SA exist +        if connection_name not in sa.keys(): +            return 'down' +        child_sas = sa[connection_name]['child-sas'] +        # Get all child SA states +        # there can be multiple SAs per tunnel +        child_sa_states = [ +            v['state'] for k, v in child_sas.items() if v['name'] == tunnel_name +        ] +        return 'up' if 'INSTALLED' in child_sa_states else 'down' + + +def _get_child_sa_info(connection_name: str, tunnel_name: str, +                       data: list) -> dict: +    """Get child SA installed info by connection and tunnel name + +    Args: +        connection_name (str): Connection name +        tunnel_name (str): Tunnel name +        data (list): List of current SAs from vici + +    Returns: +        dict: Info of the child SA in the dictionary format +    """ +    for sa in data: +        # check if parent SA exist +        if connection_name not in sa.keys(): +            return {} +        child_sas = sa[connection_name]['child-sas'] +        # Get all child SA data +        # Skip temp SA name (first key), get only SA values as dict +        # {'OFFICE-B-tunnel-0-46': {'name': 'OFFICE-B-tunnel-0'}...} +        # i.e get all data after 'OFFICE-B-tunnel-0-46' +        child_sa_info = [ +            v for k, v in child_sas.items() if 'name' in v and +            v['name'] == tunnel_name and v['state'] == 'INSTALLED' +        ] +        return child_sa_info[-1] if child_sa_info else {} + + +def _get_child_sa_proposal(child_sa_data: dict) -> dict: +    if child_sa_data and 'encr-alg' in child_sa_data: +        encr_alg = child_sa_data.get('encr-alg') +        cipher = encr_alg.split('_')[0] +        mode = encr_alg.split('_')[1] +        key_size = child_sa_data.get('encr-keysize') +        integ_alg = child_sa_data.get('integ-alg') +        dh_group = child_sa_data.get('dh-group') +        proposal = { +            'cipher': cipher, +            'mode': mode, +            'key_size': key_size, +            'hash': integ_alg, +            'dh': dh_group +        } +        return proposal +    return {} + + +def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: +    """Get configured VPN IKE connections and IPsec states + +    Args: +        list_connections (list): List of configured connections from vici +        list_sas (list): List of current SAs from vici + +    Returns: +        list: List and status of IKE/IPsec connections/tunnels +    """ +    base_dict = [] +    for connections in list_connections: +        base_list = {} +        for connection, conn_conf in connections.items(): +            base_list['ike_connection_name'] = connection +            base_list['ike_connection_state'] = _get_parent_sa_state( +                connection, list_sas) +            base_list['ike_remote_address'] = conn_conf['remote_addrs'] +            base_list['ike_proposal'] = _get_parent_sa_proposal( +                connection, list_sas) +            base_list['local_id'] = conn_conf.get('local-1', '').get('id') +            base_list['remote_id'] = conn_conf.get('remote-1', '').get('id') +            base_list['version'] = conn_conf.get('version', 'IKE') +            base_list['children'] = [] +            children = conn_conf['children'] +            for tunnel, tun_options in children.items(): +                state = _get_child_sa_state(connection, tunnel, list_sas) +                local_ts = tun_options.get('local-ts') +                remote_ts = tun_options.get('remote-ts') +                dpd_action = tun_options.get('dpd_action') +                close_action = tun_options.get('close_action') +                sa_info = _get_child_sa_info(connection, tunnel, list_sas) +                esp_proposal = _get_child_sa_proposal(sa_info) +                base_list['children'].append({ +                    'name': tunnel, +                    'state': state, +                    'local_ts': local_ts, +                    'remote_ts': remote_ts, +                    'dpd_action': dpd_action, +                    'close_action': close_action, +                    'sa': sa_info, +                    'esp_proposal': esp_proposal +                }) +        base_dict.append(base_list) +    return base_dict + + +def _get_raw_connections_summary(list_conn, list_sas): +    import jmespath +    data = _get_raw_data_connections(list_conn, list_sas) +    match = '[*].children[]' +    child = jmespath.search(match, data) +    tunnels_down = len([k for k in child if k['state'] == 'down']) +    tunnels_up = len([k for k in child if k['state'] == 'up']) +    tun_dict = { +        'tunnels': child, +        'total': len(child), +        'down': tunnels_down, +        'up': tunnels_up +    } +    return tun_dict + + +def _get_formatted_output_conections(data): +    from tabulate import tabulate +    data_entries = '' +    connections = [] +    for entry in data: +        tunnels = [] +        ike_name = entry['ike_connection_name'] +        ike_state = entry['ike_connection_state'] +        conn_type = entry.get('version', 'IKE') +        remote_addrs = ','.join(entry['ike_remote_address']) +        local_ts, remote_ts = '-', '-' +        local_id = entry['local_id'] +        remote_id = entry['remote_id'] +        proposal = '-' +        if entry.get('ike_proposal'): +            proposal = (f'{entry["ike_proposal"]["cipher"]}_' +                        f'{entry["ike_proposal"]["mode"]}/' +                        f'{entry["ike_proposal"]["key_size"]}/' +                        f'{entry["ike_proposal"]["hash"]}/' +                        f'{entry["ike_proposal"]["dh"]}') +        connections.append([ +            ike_name, ike_state, conn_type, remote_addrs, local_ts, remote_ts, +            local_id, remote_id, proposal +        ]) +        for tun in entry['children']: +            tun_name = tun.get('name') +            tun_state = tun.get('state') +            conn_type = 'IPsec' +            local_ts = '\n'.join(tun.get('local_ts')) +            remote_ts = '\n'.join(tun.get('remote_ts')) +            proposal = '-' +            if tun.get('esp_proposal'): +                proposal = (f'{tun["esp_proposal"]["cipher"]}_' +                            f'{tun["esp_proposal"]["mode"]}/' +                            f'{tun["esp_proposal"]["key_size"]}/' +                            f'{tun["esp_proposal"]["hash"]}/' +                            f'{tun["esp_proposal"]["dh"]}') +            connections.append([ +                tun_name, tun_state, conn_type, remote_addrs, local_ts, +                remote_ts, local_id, remote_id, proposal +            ]) +    connection_headers = [ +        'Connection', 'State', 'Type', 'Remote address', 'Local TS', +        'Remote TS', 'Local id', 'Remote id', 'Proposal' +    ] +    output = tabulate(connections, connection_headers, numalign='left') +    return output + + +# Connections block end + + +def get_peer_connections(peer, tunnel):      search = rf'^[\s]*({peer}-(tunnel-[\d]+|vti)).*'      matches = [] +    if not os.path.exists(SWANCTL_CONF): +        raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized") +    suffix = None if tunnel is None else (f'tunnel-{tunnel}' if +                                          tunnel.isnumeric() else tunnel)      with open(SWANCTL_CONF, 'r') as f:          for line in f.readlines():              result = re.match(search, line)              if result: -                suffix = f'tunnel-{tunnel}' if tunnel.isnumeric() else tunnel -                if return_all or (result[2] == suffix): +                if tunnel is None:                      matches.append(result[1]) +                else: +                    if result[2] == suffix: +                        matches.append(result[1])      return matches -def reset_peer(peer: str, tunnel:str): -    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): @@ -176,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/openconnect.py b/src/op_mode/openconnect.py index 00992c66a..b21890728 100755 --- a/src/op_mode/openconnect.py +++ b/src/op_mode/openconnect.py @@ -31,14 +31,7 @@ occtl_socket = '/run/ocserv/occtl.socket'  def _get_raw_data_sessions():      rc, out = rc_cmd(f'sudo {occtl} --json --socket-file {occtl_socket} show users')      if rc != 0: -        output = {'openconnect': -            { -                'configured': False, -                'return_code': rc, -                'reason': out -            } -        } -        return output +        raise vyos.opmode.DataUnavailable(out)      sessions = json.loads(out)      return sessions @@ -61,9 +54,8 @@ def _get_formatted_sessions(data):  def show_sessions(raw: bool):      config = ConfigTreeQuery() -    if not config.exists('vpn openconnect') and not raw: -        print('Openconnect is not configured') -        exit(0) +    if not config.exists('vpn openconnect'): +        raise vyos.opmode.UnconfiguredSubsystem('Openconnect is not configured')      openconnect_data = _get_raw_data_sessions()      if raw: diff --git a/src/op_mode/openvpn.py b/src/op_mode/openvpn.py new file mode 100755 index 000000000..3797a7153 --- /dev/null +++ b/src/op_mode/openvpn.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# + +import os +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.util import bytes_to_human +from vyos.util import commit_in_progress +from vyos.util import call +from vyos.config import Config + +def _get_tunnel_address(peer_host, peer_port, status_file): +    peer = peer_host + ':' + peer_port +    lst = [] + +    with open(status_file, 'r') as f: +        lines = f.readlines() +        for line in lines: +            if peer in line: +                lst.append(line) + +        # filter out subnet entries if iroute: +        # in the case that one sets, say: +        # [ ..., 'vtun10', 'server', 'client', 'client1', 'subnet','10.10.2.0/25'] +        # the status file will have an entry: +        # 10.10.2.0/25,client1,... +        lst = [l for l in lst[1:] if '/' not in l.split(',')[0]] + +        tunnel_ip = lst[0].split(',')[0] + +        return tunnel_ip + +def _get_interface_status(mode: str, interface: str) -> dict: +    status_file = f'/run/openvpn/{interface}.status' + +    data = { +        'mode': mode, +        'intf': interface, +        'local_host': '', +        'local_port': '', +        'date': '', +        'clients': [], +    } + +    if not os.path.exists(status_file): +        raise vyos.opmode.DataUnavailable('No information for interface {interface}') + +    with open(status_file, 'r') as f: +        lines = f.readlines() +        for line_no, line in enumerate(lines): +            # remove trailing newline character first +            line = line.rstrip('\n') + +            # check first line header +            if line_no == 0: +                if mode == 'server': +                    if not line == 'OpenVPN CLIENT LIST': +                        raise vyos.opmode.InternalError('Expected "OpenVPN CLIENT LIST"') +                else: +                    if not line == 'OpenVPN STATISTICS': +                        raise vyos.opmode.InternalError('Expected "OpenVPN STATISTICS"') + +                continue + +            # second line informs us when the status file has been last updated +            if line_no == 1: +                data['date'] = line.lstrip('Updated,').rstrip('\n') +                continue + +            if mode == 'server': +                # for line_no > 1, lines appear as follows: +                # +                # Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since +                # client1,172.18.202.10:55904,2880587,2882653,Fri Aug 23 16:25:48 2019 +                # client3,172.18.204.10:41328,2850832,2869729,Fri Aug 23 16:25:43 2019 +                # client2,172.18.203.10:48987,2856153,2871022,Fri Aug 23 16:25:45 2019 +                # ... +                # ROUTING TABLE +                # ... +                if line_no >= 3: +                    # indicator that there are no more clients +                    if line == 'ROUTING TABLE': +                        break +                    # otherwise, get client data +                    remote = (line.split(',')[1]).rsplit(':', maxsplit=1) + +                    client = { +                        'name': line.split(',')[0], +                        'remote_host': remote[0], +                        'remote_port': remote[1], +                        'tunnel': 'N/A', +                        'rx_bytes': bytes_to_human(int(line.split(',')[2]), +                                                   precision=1), +                        'tx_bytes': bytes_to_human(int(line.split(',')[3]), +                                                   precision=1), +                        'online_since': line.split(',')[4] +                    } +                    client['tunnel'] = _get_tunnel_address(client['remote_host'], +                                                           client['remote_port'], +                                                           status_file) +                    data['clients'].append(client) +                    continue +            else: # mode == 'client' or mode == 'site-to-site' +                if line_no == 2: +                    client = { +                        'name': 'N/A', +                        'remote_host': 'N/A', +                        'remote_port': 'N/A', +                        'tunnel': 'N/A', +                        'rx_bytes': bytes_to_human(int(line.split(',')[1]), +                                                   precision=1), +                        'tx_bytes': '', +                        'online_since': 'N/A' +                    } +                    continue + +                if line_no == 3: +                    client['tx_bytes'] = bytes_to_human(int(line.split(',')[1]), +                                                        precision=1) +                    data['clients'].append(client) +                    break + +    return data + +def _get_raw_data(mode: str) -> dict: +    data = {} +    conf = Config() +    conf_dict = conf.get_config_dict(['interfaces', 'openvpn'], +                                     get_first_key=True) +    if not conf_dict: +        return data + +    interfaces = [x for x in list(conf_dict) if conf_dict[x]['mode'] == mode] +    for intf in interfaces: +        data[intf] = _get_interface_status(mode, intf) +        d = data[intf] +        d['local_host'] = conf_dict[intf].get('local-host', '') +        d['local_port'] = conf_dict[intf].get('local-port', '') +        if mode in ['client', 'site-to-site']: +            for client in d['clients']: +                if 'shared-secret-key-file' in list(conf_dict[intf]): +                    client['name'] = 'None (PSK)' +                client['remote_host'] = conf_dict[intf].get('remote-host', [''])[0] +                client['remote_port'] = conf_dict[intf].get('remote-port', '1194') + +    return data + +def _format_openvpn(data: dict) -> str: +    if not data: +        out = 'No OpenVPN interfaces configured' +        return out + +    headers = ['Client CN', 'Remote Host', 'Tunnel IP', 'Local Host', +               'TX bytes', 'RX bytes', 'Connected Since'] + +    out = '' +    data_out = [] +    for intf in list(data): +        l_host = data[intf]['local_host'] +        l_port = data[intf]['local_port'] +        for client in list(data[intf]['clients']): +            r_host = client['remote_host'] +            r_port = client['remote_port'] + +            out += f'\nOpenVPN status on {intf}\n\n' +            name = client['name'] +            remote = r_host + ':' + r_port if r_host and r_port else 'N/A' +            tunnel = client['tunnel'] +            local = l_host + ':' + l_port if l_host and l_port else 'N/A' +            tx_bytes = client['tx_bytes'] +            rx_bytes = client['rx_bytes'] +            online_since = client['online_since'] +            data_out.append([name, remote, tunnel, local, tx_bytes, +                             rx_bytes, online_since]) + +        out += tabulate(data_out, headers) + +    return out + +def show(raw: bool, mode: str) -> str: +    openvpn_data = _get_raw_data(mode) + +    if raw: +        return openvpn_data + +    return _format_openvpn(openvpn_data) + +def reset(interface: str): +    if os.path.isfile(f'/run/openvpn/{interface}.conf'): +        if commit_in_progress(): +            raise vyos.opmode.CommitInProgress('Retry OpenVPN reset: commit in progress.') +        call(f'systemctl restart openvpn@{interface}.service') +    else: +        raise vyos.opmode.IncorrectValue(f'OpenVPN interface "{interface}" does not exist!') + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/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/show_openvpn.py b/src/op_mode/show_openvpn.py index 9a5adcffb..e29e594a5 100755 --- a/src/op_mode/show_openvpn.py +++ b/src/op_mode/show_openvpn.py @@ -59,7 +59,11 @@ def get_vpn_tunnel_address(peer, interface):          for line in lines:              if peer in line:                  lst.append(line) -        tunnel_ip = lst[1].split(',')[0] + +        # filter out subnet entries +        lst = [l for l in lst[1:] if '/' not in l.split(',')[0]] + +        tunnel_ip = lst[0].split(',')[0]          return tunnel_ip 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/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh index 43a4b79fc..4fb9a54c6 100755 --- a/src/op_mode/webproxy_update_blacklist.sh +++ b/src/op_mode/webproxy_update_blacklist.sh @@ -18,6 +18,23 @@ blacklist_url='ftp://ftp.univ-tlse1.fr/pub/reseau/cache/squidguard_contrib/black  data_dir="/opt/vyatta/etc/config/url-filtering"  archive="${data_dir}/squidguard/archive"  db_dir="${data_dir}/squidguard/db" +conf_file="/etc/squidguard/squidGuard.conf" +tmp_conf_file="/tmp/sg_update_db.conf" + +#$1-category +#$2-type +#$3-list +create_sg_db () +{ +       FILE=$db_dir/$1/$2 +       if test -f "$FILE"; then +                rm -f ${tmp_conf_file} +                printf "dbhome $db_dir\ndest $1 {\n     $3      $1/$2\n}\nacl {\n       default {\n             pass    any\n   }\n}" >> ${tmp_conf_file} +                /usr/bin/squidGuard -b -c ${tmp_conf_file} -C $FILE +                rm -f ${tmp_conf_file} +       fi + +}  while [ $# -gt 0 ]  do @@ -88,7 +105,17 @@ if [[ -n $update ]] && [[ $update -eq "yes" ]]; then      # fix permissions      chown -R proxy:proxy ${db_dir} -    chmod 2770 ${db_dir} + +    #create db +    category_list=(`find $db_dir -type d -exec basename {} \; `) +    for category in ${category_list[@]} +    do +        create_sg_db $category "domains" "domainlist" +        create_sg_db $category "urls" "urllist" +        create_sg_db $category "expressions" "expressionlist" +    done +    chown -R proxy:proxy ${db_dir} +    chmod 755 ${db_dir}      logger --priority WARNING "webproxy blacklist entries updated (${count_before}/${count_after})" 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/utils/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py index fc0dd7a87..fc0dd7a87 100644 --- a/src/services/api/graphql/utils/config_session_function.py +++ b/src/services/api/graphql/generate/config_session_function.py 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/utils/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py index ea78aaf88..49bf2440e 100755 --- a/src/services/api/graphql/utils/schema_from_config_session.py +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -19,28 +19,60 @@  # (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__ == '': -    from util import snake_to_pascal_case, map_type_name +    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 . util import snake_to_pascal_case, map_type_name +    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'] -# this will be run locally before the build -SCHEMA_PATH = '../graphql/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 = {'schema_name': '', +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 @@ -53,17 +85,29 @@ type {{ schema_name }}Result {  }  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 @@ -76,7 +120,11 @@ type {{ schema_name }}Result {  }  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 %}  }  """ @@ -100,8 +148,6 @@ def create_schema(func_name: str, func: callable, template: str) -> str:      return res  def generate_config_session_definitions(): -    from config_session_function import queries, mutations -      results = []      for name,func in queries.items():          res = create_schema(name, func, query_template) 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 57d63628b..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,17 +19,24 @@  # scripts.  import os +import sys  import json  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 -    from util import snake_to_pascal_case, map_type_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 . util import snake_to_pascal_case, map_type_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'] @@ -38,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 @@ -61,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 @@ -85,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 %}  }  """ 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 d75d72582..a7919854a 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -63,16 +63,25 @@ class GenOpMutationDirective(VyosDirective):          super().visit_field_definition(field, object_type,                                         make_resolver=make_gen_op_mutation_resolver) -class SystemStatusDirective(VyosDirective): +class CompositeQueryDirective(VyosDirective):      """      Class providing implementation of 'system_status' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_system_status_resolver) +                                       make_resolver=make_composite_query_resolver) + +class CompositeMutationDirective(VyosDirective): +    """ +    Class providing implementation of 'system_status' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_composite_mutation_resolver)  directives_dict = {"configsessionquery": ConfigSessionQueryDirective,                     "configsessionmutation": ConfigSessionMutationDirective,                     "genopquery": GenOpQueryDirective,                     "genopmutation": GenOpMutationDirective, -                   "systemstatus": SystemStatusDirective} +                   "compositequery": CompositeQueryDirective, +                   "compositemutation": CompositeMutationDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index f7d285a77..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 @@ -112,3 +132,7 @@ def make_config_session_mutation_resolver(mutation_name):  def make_gen_op_mutation_resolver(mutation_name):      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 5f3a7d005..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 @@ -113,5 +133,6 @@ def make_config_session_query_resolver(query_name):  def make_gen_op_query_resolver(query_name):      return make_query_resolver(query_name, query_name, 'gen_op_query') -def make_system_status_resolver(query_name): -    return make_query_resolver(query_name, query_name, 'system_status') +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/configsession.graphql b/src/services/api/graphql/graphql/schema/configsession.graphql deleted file mode 100644 index b1deac4b3..000000000 --- a/src/services/api/graphql/graphql/schema/configsession.graphql +++ /dev/null @@ -1,115 +0,0 @@ - -input ShowConfigInput { -    key: String! -    path: [String!]! -    configFormat: String = null -} - -type ShowConfig { -    result: Generic -} - -type ShowConfigResult { -    data: ShowConfig -    success: Boolean! -    errors: [String] -} - -extend type Query { -    ShowConfig(data: ShowConfigInput) : ShowConfigResult @configsessionquery -} - -input ShowInput { -    key: String! -    path: [String!]! -} - -type Show { -    result: Generic -} - -type ShowResult { -    data: Show -    success: Boolean! -    errors: [String] -} - -extend type Query { -    Show(data: ShowInput) : ShowResult @configsessionquery -} - -input SaveConfigFileInput { -    key: String! -    fileName: String = null -} - -type SaveConfigFile { -    result: Generic -} - -type SaveConfigFileResult { -    data: SaveConfigFile -    success: Boolean! -    errors: [String] -} - -extend type Mutation { -    SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configsessionmutation -} - -input LoadConfigFileInput { -    key: String! -    fileName: String! -} - -type LoadConfigFile { -    result: Generic -} - -type LoadConfigFileResult { -    data: LoadConfigFile -    success: Boolean! -    errors: [String] -} - -extend type Mutation { -    LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configsessionmutation -} - -input AddSystemImageInput { -    key: String! -    location: String! -} - -type AddSystemImage { -    result: Generic -} - -type AddSystemImageResult { -    data: AddSystemImage -    success: Boolean! -    errors: [String] -} - -extend type Mutation { -    AddSystemImage(data: AddSystemImageInput) : AddSystemImageResult @configsessionmutation -} - -input DeleteSystemImageInput { -    key: String! -    name: String! -} - -type DeleteSystemImage { -    result: Generic -} - -type DeleteSystemImageResult { -    data: DeleteSystemImage -    success: Boolean! -    errors: [String] -} - -extend type Mutation { -    DeleteSystemImage(data: DeleteSystemImageInput) : DeleteSystemImageResult @configsessionmutation -}
\ No newline at end of file diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 2acecade4..62b0d30bb 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -3,7 +3,8 @@ schema {      mutation: Mutation  } -directive @systemstatus 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 @@ -11,8 +12,5 @@ directive @genopmutation on FIELD_DEFINITION  scalar Generic -type Query { -    SystemStatus(data: SystemStatusInput) : SystemStatusResult @systemstatus -} - +type Query  type Mutation 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 da2bcdb5b..6939ed5d6 100644 --- a/src/services/api/graphql/utils/util.py +++ b/src/services/api/graphql/libs/op_mode.py @@ -17,14 +17,12 @@ 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) @@ -98,3 +96,6 @@ def map_type_name(type_name: type, optional: bool = False) -> str:      # 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 f990e63d0..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') @@ -149,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 4ace981ca..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__': @@ -688,16 +697,21 @@ if __name__ == '__main__':      app.state.vyos_debug = server_config['debug']      app.state.vyos_strict = server_config['strict']      app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', []) -    if 'gql' in server_config: -        app.state.vyos_gql = True -        if isinstance(server_config['gql'], dict) and 'introspection' in server_config['gql']: -            app.state.vyos_introspection = True -        else: -            app.state.vyos_introspection = False +    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_gql = False +        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/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/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/file-exists b/src/validators/file-exists deleted file mode 100755 index 5cef6b199..000000000 --- a/src/validators/file-exists +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. -# -# Description: -# Check if a given file exists on the system. Used for files that -# are referenced from the CLI and need to be preserved during an image upgrade. -# Warn the user if these aren't under /config - -import os -import sys -import argparse - - -def exit(strict, message): -    if strict: -        sys.exit(f'ERROR: {message}') -    print(f'WARNING: {message}', file=sys.stderr) -    sys.exit() - - -if __name__ == '__main__': -    parser = argparse.ArgumentParser() -    parser.add_argument("-d", "--directory", type=str, help="File must be present in this directory.") -    parser.add_argument("-e", "--error", action="store_true", help="Tread warnings as errors - change exit code to '1'") -    parser.add_argument("file", type=str, help="Path of file to validate") - -    args = parser.parse_args() - -    # -    # Always check if the given file exists -    # -    if not os.path.exists(args.file): -        exit(args.error, f"File '{args.file}' not found") - -    # -    # Optional check if the file is under a certain directory path -    # -    if args.directory: -        # remove directory path from path to verify -        rel_filename = args.file.replace(args.directory, '').lstrip('/') - -        if not os.path.exists(args.directory + '/' + rel_filename): -            exit(args.error, -                f"'{args.file}' lies outside of '{args.directory}' directory.\n" -                  "It will not get preserved during image upgrade!" -            ) - -    sys.exit() 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/interface-name b/src/validators/interface-name deleted file mode 100755 index 105815eee..000000000 --- a/src/validators/interface-name +++ /dev/null @@ -1,34 +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 - -pattern = '^(bond|br|dum|en|ersp|eth|gnv|lan|l2tp|l2tpeth|macsec|peth|ppp|pppoe|pptp|sstp|tun|vti|vtun|vxlan|wg|wlan|wwan)[0-9]+(.\d+)?|lo$' - -if __name__ == '__main__': -    if len(argv) != 2: -        exit(1) -    interface = argv[1] - -    if re.match(pattern, interface): -        exit(0) -    if os.path.exists(f'/sys/class/net/{interface}'): -        exit(0) -    exit(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) {  | 
