diff options
Diffstat (limited to 'src')
213 files changed, 8686 insertions, 3169 deletions
diff --git a/src/completion/list_bgp_neighbors.sh b/src/completion/list_bgp_neighbors.sh index f74f102ef..869a7ab0a 100755 --- a/src/completion/list_bgp_neighbors.sh +++ b/src/completion/list_bgp_neighbors.sh @@ -1,5 +1,5 @@  #!/bin/sh -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -18,19 +18,21 @@  ipv4=0  ipv6=0 +vrf=""  while [[ "$#" -gt 0 ]]; do      case $1 in          -4|--ipv4) ipv4=1 ;;          -6|--ipv6) ipv6=1 ;;          -b|--both) ipv4=1; ipv6=1 ;; +        --vrf) vrf="vrf name $2"; shift ;;          *) echo "Unknown parameter passed: $1" ;;      esac      shift  done  declare -a vals -eval "vals=($(cli-shell-api listActiveNodes protocols bgp neighbor))" +eval "vals=($(cli-shell-api listActiveNodes $vrf protocols bgp neighbor))"  if [ $ipv4 -eq 1 ] && [ $ipv6 -eq 1 ]; then      echo -n '<x.x.x.x>' '<h:h:h:h:h:h:h:h>' ${vals[@]} @@ -54,9 +56,10 @@ elif [ $ipv6 -eq 1 ] ; then       done  else      echo "Usage:" -    echo "-4|--ipv4    list only IPv4 peers" -    echo "-6|--ipv6    list only IPv6 peers" -    echo "--both       list both IP4 and IPv6 peers" +    echo "-4|--ipv4      list only IPv4 peers" +    echo "-6|--ipv6      list only IPv6 peers" +    echo "--both         list both IP4 and IPv6 peers" +    echo "--vrf <name>   apply command to given VRF (optional)"      echo ""      exit 1  fi diff --git a/src/completion/list_consoles.sh b/src/completion/list_consoles.sh new file mode 100755 index 000000000..52278c4cb --- /dev/null +++ b/src/completion/list_consoles.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# For lines like `aliases "foo";`, regex matches everything between the quotes +grep -oP '(?<=aliases ").+(?=";)' /run/conserver/conserver.cf
\ No newline at end of file diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py index 1cd8f5451..7dc5206e0 100755 --- a/src/conf_mode/arp.py +++ b/src/conf_mode/arp.py @@ -61,7 +61,7 @@ def apply(arp):                  continue              for address, address_config in interface_config['address'].items():                  mac = address_config['mac'] -                call(f'ip neigh add {address} lladdr {mac} dev {interface}') +                call(f'ip neigh replace {address} lladdr {mac} dev {interface}')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 2110fd9e0..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'): @@ -90,10 +77,10 @@ def get_config(config=None):              container['name'][name] = dict_merge(default_values, container['name'][name])      # Delete container network, delete containers -    tmp = node_changed(conf, base + ['container', 'network']) +    tmp = node_changed(conf, base + ['network'])      if tmp: container.update({'network_remove' : tmp}) -    tmp = node_changed(conf, base + ['container', 'name']) +    tmp = node_changed(conf, base + ['name'])      if tmp: container.update({'container_remove' : tmp})      return container @@ -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!') @@ -132,13 +119,10 @@ def verify(container):                  # Check if the specified container network exists                  network_name = list(container_config['network'])[0] -                if network_name not in container['network']: +                if network_name not in container.get('network', {}):                      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,17 +329,23 @@ 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 {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:          for network in container['network_remove']: +            call(f'podman network rm {network}')              tmp = f'/etc/cni/net.d/{network}.conflist'              if os.path.exists(tmp):                  os.unlink(tmp)      # Add container +    disabled_new = False      if 'name' in container:          for name, container_config in container['name'].items():              image = container_config['image'] @@ -294,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 {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/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index f1c2d1f43..d0d87d73e 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -98,6 +98,9 @@ def get_config(config=None):                              dns['authoritative_zone_errors'].append('{}.{}: at least one address is required'.format(subnode, node))                              continue +                        if subnode == 'any': +                            subnode = '*' +                          for address in rdata['address']:                              zone['records'].append({                                  'name': subnode, @@ -263,6 +266,12 @@ def verify(dns):              if 'server' not in dns['domain'][domain]:                  raise ConfigError(f'No server configured for domain {domain}!') +    if 'dns64_prefix' in dns: +        dns_prefix = dns['dns64_prefix'].split('/')[1] +        # RFC 6147 requires prefix /96 +        if int(dns_prefix) != 96: +            raise ConfigError('DNS 6to4 prefix must be of length /96') +      if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']:          for error in dns['authoritative_zone_errors']:              print(error) diff --git a/src/conf_mode/firewall-interface.py b/src/conf_mode/firewall-interface.py deleted file mode 100755 index 9a5d278e9..000000000 --- a/src/conf_mode/firewall-interface.py +++ /dev/null @@ -1,175 +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.configdict import leaf_node_changed -from vyos.ifconfig import Section -from vyos.template import render -from vyos.util import cmd -from vyos.util import dict_search_args -from vyos.util import run -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -NAME_PREFIX = 'NAME_' -NAME6_PREFIX = 'NAME6_' - -NFT_CHAINS = { -    'in': 'VYOS_FW_FORWARD', -    'out': 'VYOS_FW_FORWARD', -    'local': 'VYOS_FW_LOCAL' -} -NFT6_CHAINS = { -    'in': 'VYOS_FW6_FORWARD', -    'out': 'VYOS_FW6_FORWARD', -    'local': 'VYOS_FW6_LOCAL' -} - -def get_config(config=None): -    if config: -        conf = config -    else: -        conf = Config() - -    ifname = argv[1] -    ifpath = Section.get_config_path(ifname) -    if_firewall_path = f'interfaces {ifpath} firewall' - -    if_firewall = conf.get_config_dict(if_firewall_path, key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) - -    if_firewall['ifname'] = ifname -    if_firewall['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) - -    return if_firewall - -def verify(if_firewall): -    # bail out early - looks like removal from running config -    if not if_firewall: -        return None - -    for direction in ['in', 'out', 'local']: -        if direction in if_firewall: -            if 'name' in if_firewall[direction]: -                name = if_firewall[direction]['name'] - -                if 'name' not in if_firewall['firewall']: -                    raise ConfigError('Firewall name not configured') - -                if name not in if_firewall['firewall']['name']: -                    raise ConfigError(f'Invalid firewall name "{name}"') - -            if 'ipv6_name' in if_firewall[direction]: -                name = if_firewall[direction]['ipv6_name'] - -                if 'ipv6_name' not in if_firewall['firewall']: -                    raise ConfigError('Firewall ipv6-name not configured') - -                if name not in if_firewall['firewall']['ipv6_name']: -                    raise ConfigError(f'Invalid firewall ipv6-name "{name}"') - -    return None - -def generate(if_firewall): -    return None - -def cleanup_rule(table, chain, prefix, ifname, new_name=None): -    results = cmd(f'nft -a list chain {table} {chain}').split("\n") -    retval = None -    for line in results: -        if f'{prefix}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: -                run(f'nft delete rule {table} {chain} handle {handle_search[1]}') -    return retval - -def state_policy_handle(table, chain): -    # Find any state-policy rule to ensure interface rules are only inserted afterwards -    results = cmd(f'nft -a list chain {table} {chain}').split("\n") -    for line in results: -        if 'jump VYOS_STATE_POLICY' in line: -            handle_search = re.search('handle (\d+)', line) -            if handle_search: -                return handle_search[1] -    return None - -def apply(if_firewall): -    ifname = if_firewall['ifname'] - -    for direction in ['in', 'out', 'local']: -        chain = NFT_CHAINS[direction] -        ipv6_chain = NFT6_CHAINS[direction] -        if_prefix = 'i' if direction in ['in', 'local'] else 'o' - -        name = dict_search_args(if_firewall, direction, 'name') -        if name: -            rule_exists = cleanup_rule('ip filter', chain, if_prefix, ifname, f'{NAME_PREFIX}{name}') - -            if not rule_exists: -                rule_action = 'insert' -                rule_prefix = '' - -                handle = state_policy_handle('ip filter', chain) -                if handle: -                    rule_action = 'add' -                    rule_prefix = f'position {handle}' - -                run(f'nft {rule_action} rule ip filter {chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {NAME_PREFIX}{name}') -        else: -            cleanup_rule('ip filter', chain, if_prefix, ifname) - -        ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') -        if ipv6_name: -            rule_exists = cleanup_rule('ip6 filter', ipv6_chain, if_prefix, ifname, f'{NAME6_PREFIX}{ipv6_name}') - -            if not rule_exists: -                rule_action = 'insert' -                rule_prefix = '' - -                handle = state_policy_handle('ip6 filter', ipv6_chain) -                if handle: -                    rule_action = 'add' -                    rule_prefix = f'position {handle}' - -                run(f'nft {rule_action} rule ip6 filter {ipv6_chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {NAME6_PREFIX}{ipv6_name}') -        else: -            cleanup_rule('ip6 filter', ipv6_chain, if_prefix, 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/firewall.py b/src/conf_mode/firewall.py index 6924bf555..f68acfe02 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -26,20 +26,26 @@ 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.template import render +from vyos.util import call  from vyos.util import cmd  from vyos.util import dict_search_args +from vyos.util import dict_search_recursive  from vyos.util import process_named_running -from vyos.util import run +from vyos.util import rc_cmd  from vyos.xml import defaults  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' -nftables_defines_conf = '/run/nftables_defines.conf'  sysfs_config = {      'all_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_all', 'enable': '0', 'disable': '1'}, @@ -55,34 +61,18 @@ sysfs_config = {      'twa_hazards_protection': {'sysfs': '/proc/sys/net/ipv4/tcp_rfc1337'}  } -NAME_PREFIX = 'NAME_' -NAME6_PREFIX = 'NAME6_' - -preserve_chains = [ -    'INPUT', -    'FORWARD', -    'OUTPUT', -    'VYOS_FW_FORWARD', -    'VYOS_FW_LOCAL', -    'VYOS_FW_OUTPUT', -    'VYOS_POST_FW', -    'VYOS_FRAG_MARK', -    'VYOS_FW6_FORWARD', -    'VYOS_FW6_LOCAL', -    'VYOS_FW6_OUTPUT', -    'VYOS_POST_FW6', -    'VYOS_FRAG6_MARK' -] - -nft_iface_chains = ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL'] -nft6_iface_chains = ['VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL'] -  valid_groups = [      'address_group', +    'domain_group',      'network_group',      'port_group'  ] +nested_group_types = [ +    'address_group', 'network_group', 'mac_group', +    'port_group', 'ipv6_address_group', 'ipv6_network_group' +] +  snmp_change_type = {      'unknown': 0,      'add': 1, @@ -93,50 +83,39 @@ snmp_event_source = 1  snmp_trap_mib = 'VYATTA-TRAP-MIB'  snmp_trap_name = 'mgmtEventTrap' -def get_firewall_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 'firewall' in if_conf: -                output[prefix + ifname] = if_conf['firewall'] -            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_firewall_zones(conf): -    used_v4 = [] -    used_v6 = [] -    zone_policy = conf.get_config_dict(['zone-policy'], key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) - -    if 'zone' in zone_policy: -        for zone, zone_conf in zone_policy['zone'].items(): -            if 'from' in zone_conf: -                for from_zone, from_conf in zone_conf['from'].items(): -                    name = dict_search_args(from_conf, 'firewall', 'name') -                    if name: -                        used_v4.append(name) - -                    ipv6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') -                    if ipv6_name: -                        used_v6.append(ipv6_name) - -            if 'intra_zone_filtering' in zone_conf: -                name = dict_search_args(zone_conf, 'intra_zone_filtering', 'firewall', 'name') -                if name: -                    used_v4.append(name) - -                ipv6_name = dict_search_args(zone_conf, 'intra_zone_filtering', 'firewall', 'ipv6_name') -                if ipv6_name: -                    used_v6.append(ipv6_name) - -    return {'name': used_v4, 'ipv6_name': used_v6} +def geoip_updated(conf, firewall): +    diff = get_config_diff(conf) +    node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True) + +    out = { +        'name': [], +        'ipv6_name': [], +        'deleted_name': [], +        'deleted_ipv6_name': [] +    } +    updated = False + +    for key, path in dict_search_recursive(firewall, 'geoip'): +        set_name = f'GEOIP_CC_{path[1]}_{path[3]}' +        if path[0] == 'name': +            out['name'].append(set_name) +        elif path[0] == 'ipv6_name': +            out['ipv6_name'].append(set_name) +        updated = True + +    if 'delete' in node_diff: +        for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): +            set_name = f'GEOIP_CC_{path[1]}_{path[3]}' +            if path[0] == 'name': +                out['deleted_name'].append(set_name) +            elif path[0] == 'ipv6-name': +                out['deleted_ipv6_name'].append(set_name) +            updated = True + +    if updated: +        return out + +    return False  def get_config(config=None):      if config: @@ -148,12 +127,43 @@ def get_config(config=None):      firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,                                      no_tag_node_value_mangle=True) +    # We have gathered the dict representation of the CLI, but there are +    # default options which we need to update into the dictionary retrived. +    # XXX: T2665: we currently have no nice way for defaults under tag +    # nodes, thus we load the defaults "by hand"      default_values = defaults(base) +    for tmp in ['name', 'ipv6_name']: +        if tmp in default_values: +            del default_values[tmp] + +    if 'zone' in default_values: +        del default_values['zone'] +      firewall = dict_merge(default_values, firewall) -    firewall['policy_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) -    firewall['interfaces'] = get_firewall_interfaces(conf) -    firewall['zone_policy'] = get_firewall_zones(conf) +    # Merge in defaults for IPv4 ruleset +    if 'name' in firewall: +        default_values = defaults(base + ['name']) +        for name in firewall['name']: +            firewall['name'][name] = dict_merge(default_values, +                                                firewall['name'][name]) + +    # Merge in defaults for IPv6 ruleset +    if 'ipv6_name' in firewall: +        default_values = defaults(base + ['ipv6-name']) +        for ipv6_name in firewall['ipv6_name']: +            firewall['ipv6_name'][ipv6_name] = dict_merge(default_values, +                                                          firewall['ipv6_name'][ipv6_name]) + +    if 'zone' in firewall: +        default_values = defaults(base + ['zone']) +        for zone in firewall['zone']: +            firewall['zone'][zone] = dict_merge(default_values, firewall['zone'][zone]) + +    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) @@ -162,12 +172,30 @@ def get_config(config=None):                                          key_mangling=('-', '_'), get_first_key=True,                                          no_tag_node_value_mangle=True) +    firewall['geoip_updated'] = geoip_updated(conf, firewall) + +    fqdn_config_parse(firewall) +      return firewall  def verify_rule(firewall, rule_conf, ipv6):      if 'action' not in rule_conf:          raise ConfigError('Rule action must be defined') +    if 'jump' in rule_conf['action'] and 'jump_target' not in rule_conf: +        raise ConfigError('Action set to jump, but no jump-target specified') + +    if 'jump_target' in rule_conf: +        if 'jump' not in rule_conf['action']: +            raise ConfigError('jump-target defined, but action jump needed and it is not defined') +        target = rule_conf['jump_target'] +        if not ipv6: +            if target not in dict_search_args(firewall, 'name'): +                raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') +        else: +            if target not in dict_search_args(firewall, 'ipv6_name'): +                raise ConfigError(f'Invalid jump-target. Firewall ipv6-name {target} does not exist on the system') +      if 'fragment' in rule_conf:          if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']):              raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') @@ -207,19 +235,28 @@ def verify_rule(firewall, rule_conf, ipv6):          if side in rule_conf:              side_conf = rule_conf[side] +            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: @@ -235,100 +272,144 @@ def verify_rule(firewall, rule_conf, ipv6):                  if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']:                      raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') +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') + +        if g in seen: +            raise ConfigError(f'Group "{group_name}" has a circular reference') + +        if 'include' in groups[g]: +            verify_nested_group(g, groups[g], groups, seen) +  def verify(firewall):      if 'config_trap' in firewall and firewall['config_trap'] == 'enable':          if not firewall['trap_targets']:              raise ConfigError(f'Firewall config-trap enabled but "service snmp trap-target" is not defined') +    if 'group' in firewall: +        for group_type in nested_group_types: +            if group_type in firewall['group']: +                groups = firewall['group'][group_type] +                for group_name, group in groups.items(): +                    verify_nested_group(group_name, group, groups, []) +      for name in ['name', 'ipv6_name']:          if name in firewall:              for name_id, name_conf in firewall[name].items(): -                if name_id in preserve_chains: -                    raise ConfigError(f'Firewall name "{name_id}" is reserved for VyOS') - -                if name_id.startswith("VZONE"): -                    raise ConfigError(f'Firewall name "{name_id}" uses reserved prefix') +                if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: +                    raise ConfigError('default-action set to jump, but no default-jump-target specified') +                if 'default_jump_target' in name_conf: +                    target = name_conf['default_jump_target'] +                    if 'jump' not in name_conf['default_action']: +                        raise ConfigError('default-jump-target defined,but default-action jump needed and it is not defined') +                    if name_conf['default_jump_target'] == name_id: +                        raise ConfigError(f'Loop detected on default-jump-target.') +                    ## Now need to check that default-jump-target exists (other firewall chain/name) +                    if target not in dict_search_args(firewall, name): +                        raise ConfigError(f'Invalid jump-target. Firewall {name} {target} does not exist on the system')                  if 'rule' in name_conf:                      for rule_id, rule_conf in name_conf['rule'].items():                          verify_rule(firewall, rule_conf, name == 'ipv6_name') -    for ifname, if_firewall in firewall['interfaces'].items(): -        for direction in ['in', 'out', 'local']: -            name = dict_search_args(if_firewall, direction, 'name') -            ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') +    if 'interface' in firewall: +        for ifname, if_firewall in firewall['interface'].items(): +            # verify ifname needs to be disabled, dynamic devices come up later +            # verify_interface_exists(ifname) -            if name and dict_search_args(firewall, 'name', name) == None: -                raise ConfigError(f'Firewall name "{name}" is still referenced on interface {ifname}') +            for direction in ['in', 'out', 'local']: +                name = dict_search_args(if_firewall, direction, 'name') +                ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') -            if ipv6_name and dict_search_args(firewall, 'ipv6_name', ipv6_name) == None: -                raise ConfigError(f'Firewall ipv6-name "{ipv6_name}" is still referenced on interface {ifname}') +                if name and dict_search_args(firewall, 'name', name) == None: +                    raise ConfigError(f'Invalid firewall name "{name}" referenced on interface {ifname}') -    for fw_name, used_names in firewall['zone_policy'].items(): -        for name in used_names: -            if dict_search_args(firewall, fw_name, name) == None: -                raise ConfigError(f'Firewall {fw_name.replace("_", "-")} "{name}" is still referenced in zone-policy') +                if ipv6_name and dict_search_args(firewall, 'ipv6_name', ipv6_name) == None: +                    raise ConfigError(f'Invalid firewall ipv6-name "{ipv6_name}" referenced on interface {ifname}') -    return None +    local_zone = False +    zone_interfaces = [] + +    if 'zone' in firewall: +        for zone, zone_conf in firewall['zone'].items(): +            if 'local_zone' not in zone_conf and 'interface' not in zone_conf: +                raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') + +            if 'local_zone' in zone_conf: +                if local_zone: +                    raise ConfigError('There cannot be multiple local zones') +                if 'interface' in zone_conf: +                    raise ConfigError('Local zone cannot have interfaces assigned') +                if 'intra_zone_filtering' in zone_conf: +                    raise ConfigError('Local zone cannot use intra-zone-filtering') +                local_zone = True + +            if 'interface' in zone_conf: +                found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] + +                if found_duplicates: +                    raise ConfigError(f'Interfaces cannot be assigned to multiple zones') -def cleanup_rule(table, jump_chain): -    commands = [] -    chains = nft_iface_chains if table == 'ip filter' else nft6_iface_chains -    for chain in chains: -        results = cmd(f'nft -a list chain {table} {chain}').split("\n") -        for line in results: -            if f'jump {jump_chain}' in line: -                handle_search = re.search('handle (\d+)', line) -                if handle_search: -                    commands.append(f'delete rule {table} {chain} handle {handle_search[1]}') -    return commands - -def cleanup_commands(firewall): -    commands = [] -    commands_end = [] -    for table in ['ip filter', 'ip6 filter']: -        state_chain = 'VYOS_STATE_POLICY' if table == 'ip filter' else 'VYOS_STATE_POLICY6' -        json_str = cmd(f'nft -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 ['VYOS_STATE_POLICY', 'VYOS_STATE_POLICY6']: -                    if 'state_policy' not in firewall: -                        commands.append(f'delete chain {table} {chain}') -                    else: -                        commands.append(f'flush chain {table} {chain}') -                elif chain not in preserve_chains and not chain.startswith("VZONE"): -                    if table == 'ip filter' and dict_search_args(firewall, 'name', chain.replace(NAME_PREFIX, "", 1)) != None: -                        commands.append(f'flush chain {table} {chain}') -                    elif table == 'ip6 filter' and dict_search_args(firewall, 'ipv6_name', chain.replace(NAME6_PREFIX, "", 1)) != None: -                        commands.append(f'flush chain {table} {chain}') -                    else: -                        commands += cleanup_rule(table, chain) -                        commands.append(f'delete chain {table} {chain}') -            elif 'rule' in item: -                rule = item['rule'] -                if rule['chain'] in ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL', 'VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL']: -                    if 'expr' in rule and any([True for expr in rule['expr'] if dict_search_args(expr, 'jump', 'target') == state_chain]): -                        if 'state_policy' not in firewall: -                            chain = rule['chain'] -                            handle = rule['handle'] -                            commands.append(f'delete rule {table} {chain} handle {handle}') -            elif 'set' in item: -                set_name = item['set']['name'] -                commands_end.append(f'delete set {table} {set_name}') -    return commands + commands_end +                zone_interfaces += zone_conf['interface'] + +            if 'intra_zone_filtering' in zone_conf: +                intra_zone = zone_conf['intra_zone_filtering'] + +                if len(intra_zone) > 1: +                    raise ConfigError('Only one intra-zone-filtering action must be specified') + +                if 'firewall' in intra_zone: +                    v4_name = dict_search_args(intra_zone, 'firewall', 'name') +                    if v4_name and not dict_search_args(firewall, 'name', v4_name): +                        raise ConfigError(f'Firewall name "{v4_name}" does not exist') + +                    v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name') +                    if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): +                        raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + +                    if not v4_name and not v6_name: +                        raise ConfigError('No firewall names specified for intra-zone-filtering') + +            if 'from' in zone_conf: +                for from_zone, from_conf in zone_conf['from'].items(): +                    if from_zone not in firewall['zone']: +                        raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') + +                    v4_name = dict_search_args(from_conf, 'firewall', 'name') +                    if v4_name and not dict_search_args(firewall, 'name', v4_name): +                        raise ConfigError(f'Firewall name "{v4_name}" does not exist') + +                    v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') +                    if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): +                        raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + +    return None  def generate(firewall):      if not os.path.exists(nftables_conf):          firewall['first_install'] = True -    else: -        firewall['cleanup_commands'] = cleanup_commands(firewall) + +    if 'zone' in firewall: +        for local_zone, local_zone_conf in firewall['zone'].items(): +            if 'local_zone' not in local_zone_conf: +                continue + +            local_zone_conf['from_local'] = {} + +            for zone, zone_conf in firewall['zone'].items(): +                if zone == local_zone or 'from' not in zone_conf: +                    continue +                if local_zone in zone_conf['from']: +                    local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone]      render(nftables_conf, 'firewall/nftables.j2', firewall) -    render(nftables_defines_conf, 'firewall/nftables-defines.j2', firewall)      return None  def apply_sysfs(firewall): @@ -387,38 +468,29 @@ def post_apply_trap(firewall):                  cmd(base_cmd + ' '.join(objects)) -def state_policy_rule_exists(): -    # Determine if state policy rules already exist in nft -    search_str = cmd(f'nft list chain ip filter VYOS_FW_FORWARD') -    return 'VYOS_STATE_POLICY' in search_str - -def resync_policy_route(): -    # Update policy route as firewall groups were updated -    tmp = run(policy_route_conf_script) -    if tmp > 0: -        Warning('Failed to re-apply policy route configuration!') -  def apply(firewall): -    if 'first_install' in firewall: -        run('nfct helper add rpc inet tcp') -        run('nfct helper add rpc inet udp') -        run('nfct helper add tns inet tcp') - -    install_result = run(f'nft -f {nftables_conf}') +    install_result, output = rc_cmd(f'nft -f {nftables_conf}')      if install_result == 1: -        raise ConfigError('Failed to apply firewall') - -    if 'state_policy' in firewall and not state_policy_rule_exists(): -        for chain in ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL']: -            cmd(f'nft insert rule ip filter {chain} jump VYOS_STATE_POLICY') - -        for chain in ['VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL']: -            cmd(f'nft insert rule ip6 filter {chain} jump VYOS_STATE_POLICY6') +        raise ConfigError(f'Failed to apply firewall: {output}')      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 +        if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: +            print('Updating GeoIP. Please wait...') +            geoip_update(firewall)      post_apply_trap(firewall) diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 7750c1247..7e16235c1 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -192,6 +192,11 @@ def verify(flow_config):                      raise ConfigError("All sFlow servers must use the same IP protocol")              else:                  sflow_collector_ipver = ip_address(server).version +	 +        # check if vrf is defined for Sflow +        sflow_vrf = None +        if 'vrf' in flow_config: +            sflow_vrf = flow_config['vrf']          # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa          for server in flow_config['sflow']['server']: @@ -203,12 +208,12 @@ def verify(flow_config):          if 'agent_address' in flow_config['sflow']:              tmp = flow_config['sflow']['agent_address'] -            if not is_addr_assigned(tmp): +            if not is_addr_assigned(tmp, sflow_vrf):                  raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!')          # Check if configured netflow source-address exist in the system          if 'source_address' in flow_config['sflow']: -            if not is_addr_assigned(flow_config['sflow']['source_address']): +            if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf):                  tmp = flow_config['sflow']['source_address']                  raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index e14050dd3..8a959dc79 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -88,15 +88,12 @@ def verify(ha):                  if not {'password', 'type'} <= set(group_config['authentication']):                      raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') -            # We can not use a VRID once per interface +            # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction +            # We also need to make sure VRID is not used twice on the same interface with the +            # same address family. +              interface = group_config['interface']              vrid = group_config['vrid'] -            tmp = {'interface': interface, 'vrid': vrid} -            if tmp in used_vrid_if: -                raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface}"!') -            used_vrid_if.append(tmp) - -            # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction              # XXX: filter on map object is destructive, so we force it to list.              # Additionally, filter objects always evaluate to True, empty or not, @@ -109,6 +106,11 @@ def verify(ha):                  raise ConfigError(f'VRRP group "{group}" mixes IPv4 and IPv6 virtual addresses, this is not allowed.\n' \                                    'Create individual groups for IPv4 and IPv6!')              if vaddrs4: +                tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv4'} +                if tmp in used_vrid_if: +                    raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv4"!') +                used_vrid_if.append(tmp) +                  if 'hello_source_address' in group_config:                      if is_ipv6(group_config['hello_source_address']):                          raise ConfigError(f'VRRP group "{group}" uses IPv4 but hello-source-address is IPv6!') @@ -118,6 +120,11 @@ def verify(ha):                          raise ConfigError(f'VRRP group "{group}" uses IPv4 but peer-address is IPv6!')              if vaddrs6: +                tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv6'} +                if tmp in used_vrid_if: +                    raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv6"!') +                used_vrid_if.append(tmp) +                  if 'hello_source_address' in group_config:                      if is_ipv4(group_config['hello_source_address']):                          raise ConfigError(f'VRRP group "{group}" uses IPv6 but hello-source-address is IPv4!') diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 4a7906c17..6328294c1 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -24,9 +24,12 @@ from copy import deepcopy  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 +from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -36,6 +39,15 @@ systemd_service = '/run/systemd/system/vyos-http-api.service'  vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] +def _translate_values_to_boolean(d: dict) -> dict: +    for k in list(d): +        if d[k] == {}: +            d[k] = True +        elif isinstance(d[k], dict): +            _translate_values_to_boolean(d[k]) +        else: +            pass +  def get_config(config=None):      http_api = deepcopy(vyos.defaults.api_data)      x = http_api.get('api_keys') @@ -50,56 +62,49 @@ 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 +    api_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) + +    # One needs to 'flatten' the keys dict from the config into the +    # http-api.conf format for api_keys: +    if 'keys' in api_dict: +        api_dict['api_keys'] = [] +        for el in list(api_dict['keys']['id']): +            key = api_dict['keys']['id'][el]['key'] +            api_dict['api_keys'].append({'id': el, 'key': key}) +        del api_dict['keys'] +      # Do we run inside a VRF context?      vrf_path = ['service', 'https', 'vrf']      if conf.exists(vrf_path):          http_api['vrf'] = conf.return_value(vrf_path) -    conf.set_level('service https api') -    if conf.exists('strict'): -        http_api['strict'] = True - -    if conf.exists('debug'): -        http_api['debug'] = True - -    # this node is not available by CLI by default, and is reserved for -    # the graphql tools. One can enable it for testing, with the warning -    # that this will open an unauthenticated server. To do so -    # mkdir /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql -    # touch /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql/node.def -    # and configure; editing the config alone is insufficient. -    if conf.exists('gql'): -        http_api['gql'] = True - -    if conf.exists('socket'): -        http_api['socket'] = True - -    if conf.exists('port'): -        port = conf.return_value('port') -        http_api['port'] = port - -    if conf.exists('cors'): -        http_api['cors'] = {} -        if conf.exists('cors allow-origin'): -            origins = conf.return_values('cors allow-origin') -            http_api['cors']['origins'] = origins[:] - -    if conf.exists('keys'): -        for name in conf.list_nodes('keys id'): -            if conf.exists('keys id {0} key'.format(name)): -                key = conf.return_value('keys id {0} key'.format(name)) -                new_key = { 'id': name, 'key': key } -                http_api['api_keys'].append(new_key) -                keys_added = True +    if 'api_keys' in api_dict: +        keys_added = True + +    if 'graphql' in api_dict: +        api_dict = dict_merge(defaults(base), api_dict) + +    http_api.update(api_dict)      if keys_added and default_key:          if default_key in http_api['api_keys']:              http_api['api_keys'].remove(default_key) +    # Finally, translate entries in http_api into boolean settings for +    # backwards compatability of JSON http-api.conf file +    _translate_values_to_boolean(http_api) +      return http_api  def verify(http_api): @@ -133,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/https.py b/src/conf_mode/https.py index 3057357fc..7cd7ea42e 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -29,6 +29,8 @@ from vyos.pki import wrap_certificate  from vyos.pki import wrap_private_key  from vyos.template import render  from vyos.util import call +from vyos.util import check_port_availability +from vyos.util import is_listen_port_bind_service  from vyos.util import write_file  from vyos import airbag @@ -107,6 +109,31 @@ def verify(https):                  raise ConfigError("At least one 'virtual-host <id> server-name' "                                "matching the 'certbot domain-name' is required.") +    server_block_list = [] + +    # organize by vhosts +    vhost_dict = https.get('virtual-host', {}) + +    if not vhost_dict: +        # no specified virtual hosts (server blocks); use default +        server_block_list.append(default_server_block) +    else: +        for vhost in list(vhost_dict): +            server_block = deepcopy(default_server_block) +            data = vhost_dict.get(vhost, {}) +            server_block['address'] = data.get('listen-address', '*') +            server_block['port'] = data.get('listen-port', '443') +            server_block_list.append(server_block) + +    for entry in server_block_list: +        _address = entry.get('address') +        _address = '0.0.0.0' if _address == '*' else _address +        _port = entry.get('port') +        proto = 'tcp' +        if check_port_availability(_address, int(_port), proto) is not True and \ +                not is_listen_port_bind_service(int(_port), 'nginx'): +            raise ConfigError(f'"{proto}" port "{_port}" is used by another service') +      verify_vrf(https)      return None diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 4167594e3..b883ebef2 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -36,6 +36,7 @@ from vyos.ifconfig import BondIf  from vyos.ifconfig import Section  from vyos.util import dict_search  from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -72,57 +73,83 @@ def get_config(config=None):      # To make our own life easier transfor the list of member interfaces      # into a dictionary - we will use this to add additional information -    # later on for wach member +    # later on for each member      if 'member' in bond and 'interface' in bond['member']: -        # convert list if member interfaces to a dictionary -        bond['member']['interface'] = dict.fromkeys( -            bond['member']['interface'], {}) +        # convert list of member interfaces to a dictionary +        bond['member']['interface'] = {k: {} for k in bond['member']['interface']}      if 'mode' in bond:          bond['mode'] = get_bond_mode(bond['mode'])      tmp = leaf_node_changed(conf, base + [ifname, 'mode']) -    if tmp: bond.update({'shutdown_required': {}}) +    if tmp: bond['shutdown_required'] = {}      tmp = leaf_node_changed(conf, base + [ifname, 'lacp-rate']) -    if tmp: bond.update({'shutdown_required': {}}) +    if tmp: bond['shutdown_required'] = {}      # determine which members have been removed      interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) + +    # Reset config level to interfaces +    old_level = conf.get_level() +    conf.set_level(['interfaces']) +      if interfaces_removed: -        bond.update({'shutdown_required': {}}) +        bond['shutdown_required'] = {}          if 'member' not in bond: -            bond.update({'member': {}}) +            bond['member'] = {}          tmp = {}          for interface in interfaces_removed:              section = Section.section(interface) # this will be 'ethernet' for 'eth0' -            if conf.exists(['insterfaces', section, interface, 'disable']): -                tmp.update({interface : {'disable': ''}}) +            if conf.exists([section, interface, 'disable']): +                tmp[interface] = {'disable': ''}              else: -                tmp.update({interface : {}}) +                tmp[interface] = {}          # also present the interfaces to be removed from the bond as dictionary -        bond['member'].update({'interface_remove': tmp}) +        bond['member']['interface_remove'] = tmp + +    # Restore existing config level +    conf.set_level(old_level)      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(base + [ifname, 'member', 'interface', interface]): +                bond['shutdown_required'] = {} + +            # Check if member interface is disabled +            conf.set_level(['interfaces']) + +            section = Section.section(interface) # this will be 'ethernet' for 'eth0' +            if conf.exists([section, interface, 'disable']): +                interface_config['disable'] = '' + +            conf.set_level(old_level) +              # Check if member interface is already member of another bridge              tmp = is_member(conf, interface, 'bridge') -            if tmp: interface_config.update({'is_bridge_member' : tmp}) +            if tmp: interface_config['is_bridge_member'] = tmp              # Check if member interface is already member of a bond              tmp = is_member(conf, interface, 'bonding') -            if tmp and bond['ifname'] not in tmp: -                interface_config.update({'is_bond_member' : tmp}) +            for tmp in is_member(conf, interface, 'bonding'): +                if bond['ifname'] == tmp: +                    continue +                interface_config['is_bond_member'] = tmp              # Check if member interface is used as source-interface on another interface              tmp = is_source_interface(conf, interface) -            if tmp: interface_config.update({'is_source_interface' : tmp}) +            if tmp: interface_config['is_source_interface'] = tmp              # bond members must not have an assigned address              tmp = has_address_configured(conf, interface) -            if tmp: interface_config.update({'has_address' : ''}) +            if tmp: interface_config['has_address'] = {} + +            # bond members must not have a VRF attached +            tmp = has_vrf_configured(conf, interface) +            if tmp: interface_config['has_vrf'] = {}      return bond @@ -167,11 +194,11 @@ def verify(bond):                  raise ConfigError(error_msg + 'it does not exist!')              if 'is_bridge_member' in interface_config: -                tmp = next(iter(interface_config['is_bridge_member'])) +                tmp = interface_config['is_bridge_member']                  raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!')              if 'is_bond_member' in interface_config: -                tmp = next(iter(interface_config['is_bond_member'])) +                tmp = interface_config['is_bond_member']                  raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!')              if 'is_source_interface' in interface_config: @@ -181,6 +208,8 @@ def verify(bond):              if 'has_address' in interface_config:                  raise ConfigError(error_msg + 'it has an address assigned!') +            if 'has_vrf' in interface_config: +                raise ConfigError(error_msg + 'it has a VRF assigned!')      if 'primary' in bond:          if bond['primary'] not in bond['member']['interface']: diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index 38ae727c1..b961408db 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -31,6 +31,7 @@ from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_vrf  from vyos.ifconfig import BridgeIf  from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured  from vyos.xml import defaults  from vyos.util import cmd @@ -60,7 +61,7 @@ def get_config(config=None):          else:              bridge.update({'member' : {'interface_remove' : tmp }}) -    if dict_search('member.interface', bridge): +    if dict_search('member.interface', bridge) != None:          # XXX: T2665: we need a copy of the dict keys for iteration, else we will get:          # RuntimeError: dictionary changed size during iteration          for interface in list(bridge['member']['interface']): @@ -93,11 +94,23 @@ def get_config(config=None):              tmp = has_address_configured(conf, interface)              if tmp: bridge['member']['interface'][interface].update({'has_address' : ''}) +            # Bridge members must not have a VRF attached +            tmp = has_vrf_configured(conf, interface) +            if tmp: bridge['member']['interface'][interface].update({'has_vrf' : ''}) +              # VLAN-aware bridge members must not have VLAN interface configuration              tmp = has_vlan_subinterface_configured(conf,interface)              if 'enable_vlan' in bridge and tmp:                  bridge['member']['interface'][interface].update({'has_vlan' : ''}) +    # delete empty dictionary keys - no need to run code paths if nothing is there to do +    if 'member' in bridge: +        if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: +            del bridge['member']['interface'] + +        if len(bridge['member']) == 0: +            del bridge['member'] +      return bridge  def verify(bridge): @@ -118,11 +131,11 @@ def verify(bridge):                  raise ConfigError('Loopback interface "lo" can not be added to a bridge')              if 'is_bridge_member' in interface_config: -                tmp = next(iter(interface_config['is_bridge_member'])) +                tmp = interface_config['is_bridge_member']                  raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!')              if 'is_bond_member' in interface_config: -                tmp = next(iter(interface_config['is_bond_member'])) +                tmp = interface_config['is_bond_member']                  raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!')              if 'is_source_interface' in interface_config: @@ -132,9 +145,12 @@ def verify(bridge):              if 'has_address' in interface_config:                  raise ConfigError(error_msg + 'it has an address assigned!') +            if 'has_vrf' in interface_config: +                raise ConfigError(error_msg + 'it has a VRF assigned!') +              if 'enable_vlan' in bridge:                  if 'has_vlan' in interface_config: -                    raise ConfigError(error_msg + 'it has an VLAN subinterface assigned!') +                    raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!')                  if 'wlan' in interface:                      raise ConfigError(error_msg + 'VLAN aware cannot be set!') diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index fec4456fb..b49c945cd 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -31,6 +31,7 @@ from vyos.configverify import verify_mtu  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_vlan_config  from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member  from vyos.ethtool import Ethtool  from vyos.ifconfig import EthernetIf  from vyos.pki import find_chain @@ -83,6 +84,7 @@ def verify(ethernet):      verify_dhcpv6(ethernet)      verify_address(ethernet)      verify_vrf(ethernet) +    verify_bond_bridge_member(ethernet)      verify_eapol(ethernet)      verify_mirror_redirect(ethernet) @@ -151,11 +153,20 @@ def verify(ethernet):      return None  def generate(ethernet): -    if 'eapol' in ethernet: -        render(wpa_suppl_conf.format(**ethernet), -               'ethernet/wpa_supplicant.conf.j2', ethernet) +    # render real configuration file once +    wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) + +    if 'deleted' in ethernet: +        # delete configuration on interface removal +        if os.path.isfile(wpa_supplicant_conf): +            os.unlink(wpa_supplicant_conf) +        return None +    if 'eapol' in ethernet:          ifname = ethernet['ifname'] + +        render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) +          cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem')          cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') @@ -164,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) @@ -182,10 +193,6 @@ def generate(ethernet):              write_file(ca_cert_file_path,                         '\n'.join(encode_certificate(c) for c in ca_full_chain)) -    else: -        # delete configuration on interface removal -        if os.path.isfile(wpa_suppl_conf.format(**ethernet)): -            os.unlink(wpa_suppl_conf.format(**ethernet))      return None @@ -201,9 +208,9 @@ def apply(ethernet):      else:          e.update(ethernet)          if 'eapol' in ethernet: -            eapol_action='restart' +            eapol_action='reload-or-restart' -    call(f'systemctl {eapol_action} wpa_supplicant-macsec@{ifname}') +    call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index b9cf2fa3c..08cc3a48d 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.py @@ -27,6 +27,7 @@ from vyos.configverify import verify_address  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import GeneveIf  from vyos import ConfigError @@ -64,6 +65,7 @@ def verify(geneve):      verify_mtu_ipv6(geneve)      verify_address(geneve) +    verify_bond_bridge_member(geneve)      verify_mirror_redirect(geneve)      if 'remote' not in geneve: diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index 6a486f969..ca321e01d 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -26,6 +26,7 @@ from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import L2TPv3If  from vyos.util import check_kmod  from vyos.validate import is_addr_assigned @@ -77,6 +78,7 @@ def verify(l2tpv3):      verify_mtu_ipv6(l2tpv3)      verify_address(l2tpv3) +    verify_bond_bridge_member(l2tpv3)      verify_mirror_redirect(l2tpv3)      return None diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index 279dd119b..649ea8d50 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-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 @@ -21,16 +21,21 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict -from vyos.ifconfig import MACsecIf -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.util import call +from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_source_interface +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import MACsecIf +from vyos.ifconfig import Interface +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 import ConfigError  from vyos import airbag  airbag.enable() @@ -52,9 +57,19 @@ def get_config(config=None):      # Check if interface has been removed      if 'deleted' in macsec: -        source_interface = conf.return_effective_value(['source-interface']) +        source_interface = conf.return_effective_value(base + [ifname, 'source-interface'])          macsec.update({'source_interface': source_interface}) +    if is_node_changed(conf, base + [ifname, 'security']): +        macsec.update({'shutdown_required': {}}) + +    if is_node_changed(conf, base + [ifname, 'source_interface']): +        macsec.update({'shutdown_required': {}}) + +    if 'source_interface' in macsec: +        tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) +        if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) +      return macsec @@ -67,22 +82,25 @@ def verify(macsec):      verify_vrf(macsec)      verify_mtu_ipv6(macsec)      verify_address(macsec) +    verify_bond_bridge_member(macsec)      verify_mirror_redirect(macsec) -    if not (('security' in macsec) and -            ('cipher' in macsec['security'])): -        raise ConfigError( -            'Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) +    if dict_search('security.cipher', macsec) == None: +        raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) + +    if dict_search('security.encrypt', macsec) != None: +        if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: +            raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') + +        cak_len = len(dict_search('security.mka.cak', macsec)) -    if (('security' in macsec) and -        ('encrypt' in macsec['security'])): -        tmp = macsec.get('security') +        if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != 32: +            # gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit +            raise ConfigError('gcm-aes-128 requires a 128bit long key!') -        if not (('mka' in tmp) and -                ('cak' in tmp['mka']) and -                ('ckn' in tmp['mka'])): -            raise ConfigError('Missing mandatory MACsec security ' -                              'keys as encryption is enabled!') +        elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != 64: +            # gcm-aes-128 requires a 128bit long key - 64 characters (string) = 32byte = 256bit +            raise ConfigError('gcm-aes-128 requires a 256bit long key!')      if 'source_interface' in macsec:          # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad @@ -97,33 +115,35 @@ def verify(macsec):  def generate(macsec): -    render(wpa_suppl_conf.format(**macsec), -           'macsec/wpa_supplicant.conf.j2', macsec) +    render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec)      return None  def apply(macsec): -    # Remove macsec interface -    if 'deleted' in macsec: -        call('systemctl stop wpa_supplicant-macsec@{source_interface}' -             .format(**macsec)) +    systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec) + +    # Remove macsec interface on deletion or mandatory parameter change +    if 'deleted' in macsec or 'shutdown_required' in macsec: +        call(f'systemctl stop {systemd_service}')          if macsec['ifname'] in interfaces():              tmp = MACsecIf(macsec['ifname'])              tmp.remove() -        # delete configuration on interface removal -        if os.path.isfile(wpa_suppl_conf.format(**macsec)): -            os.unlink(wpa_suppl_conf.format(**macsec)) +        if 'deleted' in macsec: +            # delete configuration on interface removal +            if os.path.isfile(wpa_suppl_conf.format(**macsec)): +                os.unlink(wpa_suppl_conf.format(**macsec)) -    else: -        # It is safe to "re-create" the interface always, there is a sanity -        # check that the interface will only be create if its non existent -        i = MACsecIf(**macsec) -        i.update(macsec) +            return None + +    # It is safe to "re-create" the interface always, there is a sanity +    # check that the interface will only be create if its non existent +    i = MACsecIf(**macsec) +    i.update(macsec) -        call('systemctl restart wpa_supplicant-macsec@{source_interface}' -             .format(**macsec)) +    if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: +        call(f'systemctl reload-or-restart {systemd_service}')      return None diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 4750ca3e8..8155f36c2 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -36,9 +36,12 @@ from vyos.configdict import is_node_changed  from vyos.configverify import verify_vrf  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import VTunIf  from vyos.pki import load_dh_parameters  from vyos.pki import load_private_key +from vyos.pki import sort_ca_chain +from vyos.pki import verify_ca_chain  from vyos.pki import wrap_certificate  from vyos.pki import wrap_crl  from vyos.pki import wrap_dh_parameters @@ -52,6 +55,7 @@ from vyos.util import chown  from vyos.util import cmd  from vyos.util import dict_search  from vyos.util import dict_search_args +from vyos.util import is_list_equal  from vyos.util import makedir  from vyos.util import read_file  from vyos.util import write_file @@ -148,8 +152,14 @@ def verify_pki(openvpn):          if 'ca_certificate' not in tls:              raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface}') -        if tls['ca_certificate'] not in pki['ca']: -            raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') +        for ca_name in tls['ca_certificate']: +            if ca_name not in pki['ca']: +                raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + +        if len(tls['ca_certificate']) > 1: +            sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) +            if not verify_ca_chain(sorted_chain, pki['ca']): +                raise ConfigError(f'CA certificates are not a valid chain')          if mode != 'client' and 'auth_key' not in tls:              if 'certificate' not in tls: @@ -265,7 +275,7 @@ def verify(openvpn):              elif v6remAddr and not v6loAddr:                  raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') -            if (v4loAddr == v4remAddr) or (v6remAddr == v4remAddr): +            if is_list_equal(v4loAddr, v4remAddr) or is_list_equal(v6loAddr, v6remAddr):                  raise ConfigError('"local-address" and "remote-address" cannot be the same')              if dict_search('local_host', openvpn) in dict_search('local_address', openvpn): @@ -495,6 +505,7 @@ def verify(openvpn):              raise ConfigError('Username for authentication is missing')      verify_vrf(openvpn) +    verify_bond_bridge_member(openvpn)      verify_mirror_redirect(openvpn)      return None @@ -516,21 +527,28 @@ def generate_pki_files(openvpn):      if tls:          if 'ca_certificate' in tls: -            cert_name = tls['ca_certificate'] -            pki_ca = pki['ca'][cert_name] +            cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') +            crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') -            if 'certificate' in pki_ca: -                cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') -                write_file(cert_path, wrap_certificate(pki_ca['certificate']), -                           user=user, group=group, mode=0o600) +            if os.path.exists(cert_path): +                os.unlink(cert_path) + +            if os.path.exists(crl_path): +                os.unlink(crl_path) + +            for cert_name in sort_ca_chain(tls['ca_certificate'], pki['ca']): +                pki_ca = pki['ca'][cert_name] + +                if 'certificate' in pki_ca: +                    write_file(cert_path, wrap_certificate(pki_ca['certificate']) + "\n", +                               user=user, group=group, mode=0o600, append=True) -            if 'crl' in pki_ca: -                for crl in pki_ca['crl']: -                    crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') -                    write_file(crl_path, wrap_crl(crl), user=user, group=group, -                               mode=0o600) +                if 'crl' in pki_ca: +                    for crl in pki_ca['crl']: +                        write_file(crl_path, wrap_crl(crl) + "\n", user=user, group=group, +                                   mode=0o600, append=True) -                openvpn['tls']['crl'] = True +                    openvpn['tls']['crl'] = True          if 'certificate' in tls:              cert_name = tls['certificate'] 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-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 1cd3fe276..4c65bc0b6 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -15,10 +15,13 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  from sys import exit +from netifaces import interfaces  from vyos.config import Config  from vyos.configdict import get_interface_dict  from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface +from vyos.configdict import leaf_node_changed  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete @@ -26,6 +29,7 @@ from vyos.configverify import verify_source_interface  from vyos.configverify import verify_vlan_config  from vyos.configverify import verify_mtu_parent  from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import MACVLANIf  from vyos import ConfigError @@ -47,9 +51,16 @@ def get_config(config=None):      mode = is_node_changed(conf, ['mode'])      if mode: peth.update({'shutdown_required' : {}}) +    if leaf_node_changed(conf, base + [ifname, 'mode']): +        peth.update({'rebuild_required': {}}) +      if 'source_interface' in peth:          _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'],                                                 peth['source_interface']) +        # test if source-interface is maybe already used by another interface +        tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) +        if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) +      return peth  def verify(peth): @@ -71,21 +82,18 @@ def generate(peth):      return None  def apply(peth): -    if 'deleted' in peth: -        # delete interface -        MACVLANIf(peth['ifname']).remove() -        return None +    # Check if the MACVLAN interface already exists +    if 'rebuild_required' in peth or 'deleted' in peth: +        if peth['ifname'] in interfaces(): +            p = MACVLANIf(peth['ifname']) +            # MACVLAN is always needs to be recreated, +            # thus we can simply always delete it first. +            p.remove() -    # Check if MACVLAN interface already exists. Parameters like the underlaying -    # source-interface device or mode can not be changed on the fly and the -    # interface needs to be recreated from the bottom. -    if 'mode_old' in peth: -        MACVLANIf(peth['ifname']).remove() +    if 'deleted' not in peth: +        p = MACVLANIf(**peth) +        p.update(peth) -    # It is safe to "re-create" the interface always, there is a sanity check -    # that the interface will only be create if its non existent -    p = MACVLANIf(**peth) -    p.update(peth)      return None  if __name__ == '__main__': diff --git a/src/conf_mode/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-tunnel.py b/src/conf_mode/interfaces-tunnel.py index eff7f373c..acef1fda7 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -29,6 +29,7 @@ from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_vrf  from vyos.configverify import verify_tunnel +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import Interface  from vyos.ifconfig import Section  from vyos.ifconfig import TunnelIf @@ -158,6 +159,7 @@ def verify(tunnel):      verify_mtu_ipv6(tunnel)      verify_address(tunnel)      verify_vrf(tunnel) +    verify_bond_bridge_member(tunnel)      verify_mirror_redirect(tunnel)      if 'source_interface' in tunnel: 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-vxlan.py b/src/conf_mode/interfaces-vxlan.py index f44d754ba..af2d0588d 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -29,6 +29,7 @@ from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_source_interface +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import Interface  from vyos.ifconfig import VXLANIf  from vyos.template import is_ipv6 @@ -117,6 +118,11 @@ def verify(vxlan):              # in use.              vxlan_overhead += 20 +        # If source_address is not used - check IPv6 'remote' list +        elif 'remote' in vxlan: +            if any(is_ipv6(a) for a in vxlan['remote']): +                vxlan_overhead += 20 +          lower_mtu = Interface(vxlan['source_interface']).get_mtu()          if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead):              raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ @@ -144,6 +150,7 @@ def verify(vxlan):      verify_mtu_ipv6(vxlan)      verify_address(vxlan) +    verify_bond_bridge_member(vxlan)      verify_mirror_redirect(vxlan)      return None diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 180ffa507..762bad94f 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -14,21 +14,18 @@  # 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 copy import deepcopy  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import get_interface_dict -from vyos.configdict import node_changed -from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_mtu_ipv6  from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import WireGuardIf  from vyos.util import check_kmod  from vyos.util import check_port_availability @@ -49,17 +46,20 @@ def get_config(config=None):      ifname, wireguard = get_interface_dict(conf, base)      # Check if a port was changed -    wireguard['port_changed'] = leaf_node_changed(conf, base + [ifname, 'port']) +    tmp = is_node_changed(conf, base + [ifname, 'port']) +    if tmp: wireguard['port_changed'] = {}      # Determine which Wireguard peer has been removed.      # Peers can only be removed with their public key! -    dict = {} -    tmp = node_changed(conf, base + [ifname, 'peer'], key_mangling=('-', '_')) -    for peer in (tmp or []): -        public_key = leaf_node_changed(conf, base + [ifname, 'peer', peer, 'public_key']) -        if public_key: -            dict = dict_merge({'peer_remove' : {peer : {'public_key' : public_key[0]}}}, dict) -            wireguard.update(dict) +    if 'peer' in wireguard: +        peer_remove = {} +        for peer, peer_config in wireguard['peer'].items(): +            # T4702: If anything on a peer changes we remove the peer first and re-add it +            if is_node_changed(conf, base + [ifname, 'peer', peer]): +                if 'public_key' in peer_config: +                    peer_remove = dict_merge({'peer_remove' : {peer : peer_config['public_key']}}, peer_remove) +        if peer_remove: +           wireguard.update(peer_remove)      return wireguard @@ -71,6 +71,7 @@ def verify(wireguard):      verify_mtu_ipv6(wireguard)      verify_address(wireguard)      verify_vrf(wireguard) +    verify_bond_bridge_member(wireguard)      verify_mirror_redirect(wireguard)      if 'private_key' not in wireguard: @@ -79,14 +80,15 @@ def verify(wireguard):      if 'peer' not in wireguard:          raise ConfigError('At least one Wireguard peer is required!') -    if 'port' in wireguard and wireguard['port_changed']: +    if 'port' in wireguard and 'port_changed' in wireguard:          listen_port = int(wireguard['port'])          if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: -            raise ConfigError( -                f'The UDP port {listen_port} is busy or unavailable and cannot be used for the interface' -            ) +            raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' +                               'cannot be used for the interface!')      # run checks on individual configured WireGuard peer +    public_keys = [] +      for tmp in wireguard['peer']:          peer = wireguard['peer'][tmp] @@ -100,6 +102,11 @@ def verify(wireguard):              raise ConfigError('Both Wireguard port and address must be defined '                                f'for peer "{tmp}" if either one of them is set!') +        if peer['public_key'] in public_keys: +            raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + +        public_keys.append(peer['public_key']) +  def apply(wireguard):      tmp = WireGuardIf(wireguard['ifname'])      if 'deleted' in wireguard: diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index d34297063..dd798b5a2 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_source_interface  from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_vlan_config  from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member  from vyos.ifconfig import WiFiIf  from vyos.template import render  from vyos.util import call @@ -194,6 +195,7 @@ def verify(wifi):      verify_address(wifi)      verify_vrf(wifi) +    verify_bond_bridge_member(wifi)      verify_mirror_redirect(wifi)      # use common function to verify VLAN configuration diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index e275ace84..a14a992ae 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -76,7 +76,7 @@ def get_config(config=None):      # We need to know the amount of other WWAN interfaces as ModemManager needs      # to be started or stopped.      conf.set_level(base) -    _, wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), +    wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'),                                                         get_first_key=True,                                                         no_tag_node_value_mangle=True) @@ -116,7 +116,7 @@ def generate(wwan):      # disconnect - e.g. happens during RF signal loss. The script watches every      # WWAN interface - so there is only one instance.      if not os.path.exists(cron_script): -        write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py') +        write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n')      return None diff --git a/src/conf_mode/load-balancing-wan.py b/src/conf_mode/load-balancing-wan.py new file mode 100755 index 000000000..11840249f --- /dev/null +++ b/src/conf_mode/load-balancing-wan.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.util import call +from vyos import ConfigError +from pprint import pprint +from vyos import airbag +airbag.enable() + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['load-balancing', 'wan'] +    lb = conf.get_config_dict(base, get_first_key=True, +                                       no_tag_node_value_mangle=True) + +    pprint(lb) +    return lb + +def verify(lb): +    return None + + +def generate(lb): +    if not lb: +        return None + +    return None + + +def apply(lb): + +    return None + +if __name__ == '__main__': +    try: +        c = get_config() +        verify(c) +        generate(c) +        apply(c) +    except ConfigError as e: +        print(e) +        exit(1) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 85819a77e..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 @@ -44,7 +45,15 @@ if LooseVersion(kernel_version()) > LooseVersion('5.1'):  else:      k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] -nftables_nat_config = '/tmp/vyos-nat-rules.nft' +nftables_nat_config = '/run/nftables_nat.conf' +nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' + +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. @@ -59,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 @@ -77,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 @@ -88,7 +136,7 @@ def get_config(config=None):      # T2665: we must add the tagNode defaults individually until this is      # moved to the base class -    for direction in ['source', 'destination']: +    for direction in ['source', 'destination', 'static']:          if direction in nat:              default_values = defaults(base + [direction, 'rule'])              for rule in dict_search(f'{direction}.rule', nat) or []: @@ -104,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'): @@ -145,18 +197,18 @@ 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: -                if addr != 'masquerade' and not is_ip_network(addr): -                    for ip in addr.split('-'): -                        if not is_addr_assigned(ip): -                            Warning(f'IP address {ip} does not exist on the system!') -            elif 'exclude' not in config: -                raise ConfigError(f'{err_msg}\n' \ -                                  'translation address not specified') +            if addr != None and addr != 'masquerade' and not is_ip_network(addr): +                for ip in addr.split('-'): +                    if not is_addr_assigned(ip): +                        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,36 +218,54 @@ def verify(nat):              if 'inbound_interface' not in config:                  raise ConfigError(f'{err_msg}\n' \                                    'inbound-interface not specified') -            else: -                if 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') +            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') -            if dict_search('translation.address', config) == None and 'exclude' not in config: +            # common rule verification +            verify_rule(config, err_msg, nat['firewall_group']) + +    if dict_search('static.rule', nat): +        for rule, config in dict_search('static.rule', nat).items(): +            err_msg = f'Static NAT configuration error in rule {rule}:' + +            if 'inbound_interface' not in config:                  raise ConfigError(f'{err_msg}\n' \ -                                  'translation address not specified') +                                  'inbound-interface not specified')              # common rule verification -            verify_rule(config, err_msg) +            verify_rule(config, err_msg, nat['firewall_group'])      return None  def generate(nat): +    if not os.path.exists(nftables_nat_config): +        nat['first_install'] = True +      render(nftables_nat_config, 'firewall/nftables-nat.j2', nat) +    render(nftables_static_nat_conf, 'firewall/nftables-static-nat.j2', nat)      # dry-run newly generated configuration      tmp = run(f'nft -c -f {nftables_nat_config}')      if tmp > 0: -        if os.path.exists(nftables_nat_config): -            os.unlink(nftables_nat_config) +        raise ConfigError('Configuration file errors encountered!') + +    tmp = run(f'nft -c -f {nftables_static_nat_conf}') +    if tmp > 0:          raise ConfigError('Configuration file errors encountered!')      return None  def apply(nat):      cmd(f'nft -f {nftables_nat_config}') -    if os.path.isfile(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 diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 0972151a0..d8f913b0c 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -36,7 +36,7 @@ airbag.enable()  k_mod = ['nft_nat', 'nft_chain_nat'] -nftables_nat66_config = '/tmp/vyos-nat66-rules.nft' +nftables_nat66_config = '/run/nftables_nat66.nft'  ndppd_config = '/run/ndppd/ndppd.conf'  def get_handler(json, chain, target): @@ -125,7 +125,8 @@ def verify(nat):                  if addr != 'masquerade' and not is_ipv6(addr):                      raise ConfigError(f'IPv6 address {addr} is not a valid address')              else: -                raise ConfigError(f'{err_msg} translation address not specified') +                if 'exclude' not in config: +                    raise ConfigError(f'{err_msg} translation address not specified')              prefix = dict_search('source.prefix', config)              if prefix != None: @@ -146,6 +147,9 @@ def verify(nat):      return None  def generate(nat): +    if not os.path.exists(nftables_nat66_config): +        nat['first_install'] = True +      render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755)      render(ndppd_config, 'ndppd/ndppd.conf.j2', nat, permission=0o755)      return None @@ -153,15 +157,15 @@ def generate(nat):  def apply(nat):      if not nat:          return None -    cmd(f'{nftables_nat66_config}') + +    cmd(f'nft -f {nftables_nat66_config}') +      if 'deleted' in nat or not dict_search('source.rule', nat):          cmd('systemctl stop ndppd')          if os.path.isfile(ndppd_config):              os.unlink(ndppd_config)      else:          cmd('systemctl restart ndppd') -    if os.path.isfile(nftables_nat66_config): -        os.unlink(nftables_nat66_config)      return None diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py index 0d6ec9ace..0ecb4d736 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/ntp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -17,10 +17,13 @@  import os  from vyos.config import Config +from vyos.configdict import is_node_changed  from vyos.configverify import verify_vrf -from vyos import ConfigError +from vyos.configverify import verify_interface_exists  from vyos.util import call +from vyos.util import get_interface_config  from vyos.template import render +from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -38,6 +41,10 @@ def get_config(config=None):      ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)      ntp['config_file'] = config_file + +    tmp = is_node_changed(conf, base + ['vrf']) +    if tmp: ntp.update({'restart_required': {}}) +      return ntp  def verify(ntp): @@ -49,6 +56,20 @@ def verify(ntp):          raise ConfigError('NTP server not configured')      verify_vrf(ntp) + +    if 'interface' in ntp: +        # If ntpd should listen on a given interface, ensure it exists +        for interface in ntp['interface']: +            verify_interface_exists(interface) + +            # If we run in a VRF, our interface must belong to this VRF, too +            if 'vrf' in ntp: +                tmp = get_interface_config(interface) +                vrf_name = ntp['vrf'] +                if 'master' not in tmp or tmp['master'] != vrf_name: +                    raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ +                                      f'does not belong to this VRF!') +      return None  def generate(ntp): @@ -62,19 +83,25 @@ def generate(ntp):      return None  def apply(ntp): +    systemd_service = 'ntp.service' +    # Reload systemd manager configuration +    call('systemctl daemon-reload') +      if not ntp:          # NTP support is removed in the commit -        call('systemctl stop ntp.service') +        call(f'systemctl stop {systemd_service}')          if os.path.exists(config_file):              os.unlink(config_file)          if os.path.isfile(systemd_override):              os.unlink(systemd_override) +        return -    # Reload systemd manager configuration -    call('systemctl daemon-reload') -    if ntp: -        call('systemctl restart ntp.service') +    # we need to restart the service if e.g. the VRF name changed +    systemd_action = 'reload-or-restart' +    if 'restart_required' in ntp: +        systemd_action = 'restart' +    call(f'systemctl {systemd_action} {systemd_service}')      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 1108aebe6..000000000 --- a/src/conf_mode/policy-route-interface.py +++ /dev/null @@ -1,120 +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 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(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}"') - -    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 5de341beb..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 @@ -33,35 +32,13 @@ airbag.enable()  mark_offset = 0x7FFFFFFF  nftables_conf = '/run/nftables_policy.conf' -preserve_chains = [ -    'VYOS_PBR_PREROUTING', -    'VYOS_PBR_POSTROUTING', -    'VYOS_PBR6_PREROUTING', -    'VYOS_PBR6_POSTROUTING' -] -  valid_groups = [      'address_group', +    'domain_group',      'network_group',      '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 @@ -74,11 +51,10 @@ 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 -def verify_rule(policy, name, rule_conf, ipv6): +def verify_rule(policy, name, rule_conf, ipv6, rule_id):      icmp = 'icmp' if not ipv6 else 'icmpv6'      if icmp in rule_conf:          icmp_defined = False @@ -118,8 +94,8 @@ def verify_rule(policy, name, rule_conf, ipv6):              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']: @@ -152,57 +128,13 @@ def verify(policy):              for name, pol_conf in policy[route].items():                  if 'rule' in pol_conf:                      for rule_id, rule_conf in pol_conf['rule'].items(): -                        verify_rule(policy, name, rule_conf, ipv6) - -    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}') +                        verify_rule(policy, name, rule_conf, ipv6, rule_id)      return None -def cleanup_rule(table, jump_chain): -    commands = [] -    results = cmd(f'nft -a list table {table}').split("\n") -    for line in results: -        if f'jump {jump_chain}' in line: -            handle_search = re.search('handle (\d+)', line) -            if handle_search: -                commands.append(f'delete rule {table} {chain} handle {handle_search[1]}') -    return commands - -def cleanup_commands(policy): -    commands = [] -    for table in ['ip mangle', 'ip6 mangle']: -        json_str = cmd(f'nft -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 not chain.startswith("VYOS_PBR"): -                    continue -                if chain not in preserve_chains: -                    if table == 'ip mangle' and dict_search_args(policy, 'route', chain.replace("VYOS_PBR_", "", 1)): -                        commands.append(f'flush chain {table} {chain}') -                    elif table == 'ip6 mangle' and dict_search_args(policy, 'route6', chain.replace("VYOS_PBR6_", "", 1)): -                        commands.append(f'flush chain {table} {chain}') -                    else: -                        commands += cleanup_rule(table, chain) -                        commands.append(f'delete chain {table} {chain}') -    return commands -  def generate(policy):      if not os.path.exists(nftables_conf):          policy['first_install'] = True -    else: -        policy['cleanup_commands'] = cleanup_commands(policy)      render(nftables_conf, 'firewall/nftables-policy.j2', policy)      return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index 3008a20e0..331194fec 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -23,8 +23,42 @@ from vyos.util import dict_search  from vyos import ConfigError  from vyos import frr  from vyos import airbag +  airbag.enable() + +def community_action_compatibility(actions: dict) -> bool: +    """ +    Check compatibility of values in community and large community sections +    :param actions: dictionary with community +    :type actions: dict +    :return: true if compatible, false if not +    :rtype: bool +    """ +    if ('none' in actions) and ('replace' in actions or 'add' in actions): +        return False +    if 'replace' in actions and 'add' in actions: +        return False +    if ('delete' in actions) and ('none' in actions or 'replace' in actions): +        return False +    return True + + +def extcommunity_action_compatibility(actions: dict) -> bool: +    """ +    Check compatibility of values in extended community sections +    :param actions: dictionary with community +    :type actions: dict +    :return: true if compatible, false if not +    :rtype: bool +    """ +    if ('none' in actions) and ( +            'rt' in actions or 'soo' in actions or 'bandwidth' in actions or 'bandwidth_non_transitive' in actions): +        return False +    if ('bandwidth_non_transitive' in actions) and ('bandwidth' not in actions): +        return False +    return True +  def routing_policy_find(key, dictionary):      # Recursively traverse a dictionary and extract the value assigned to      # a given key as generator object. This is made for routing policies, @@ -46,6 +80,7 @@ def routing_policy_find(key, dictionary):                      for result in routing_policy_find(key, d):                          yield result +  def get_config(config=None):      if config:          conf = config @@ -53,7 +88,8 @@ def get_config(config=None):          conf = Config()      base = ['policy'] -    policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +    policy = conf.get_config_dict(base, key_mangling=('-', '_'), +                                  get_first_key=True,                                    no_tag_node_value_mangle=True)      # We also need some additional information from the config, prefix-lists @@ -67,12 +103,14 @@ def get_config(config=None):      policy = dict_merge(tmp, policy)      return policy +  def verify(policy):      if not policy:          return None      for policy_type in ['access_list', 'access_list6', 'as_path_list', -                        'community_list', 'extcommunity_list', 'large_community_list', +                        'community_list', 'extcommunity_list', +                        'large_community_list',                          'prefix_list', 'prefix_list6', 'route_map']:          # Bail out early and continue with next policy type          if policy_type not in policy: @@ -97,15 +135,18 @@ def verify(policy):                      if 'source' not in rule_config:                          raise ConfigError(f'A source {mandatory_error}') -                    if int(instance) in range(100, 200) or int(instance) in range(2000, 2700): +                    if int(instance) in range(100, 200) or int( +                            instance) in range(2000, 2700):                          if 'destination' not in rule_config: -                            raise ConfigError(f'A destination {mandatory_error}') +                            raise ConfigError( +                                f'A destination {mandatory_error}')                  if policy_type == 'access_list6':                      if 'source' not in rule_config:                          raise ConfigError(f'A source {mandatory_error}') -                if policy_type in ['as_path_list', 'community_list', 'extcommunity_list', +                if policy_type in ['as_path_list', 'community_list', +                                   'extcommunity_list',                                     'large_community_list']:                      if 'regex' not in rule_config:                          raise ConfigError(f'A regex {mandatory_error}') @@ -115,10 +156,10 @@ def verify(policy):                          raise ConfigError(f'A prefix {mandatory_error}')                      if rule_config in entries: -                        raise ConfigError(f'Rule "{rule}" contains a duplicate prefix definition!') +                        raise ConfigError( +                            f'Rule "{rule}" contains a duplicate prefix definition!')                      entries.append(rule_config) -      # route-maps tend to be a bit more complex so they get their own verify() section      if 'route_map' in policy:          for route_map, route_map_config in policy['route_map'].items(): @@ -126,20 +167,29 @@ def verify(policy):                  continue              for rule, rule_config in route_map_config['rule'].items(): +                # Action 'deny' cannot be used with "continue" +                # FRR does not validate it T4827 +                if rule_config['action'] == 'deny' and 'continue' in rule_config: +                    raise ConfigError(f'rule {rule} "continue" cannot be used with action deny!') +                  # Specified community-list must exist -                tmp = dict_search('match.community.community_list', rule_config) +                tmp = dict_search('match.community.community_list', +                                  rule_config)                  if tmp and tmp not in policy.get('community_list', []):                      raise ConfigError(f'community-list {tmp} does not exist!')                  # Specified extended community-list must exist                  tmp = dict_search('match.extcommunity', rule_config)                  if tmp and tmp not in policy.get('extcommunity_list', []): -                    raise ConfigError(f'extcommunity-list {tmp} does not exist!') +                    raise ConfigError( +                        f'extcommunity-list {tmp} does not exist!')                  # Specified large-community-list must exist -                tmp = dict_search('match.large_community.large_community_list', rule_config) +                tmp = dict_search('match.large_community.large_community_list', +                                  rule_config)                  if tmp and tmp not in policy.get('large_community_list', []): -                    raise ConfigError(f'large-community-list {tmp} does not exist!') +                    raise ConfigError( +                        f'large-community-list {tmp} does not exist!')                  # Specified prefix-list must exist                  tmp = dict_search('match.ip.address.prefix_list', rule_config) @@ -147,49 +197,87 @@ def verify(policy):                      raise ConfigError(f'prefix-list {tmp} does not exist!')                  # Specified prefix-list must exist -                tmp = dict_search('match.ipv6.address.prefix_list', rule_config) +                tmp = dict_search('match.ipv6.address.prefix_list', +                                  rule_config)                  if tmp and tmp not in policy.get('prefix_list6', []):                      raise ConfigError(f'prefix-list6 {tmp} does not exist!') -                     +                  # Specified access_list6 in nexthop must exist -                tmp = dict_search('match.ipv6.nexthop.access_list', rule_config) +                tmp = dict_search('match.ipv6.nexthop.access_list', +                                  rule_config)                  if tmp and tmp not in policy.get('access_list6', []):                      raise ConfigError(f'access_list6 {tmp} does not exist!')                  # Specified prefix-list6 in nexthop must exist -                tmp = dict_search('match.ipv6.nexthop.prefix_list', rule_config) +                tmp = dict_search('match.ipv6.nexthop.prefix_list', +                                  rule_config)                  if tmp and tmp not in policy.get('prefix_list6', []):                      raise ConfigError(f'prefix-list6 {tmp} does not exist!') +                tmp = dict_search('set.community.delete', rule_config) +                if tmp and tmp not in policy.get('community_list', []): +                    raise ConfigError(f'community-list {tmp} does not exist!') + +                tmp = dict_search('set.large_community.delete', +                                  rule_config) +                if tmp and tmp not in policy.get('large_community_list', []): +                    raise ConfigError( +                        f'large-community-list {tmp} does not exist!') + +                if 'set' in rule_config: +                    rule_action = rule_config['set'] +                    if 'community' in rule_action: +                        if not community_action_compatibility( +                                rule_action['community']): +                            raise ConfigError( +                                f'Unexpected combination between action replace, add, delete or none in community') +                    if 'large_community' in rule_action: +                        if not community_action_compatibility( +                                rule_action['large_community']): +                            raise ConfigError( +                                f'Unexpected combination between action replace, add, delete or none in large-community') +                    if 'extcommunity' in rule_action: +                        if not extcommunity_action_compatibility( +                                rule_action['extcommunity']): +                            raise ConfigError( +                                f'Unexpected combination between none, rt, soo, bandwidth, bandwidth-non-transitive in extended-community')      # When routing protocols are active some use prefix-lists, route-maps etc.      # to apply the systems routing policy to the learned or redistributed routes.      # When the "routing policy" changes and policies, route-maps etc. are deleted,      # it is our responsibility to verify that the policy can not be deleted if it      # is used by any routing protocol      if 'protocols' in policy: -        for policy_type in ['access_list', 'access_list6', 'as_path_list', 'community_list', -                            'extcommunity_list', 'large_community_list', 'prefix_list', 'route_map']: +        for policy_type in ['access_list', 'access_list6', 'as_path_list', +                            'community_list', +                            'extcommunity_list', 'large_community_list', +                            'prefix_list', 'route_map']:              if policy_type in policy: -                for policy_name in list(set(routing_policy_find(policy_type, policy['protocols']))): +                for policy_name in list(set(routing_policy_find(policy_type, +                                                                policy[ +                                                                    'protocols']))):                      found = False                      if policy_name in policy[policy_type]:                          found = True                      # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related                      # list - we need to go the extra mile here and check both prefix-lists -                    if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in policy['prefix_list6']: +                    if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \ +                            policy['prefix_list6']:                          found = True                      if not found: -                        tmp = policy_type.replace('_','-') -                        raise ConfigError(f'Can not delete {tmp} "{policy_name}", still in use!') +                        tmp = policy_type.replace('_', '-') +                        raise ConfigError( +                            f'Can not delete {tmp} "{policy_name}", still in use!')      return None +  def generate(policy):      if not policy:          return None      policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy)      return None +  def apply(policy):      bgp_daemon = 'bgpd'      zebra_daemon = 'zebra' @@ -203,7 +291,8 @@ def apply(policy):      frr_cfg.modify_section(r'^bgp community-list .*')      frr_cfg.modify_section(r'^bgp extcommunity-list .*')      frr_cfg.modify_section(r'^bgp large-community-list .*') -    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', +                           remove_stop_mark=True)      if 'new_frr_config' in policy:          frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])      frr_cfg.commit_configuration(bgp_daemon) @@ -214,13 +303,15 @@ def apply(policy):      frr_cfg.modify_section(r'^ipv6 access-list .*')      frr_cfg.modify_section(r'^ip prefix-list .*')      frr_cfg.modify_section(r'^ipv6 prefix-list .*') -    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', +                           remove_stop_mark=True)      if 'new_frr_config' in policy:          frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])      frr_cfg.commit_configuration(zebra_daemon)      return None +  if __name__ == '__main__':      try:          c = get_config() diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index cd46cbcb4..ff568d470 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -19,6 +19,7 @@ import os  from sys import exit  from sys import argv +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configverify import verify_prefix_list @@ -100,6 +101,17 @@ def verify_remote_as(peer_config, bgp_config):      return None +def verify_afi(peer_config, bgp_config): +    if 'address_family' in peer_config: +        return True + +    if 'peer_group' in peer_config: +        peer_group_name = peer_config['peer_group'] +        tmp = dict_search(f'peer_group.{peer_group_name}.address_family', bgp_config) +        if tmp: return True + +    return False +  def verify(bgp):      if not bgp or 'deleted' in bgp:          if 'dependent_vrfs' in bgp: @@ -109,8 +121,8 @@ def verify(bgp):                                        'dependent VRF instance(s) exist!')          return None -    if 'local_as' not in bgp: -        raise ConfigError('BGP local-as number must be defined!') +    if 'system_as' not in bgp: +        raise ConfigError('BGP system-as number must be defined!')      # Common verification for both peer-group and neighbor statements      for neighbor in ['neighbor', 'peer_group']: @@ -135,8 +147,8 @@ def verify(bgp):                  # Neighbor local-as override can not be the same as the local-as                  # we use for this BGP instane!                  asn = list(peer_config['local_as'].keys())[0] -                if asn == bgp['local_as']: -                    raise ConfigError('Cannot have local-as same as BGP AS number') +                if asn == bgp['system_as']: +                    raise ConfigError('Cannot have local-as same as system-as number')                  # Neighbor AS specified for local-as and remote-as can not be the same                  if dict_search('remote_as', peer_config) == asn: @@ -147,6 +159,11 @@ def verify(bgp):              if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config:                  raise ConfigError('You can not set both ebgp-multihop and ttl-security hops') +            # interface and ebgp-multihop can't be used in the same configration +            if 'ebgp_multihop' in peer_config and 'interface' in peer_config: +                raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\ +                                  f'neighbor "{peer}"') +              # Check if neighbor has both override capability and strict capability match              # configured at the same time.              if 'override_capability' in peer_config and 'strict_capability_match' in peer_config: @@ -164,6 +181,9 @@ def verify(bgp):                  if not verify_remote_as(peer_config, bgp):                      raise ConfigError(f'Neighbor "{peer}" remote-as must be set!') +                if not verify_afi(peer_config, bgp): +                    Warning(f'BGP neighbor "{peer}" requires address-family!') +                  # Peer-group member cannot override remote-as of peer-group                  if 'peer_group' in peer_config:                      peer_group = peer_config['peer_group'] @@ -198,6 +218,12 @@ def verify(bgp):                      if 'source_interface' in peer_config['interface']:                          raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"') +            # Local-AS allowed only for EBGP peers +            if 'local_as' in peer_config: +                remote_as = verify_remote_as(peer_config, bgp) +                if remote_as == bgp['system_as']: +                    raise ConfigError(f'local-as configured for "{peer}", allowed only for eBGP peers!') +              for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec',                          'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec',                          'l2vpn_evpn']: @@ -258,12 +284,12 @@ def verify(bgp):                              verify_route_map(afi_config['route_map'][tmp], bgp)                  if 'route_reflector_client' in afi_config: -                    if 'remote_as' in peer_config and peer_config['remote_as'] != 'internal' and peer_config['remote_as'] != bgp['local_as']: +                    if 'remote_as' in peer_config and peer_config['remote_as'] != 'internal' and peer_config['remote_as'] != bgp['system_as']:                          raise ConfigError('route-reflector-client only supported for iBGP peers')                      else:                          if 'peer_group' in peer_config:                              peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp) -                            if peer_group_as != None and peer_group_as != 'internal' and peer_group_as != bgp['local_as']: +                            if peer_group_as != None and peer_group_as != 'internal' and peer_group_as != bgp['system_as']:                                  raise ConfigError('route-reflector-client only supported for iBGP peers')      # Throw an error if a peer group is not configured for allow range diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 5dafd26d0..cb8ea3be4 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -203,6 +203,28 @@ def verify(isis):          if list(set(global_range) & set(local_range)):              raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\                                f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') +         +    # Check for a blank or invalid value per prefix +    if dict_search('segment_routing.prefix', isis): +        for prefix, prefix_config in isis['segment_routing']['prefix'].items(): +            if 'absolute' in prefix_config: +                if prefix_config['absolute'].get('value') is None: +                    raise ConfigError(f'Segment routing prefix {prefix} absolute value cannot be blank.') +            elif 'index' in prefix_config: +                if prefix_config['index'].get('value') is None: +                    raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.') + +    # Check for explicit-null and no-php-flag configured at the same time per prefix +    if dict_search('segment_routing.prefix', isis): +        for prefix, prefix_config in isis['segment_routing']['prefix'].items(): +            if 'absolute' in prefix_config: +                if ("explicit_null" in prefix_config['absolute']) and ("no_php_flag" in prefix_config['absolute']):  +                    raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ +                                      f'and no-php-flag configured at the same time.') +            elif 'index' in prefix_config: +                if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): +                    raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ +                                      f'and no-php-flag configured at the same time.')      return None 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/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 56939955d..d28ced4fd 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# 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 @@ -14,10 +14,10 @@  # 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 vyos.config import Config  from vyos.configdict import node_changed -from vyos.firewall import find_nftables_rule -from vyos.firewall import remove_nftables_rule  from vyos.template import render  from vyos.util import process_named_running  from vyos.util import run @@ -26,6 +26,7 @@ from vyos import airbag  airbag.enable()  opennhrp_conf = '/run/opennhrp/opennhrp.conf' +nhrp_nftables_conf = '/run/nftables_nhrp.conf'  def get_config(config=None):      if config: @@ -81,36 +82,26 @@ def verify(nhrp):                  for map_name, map_conf in nhrp_conf['dynamic_map'].items():                      if 'nbma_domain_name' not in map_conf:                          raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') - -            if 'cisco_authentication' in nhrp_conf: -                if len(nhrp_conf['cisco_authentication']) > 8: -                    raise ConfigError('Maximum length of the secret is 8 characters!') -      return None  def generate(nhrp): +    if not os.path.exists(nhrp_nftables_conf): +        nhrp['first_install'] = True +      render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp) +    render(nhrp_nftables_conf, 'nhrp/nftables.conf.j2', nhrp)      return None  def apply(nhrp): -    if 'tunnel' in nhrp: -        for tunnel, tunnel_conf in nhrp['tunnel'].items(): -            if 'source_address' in nhrp['if_tunnel'][tunnel]: -                comment = f'VYOS_NHRP_{tunnel}' -                source_address = nhrp['if_tunnel'][tunnel]['source_address'] - -                rule_handle = find_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', ['ip protocol gre', f'ip saddr {source_address}', 'ip daddr 224.0.0.0/4']) -                if not rule_handle: -                    run(f'sudo nft insert rule ip filter VYOS_FW_OUTPUT ip protocol gre ip saddr {source_address} ip daddr 224.0.0.0/4 counter drop comment "{comment}"') - -    for tunnel in nhrp['del_tunnels']: -        comment = f'VYOS_NHRP_{tunnel}' -        rule_handle = find_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', [f'comment "{comment}"']) -        if rule_handle: -            remove_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', rule_handle) +    nft_rc = run(f'nft -f {nhrp_nftables_conf}') +    if nft_rc != 0: +        raise ConfigError('Failed to apply NHRP tunnel firewall rules')      action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' -    run(f'systemctl {action} opennhrp.service') +    service_rc = run(f'systemctl {action} opennhrp.service') +    if service_rc != 0: +        raise ConfigError(f'Failed to {action} the NHRP service') +      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 5b4874ba2..0582d32be 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -198,6 +198,58 @@ def verify(ospf):                  if 'master' not in tmp or tmp['master'] != vrf:                      raise ConfigError(f'Interface {interface} is not a member of VRF {vrf}!') +    # Segment routing checks +    if dict_search('segment_routing.global_block', ospf): +        g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf) +        g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf) + +        # If segment routing global block high or low value is blank, throw error +        if not (g_low_label_value or g_high_label_value): +            raise ConfigError('Segment routing global-block requires both low and high value!') + +        # If segment routing global block low value is higher than the high value, throw error +        if int(g_low_label_value) > int(g_high_label_value): +            raise ConfigError('Segment routing global-block low value must be lower than high value') + +    if dict_search('segment_routing.local_block', ospf): +        if dict_search('segment_routing.global_block', ospf) == None: +            raise ConfigError('Segment routing local-block requires global-block to be configured!') + +        l_high_label_value = dict_search('segment_routing.local_block.high_label_value', ospf) +        l_low_label_value = dict_search('segment_routing.local_block.low_label_value', ospf) + +        # If segment routing local-block high or low value is blank, throw error +        if not (l_low_label_value or l_high_label_value): +            raise ConfigError('Segment routing local-block requires both high and low value!') + +        # If segment routing local-block low value is higher than the high value, throw error +        if int(l_low_label_value) > int(l_high_label_value): +            raise ConfigError('Segment routing local-block low value must be lower than high value') + +        # local-block most live outside global block +        global_range = range(int(g_low_label_value), int(g_high_label_value) +1) +        local_range  = range(int(l_low_label_value), int(l_high_label_value) +1) + +        # Check for overlapping ranges +        if list(set(global_range) & set(local_range)): +            raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ +                              f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') +         +    # Check for a blank or invalid value per prefix +    if dict_search('segment_routing.prefix', ospf): +        for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): +            if 'index' in prefix_config: +                if prefix_config['index'].get('value') is None: +                    raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.') + +    # Check for explicit-null and no-php-flag configured at the same time per prefix +    if dict_search('segment_routing.prefix', ospf): +        for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): +            if 'index' in prefix_config: +                if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): +                    raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ +                                      f'and no-php-flag configured at the same time.') +      return None  def generate(ospf): diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py index a2e411e49..ee4fe42ab 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -61,6 +61,7 @@ def verify(proxy):      if not proxy:          return None +    aliases = []      processes = process_iter(['name', 'cmdline'])      if 'device' in proxy:          for device, device_config in proxy['device'].items(): @@ -75,6 +76,12 @@ def verify(proxy):              if 'ssh' in device_config and 'port' not in device_config['ssh']:                  raise ConfigError(f'Port "{device}" requires SSH port to be set!') +            if 'alias' in device_config: +                if device_config['alias'] in aliases: +                    raise ConfigError("Console aliases must be unique") +                else: +                    aliases.append(device_config['alias']) +      return None  def generate(proxy): diff --git a/src/conf_mode/service_event_handler.py b/src/conf_mode/service_event_handler.py new file mode 100755 index 000000000..5440d1056 --- /dev/null +++ b/src/conf_mode/service_event_handler.py @@ -0,0 +1,91 @@ +#!/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.util import call, dict_search +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +service_name = 'vyos-event-handler' +service_conf = Path(f'/run/{service_name}.conf') + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['service', 'event-handler', 'event'] +    config = conf.get_config_dict(base, +                                  get_first_key=True, +                                  no_tag_node_value_mangle=True) + +    return config + + +def verify(config): +    # bail out early - looks like removal from running config +    if not config: +        return None + +    for name, event_config in config.items(): +        if not dict_search('filter.pattern', event_config) or not dict_search( +                'script.path', event_config): +            raise ConfigError( +                'Event-handler: both pattern and script path items are mandatory' +            ) + +        if dict_search('script.environment.message', event_config): +            raise ConfigError( +                'Event-handler: "message" environment variable is reserved for log message text' +            ) + + +def generate(config): +    if not config: +        # Remove old config and return +        service_conf.unlink(missing_ok=True) +        return None + +    # Write configuration file +    conf_json = json.dumps(config, indent=4) +    service_conf.write_text(conf_json) + +    return None + + +def apply(config): +    if config: +        call(f'systemctl restart {service_name}.service') +    else: +        call(f'systemctl stop {service_name}.service') + + +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/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index ae7e582ec..c58f8db9a 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -19,14 +19,17 @@ import os  from sys import exit  from vyos.config import Config -from vyos import ConfigError -from vyos.util import call +from vyos.configdict import dict_merge  from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError  from vyos import airbag  airbag.enable() -config_file = r'/etc/fastnetmon.conf' -networks_list = r'/etc/networks_list' +config_file = r'/run/fastnetmon/fastnetmon.conf' +networks_list = r'/run/fastnetmon/networks_list' +excluded_networks_list = r'/run/fastnetmon/excluded_networks_list'  def get_config(config=None):      if config: @@ -34,50 +37,55 @@ def get_config(config=None):      else:          conf = Config()      base = ['service', 'ids', 'ddos-protection'] +    if not conf.exists(base): +        return None +      fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    # We have gathered the dict representation of the CLI, but there are default +    # options which we need to update into the dictionary retrived. +    default_values = defaults(base) +    fastnetmon = dict_merge(default_values, fastnetmon) +      return fastnetmon  def verify(fastnetmon):      if not fastnetmon:          return None -    if not "mode" in fastnetmon: -        raise ConfigError('ddos-protection mode is mandatory!') - -    if not "network" in fastnetmon: -        raise ConfigError('Required define network!') +    if 'mode' not in fastnetmon: +        raise ConfigError('Specify operating mode!') -    if not "listen_interface" in fastnetmon: -        raise ConfigError('Define listen-interface is mandatory!') +    if 'listen_interface' not in fastnetmon: +        raise ConfigError('Specify interface(s) for traffic capture') -    if "alert_script" in fastnetmon: -        if os.path.isfile(fastnetmon["alert_script"]): +    if 'alert_script' in fastnetmon: +        if os.path.isfile(fastnetmon['alert_script']):              # Check script permissions -            if not os.access(fastnetmon["alert_script"], os.X_OK): -                raise ConfigError('Script {0} does not have permissions for execution'.format(fastnetmon["alert_script"])) +            if not os.access(fastnetmon['alert_script'], os.X_OK): +                raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script']))          else: -           raise ConfigError('File {0} does not exists!'.format(fastnetmon["alert_script"])) +           raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon))  def generate(fastnetmon):      if not fastnetmon: -        if os.path.isfile(config_file): -            os.unlink(config_file) -        if os.path.isfile(networks_list): -            os.unlink(networks_list) +        for file in [config_file, networks_list]: +            if os.path.isfile(file): +                os.unlink(file) -        return +        return None      render(config_file, 'ids/fastnetmon.j2', fastnetmon)      render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) - +    render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon)      return None  def apply(fastnetmon): +    systemd_service = 'fastnetmon.service'      if not fastnetmon:          # Stop fastnetmon service if removed -        call('systemctl stop fastnetmon.service') +        call(f'systemctl stop {systemd_service}')      else: -        call('systemctl restart fastnetmon.service') +        call(f'systemctl reload-or-restart {systemd_service}')      return None diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index 559d1bcd5..e9afd6a55 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -15,252 +15,34 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os -import re -from copy import deepcopy -from stat import S_IRUSR, S_IWUSR, S_IRGRP  from sys import exit  from vyos.config import Config +from vyos.configdict import get_accel_dict +from vyos.configverify import verify_accel_ppp_base_service +from vyos.configverify import verify_interface_exists  from vyos.template import render -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 -from vyos.util import call, get_half_cpus +from vyos.util import call +from vyos.util import dict_search  from vyos import ConfigError -  from vyos import airbag  airbag.enable()  ipoe_conf = '/run/accel-pppd/ipoe.conf'  ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets' -default_config_data = { -    'auth_mode': 'local', -    'auth_interfaces': [], -    'chap_secrets_file': ipoe_chap_secrets, # used in Jinja2 template -    'interfaces': [], -    'dnsv4': [], -    'dnsv6': [], -    'client_named_ip_pool': [], -    'client_ipv6_pool': [], -    'client_ipv6_delegate_prefix': [], -    'radius_server': [], -    'radius_acct_inter_jitter': '', -    'radius_acct_tmo': '3', -    'radius_max_try': '3', -    'radius_timeout': '3', -    'radius_nas_id': '', -    'radius_nas_ip': '', -    'radius_source_address': '', -    'radius_shaper_attr': '', -    'radius_shaper_vendor': '', -    'radius_dynamic_author': '', -    'thread_cnt': get_half_cpus() -} -  def get_config(config=None):      if config:          conf = config      else:          conf = Config() -    base_path = ['service', 'ipoe-server'] -    if not conf.exists(base_path): +    base = ['service', 'ipoe-server'] +    if not conf.exists(base):          return None -    conf.set_level(base_path) -    ipoe = deepcopy(default_config_data) - -    for interface in conf.list_nodes(['interface']): -        tmp  = { -            'mode': 'L2', -            'name': interface, -            'shared': '1', -            # may need a config option, can be dhcpv4 or up for unclassified pkts -            'sess_start': 'dhcpv4', -            'range': None, -            'ifcfg': '1', -            'vlan_mon': [] -        } - -        conf.set_level(base_path + ['interface', interface]) - -        if conf.exists(['network-mode']): -            tmp['mode'] = conf.return_value(['network-mode']) - -        if conf.exists(['network']): -            mode = conf.return_value(['network']) -            if mode == 'vlan': -                tmp['shared'] = '0' - -                if conf.exists(['vlan-id']): -                    tmp['vlan_mon'] += conf.return_values(['vlan-id']) - -                if conf.exists(['vlan-range']): -                    tmp['vlan_mon'] += conf.return_values(['vlan-range']) - -        if conf.exists(['client-subnet']): -            tmp['range'] = conf.return_value(['client-subnet']) - -        ipoe['interfaces'].append(tmp) - -    conf.set_level(base_path) - -    if conf.exists(['name-server']): -        for name_server in conf.return_values(['name-server']): -            if is_ipv4(name_server): -                ipoe['dnsv4'].append(name_server) -            else: -                ipoe['dnsv6'].append(name_server) - -    if conf.exists(['authentication', 'mode']): -        ipoe['auth_mode'] = conf.return_value(['authentication', 'mode']) - -    if conf.exists(['authentication', 'interface']): -        for interface in conf.list_nodes(['authentication', 'interface']): -            tmp = { -                'name': interface, -                'mac': [] -            } -            for mac in conf.list_nodes(['authentication', 'interface', interface, 'mac-address']): -                client = { -                    'address': mac, -                    'rate_download': '', -                    'rate_upload': '', -                    'vlan_id': '' -                } -                conf.set_level(base_path + ['authentication', 'interface', interface, 'mac-address', mac]) - -                if conf.exists(['rate-limit', 'download']): -                    client['rate_download'] = conf.return_value(['rate-limit', 'download']) - -                if conf.exists(['rate-limit', 'upload']): -                    client['rate_upload'] = conf.return_value(['rate-limit', 'upload']) - -                if conf.exists(['vlan-id']): -                    client['vlan'] = conf.return_value(['vlan-id']) - -                tmp['mac'].append(client) - -            ipoe['auth_interfaces'].append(tmp) - -    conf.set_level(base_path) - -    # -    # authentication mode radius servers and settings -    if conf.exists(['authentication', 'mode', 'radius']): -        for server in conf.list_nodes(['authentication', 'radius', 'server']): -            radius = { -                'server' : server, -                'key' : '', -                'fail_time' : 0, -                'port' : '1812', -                'acct_port' : '1813' -            } - -            conf.set_level(base_path + ['authentication', 'radius', 'server', server]) - -            if conf.exists(['fail-time']): -                radius['fail_time'] = conf.return_value(['fail-time']) - -            if conf.exists(['port']): -                radius['port'] = conf.return_value(['port']) - -            if conf.exists(['acct-port']): -                radius['acct_port'] = conf.return_value(['acct-port']) - -            if conf.exists(['key']): -                radius['key'] = conf.return_value(['key']) - -            if not conf.exists(['disable']): -                ipoe['radius_server'].append(radius) - -    # -    # advanced radius-setting -    conf.set_level(base_path + ['authentication', 'radius']) - -    if conf.exists(['acct-interim-jitter']): -        ipoe['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) - -    if conf.exists(['acct-timeout']): -        ipoe['radius_acct_tmo'] = conf.return_value(['acct-timeout']) - -    if conf.exists(['max-try']): -        ipoe['radius_max_try'] = conf.return_value(['max-try']) - -    if conf.exists(['timeout']): -        ipoe['radius_timeout'] = conf.return_value(['timeout']) - -    if conf.exists(['nas-identifier']): -        ipoe['radius_nas_id'] = conf.return_value(['nas-identifier']) - -    if conf.exists(['nas-ip-address']): -        ipoe['radius_nas_ip'] = conf.return_value(['nas-ip-address']) - -    if conf.exists(['source-address']): -        ipoe['radius_source_address'] = conf.return_value(['source-address']) - -    # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) -    if conf.exists(['dynamic-author']): -        dae = { -            'port' : '', -            'server' : '', -            'key' : '' -        } - -        if conf.exists(['dynamic-author', 'server']): -            dae['server'] = conf.return_value(['dynamic-author', 'server']) - -        if conf.exists(['dynamic-author', 'port']): -            dae['port'] = conf.return_value(['dynamic-author', 'port']) - -        if conf.exists(['dynamic-author', 'key']): -            dae['key'] = conf.return_value(['dynamic-author', 'key']) - -        ipoe['radius_dynamic_author'] = dae - - -    conf.set_level(base_path) -    # Named client-ip-pool -    if conf.exists(['client-ip-pool', 'name']): -        for name in conf.list_nodes(['client-ip-pool', 'name']): -            tmp = { -                'name': name, -                'gateway_address': '', -                'subnet': '' -            } - -            if conf.exists(['client-ip-pool', 'name', name, 'gateway-address']): -                tmp['gateway_address'] += conf.return_value(['client-ip-pool', 'name', name, 'gateway-address']) -            if conf.exists(['client-ip-pool', 'name', name, 'subnet']): -                tmp['subnet'] += conf.return_value(['client-ip-pool', 'name', name, 'subnet']) - -            ipoe['client_named_ip_pool'].append(tmp) - -    if conf.exists(['client-ipv6-pool', 'prefix']): -        for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): -            tmp = { -                'prefix': prefix, -                'mask': '64' -            } - -            if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): -                tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) - -            ipoe['client_ipv6_pool'].append(tmp) - - -    if conf.exists(['client-ipv6-pool', 'delegate']): -        for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): -            tmp = { -                'prefix': prefix, -                'mask': '' -            } - -            if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): -                tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) - -            ipoe['client_ipv6_delegate_prefix'].append(tmp) - +    # retrieve common dictionary keys +    ipoe = get_accel_dict(conf, base, ipoe_chap_secrets)      return ipoe @@ -268,26 +50,17 @@ def verify(ipoe):      if not ipoe:          return None -    if not ipoe['interfaces']: +    if 'interface' not in ipoe:          raise ConfigError('No IPoE interface configured') -    if len(ipoe['dnsv4']) > 2: -        raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') - -    if len(ipoe['dnsv6']) > 3: -        raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') - -    if ipoe['auth_mode'] == 'radius': -        if len(ipoe['radius_server']) == 0: -            raise ConfigError('RADIUS authentication requires at least one server') +    for interface in ipoe['interface']: +        verify_interface_exists(interface) -        for radius in ipoe['radius_server']: -            if not radius['key']: -                server = radius['server'] -                raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') +    #verify_accel_ppp_base_service(ipoe, local_users=False) -    if ipoe['client_ipv6_delegate_prefix'] and not ipoe['client_ipv6_pool']: -        raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!') +    if 'client_ipv6_pool' in ipoe: +        if 'delegate' in ipoe['client_ipv6_pool'] and 'prefix' not in ipoe['client_ipv6_pool']: +            raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!')      return None @@ -298,27 +71,23 @@ def generate(ipoe):      render(ipoe_conf, 'accel-ppp/ipoe.config.j2', ipoe) -    if ipoe['auth_mode'] == 'local': -        render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', ipoe) -        os.chmod(ipoe_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) - -    else: -        if os.path.exists(ipoe_chap_secrets): -             os.unlink(ipoe_chap_secrets) - +    if dict_search('authentication.mode', ipoe) == 'local': +        render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', +               ipoe, permission=0o640)      return None  def apply(ipoe): +    systemd_service = 'accel-ppp@ipoe.service'      if ipoe == None: -        call('systemctl stop accel-ppp@ipoe.service') +        call(f'systemctl stop {systemd_service}')          for file in [ipoe_conf, ipoe_chap_secrets]:              if os.path.exists(file):                  os.unlink(file)          return None -    call('systemctl restart accel-ppp@ipoe.service') +    call(f'systemctl reload-or-restart {systemd_service}')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index daf75d740..aafece47a 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -22,6 +22,8 @@ from shutil import rmtree  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf  from vyos.ifconfig import Section  from vyos.template import render  from vyos.util import call @@ -32,40 +34,19 @@ from vyos import ConfigError  from vyos import airbag  airbag.enable() - -base_dir = '/run/telegraf'  cache_dir = f'/etc/telegraf/.cache' -config_telegraf = f'{base_dir}/vyos-telegraf.conf' +config_telegraf = f'/run/telegraf/telegraf.conf'  custom_scripts_dir = '/etc/telegraf/custom_scripts'  syslog_telegraf = '/etc/rsyslog.d/50-telegraf.conf' -systemd_telegraf_service = '/etc/systemd/system/vyos-telegraf.service' -systemd_telegraf_override_dir = '/etc/systemd/system/vyos-telegraf.service.d' -systemd_override = f'{systemd_telegraf_override_dir}/10-override.conf' - - -def get_interfaces(type='', vlan=True): -    """ -    Get interfaces -    get_interfaces() -    ['dum0', 'eth0', 'eth1', 'eth1.5', 'lo', 'tun0'] - -    get_interfaces("dummy") -    ['dum0'] -    """ -    interfaces = [] -    ifaces = Section.interfaces(type) -    for iface in ifaces: -        if vlan == False and '.' in iface: -            continue -        interfaces.append(iface) - -    return interfaces +systemd_override = '/etc/systemd/system/telegraf.service.d/10-override.conf'  def get_nft_filter_chains(): -    """ -    Get nft chains for table filter -    """ -    nft = cmd('nft --json list table ip filter') +    """ Get nft chains for table filter """ +    try: +        nft = cmd('nft --json list table ip vyos_filter') +    except Exception: +        print('nft table ip vyos_filter not found') +        return []      nft = json.loads(nft)      chain_list = [] @@ -76,9 +57,7 @@ def get_nft_filter_chains():      return chain_list -  def get_config(config=None): -      if config:          conf = config      else: @@ -87,8 +66,12 @@ def get_config(config=None):      if not conf.exists(base):          return None -    monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, -                                    no_tag_node_value_mangle=True) +    monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), +                                      get_first_key=True, +                                      no_tag_node_value_mangle=True) + +    tmp = is_node_changed(conf, base + ['vrf']) +    if tmp: monitoring.update({'restart_required': {}})      # We have gathered the dict representation of the CLI, but there are default      # options which we need to update into the dictionary retrived. @@ -96,13 +79,9 @@ def get_config(config=None):      monitoring = dict_merge(default_values, monitoring)      monitoring['custom_scripts_dir'] = custom_scripts_dir -    monitoring['interfaces_ethernet'] = get_interfaces('ethernet', vlan=False) +    monitoring['interfaces_ethernet'] = Section.interfaces('ethernet', vlan=False)      monitoring['nft_chains'] = get_nft_filter_chains() -    if 'authentication' in monitoring or \ -       'url' in monitoring: -        monitoring['influxdb_configured'] = True -      # Redefine azure group-metrics 'single-table' and 'table-per-metric'      if 'azure_data_explorer' in monitoring:          if 'single-table' in monitoring['azure_data_explorer']['group_metrics']: @@ -119,6 +98,9 @@ def get_config(config=None):      # Ignore default XML values if config doesn't exists      # Delete key from dict +    if not conf.exists(base + ['influxdb']): +        del monitoring['influxdb'] +      if not conf.exists(base + ['prometheus-client']):          del monitoring['prometheus_client'] @@ -132,14 +114,17 @@ def verify(monitoring):      if not monitoring:          return None -    if 'influxdb_configured' in monitoring: -        if 'authentication' not in monitoring or \ -           'organization' not in monitoring['authentication'] or \ -           'token' not in monitoring['authentication']: -            raise ConfigError(f'Authentication "organization and token" are mandatory!') +    verify_vrf(monitoring) -        if 'url' not in monitoring: -            raise ConfigError(f'Monitoring "url" is mandatory!') +    # Verify influxdb +    if 'influxdb' in monitoring: +        if 'authentication' not in monitoring['influxdb'] or \ +           'organization' not in monitoring['influxdb']['authentication'] or \ +           'token' not in monitoring['influxdb']['authentication']: +            raise ConfigError(f'influxdb authentication "organization and token" are mandatory!') + +        if 'url' not in monitoring['influxdb']: +            raise ConfigError(f'Monitoring influxdb "url" is mandatory!')      # Verify azure-data-explorer      if 'azure_data_explorer' in monitoring: @@ -173,7 +158,7 @@ def verify(monitoring):  def generate(monitoring):      if not monitoring:          # Delete config and systemd files -        config_files = [config_telegraf, systemd_telegraf_service, systemd_override, syslog_telegraf] +        config_files = [config_telegraf, systemd_override, syslog_telegraf]          for file in config_files:              if os.path.isfile(file):                  os.unlink(file) @@ -190,33 +175,34 @@ def generate(monitoring):      chown(cache_dir, 'telegraf', 'telegraf') -    # Create systemd override dir -    if not os.path.exists(systemd_telegraf_override_dir): -        os.mkdir(systemd_telegraf_override_dir) -      # Create custome scripts dir      if not os.path.exists(custom_scripts_dir):          os.mkdir(custom_scripts_dir)      # Render telegraf configuration and systemd override -    render(config_telegraf, 'monitoring/telegraf.j2', monitoring) -    render(systemd_telegraf_service, 'monitoring/systemd_vyos_telegraf_service.j2', monitoring) -    render(systemd_override, 'monitoring/override.conf.j2', monitoring, permission=0o640) -    render(syslog_telegraf, 'monitoring/syslog_telegraf.j2', monitoring) - -    chown(base_dir, 'telegraf', 'telegraf') +    render(config_telegraf, 'telegraf/telegraf.j2', monitoring, user='telegraf', group='telegraf') +    render(systemd_override, 'telegraf/override.conf.j2', monitoring) +    render(syslog_telegraf, 'telegraf/syslog_telegraf.j2', monitoring)      return None  def apply(monitoring):      # Reload systemd manager configuration +    systemd_service = 'telegraf.service'      call('systemctl daemon-reload') -    if monitoring: -        call('systemctl restart vyos-telegraf.service') -    else: -        call('systemctl stop vyos-telegraf.service') +    if not monitoring: +        call(f'systemctl stop {systemd_service}') +        return + +    # we need to restart the service if e.g. the VRF name changed +    systemd_action = 'reload-or-restart' +    if 'restart_required' in monitoring: +        systemd_action = 'restart' + +    call(f'systemctl {systemd_action} {systemd_service}') +      # Telegraf include custom rsyslog config changes -    call('systemctl restart rsyslog') +    call('systemctl reload-or-restart rsyslog')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 6086ef859..600ba4e92 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -20,15 +20,14 @@ 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  from vyos.util import call  from vyos.util import dict_search -from vyos.util import get_interface_config  from vyos import ConfigError  from vyos import airbag -from vyos.range_regex import range_to_regex -  airbag.enable()  pppoe_conf = r'/run/accel-pppd/pppoe.conf' @@ -45,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): @@ -54,15 +60,14 @@ def verify(pppoe):      verify_accel_ppp_base_service(pppoe)      if 'wins_server' in pppoe and len(pppoe['wins_server']) > 2: -        raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') +        raise ConfigError('Not more then two WINS name-servers can be configured')      if 'interface' not in pppoe:          raise ConfigError('At least one listen interface must be defined!')      # Check is interface exists in the system -    for iface in pppoe['interface']: -        if not get_interface_config(iface): -            raise ConfigError(f'Interface {iface} does not exist!') +    for interface in pppoe['interface']: +        verify_interface_exists(interface)      # local ippool and gateway settings config checks      if not (dict_search('client_ip_pool.subnet', pppoe) or @@ -81,35 +86,27 @@ def generate(pppoe):      if not pppoe:          return None -    # Generate special regex for dynamic interfaces -    for iface in pppoe['interface']: -        if 'vlan_range' in pppoe['interface'][iface]: -            pppoe['interface'][iface]['regex'] = [] -            for vlan_range in pppoe['interface'][iface]['vlan_range']: -                pppoe['interface'][iface]['regex'].append(range_to_regex(vlan_range)) -      render(pppoe_conf, 'accel-ppp/pppoe.config.j2', pppoe)      if dict_search('authentication.mode', pppoe) == 'local':          render(pppoe_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',                 pppoe, permission=0o640) -    else: -        if os.path.exists(pppoe_chap_secrets): -            os.unlink(pppoe_chap_secrets) -      return None  def apply(pppoe): +    systemd_service = 'accel-ppp@pppoe.service'      if not pppoe: -        call('systemctl stop accel-ppp@pppoe.service') +        call(f'systemctl stop {systemd_service}')          for file in [pppoe_conf, pppoe_chap_secrets]:              if os.path.exists(file):                  os.unlink(file) -          return None -    call('systemctl restart accel-ppp@pppoe.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_router-advert.py b/src/conf_mode/service_router-advert.py index 71b758399..1b8377a4a 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -17,7 +17,7 @@  import os  from sys import exit - +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.template import render @@ -79,21 +79,34 @@ def verify(rtradv):      if 'interface' not in rtradv:          return None -    for interface in rtradv['interface']: -        interface = rtradv['interface'][interface] +    for interface, interface_config in rtradv['interface'].items():          if 'prefix' in interface: -            for prefix in interface['prefix']: -                prefix = interface['prefix'][prefix] -                valid_lifetime = prefix['valid_lifetime'] +            for prefix, prefix_config in interface_config['prefix'].items(): +                valid_lifetime = prefix_config['valid_lifetime']                  if valid_lifetime == 'infinity':                      valid_lifetime = 4294967295 -                preferred_lifetime = prefix['preferred_lifetime'] +                preferred_lifetime = prefix_config['preferred_lifetime']                  if preferred_lifetime == 'infinity':                      preferred_lifetime = 4294967295 -                if not (int(valid_lifetime) > int(preferred_lifetime)): -                    raise ConfigError('Prefix valid-lifetime must be greater then preferred-lifetime') +                if not (int(valid_lifetime) >= int(preferred_lifetime)): +                    raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime') + +        if 'name_server_lifetime' in interface_config: +            # man page states: +            # The maximum duration how long the RDNSS entries are used for name +            # resolution. A value of 0 means the nameserver must no longer be +            # used. The value, if not 0, must be at least MaxRtrAdvInterval. To +            # ensure stale RDNSS info gets removed in a timely fashion, this +            # should not be greater than 2*MaxRtrAdvInterval. +            lifetime = int(interface_config['name_server_lifetime']) +            interval_max = int(interface_config['interval']['max']) +            if lifetime > 0: +                if lifetime < int(interval_max): +                    raise ConfigError(f'RDNSS lifetime must be at least "{interval_max}" seconds!') +                if lifetime > 2* interval_max: +                    Warning(f'RDNSS lifetime should not exceed "{2 * interval_max}" which is two times "interval max"!')      return None @@ -105,15 +118,16 @@ def generate(rtradv):      return None  def apply(rtradv): +    systemd_service = 'radvd.service'      if not rtradv:          # bail out early - looks like removal from running config -        call('systemctl stop radvd.service') +        call(f'systemctl stop {systemd_service}')          if os.path.exists(config_file):              os.unlink(config_file)          return None -    call('systemctl restart radvd.service') +    call(f'systemctl reload-or-restart {systemd_service}')      return None diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py index 36f3e18a7..c798fd515 100755 --- a/src/conf_mode/service_upnp.py +++ b/src/conf_mode/service_upnp.py @@ -24,8 +24,6 @@ from ipaddress import IPv6Network  from vyos.config import Config  from vyos.configdict import dict_merge -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_vrf  from vyos.util import call  from vyos.template import render  from vyos.template import is_ipv4 @@ -113,19 +111,28 @@ def verify(upnpd):      listen_dev = []      system_addrs_cidr = get_all_interface_addr(True, [], [netifaces.AF_INET, netifaces.AF_INET6])      system_addrs = get_all_interface_addr(False, [], [netifaces.AF_INET, netifaces.AF_INET6]) +    if 'listen' not in upnpd: +        raise ConfigError(f'Listen address or interface is required!')      for listen_if_or_addr in upnpd['listen']:          if listen_if_or_addr not in netifaces.interfaces():              listen_dev.append(listen_if_or_addr) -        if (listen_if_or_addr not in system_addrs) and (listen_if_or_addr not in system_addrs_cidr) and (listen_if_or_addr not in netifaces.interfaces()): +        if (listen_if_or_addr not in system_addrs) and (listen_if_or_addr not in system_addrs_cidr) and \ +                (listen_if_or_addr not in netifaces.interfaces()):              if is_ipv4(listen_if_or_addr) and IPv4Network(listen_if_or_addr).is_multicast: -                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') +                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' +                                  f'to listen on. It is not an interface address nor a multicast address!')              if is_ipv6(listen_if_or_addr) and IPv6Network(listen_if_or_addr).is_multicast: -                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') +                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' +                                  f'to listen on. It is not an interface address nor a multicast address!')      system_listening_dev_addrs_cidr = get_all_interface_addr(True, listen_dev, [netifaces.AF_INET6])      system_listening_dev_addrs = get_all_interface_addr(False, listen_dev, [netifaces.AF_INET6])      for listen_if_or_addr in upnpd['listen']: -        if listen_if_or_addr not in netifaces.interfaces() and (listen_if_or_addr not in system_listening_dev_addrs_cidr) and (listen_if_or_addr not in system_listening_dev_addrs) and is_ipv6(listen_if_or_addr) and (not IPv6Network(listen_if_or_addr).is_multicast): +        if listen_if_or_addr not in netifaces.interfaces() and \ +                (listen_if_or_addr not in system_listening_dev_addrs_cidr) and \ +                (listen_if_or_addr not in system_listening_dev_addrs) and \ +                is_ipv6(listen_if_or_addr) and \ +                (not IPv6Network(listen_if_or_addr).is_multicast):              raise ConfigError(f'{listen_if_or_addr} must listen on the interface of the network card')  def generate(upnpd): diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 28669694b..8746cc701 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -22,6 +22,7 @@ from syslog import LOG_INFO  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed  from vyos.configverify import verify_vrf  from vyos.util import call  from vyos.template import render @@ -50,6 +51,10 @@ def get_config(config=None):          return None      ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + +    tmp = is_node_changed(conf, base + ['vrf']) +    if tmp: ssh.update({'restart_required': {}}) +      # 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) @@ -68,6 +73,9 @@ def verify(ssh):      if not ssh:          return None +    if 'rekey' in ssh and 'data' not in ssh['rekey']: +        raise ConfigError(f'Rekey data is required!') +      verify_vrf(ssh)      return None @@ -104,17 +112,25 @@ def generate(ssh):      return None  def apply(ssh): +    systemd_service_ssh = 'ssh.service' +    systemd_service_sshguard = 'sshguard.service'      if not ssh:          # SSH access is removed in the commit -        call('systemctl stop ssh.service') -        call('systemctl stop sshguard.service') +        call(f'systemctl stop {systemd_service_ssh}') +        call(f'systemctl stop {systemd_service_sshguard}')          return None +      if 'dynamic_protection' not in ssh: -        call('systemctl stop sshguard.service') +        call(f'systemctl stop {systemd_service_sshguard}')      else: -        call('systemctl restart sshguard.service') +        call(f'systemctl reload-or-restart {systemd_service_sshguard}') + +    # we need to restart the service if e.g. the VRF name changed +    systemd_action = 'reload-or-restart' +    if 'restart_required' in ssh: +        systemd_action = 'restart' -    call('systemctl restart ssh.service') +    call(f'systemctl {systemd_action} {systemd_service_ssh}')      return None  if __name__ == '__main__': diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 05fc3a97a..0c5063ed3 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system-ip.py @@ -64,6 +64,11 @@ def apply(opt):      value = '0' if (tmp != None) else '1'      write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) +    # enable/disable IPv4 directed broadcast forwarding +    tmp = dict_search('disable_directed_broadcast', opt) +    value = '0' if (tmp != None) else '1' +    write_file('/proc/sys/net/ipv4/conf/all/bc_forwarding', value) +      # configure multipath      tmp = dict_search('multipath.ignore_unreachable_nexthops', opt)      value = '1' if (tmp != None) else '0' diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index c717286ae..e26b81e3d 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -40,6 +40,7 @@ from vyos import ConfigError  from vyos import airbag  airbag.enable() +autologout_file = "/etc/profile.d/autologout.sh"  radius_config_file = "/etc/pam_radius_auth.conf"  def get_local_users(): @@ -203,6 +204,13 @@ def generate(login):          if os.path.isfile(radius_config_file):              os.unlink(radius_config_file) +    if 'timeout' in login: +        render(autologout_file, 'login/autologout.j2', login, +                   permission=0o755, user='root', group='root') +    else: +        if os.path.isfile(autologout_file): +            os.unlink(autologout_file) +      return None @@ -231,7 +239,7 @@ def apply(login):              if tmp: command += f" --home '{tmp}'"              else: command += f" --home '/home/{user}'" -            command += f' --groups frrvty,vyattacfg,sudo,adm,dip,disk {user}' +            command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}'              try:                  cmd(command) @@ -249,6 +257,15 @@ def apply(login):              except Exception as e:                  raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') +            # Generate 2FA/MFA One-Time-Pad configuration +            if dict_search('authentication.otp.key', user_config): +                render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', +                       user_config, permission=0o400, user=user, group='users') +            else: +                # delete configuration as it's not enabled for the user +                if os.path.exists(f'{home_dir}/.google_authenticator'): +                    os.remove(f'{home_dir}/.google_authenticator') +      if 'rm_users' in login:          for user in login['rm_users']:              try: diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py index a9d3bbe31..20132456c 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system-syslog.py @@ -52,8 +52,6 @@ def get_config(config=None):          {              'global': {                  'log-file': '/var/log/messages', -                'max-size': 262144, -                'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog',                  'selectors': '*.notice;local7.debug',                  'max-files': '5',                  'preserver_fqdn': False diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 86985d765..e922edc4e 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -16,6 +16,7 @@  import os  import re +from pathlib import Path  from vyos.config import Config  from vyos.configdict import dict_merge @@ -68,18 +69,15 @@ def verify(console):              # amount of connected devices. We will resolve the fixed device name              # to its dynamic device file - and create a new dict entry for it.              by_bus_device = f'{by_bus_dir}/{device}' -            if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): -                device = os.path.basename(os.readlink(by_bus_device)) - -        # If the device name still starts with usbXXX no matching tty was found -        # and it can not be used as a serial interface -        if device.startswith('usb'): -            raise ConfigError(f'Device {device} does not support beeing used as tty') +            # If the device name still starts with usbXXX no matching tty was found +            # and it can not be used as a serial interface +            if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device): +                raise ConfigError(f'Device {device} does not support beeing used as tty')      return None  def generate(console): -    base_dir = '/etc/systemd/system' +    base_dir = '/run/systemd/system'      # Remove all serial-getty configuration files in advance      for root, dirs, files in os.walk(base_dir):          for basename in files: @@ -90,7 +88,8 @@ def generate(console):      if not console or 'device' not in console:          return None -    for device, device_config in console['device'].items(): +    # replace keys in the config for ttyUSB items to use them in `apply()` later +    for device in console['device'].copy():          if device.startswith('usb'):              # It is much easiert to work with the native ttyUSBn name when using              # getty, but that name may change across reboots - depending on the @@ -98,9 +97,17 @@ def generate(console):              # to its dynamic device file - and create a new dict entry for it.              by_bus_device = f'{by_bus_dir}/{device}'              if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): -                device = os.path.basename(os.readlink(by_bus_device)) +                device_updated = os.path.basename(os.readlink(by_bus_device)) + +                # replace keys in the config to use them in `apply()` later +                console['device'][device_updated] = console['device'][device] +                del console['device'][device] +            else: +                raise ConfigError(f'Device {device} does not support beeing used as tty') +    for device, device_config in console['device'].items():          config_file = base_dir + f'/serial-getty@{device}.service' +        Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True)          getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service'          render(config_file, 'getty/serial-getty.service.j2', device_config) diff --git a/src/conf_mode/system_update_check.py b/src/conf_mode/system_update_check.py new file mode 100755 index 000000000..08ecfcb81 --- /dev/null +++ b/src/conf_mode/system_update_check.py @@ -0,0 +1,93 @@ +#!/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 json +import jmespath + +from pathlib import Path +from sys import exit + +from vyos.config import Config +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +base = ['system', 'update-check'] +service_name = 'vyos-system-update' +service_conf = Path(f'/run/{service_name}.conf') +motd_file = Path('/run/motd.d/10-vyos-update') + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    if not conf.exists(base): +        return None + +    config = conf.get_config_dict(base, key_mangling=('-', '_'), +                                  get_first_key=True, no_tag_node_value_mangle=True) + +    return config + + +def verify(config): +    # bail out early - looks like removal from running config +    if config is None: +        return + +    if 'url' not in config: +        raise ConfigError('URL is required!') + + +def generate(config): +    # bail out early - looks like removal from running config +    if config is None: +        # Remove old config and return +        service_conf.unlink(missing_ok=True) +        # MOTD used in /run/motd.d/10-update +        motd_file.unlink(missing_ok=True) +        return None + +    # Write configuration file +    conf_json = json.dumps(config, indent=4) +    service_conf.write_text(conf_json) + +    return None + + +def apply(config): +    if config: +        if 'auto_check' in config: +            call(f'systemctl restart {service_name}.service') +    else: +        call(f'systemctl stop {service_name}.service') + + +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/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index bad9cfbd8..b79e9847a 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -16,11 +16,13 @@  import ipaddress  import os +import re  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 @@ -116,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, @@ -264,7 +279,7 @@ def verify(ipsec):                      ike = ra_conf['ike_group']                      if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': -                        raise ConfigError('IPSec remote-access connections requires IKEv2!') +                        raise ConfigError('IPsec remote-access connections requires IKEv2!')                  else:                      raise ConfigError(f"Missing ike-group on {name} remote-access config") @@ -307,10 +322,10 @@ def verify(ipsec):                      for pool in ra_conf['pool']:                          if pool == 'dhcp':                              if dict_search('remote_access.dhcp.server', ipsec) == None: -                                raise ConfigError('IPSec DHCP server is not configured!') +                                raise ConfigError('IPsec DHCP server is not configured!')                          elif pool == 'radius':                              if dict_search('remote_access.radius.server', ipsec) == None: -                                raise ConfigError('IPSec RADIUS server is not configured!') +                                raise ConfigError('IPsec RADIUS server is not configured!')                              if dict_search('authentication.client_mode', ra_conf) != 'eap-radius':                                  raise ConfigError('RADIUS IP pool requires eap-radius client authentication!') @@ -348,6 +363,14 @@ def verify(ipsec):      if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']:          for peer, peer_conf in ipsec['site_to_site']['peer'].items():              has_default_esp = False +            # Peer name it is swanctl connection name and shouldn't contain dots or colons, T4118 +            if bool(re.search(':|\.', peer)): +                raise ConfigError(f'Incorrect peer name "{peer}" ' +                                  f'Peer name can contain alpha-numeric letters, hyphen and underscore') + +            if 'remote_address' not in peer_conf: +                print(f'You should set correct remote-address "peer {peer} remote-address x.x.x.x"\n') +              if 'default_esp_group' in peer_conf:                  has_default_esp = True                  if 'esp_group' not in ipsec or peer_conf['default_esp_group'] not in ipsec['esp_group']: @@ -416,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}'): @@ -595,13 +622,11 @@ def wait_for_vici_socket(timeout=5, sleep_interval=0.1):          sleep(sleep_interval)  def apply(ipsec): +    systemd_service = 'strongswan-starter.service'      if not ipsec: -        call('sudo ipsec stop') +        call(f'systemctl stop {systemd_service}')      else: -        call('sudo ipsec restart') -        call('sudo ipsec rereadall') -        call('sudo ipsec reload') - +        call(f'systemctl reload-or-restart {systemd_service}')          if wait_for_vici_socket():              call('sudo swanctl -q') 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 8e0e30bbf..af3c51efc 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -23,7 +23,9 @@ from vyos.pki import wrap_certificate  from vyos.pki import wrap_private_key  from vyos.template import render  from vyos.util import call +from vyos.util import check_port_availability  from vyos.util import is_systemd_service_running +from vyos.util import is_listen_port_bind_service  from vyos.util import dict_search  from vyos.xml import defaults  from vyos import ConfigError @@ -56,15 +58,16 @@ def get_config():      default_values = defaults(base)      ocserv = dict_merge(default_values, ocserv) -    # workaround a "know limitation" - https://phabricator.vyos.net/T2665 -    del ocserv['authentication']['local_users']['username']['otp'] -    if not ocserv["authentication"]["local_users"]["username"]: -        raise ConfigError('openconnect mode local required at least one user') -    default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] -    for user, params in ocserv['authentication']['local_users']['username'].items(): -        # Not every configuration requires OTP settings -        if ocserv['authentication']['local_users']['username'][user].get('otp'): -            ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp']) +    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"]: +            raise ConfigError('openconnect mode local required at least one user') +        default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] +        for user, params in ocserv['authentication']['local_users']['username'].items(): +            # Not every configuration requires OTP settings +            if ocserv['authentication']['local_users']['username'][user].get('otp'): +                ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp'])      if ocserv:          ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), @@ -75,6 +78,13 @@ def get_config():  def verify(ocserv):      if ocserv is None:          return None +    # Check if listen-ports not binded other services +    # It can be only listen by 'ocserv-main' +    for proto, port in ocserv.get('listen_ports').items(): +        if check_port_availability(ocserv['listen_address'], int(port), proto) is not True and \ +                not is_listen_port_bind_service(int(port), 'ocserv-main'): +            raise ConfigError(f'"{proto}" port "{port}" is used by another service') +      # Check authentication      if "authentication" in ocserv:          if "mode" in ocserv["authentication"]: @@ -147,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') @@ -237,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/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index db53463cf..2949ab290 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -20,12 +20,15 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_accel_dict +from vyos.configdict import dict_merge  from vyos.configverify import verify_accel_ppp_base_service  from vyos.pki import wrap_certificate  from vyos.pki import wrap_private_key  from vyos.template import render  from vyos.util import call +from vyos.util import check_port_availability  from vyos.util import dict_search +from vyos.util import is_listen_port_bind_service  from vyos.util import write_file  from vyos import ConfigError  from vyos import airbag @@ -50,10 +53,10 @@ def get_config(config=None):      # retrieve common dictionary keys      sstp = get_accel_dict(conf, base, sstp_chap_secrets) -      if sstp:          sstp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), -                                get_first_key=True, no_tag_node_value_mangle=True) +                                           get_first_key=True, +                                           no_tag_node_value_mangle=True)      return sstp @@ -61,6 +64,12 @@ def verify(sstp):      if not sstp:          return None +    port = sstp.get('port') +    proto = 'tcp' +    if check_port_availability('0.0.0.0', 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') +      verify_accel_ppp_base_service(sstp)      if 'client_ip_pool' not in sstp and 'client_ipv6_pool' not in sstp: @@ -121,7 +130,6 @@ def generate(sstp):      ca_cert_name = sstp['ssl']['ca_certificate']      pki_ca = sstp['pki']['ca'][ca_cert_name] -      write_file(cert_file_path, wrap_certificate(pki_cert['certificate']))      write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))      write_file(ca_cert_file_path, wrap_certificate(pki_ca['certificate'])) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 972d0289b..1b4156895 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -113,8 +113,14 @@ def verify(vrf):                                    f'static routes installed!')      if 'name' in vrf: +        reserved_names = ["add", "all", "broadcast", "default", "delete", "dev", "get", "inet", "mtu", "link", "type", +                          "vrf"]          table_ids = []          for name, config in vrf['name'].items(): +            # Reserved VRF names +            if name in reserved_names: +                raise ConfigError(f'VRF name "{name}" is reserved and connot be used!') +              # table id is mandatory              if 'table' not in config:                  raise ConfigError(f'VRF "{name}" table id is mandatory!') diff --git a/src/conf_mode/zone_policy.py b/src/conf_mode/zone_policy.py deleted file mode 100755 index 070a4deea..000000000 --- a/src/conf_mode/zone_policy.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021-2022 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import os - -from json import loads -from sys import exit - -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.template import render -from vyos.util import cmd -from vyos.util import dict_search_args -from vyos.util import run -from vyos.xml import defaults -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -nftables_conf = '/run/nftables_zone.conf' - -def get_config(config=None): -    if config: -        conf = config -    else: -        conf = Config() -    base = ['zone-policy'] -    zone_policy = conf.get_config_dict(base, key_mangling=('-', '_'), -                                       get_first_key=True, -                                       no_tag_node_value_mangle=True) - -    zone_policy['firewall'] = conf.get_config_dict(['firewall'], -                                                   key_mangling=('-', '_'), -                                                   get_first_key=True, -                                                   no_tag_node_value_mangle=True) - -    if 'zone' in zone_policy: -        # 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 + ['zone']) -        for zone in zone_policy['zone']: -            zone_policy['zone'][zone] = dict_merge(default_values, -                                                   zone_policy['zone'][zone]) - -    return zone_policy - -def verify(zone_policy): -    # bail out early - looks like removal from running config -    if not zone_policy: -        return None - -    local_zone = False -    interfaces = [] - -    if 'zone' in zone_policy: -        for zone, zone_conf in zone_policy['zone'].items(): -            if 'local_zone' not in zone_conf and 'interface' not in zone_conf: -                raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') - -            if 'local_zone' in zone_conf: -                if local_zone: -                    raise ConfigError('There cannot be multiple local zones') -                if 'interface' in zone_conf: -                    raise ConfigError('Local zone cannot have interfaces assigned') -                if 'intra_zone_filtering' in zone_conf: -                    raise ConfigError('Local zone cannot use intra-zone-filtering') -                local_zone = True - -            if 'interface' in zone_conf: -                found_duplicates = [intf for intf in zone_conf['interface'] if intf in interfaces] - -                if found_duplicates: -                    raise ConfigError(f'Interfaces cannot be assigned to multiple zones') - -                interfaces += zone_conf['interface'] - -            if 'intra_zone_filtering' in zone_conf: -                intra_zone = zone_conf['intra_zone_filtering'] - -                if len(intra_zone) > 1: -                    raise ConfigError('Only one intra-zone-filtering action must be specified') - -                if 'firewall' in intra_zone: -                    v4_name = dict_search_args(intra_zone, 'firewall', 'name') -                    if v4_name and not dict_search_args(zone_policy, 'firewall', 'name', v4_name): -                        raise ConfigError(f'Firewall name "{v4_name}" does not exist') - -                    v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6-name') -                    if v6_name and not dict_search_args(zone_policy, 'firewall', 'ipv6-name', v6_name): -                        raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - -                    if not v4_name and not v6_name: -                        raise ConfigError('No firewall names specified for intra-zone-filtering') - -            if 'from' in zone_conf: -                for from_zone, from_conf in zone_conf['from'].items(): -                    if from_zone not in zone_policy['zone']: -                        raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') - -                    v4_name = dict_search_args(from_conf, 'firewall', 'name') -                    if v4_name: -                        if 'name' not in zone_policy['firewall']: -                            raise ConfigError(f'Firewall name "{v4_name}" does not exist') - -                        if not dict_search_args(zone_policy, 'firewall', 'name', v4_name): -                            raise ConfigError(f'Firewall name "{v4_name}" does not exist') - -                    v6_name = dict_search_args(from_conf, 'firewall', 'v6_name') -                    if v6_name: -                        if 'ipv6_name' not in zone_policy['firewall']: -                            raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - -                        if not dict_search_args(zone_policy, 'firewall', 'ipv6_name', v6_name): -                            raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - -    return None - -def has_ipv4_fw(zone_conf): -    if 'from' not in zone_conf: -        return False -    zone_from = zone_conf['from'] -    return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'name')]) - -def has_ipv6_fw(zone_conf): -    if 'from' not in zone_conf: -        return False -    zone_from = zone_conf['from'] -    return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'ipv6_name')]) - -def get_local_from(zone_policy, local_zone_name): -    # Get all zone firewall names from the local zone -    out = {} -    for zone, zone_conf in zone_policy['zone'].items(): -        if zone == local_zone_name: -            continue -        if 'from' not in zone_conf: -            continue -        if local_zone_name in zone_conf['from']: -            out[zone] = zone_conf['from'][local_zone_name] -    return out - -def cleanup_commands(): -    commands = [] -    for table in ['ip filter', 'ip6 filter']: -        json_str = cmd(f'nft -j list table {table}') -        obj = loads(json_str) -        if 'nftables' not in obj: -            continue -        for item in obj['nftables']: -            if 'rule' in item: -                chain = item['rule']['chain'] -                handle = item['rule']['handle'] -                if 'expr' not in item['rule']: -                    continue -                for expr in item['rule']['expr']: -                    target = dict_search_args(expr, 'jump', 'target') -                    if not target: -                        continue -                    if target.startswith("VZONE") or target.startswith("VYOS_STATE_POLICY"): -                        commands.append(f'delete rule {table} {chain} handle {handle}') -        for item in obj['nftables']: -            if 'chain' in item: -                if item['chain']['name'].startswith("VZONE"): -                    chain = item['chain']['name'] -                    commands.append(f'delete chain {table} {chain}') -    return commands - -def generate(zone_policy): -    data = zone_policy or {} - -    if os.path.exists(nftables_conf): # Check to see if we've run before -        data['cleanup_commands'] = cleanup_commands() - -    if 'zone' in data: -        for zone, zone_conf in data['zone'].items(): -            zone_conf['ipv4'] = has_ipv4_fw(zone_conf) -            zone_conf['ipv6'] = has_ipv6_fw(zone_conf) - -            if 'local_zone' in zone_conf: -                zone_conf['from_local'] = get_local_from(data, zone) - -    render(nftables_conf, 'zone_policy/nftables.j2', data) -    return None - -def apply(zone_policy): -    install_result = run(f'nft -f {nftables_conf}') -    if install_result != 0: -        raise ConfigError('Failed to apply zone-policy') - -    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/etc/cron.d/vyos-geoip b/src/etc/cron.d/vyos-geoip new file mode 100644 index 000000000..9bb38a850 --- /dev/null +++ b/src/etc/cron.d/vyos-geoip @@ -0,0 +1 @@ +30 4 * * 1 root sg vyattacfg "/usr/libexec/vyos/geoip-update.py --force" >/tmp/geoip-update.log 2>&1 diff --git a/src/etc/cron.hourly/vyos-logrotate-hourly b/src/etc/cron.hourly/vyos-logrotate-hourly deleted file mode 100755 index f4f56a9c2..000000000 --- a/src/etc/cron.hourly/vyos-logrotate-hourly +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -test -x /usr/sbin/logrotate || exit 0 -/usr/sbin/logrotate /etc/logrotate.conf 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/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py index f7487ee5f..bf25a7331 100755 --- a/src/etc/opennhrp/opennhrp-script.py +++ b/src/etc/opennhrp/opennhrp-script.py @@ -14,114 +14,366 @@  # You should have received a copy of the GNU General Public License  # along with this program.  If not, see <http://www.gnu.org/licenses/>. -from pprint import pprint  import os  import re  import sys  import vici +from json import loads +from pathlib import Path + +from vyos.logger import getLogger  from vyos.util import cmd  from vyos.util import process_named_running -NHRP_CONFIG="/run/opennhrp/opennhrp.conf" +NHRP_CONFIG: str = '/run/opennhrp/opennhrp.conf' + + +def vici_get_ipsec_uniqueid(conn: str, src_nbma: str, +                            dst_nbma: str) -> list[str]: +    """ Find and return IKE SAs by src nbma and dst nbma -def parse_type_ipsec(interface): -    with open(NHRP_CONFIG, 'r') as f: -        lines = f.readlines() -        match = rf'^interface {interface} #(hub|spoke)(?:\s([\w-]+))?$' -        for line in lines: -            m = re.match(match, line) -            if m: -                return m[1], m[2] -    return None, None +    Args: +        conn (str): a connection name +        src_nbma (str): an IP address of NBMA source +        dst_nbma (str): an IP address of NBMA destination + +    Returns: +        list: a list of IKE connections that match a criteria +    """ +    if not conn or not src_nbma or not dst_nbma: +        logger.error( +            f'Incomplete input data for resolving IKE unique ids: ' +            f'conn: {conn}, src_nbma: {src_nbma}, dst_nbma: {dst_nbma}') +        return [] + +    try: +        logger.info( +            f'Resolving IKE unique ids for: conn: {conn}, ' +            f'src_nbma: {src_nbma}, dst_nbma: {dst_nbma}') +        session: vici.Session = vici.Session() +        list_ikeid: list[str] = [] +        list_sa = session.list_sas({'ike': conn}) +        for sa in list_sa: +            if sa[conn]['local-host'].decode('ascii') == src_nbma \ +                    and sa[conn]['remote-host'].decode('ascii') == dst_nbma: +                list_ikeid.append(sa[conn]['uniqueid'].decode('ascii')) +        return list_ikeid +    except Exception as err: +        logger.error(f'Unable to find unique ids for IKE: {err}') +        return [] + + +def vici_ike_terminate(list_ikeid: list[str]) -> bool: +    """Terminating IKE SAs by list of IKE IDs + +    Args: +        list_ikeid (list[str]): a list of IKE ids to terminate + +    Returns: +        bool: result of termination action +    """ +    if not list: +        logger.warning('An empty list for termination was provided') +        return False -def vici_initiate(conn, child_sa, src_addr, dest_addr):      try:          session = vici.Session() -        logs = session.initiate({ -            'ike': conn, -            'child': child_sa, -            'timeout': '-1', -            'my-host': src_addr, -            'other-host': dest_addr -        }) -        for log in logs: -            message = log['msg'].decode('ascii') -            print('INIT LOG:', message) +        for ikeid in list_ikeid: +            logger.info(f'Terminating IKE SA with id {ikeid}') +            session_generator = session.terminate( +                {'ike-id': ikeid, 'timeout': '-1'}) +            # a dummy `for` loop is required because of requirements +            # from vici. Without a full iteration on the output, the +            # command to vici may not be executed completely +            for _ in session_generator: +                pass          return True -    except: -        return None +    except Exception as err: +        logger.error(f'Failed to terminate SA for IKE ids {list_ikeid}: {err}') +        return False + -def vici_terminate(conn, child_sa, src_addr, dest_addr): +def parse_type_ipsec(interface: str) -> tuple[str, str]: +    """Get DMVPN Type and NHRP Profile from the configuration + +    Args: +        interface (str): a name of interface + +    Returns: +        tuple[str, str]: `peer_type` and `profile_name` +    """ +    if not interface: +        logger.error('Cannot find peer type - no input provided') +        return '', '' + +    config_file: str = Path(NHRP_CONFIG).read_text() +    regex: str = rf'^interface {interface} #(?P<peer_type>hub|spoke) ?(?P<profile_name>[^\n]*)$' +    match = re.search(regex, config_file, re.M) +    if match: +        return match.groupdict()['peer_type'], match.groupdict()[ +            'profile_name'] +    return '', '' + + +def add_peer_route(nbma_src: str, nbma_dst: str, mtu: str) -> None: +    """Add a route to a NBMA peer + +    Args: +        nbma_src (str): a local IP address +        nbma_dst (str): a remote IP address +        mtu (str): a MTU for a route +    """ +    logger.info(f'Adding route from {nbma_src} to {nbma_dst} with MTU {mtu}') +    # Find routes to a peer +    route_get_cmd: str = f'sudo ip --json route get {nbma_dst} from {nbma_src}' +    try: +        route_info_data = loads(cmd(route_get_cmd)) +    except Exception as err: +        logger.error(f'Unable to find a route to {nbma_dst}: {err}') +        return + +    # Check if an output has an expected format +    if not isinstance(route_info_data, list): +        logger.error( +            f'Garbage returned from the "{route_get_cmd}" ' +            f'command: {route_info_data}') +        return + +    # Add static routes to a peer +    for route_item in route_info_data: +        route_dev = route_item.get('dev') +        route_dst = route_item.get('dst') +        route_gateway = route_item.get('gateway') +        # Prepare a command to add a route +        route_add_cmd = 'sudo ip route add' +        if route_dst: +            route_add_cmd = f'{route_add_cmd} {route_dst}' +        if route_gateway: +            route_add_cmd = f'{route_add_cmd} via {route_gateway}' +        if route_dev: +            route_add_cmd = f'{route_add_cmd} dev {route_dev}' +        route_add_cmd = f'{route_add_cmd} proto 42 mtu {mtu}' +        # Add a route +        try: +            cmd(route_add_cmd) +        except Exception as err: +            logger.error( +                f'Unable to add a route using command "{route_add_cmd}": ' +                f'{err}') + + +def vici_initiate(conn: str, child_sa: str, src_addr: str, +                  dest_addr: str) -> bool: +    """Initiate IKE SA connection with specific peer + +    Args: +        conn (str): an IKE connection name +        child_sa (str): a child SA profile name +        src_addr (str): NBMA local address +        dest_addr (str): NBMA address of a peer + +    Returns: +        bool: a result of initiation command +    """ +    logger.info( +        f'Trying to initiate connection. Name: {conn}, child sa: {child_sa}, ' +        f'src_addr: {src_addr}, dst_addr: {dest_addr}')      try:          session = vici.Session() -        logs = session.terminate({ +        session_generator = session.initiate({              'ike': conn,              'child': child_sa,              'timeout': '-1',              'my-host': src_addr,              'other-host': dest_addr          }) -        for log in logs: -            message = log['msg'].decode('ascii') -            print('TERM LOG:', message) +        # a dummy `for` loop is required because of requirements +        # from vici. Without a full iteration on the output, the +        # command to vici may not be executed completely +        for _ in session_generator: +            pass          return True -    except: -        return None +    except Exception as err: +        logger.error(f'Unable to initiate connection {err}') +        return False + + +def vici_terminate(conn: str, src_addr: str, dest_addr: str) -> None: +    """Find and terminate IKE SAs by local NBMA and remote NBMA addresses + +    Args: +        conn (str): IKE connection name +        src_addr (str): NBMA local address +        dest_addr (str): NBMA address of a peer +    """ +    logger.info( +        f'Terminating IKE connection {conn} between {src_addr} ' +        f'and {dest_addr}') -def iface_up(interface): -    cmd(f'sudo ip route flush proto 42 dev {interface}') -    cmd(f'sudo ip neigh flush dev {interface}') +    ikeid_list: list[str] = vici_get_ipsec_uniqueid(conn, src_addr, dest_addr) -def peer_up(dmvpn_type, conn): -    src_addr = os.getenv('NHRP_SRCADDR') +    if not ikeid_list: +        logger.warning( +            f'No active sessions found for IKE profile {conn}, ' +            f'local NBMA {src_addr}, remote NBMA {dest_addr}') +    else: +        vici_ike_terminate(ikeid_list) + + +def iface_up(interface: str) -> None: +    """Proceed tunnel interface UP event + +    Args: +        interface (str): an interface name +    """ +    if not interface: +        logger.warning('No interface name provided for UP event') + +    logger.info(f'Turning up interface {interface}') +    try: +        cmd(f'sudo ip route flush proto 42 dev {interface}') +        cmd(f'sudo ip neigh flush dev {interface}') +    except Exception as err: +        logger.error( +            f'Unable to flush route on interface "{interface}": {err}') + + +def peer_up(dmvpn_type: str, conn: str) -> None: +    """Proceed NHRP peer UP event + +    Args: +        dmvpn_type (str): a type of peer +        conn (str): an IKE profile name +    """ +    logger.info(f'Peer UP event for {dmvpn_type} using IKE profile {conn}')      src_nbma = os.getenv('NHRP_SRCNBMA') -    dest_addr = os.getenv('NHRP_DESTADDR')      dest_nbma = os.getenv('NHRP_DESTNBMA')      dest_mtu = os.getenv('NHRP_DESTMTU') -    if dest_mtu: -        args = cmd(f'sudo ip route get {dest_nbma} from {src_nbma}') -        cmd(f'sudo ip route add {args} proto 42 mtu {dest_mtu}') +    if not src_nbma or not dest_nbma: +        logger.error( +            f'Can not get NHRP NBMA addresses: local {src_nbma}, ' +            f'remote {dest_nbma}') +        return +    logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}') +    if dest_mtu: +        add_peer_route(src_nbma, dest_nbma, dest_mtu)      if conn and dmvpn_type == 'spoke' and process_named_running('charon'): -        vici_terminate(conn, 'dmvpn', src_nbma, dest_nbma) +        vici_terminate(conn, src_nbma, dest_nbma)          vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma) -def peer_down(dmvpn_type, conn): + +def peer_down(dmvpn_type: str, conn: str) -> None: +    """Proceed NHRP peer DOWN event + +    Args: +        dmvpn_type (str): a type of peer +        conn (str): an IKE profile name +    """ +    logger.info(f'Peer DOWN event for {dmvpn_type} using IKE profile {conn}') +      src_nbma = os.getenv('NHRP_SRCNBMA')      dest_nbma = os.getenv('NHRP_DESTNBMA') +    if not src_nbma or not dest_nbma: +        logger.error( +            f'Can not get NHRP NBMA addresses: local {src_nbma}, ' +            f'remote {dest_nbma}') +        return + +    logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}')      if conn and dmvpn_type == 'spoke' and process_named_running('charon'): -        vici_terminate(conn, 'dmvpn', src_nbma, dest_nbma) +        vici_terminate(conn, src_nbma, dest_nbma) +    try: +        cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') +    except Exception as err: +        logger.error( +            f'Unable to del route from {src_nbma} to {dest_nbma}: {err}') + -    cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') +def route_up(interface: str) -> None: +    """Proceed NHRP route UP event + +    Args: +        interface (str): an interface name +    """ +    logger.info(f'Route UP event for interface {interface}') -def route_up(interface):      dest_addr = os.getenv('NHRP_DESTADDR')      dest_prefix = os.getenv('NHRP_DESTPREFIX')      next_hop = os.getenv('NHRP_NEXTHOP') -    cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 via {next_hop} dev {interface}') -    cmd('sudo ip route flush cache') +    if not dest_addr or not dest_prefix or not next_hop: +        logger.error( +            f'Can not get route details: dest_addr {dest_addr}, ' +            f'dest_prefix {dest_prefix}, next_hop {next_hop}') +        return + +    logger.info( +        f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}, ' +        f'next_hop {next_hop}') + +    try: +        cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 \ +                via {next_hop} dev {interface}') +        cmd('sudo ip route flush cache') +    except Exception as err: +        logger.error( +            f'Unable replace or flush route to {dest_addr}/{dest_prefix} ' +            f'via {next_hop} dev {interface}: {err}') + + +def route_down(interface: str) -> None: +    """Proceed NHRP route DOWN event + +    Args: +        interface (str): an interface name +    """ +    logger.info(f'Route DOWN event for interface {interface}') -def route_down(interface):      dest_addr = os.getenv('NHRP_DESTADDR')      dest_prefix = os.getenv('NHRP_DESTPREFIX') -    cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42') -    cmd('sudo ip route flush cache') +    if not dest_addr or not dest_prefix: +        logger.error( +            f'Can not get route details: dest_addr {dest_addr}, ' +            f'dest_prefix {dest_prefix}') +        return + +    logger.info( +        f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}') +    try: +        cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42') +        cmd('sudo ip route flush cache') +    except Exception as err: +        logger.error( +            f'Unable delete or flush route to {dest_addr}/{dest_prefix}: ' +            f'{err}') +  if __name__ == '__main__': +    logger = getLogger('opennhrp-script', syslog=True) +    logger.debug( +        f'Running script with arguments: {sys.argv}, ' +        f'environment: {os.environ}') +      action = sys.argv[1]      interface = os.getenv('NHRP_INTERFACE') -    dmvpn_type, profile_name = parse_type_ipsec(interface) -    dmvpn_conn = None +    if not interface: +        logger.error('Can not get NHRP interface name') +        sys.exit(1) -    if profile_name: -        dmvpn_conn = f'dmvpn-{profile_name}-{interface}' +    dmvpn_type, profile_name = parse_type_ipsec(interface) +    if not dmvpn_type: +        logger.info(f'Interface {interface} is not NHRP tunnel') +        sys.exit() +    dmvpn_conn: str = '' +    if profile_name: +        dmvpn_conn: str = f'dmvpn-{profile_name}-{interface}'      if action == 'interface-up':          iface_up(interface)      elif action == 'peer-register': @@ -134,3 +386,5 @@ if __name__ == '__main__':          route_up(interface)      elif action == 'route-down':          route_down(interface) + +    sys.exit() 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/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos index f760b417f..e0fd8cb0b 100644 --- a/src/etc/sudoers.d/vyos +++ b/src/etc/sudoers.d/vyos @@ -40,10 +40,13 @@ Cmnd_Alias PCAPTURE = /usr/bin/tcpdump  Cmnd_Alias HWINFO   = /usr/bin/lspci  Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \                             /usr/share/heartbeat/hb_standby +Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *,       \ +                         /bin/ip vrf exec * /bin/traceroute *, \ +                         /usr/libexec/vyos/op_mode/*  %operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \  			PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \                          DMIDECODE, DISK, CONNTRACK, IP6TABLES,  \ -                        FORCE_CLUSTER +                        FORCE_CLUSTER, DIAGNOSTICS  # Allow any user to run files in sudo-users  %users ALL=NOPASSWD: /opt/vyatta/bin/sudo-users/ diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index e03d3a29c..411429510 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -27,6 +27,12 @@ net.ipv4.conf.all.arp_announce=2  # Enable packet forwarding for IPv4  net.ipv4.ip_forward=1 +# Enable directed broadcast forwarding feature described in rfc1812#section-5.3.5.2 and rfc2644. +# Note that setting the 'all' entry to 1 doesn't enable directed broadcast forwarding on all interfaces. +# To enable directed broadcast forwarding on an interface, both the 'all' entry and the input interface entry should be set to 1. +net.ipv4.conf.all.bc_forwarding=1 +net.ipv4.conf.default.bc_forwarding=0 +  # if a primary address is removed from an interface promote the  # secondary address if available  net.ipv4.conf.all.promote_secondaries=1 @@ -103,3 +109,7 @@ net.ipv4.neigh.default.gc_thresh3 = 8192  net.ipv6.neigh.default.gc_thresh1 = 1024  net.ipv6.neigh.default.gc_thresh2 = 4096  net.ipv6.neigh.default.gc_thresh3 = 8192 + +# Enable global RFS (Receive Flow Steering) configuration. RFS is inactive +# until explicitly configured at the interface level +net.core.rps_sock_flow_entries = 32768 diff --git a/src/etc/systemd/system/fastnetmon.service.d/override.conf b/src/etc/systemd/system/fastnetmon.service.d/override.conf new file mode 100644 index 000000000..841666070 --- /dev/null +++ b/src/etc/systemd/system/fastnetmon.service.d/override.conf @@ -0,0 +1,12 @@ +[Unit] +RequiresMountsFor=/run +ConditionPathExists=/run/fastnetmon/fastnetmon.conf +After= +After=vyos-router.service + +[Service] +Type=simple +WorkingDirectory=/run/fastnetmon +PIDFile=/run/fastnetmon.pid +ExecStart= +ExecStart=/usr/sbin/fastnetmon --configuration_file /run/fastnetmon/fastnetmon.conf diff --git a/src/etc/systemd/system/frr.service.d/override.conf b/src/etc/systemd/system/frr.service.d/override.conf new file mode 100644 index 000000000..69eb1a86a --- /dev/null +++ b/src/etc/systemd/system/frr.service.d/override.conf @@ -0,0 +1,11 @@ +[Unit] +Before= +Before=vyos-router.service + +[Service] +ExecStartPre=/bin/bash -c 'mkdir -p /run/frr/config; \ +             echo "log syslog" > /run/frr/config/frr.conf; \ +             echo "log facility local7" >> /run/frr/config/frr.conf; \ +             chown frr:frr /run/frr/config/frr.conf; \ +             chmod 664 /run/frr/config/frr.conf; \ +             mount --bind /run/frr/config/frr.conf /etc/frr/frr.conf' diff --git a/src/etc/systemd/system/logrotate.timer.d/10-override.conf b/src/etc/systemd/system/logrotate.timer.d/10-override.conf new file mode 100644 index 000000000..f50c2b082 --- /dev/null +++ b/src/etc/systemd/system/logrotate.timer.d/10-override.conf @@ -0,0 +1,2 @@ +[Timer] +OnCalendar=hourly diff --git a/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf b/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf new file mode 100644 index 000000000..030b89a2b --- /dev/null +++ b/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf @@ -0,0 +1,11 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +WorkingDirectory= +WorkingDirectory=/run/wpa_supplicant +PIDFile=/run/wpa_supplicant/%I.pid +ExecStart= +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dwired -P/run/wpa_supplicant/%I.pid -i%I +ExecReload=/bin/kill -HUP $MAINPID diff --git a/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf index a895e675f..5cffb7987 100644 --- a/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf +++ b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf @@ -7,4 +7,5 @@ WorkingDirectory=  WorkingDirectory=/run/wpa_supplicant  PIDFile=/run/wpa_supplicant/%I.pid  ExecStart= -ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dnl80211,wext -i%I +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dnl80211,wext -P/run/wpa_supplicant/%I.pid -i%I +ExecReload=/bin/kill -HUP $MAINPID diff --git a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py index bf4bfd05d..d7eca5894 100755 --- a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py +++ b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py @@ -11,7 +11,10 @@ def get_nft_filter_chains():      """      Get list of nft chains for table filter      """ -    nft = cmd('/usr/sbin/nft --json list table ip filter') +    try: +        nft = cmd('/usr/sbin/nft --json list table ip vyos_filter') +    except Exception: +        return []      nft = json.loads(nft)      chain_list = [] @@ -27,7 +30,7 @@ def get_nftables_details(name):      """      Get dict, counters packets and bytes for chain      """ -    command = f'/usr/sbin/nft list chain ip filter {name}' +    command = f'/usr/sbin/nft list chain ip vyos_filter {name}'      try:          results = cmd(command)      except: @@ -60,7 +63,7 @@ def get_nft_telegraf(name):      Get data for telegraf in influxDB format      """      for rule, rule_config in get_nftables_details(name).items(): -        print(f'nftables,table=filter,chain={name},' +        print(f'nftables,table=vyos_filter,chain={name},'                f'ruleid={rule} '                f'pkts={rule_config["packets"]}i,'                f'bytes={rule_config["bytes"]}i ' diff --git a/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py index 0c7474156..6f14d6a8e 100755 --- a/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py +++ b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py @@ -5,20 +5,6 @@ from vyos.ifconfig import Interface  import time -def get_interfaces(type='', vlan=True): -    """ -    Get interfaces: -    ['dum0', 'eth0', 'eth1', 'eth1.5', 'lo', 'tun0'] -    """ -    interfaces = [] -    ifaces = Section.interfaces(type) -    for iface in ifaces: -        if vlan == False and '.' in iface: -            continue -        interfaces.append(iface) - -    return interfaces -  def get_interface_addresses(iface, link_local_v6=False):      """      Get IP and IPv6 addresses from interface in one string @@ -77,7 +63,7 @@ def get_interface_oper_state(iface):      return oper_state -interfaces = get_interfaces() +interfaces = Section.interfaces('')  for iface in interfaces:      print(f'show_interfaces,interface={iface} ' diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py new file mode 100755 index 000000000..34accf2cc --- /dev/null +++ b/src/helpers/geoip-update.py @@ -0,0 +1,44 @@ +#!/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 argparse +import sys + +from vyos.configquery import ConfigTreeQuery +from vyos.firewall import geoip_update + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = ConfigTreeQuery() +    base = ['firewall'] + +    if not conf.exists(base): +        return None + +    return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +                                    no_tag_node_value_mangle=True) + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument("--force", help="Force update", action="store_true") +    args = parser.parse_args() + +    firewall = get_config() + +    if not geoip_update(firewall, force=args.force): +        sys.exit(1) 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-resolver.py b/src/helpers/vyos-domain-resolver.py new file mode 100755 index 000000000..e31d9238e --- /dev/null +++ b/src/helpers/vyos-domain-resolver.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import json +import os +import time + +from vyos.configdict import dict_merge +from vyos.configquery import ConfigTreeQuery +from vyos.firewall import fqdn_config_parse +from vyos.firewall import fqdn_resolve +from vyos.util import cmd +from vyos.util import commit_in_progress +from vyos.util import dict_search_args +from vyos.util import run +from vyos.xml import defaults + +base = ['firewall'] +timeout = 300 +cache = False + +domain_state = {} + +ipv4_tables = { +    'ip vyos_mangle', +    'ip vyos_filter', +    'ip vyos_nat' +} + +ipv6_tables = { +    'ip6 vyos_mangle', +    'ip6 vyos_filter' +} + +def get_config(conf): +    firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +                                    no_tag_node_value_mangle=True) + +    default_values = defaults(base) +    for tmp in ['name', 'ipv6_name']: +        if tmp in default_values: +            del default_values[tmp] + +    if 'zone' in default_values: +        del default_values['zone'] + +    firewall = dict_merge(default_values, firewall) + +    global timeout, cache + +    if 'resolver_interval' in firewall: +        timeout = int(firewall['resolver_interval']) + +    if 'resolver_cache' in firewall: +        cache = True + +    fqdn_config_parse(firewall) + +    return firewall + +def resolve(domains, ipv6=False): +    global domain_state + +    ip_list = set() + +    for domain in domains: +        resolved = fqdn_resolve(domain, ipv6=ipv6) + +        if resolved and cache: +            domain_state[domain] = resolved +        elif not resolved: +            if domain not in domain_state: +                continue +            resolved = domain_state[domain] + +        ip_list = ip_list | resolved +    return ip_list + +def nft_output(table, set_name, ip_list): +    output = [f'flush set {table} {set_name}'] +    if ip_list: +        ip_str = ','.join(ip_list) +        output.append(f'add element {table} {set_name} {{ {ip_str} }}') +    return output + +def nft_valid_sets(): +    try: +        valid_sets = [] +        sets_json = cmd('nft -j list sets') +        sets_obj = json.loads(sets_json) + +        for obj in sets_obj['nftables']: +            if 'set' in obj: +                family = obj['set']['family'] +                table = obj['set']['table'] +                name = obj['set']['name'] +                valid_sets.append((f'{family} {table}', name)) + +        return valid_sets +    except: +        return [] + +def update(firewall): +    conf_lines = [] +    count = 0 + +    valid_sets = nft_valid_sets() + +    domain_groups = dict_search_args(firewall, 'group', 'domain_group') +    if domain_groups: +        for set_name, domain_config in domain_groups.items(): +            if 'address' not in domain_config: +                continue + +            nft_set_name = f'D_{set_name}' +            domains = domain_config['address'] + +            ip_list = resolve(domains, ipv6=False) +            for table in ipv4_tables: +                if (table, nft_set_name) in valid_sets: +                    conf_lines += nft_output(table, nft_set_name, ip_list) + +            ip6_list = resolve(domains, ipv6=True) +            for table in ipv6_tables: +                if (table, nft_set_name) in valid_sets: +                    conf_lines += nft_output(table, nft_set_name, ip6_list) +            count += 1 + +    for set_name, domain in firewall['ip_fqdn'].items(): +        table = 'ip vyos_filter' +        nft_set_name = f'FQDN_{set_name}' + +        ip_list = resolve([domain], ipv6=False) + +        if (table, nft_set_name) in valid_sets: +            conf_lines += nft_output(table, nft_set_name, ip_list) +        count += 1 + +    for set_name, domain in firewall['ip6_fqdn'].items(): +        table = 'ip6 vyos_filter' +        nft_set_name = f'FQDN_{set_name}' + +        ip_list = resolve([domain], ipv6=True) +        if (table, nft_set_name) in valid_sets: +            conf_lines += nft_output(table, nft_set_name, ip_list) +        count += 1 + +    nft_conf_str = "\n".join(conf_lines) + "\n" +    code = run(f'nft -f -', input=nft_conf_str) + +    print(f'Updated {count} sets - result: {code}') + +if __name__ == '__main__': +    print(f'VyOS domain resolver') + +    count = 1 +    while commit_in_progress(): +        if ( count % 60 == 0 ): +            print(f'Commit still in progress after {count}s - waiting') +        count += 1 +        time.sleep(1) + +    conf = ConfigTreeQuery() +    firewall = get_config(conf) + +    print(f'interval: {timeout}s - cache: {cache}') + +    while True: +        update(firewall) +        time.sleep(timeout) diff --git a/src/migration-scripts/bgp/2-to-3 b/src/migration-scripts/bgp/2-to-3 new file mode 100755 index 000000000..7ced0a3b0 --- /dev/null +++ b/src/migration-scripts/bgp/2-to-3 @@ -0,0 +1,51 @@ +#!/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/>. + +# T4257: Discussion on changing BGP autonomous system number syntax + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +# Check if BGP is even configured. Then check if local-as exists, then add the system-as, then remove the local-as. This is for global configuration. +if config.exists(['protocols', 'bgp']): +    if config.exists(['protocols', 'bgp', 'local-as']): +        config.rename(['protocols', 'bgp', 'local-as'], 'system-as') + +# Check if vrf names are configured. Then check if local-as exists inside of a name, then add the system-as, then remove the local-as. This is for vrf configuration. +if config.exists(['vrf', 'name']): +    for vrf in config.list_nodes(['vrf', 'name']): +        if config.exists(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as']): +            config.rename(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as'], 'system-as') + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/firewall/6-to-7 b/src/migration-scripts/firewall/6-to-7 index 5f4cff90d..626d6849f 100755 --- a/src/migration-scripts/firewall/6-to-7 +++ b/src/migration-scripts/firewall/6-to-7 @@ -194,11 +194,12 @@ if config.exists(base + ['ipv6-name']):              if config.exists(rule_icmp + ['type']):                  tmp = config.return_value(rule_icmp + ['type']) -                type_code_match = re.match(r'^(\d+)/(\d+)$', tmp) +                type_code_match = re.match(r'^(\d+)(?:/(\d+))?$', tmp)                  if type_code_match:                      config.set(rule_icmp + ['type'], value=type_code_match[1]) -                    config.set(rule_icmp + ['code'], value=type_code_match[2]) +                    if type_code_match[2]: +                        config.set(rule_icmp + ['code'], value=type_code_match[2])                  elif tmp in icmpv6_remove:                      config.delete(rule_icmp + ['type'])                  elif tmp in icmpv6_translations: diff --git a/src/migration-scripts/firewall/7-to-8 b/src/migration-scripts/firewall/7-to-8 new file mode 100755 index 000000000..ce527acf5 --- /dev/null +++ b/src/migration-scripts/firewall/7-to-8 @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T2199: Migrate interface firewall nodes to firewall interfaces <ifname> <direction> name/ipv6-name <name> +# T2199: Migrate zone-policy to firewall node + +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() + +base = ['firewall'] +zone_base = ['zone-policy'] +config = ConfigTree(config_file) + +if not config.exists(base) and not config.exists(zone_base): +    # 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 + ['firewall']): +        return + +    if not config.exists(['firewall', 'interface']): +        config.set(['firewall', 'interface']) +        config.set_tag(['firewall', 'interface']) + +    config.copy(if_path + ['firewall'], ['firewall', 'interface', ifname_full]) +    config.delete(if_path + ['firewall']) + +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) + +if config.exists(zone_base + ['zone']): +    config.set(['firewall', 'zone']) +    config.set_tag(['firewall', 'zone']) + +    for zone in config.list_nodes(zone_base + ['zone']): +        config.copy(zone_base + ['zone', zone], ['firewall', 'zone', zone]) +    config.delete(zone_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)) +    exit(1) 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/ids/0-to-1 b/src/migration-scripts/ids/0-to-1 new file mode 100755 index 000000000..9f08f7dc7 --- /dev/null +++ b/src/migration-scripts/ids/0-to-1 @@ -0,0 +1,56 @@ +#!/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 argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['service', 'ids', 'ddos-protection'] +config = ConfigTree(config_file) + +if not config.exists(base + ['threshold']): +    # Nothing to do +    exit(0) +else: +    if config.exists(base + ['threshold', 'fps']): +        tmp = config.return_value(base + ['threshold', 'fps']) +        config.delete(base + ['threshold', 'fps']) +        config.set(base + ['threshold', 'general', 'fps'], value=tmp) +    if config.exists(base + ['threshold', 'mbps']): +        tmp = config.return_value(base + ['threshold', 'mbps']) +        config.delete(base + ['threshold', 'mbps']) +        config.set(base + ['threshold', 'general', 'mbps'], value=tmp) +    if config.exists(base + ['threshold', 'pps']): +        tmp = config.return_value(base + ['threshold', 'pps']) +        config.delete(base + ['threshold', 'pps']) +        config.set(base + ['threshold', 'general', 'pps'], value=tmp) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/interfaces/24-to-25 b/src/migration-scripts/interfaces/24-to-25 index 93ce9215f..4095f2a3e 100755 --- a/src/migration-scripts/interfaces/24-to-25 +++ b/src/migration-scripts/interfaces/24-to-25 @@ -20,6 +20,7 @@  import os  import sys  from vyos.configtree import ConfigTree +from vyos.pki import CERT_BEGIN  from vyos.pki import load_certificate  from vyos.pki import load_crl  from vyos.pki import load_dh_parameters @@ -27,6 +28,7 @@ from vyos.pki import load_private_key  from vyos.pki import encode_certificate  from vyos.pki import encode_dh_parameters  from vyos.pki import encode_private_key +from vyos.pki import verify_crl  from vyos.util import run  def wrapped_pem_to_config_value(pem): @@ -129,6 +131,8 @@ if config.exists(base):              config.delete(base + [interface, 'tls', 'crypt-file']) +        ca_certs = {} +          if config.exists(x509_base + ['ca-cert-file']):              if not config.exists(pki_base + ['ca']):                  config.set(pki_base + ['ca']) @@ -136,20 +140,27 @@ if config.exists(base):              cert_file = config.return_value(x509_base + ['ca-cert-file'])              cert_path = os.path.join(AUTH_DIR, cert_file) -            cert = None              if os.path.isfile(cert_path):                  if not os.access(cert_path, os.R_OK):                      run(f'sudo chmod 644 {cert_path}')                  with open(cert_path, 'r') as f: -                    cert_data = f.read() -                    cert = load_certificate(cert_data, wrap_tags=False) - -            if cert: -                cert_pem = encode_certificate(cert) -                config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) -                config.set(x509_base + ['ca-certificate'], value=pki_name) +                    certs_str = f.read() +                    certs_data = certs_str.split(CERT_BEGIN) +                    index = 1 +                    for cert_data in certs_data[1:]: +                        cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False) + +                        if cert: +                            ca_certs[f'{pki_name}_{index}'] = cert +                            cert_pem = encode_certificate(cert) +                            config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) +                            config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False) +                        else: +                            print(f'Failed to migrate CA certificate on openvpn interface {interface}') + +                        index += 1              else:                  print(f'Failed to migrate CA certificate on openvpn interface {interface}') @@ -163,6 +174,7 @@ if config.exists(base):              crl_file = config.return_value(x509_base + ['crl-file'])              crl_path = os.path.join(AUTH_DIR, crl_file)              crl = None +            crl_ca_name = None              if os.path.isfile(crl_path):                  if not os.access(crl_path, os.R_OK): @@ -172,9 +184,14 @@ if config.exists(base):                      crl_data = f.read()                      crl = load_crl(crl_data, wrap_tags=False) -            if crl: +                    for ca_name, ca_cert in ca_certs.items(): +                        if verify_crl(crl, ca_cert): +                            crl_ca_name = ca_name +                            break + +            if crl and crl_ca_name:                  crl_pem = encode_certificate(crl) -                config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) +                config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))              else:                  print(f'Failed to migrate CRL on openvpn interface {interface}') diff --git a/src/migration-scripts/ipoe-server/0-to-1 b/src/migration-scripts/ipoe-server/0-to-1 index f328ebced..d768758ba 100755 --- a/src/migration-scripts/ipoe-server/0-to-1 +++ b/src/migration-scripts/ipoe-server/0-to-1 @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# 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 @@ -14,8 +14,11 @@  # You should have received a copy of the GNU General Public License  # along with this program.  If not, see <http://www.gnu.org/licenses/>. -# - remove primary/secondary identifier from nameserver -# - Unifi RADIUS configuration by placing it all under "authentication radius" node +# - T4703: merge vlan-id and vlan-range to vlan CLI node + +# L2|L3 -> l2|l3 +# mac-address -> mac +# network-mode -> mode  import os  import sys @@ -37,97 +40,35 @@ base = ['service', 'ipoe-server']  if not config.exists(base):      # Nothing to do      exit(0) -else: - -    # Migrate IPv4 DNS servers -    dns_base = base + ['dns-server'] -    if config.exists(dns_base): -        for server in ['server-1', 'server-2']: -          if config.exists(dns_base + [server]): -            dns = config.return_value(dns_base + [server]) -            config.set(base + ['name-server'], value=dns, replace=False) - -        config.delete(dns_base) - -    # Migrate IPv6 DNS servers -    dns_base = base + ['dnsv6-server'] -    if config.exists(dns_base): -        for server in ['server-1', 'server-2', 'server-3']: -          if config.exists(dns_base + [server]): -            dns = config.return_value(dns_base + [server]) -            config.set(base + ['name-server'], value=dns, replace=False) - -        config.delete(dns_base) - -    # Migrate radius-settings node to RADIUS and use this as base for the -    # later migration of the RADIUS servers - this will save a lot of code -    radius_settings = base + ['authentication', 'radius-settings'] -    if config.exists(radius_settings): -        config.rename(radius_settings, 'radius') - -    # Migrate RADIUS dynamic author / change of authorisation server -    dae_old = base + ['authentication', 'radius', 'dae-server'] -    if config.exists(dae_old): -        config.rename(dae_old, 'dynamic-author') -        dae_new = base + ['authentication', 'radius', 'dynamic-author'] - -        if config.exists(dae_new + ['ip-address']): -            config.rename(dae_new + ['ip-address'], 'server') - -        if config.exists(dae_new + ['secret']): -            config.rename(dae_new + ['secret'], 'key') -    # Migrate RADIUS server -    radius_server = base + ['authentication', 'radius-server'] -    if config.exists(radius_server): -        new_base = base + ['authentication', 'radius', 'server'] -        config.set(new_base) -        config.set_tag(new_base) -        for server in config.list_nodes(radius_server): -            old_base = radius_server + [server] -            config.copy(old_base, new_base + [server]) - -            # migrate key -            if config.exists(new_base + [server, 'secret']): -                config.rename(new_base + [server, 'secret'], 'key') - -            # remove old req-limit node -            if config.exists(new_base + [server, 'req-limit']): -                config.delete(new_base + [server, 'req-limit']) - -        config.delete(radius_server) - -    # Migrate IPv6 prefixes -    ipv6_base = base + ['client-ipv6-pool'] -    if config.exists(ipv6_base + ['prefix']): -        prefix_old = config.return_values(ipv6_base + ['prefix']) -        # delete old prefix CLI nodes -        config.delete(ipv6_base + ['prefix']) -        # create ned prefix tag node -        config.set(ipv6_base + ['prefix']) -        config.set_tag(ipv6_base + ['prefix']) - -        for p in prefix_old: -            prefix = p.split(',')[0] -            mask = p.split(',')[1] -            config.set(ipv6_base + ['prefix', prefix, 'mask'], value=mask) - -    if config.exists(ipv6_base + ['delegate-prefix']): -        prefix_old = config.return_values(ipv6_base + ['delegate-prefix']) -        # delete old delegate prefix CLI nodes -        config.delete(ipv6_base + ['delegate-prefix']) -        # create ned delegation tag node -        config.set(ipv6_base + ['delegate']) -        config.set_tag(ipv6_base + ['delegate']) - -        for p in prefix_old: -            prefix = p.split(',')[0] -            mask = p.split(',')[1] -            config.set(ipv6_base + ['delegate', prefix, 'delegation-prefix'], value=mask) - -    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) +if config.exists(base + ['authentication', 'interface']): +    for interface in config.list_nodes(base + ['authentication', 'interface']): +        config.rename(base + ['authentication', 'interface', interface, 'mac-address'], 'mac') + +        mac_base = base + ['authentication', 'interface', interface, 'mac'] +        for mac in config.list_nodes(mac_base): +            vlan_config = mac_base + [mac, 'vlan-id'] +            if config.exists(vlan_config): +                config.rename(vlan_config, 'vlan') + +for interface in config.list_nodes(base + ['interface']): +    base_path = base + ['interface', interface] +    for vlan in ['vlan-id', 'vlan-range']: +        if config.exists(base_path + [vlan]): +            print(interface, vlan) +            for tmp in config.return_values(base_path + [vlan]): +                config.set(base_path + ['vlan'], value=tmp, replace=False) +            config.delete(base_path + [vlan]) + +    if config.exists(base_path + ['network-mode']): +        tmp = config.return_value(base_path + ['network-mode']) +        config.delete(base_path + ['network-mode']) +        # Change L2|L3 to lower case l2|l3 +        config.set(base_path + ['mode'], value=tmp.lower()) + +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/migration-scripts/ipsec/9-to-10 b/src/migration-scripts/ipsec/9-to-10 new file mode 100755 index 000000000..1254104cb --- /dev/null +++ b/src/migration-scripts/ipsec/9-to-10 @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 + + +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() + +base = ['vpn', 'ipsec'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +# IKE changes, T4118: +if config.exists(base + ['ike-group']): +    for ike_group in config.list_nodes(base + ['ike-group']): +        # replace 'ipsec ike-group <tag> mobike disable' +        #      => 'ipsec ike-group <tag> disable-mobike' +        mobike = base + ['ike-group', ike_group, 'mobike'] +        if config.exists(mobike): +            if config.return_value(mobike) == 'disable': +                config.set(base + ['ike-group', ike_group, 'disable-mobike']) +            config.delete(mobike) + +        # replace 'ipsec ike-group <tag> ikev2-reauth yes' +        #      => 'ipsec ike-group <tag> ikev2-reauth' +        reauth = base + ['ike-group', ike_group, 'ikev2-reauth'] +        if config.exists(reauth): +            if config.return_value(reauth) == 'yes': +                config.delete(reauth) +                config.set(reauth) +            else: +                config.delete(reauth) + +# ESP changes +# replace 'ipsec esp-group <tag> compression enable' +#      => 'ipsec esp-group <tag> compression' +if config.exists(base + ['esp-group']): +    for esp_group in config.list_nodes(base + ['esp-group']): +        compression = base + ['esp-group', esp_group, 'compression'] +        if config.exists(compression): +            if config.return_value(compression) == 'enable': +                config.delete(compression) +                config.set(compression) +            else: +                config.delete(compression) + +# PEER changes +if config.exists(base + ['site-to-site', 'peer']): +    for peer in config.list_nodes(base + ['site-to-site', 'peer']): +        peer_base = base + ['site-to-site', 'peer', peer] + +        # replace: 'peer <tag> id x' +        #       => 'peer <tag> local-id x' +        if config.exists(peer_base + ['authentication', 'id']): +            config.rename(peer_base + ['authentication', 'id'], 'local-id') + +        # For the peer '@foo' set remote-id 'foo' if remote-id is not defined +        if peer.startswith('@'): +            if not config.exists(peer_base + ['authentication', 'remote-id']): +                tmp = peer.replace('@', '') +                config.set(peer_base + ['authentication', 'remote-id'], value=tmp) + +        # replace: 'peer <tag> force-encapsulation enable' +        #       => 'peer <tag> force-udp-encapsulation' +        force_enc = peer_base + ['force-encapsulation'] +        if config.exists(force_enc): +            if config.return_value(force_enc) == 'enable': +                config.delete(force_enc) +                config.set(peer_base + ['force-udp-encapsulation']) +            else: +                config.delete(force_enc) + +        # add option: 'peer <tag> remote-address x.x.x.x' +        remote_address = peer +        if peer.startswith('@'): +            remote_address = 'any' +        config.set(peer_base + ['remote-address'], value=remote_address) +        # Peer name it is swanctl connection name and shouldn't contain dots or colons +        # rename peer: +        #   peer 192.0.2.1   => peer peer_192-0-2-1 +        #   peer 2001:db8::2 => peer peer_2001-db8--2 +        #   peer @foo        => peer peer_foo +        re_peer_name = re.sub(':|\.', '-', peer) +        if re_peer_name.startswith('@'): +            re_peer_name = re.sub('@', '', re_peer_name) +        new_peer_name = f'peer_{re_peer_name}' + +        config.rename(peer_base, new_peer_name) + +# remote-access/road-warrior changes +if config.exists(base + ['remote-access', 'connection']): +    for connection in config.list_nodes(base + ['remote-access', 'connection']): +        ra_base = base + ['remote-access', 'connection', connection] +        # replace: 'remote-access connection <tag> authentication id x' +        #       => 'remote-access connection <tag> authentication local-id x' +        if config.exists(ra_base + ['authentication', 'id']): +            config.rename(ra_base + ['authentication', 'id'], 'local-id') + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/isis/1-to-2 b/src/migration-scripts/isis/1-to-2 new file mode 100755 index 000000000..f914ea995 --- /dev/null +++ b/src/migration-scripts/isis/1-to-2 @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T4739 refactor, and remove "on" from segment routing from the configuration + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +# Check if ISIS segment routing is configured. Then check if segment routing "on" exists, then delete the "on" as it is no longer needed. This is for global configuration. +if config.exists(['protocols', 'isis']): +    if config.exists(['protocols', 'isis', 'segment-routing']): +        if config.exists(['protocols', 'isis', 'segment-routing', 'enable']): +            config.delete(['protocols', 'isis', 'segment-routing', 'enable']) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/monitoring/0-to-1 b/src/migration-scripts/monitoring/0-to-1 new file mode 100755 index 000000000..803cdb49c --- /dev/null +++ b/src/migration-scripts/monitoring/0-to-1 @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['service', 'monitoring', 'telegraf'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +if config.exists(base + ['authentication', 'organization']): +    tmp = config.return_value(base + ['authentication', 'organization']) +    config.delete(base + ['authentication', 'organization']) +    config.set(base + ['influxdb', 'authentication', 'organization'], value=tmp) + +if config.exists(base + ['authentication', 'token']): +    tmp = config.return_value(base + ['authentication', 'token']) +    config.delete(base + ['authentication', 'token']) +    config.set(base + ['influxdb', 'authentication', 'token'], value=tmp) + +if config.exists(base + ['bucket']): +    tmp = config.return_value(base + ['bucket']) +    config.delete(base + ['bucket']) +    config.set(base + ['influxdb', 'bucket'], value=tmp) + +if config.exists(base + ['port']): +    tmp = config.return_value(base + ['port']) +    config.delete(base + ['port']) +    config.set(base + ['influxdb', 'port'], value=tmp) + +if config.exists(base + ['url']): +    tmp = config.return_value(base + ['url']) +    config.delete(base + ['url']) +    config.set(base + ['influxdb', 'url'], value=tmp) + + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/policy/3-to-4 b/src/migration-scripts/policy/3-to-4 new file mode 100755 index 000000000..bae30cffc --- /dev/null +++ b/src/migration-scripts/policy/3-to-4 @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T4660: change cli +#     from: set policy route-map FOO rule 10 set community 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set community replace <community> +#     Multiple value +#     to: set policy route-map FOO rule 10 set community add <community> +#     to: set policy route-map FOO rule 10 set community none +# +#     from: set policy route-map FOO rule 10 set large-community 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set large-community replace <community> +#     Multiple value +#     to: set policy route-map FOO rule 10 set large-community add <community> +#     to: set policy route-map FOO rule 10 set large-community none +# +#     from: set policy route-map FOO rule 10 set extecommunity [rt|soo] 'TEXT' +#     Multiple value +#     to: set policy route-map FOO rule 10 set extcommunity [rt|soo] <community> + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + + +# Migration function for large and regular communities +def community_migrate(config: ConfigTree, rule: list[str]) -> bool: +    """ + +    :param config: configuration object +    :type config: ConfigTree +    :param rule: Path to variable +    :type rule: list[str] +    :return: True if additive presents in community string +    :rtype: bool +    """ +    community_list = list((config.return_value(rule)).split(" ")) +    config.delete(rule) +    if 'none' in community_list: +        config.set(rule + ['none']) +        return False +    else: +        community_action: str = 'replace' +        if 'additive' in community_list: +            community_action = 'add' +            community_list.remove('additive') +        for community in community_list: +            config.set(rule + [community_action], value=community, +                       replace=False) +        if community_action == 'replace': +            return False +        else: +            return True + + +# Migration function for extcommunities +def extcommunity_migrate(config: ConfigTree, rule: list[str]) -> None: +    """ + +    :param config: configuration object +    :type config: ConfigTree +    :param rule: Path to variable +    :type rule: list[str] +    """ +    # if config.exists(rule + ['bandwidth']): +    #     bandwidth: str = config.return_value(rule + ['bandwidth']) +    #     config.delete(rule + ['bandwidth']) +    #     config.set(rule + ['bandwidth'], value=bandwidth) + +    if config.exists(rule + ['rt']): +        community_list = list((config.return_value(rule + ['rt'])).split(" ")) +        config.delete(rule + ['rt']) +        for community in community_list: +            config.set(rule + ['rt'], value=community, replace=False) + +    if config.exists(rule + ['soo']): +        community_list = list((config.return_value(rule + ['soo'])).split(" ")) +        config.delete(rule + ['soo']) +        for community in community_list: +            config.set(rule + ['soo'], value=community, replace=False) + + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name: str = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base: list[str] = ['policy', 'route-map'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +for route_map in config.list_nodes(base): +    if not config.exists(base + [route_map, 'rule']): +        continue +    for rule in config.list_nodes(base + [route_map, 'rule']): +        base_rule: list[str] = base + [route_map, 'rule', rule, 'set'] + +        # IF additive presents in coummunity then comm-list is redundant +        isAdditive: bool = True +        #### Change Set community ######## +        if config.exists(base_rule + ['community']): +            isAdditive = community_migrate(config, +                                           base_rule + ['community']) + +        #### Change Set community-list delete migrate ######## +        if config.exists(base_rule + ['comm-list', 'comm-list']): +            if isAdditive: +                tmp = config.return_value( +                    base_rule + ['comm-list', 'comm-list']) +                config.delete(base_rule + ['comm-list']) +                config.set(base_rule + ['community', 'delete'], value=tmp) +            else: +                config.delete(base_rule + ['comm-list']) + +        isAdditive = False +        #### Change Set large-community ######## +        if config.exists(base_rule + ['large-community']): +            isAdditive = community_migrate(config, +                                           base_rule + ['large-community']) + +        #### Change Set large-community delete by List ######## +        if config.exists(base_rule + ['large-comm-list-delete']): +            if isAdditive: +                tmp = config.return_value( +                    base_rule + ['large-comm-list-delete']) +                config.delete(base_rule + ['large-comm-list-delete']) +                config.set(base_rule + ['large-community', 'delete'], +                           value=tmp) +            else: +                config.delete(base_rule + ['large-comm-list-delete']) + +        #### Change Set extcommunity ######## +        extcommunity_migrate(config, base_rule + ['extcommunity']) +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5 new file mode 100755 index 000000000..33c9e6ade --- /dev/null +++ b/src/migration-scripts/policy/4-to-5 @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T2199: Migrate interface policy nodes to policy route <name> interface <ifname> + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base4 = ['policy', 'route'] +base6 = ['policy', 'route6'] +config = ConfigTree(config_file) + +if not config.exists(base4) and not config.exists(base6): +    # Nothing to do +    exit(0) + +def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None): +    if_path = ['interfaces', iftype, ifname] +    ifname_full = ifname + +    if vif: +        if_path += ['vif', vif] +        ifname_full = f'{ifname}.{vif}' +    elif vifs: +        if_path += ['vif-s', vifs] +        ifname_full = f'{ifname}.{vifs}' +        if vifc: +            if_path += ['vif-c', vifc] +            ifname_full = f'{ifname}.{vifs}.{vifc}' + +    if not config.exists(if_path + ['policy']): +        return + +    if config.exists(if_path + ['policy', 'route']): +        route_name = config.return_value(if_path + ['policy', 'route']) +        config.set(base4 + [route_name, 'interface'], value=ifname_full, replace=False) + +    if config.exists(if_path + ['policy', 'route6']): +        route_name = config.return_value(if_path + ['policy', 'route6']) +        config.set(base6 + [route_name, 'interface'], value=ifname_full, replace=False) + +    config.delete(if_path + ['policy']) + +for iftype in config.list_nodes(['interfaces']): +    for ifname in config.list_nodes(['interfaces', iftype]): +        migrate_interface(config, iftype, ifname) + +        if config.exists(['interfaces', iftype, ifname, 'vif']): +            for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']): +                migrate_interface(config, iftype, ifname, vif=vif) + +        if config.exists(['interfaces', iftype, ifname, 'vif-s']): +            for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']): +                migrate_interface(config, iftype, ifname, vifs=vifs) + +                if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): +                    for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): +                        migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    exit(1) diff --git a/src/migration-scripts/pppoe-server/5-to-6 b/src/migration-scripts/pppoe-server/5-to-6 new file mode 100755 index 000000000..e4888f4db --- /dev/null +++ b/src/migration-scripts/pppoe-server/5-to-6 @@ -0,0 +1,52 @@ +#!/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/>. + +# - T4703: merge vlan-id and vlan-range to vlan CLI node + +from vyos.configtree import ConfigTree +from sys import argv +from sys import exit + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) +base_path = ['service', 'pppoe-server', 'interface'] +if not config.exists(base_path): +    # Nothing to do +    exit(0) + +for interface in config.list_nodes(base_path): +    for vlan in ['vlan-id', 'vlan-range']: +        if config.exists(base_path + [interface, vlan]): +            print(interface, vlan) +            for tmp in config.return_values(base_path + [interface, vlan]): +                config.set(base_path + [interface, 'vlan'], value=tmp, replace=False) +            config.delete(base_path + [interface, vlan]) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) + diff --git a/src/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24 index 5ea71d51a..97fe82462 100755 --- a/src/migration-scripts/system/23-to-24 +++ b/src/migration-scripts/system/23-to-24 @@ -20,6 +20,7 @@ from ipaddress import ip_interface  from ipaddress import ip_address  from sys import exit, argv  from vyos.configtree import ConfigTree +from vyos.template import is_ipv4  if (len(argv) < 1):      print("Must specify file name!") @@ -37,6 +38,9 @@ def fixup_cli(config, path, interface):      if config.exists(path + ['address']):          for address in config.return_values(path + ['address']):              tmp = ip_interface(address) +            # ARP is only available for IPv4 ;-) +            if not is_ipv4(tmp): +                continue              if ip_address(host) in tmp.network.hosts():                  mac = config.return_value(tmp_base + [host, 'hwaddr'])                  iface_path = ['protocols', 'static', 'arp', 'interface'] diff --git a/src/migration-scripts/system/24-to-25 b/src/migration-scripts/system/24-to-25 new file mode 100755 index 000000000..c2f70689d --- /dev/null +++ b/src/migration-scripts/system/24-to-25 @@ -0,0 +1,52 @@ +#!/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/>. +# +# Migrate system syslog global archive to system logs logrotate messages + +from sys import exit, argv +from vyos.configtree import ConfigTree + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['system', 'syslog', 'global', 'archive'] +config = ConfigTree(config_file) + +if not config.exists(base): +    exit(0) + +if config.exists(base + ['file']): +    tmp = config.return_value(base + ['file']) +    config.set(['system', 'logs', 'logrotate', 'messages', 'rotate'], value=tmp) + +if config.exists(base + ['size']): +    tmp = config.return_value(base + ['size']) +    tmp = max(round(int(tmp) / 1024), 1) # kb -> mb +    config.set(['system', 'logs', 'logrotate', 'messages', 'max-size'], value=tmp) + +config.delete(base) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/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 new file mode 100755 index 000000000..d6098c158 --- /dev/null +++ b/src/op_mode/bridge.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import jmespath +import json +import sys +import typing + +from sys import exit +from tabulate import tabulate + +from vyos.util import cmd, rc_cmd +from vyos.util import dict_search + +import vyos.opmode + + +def _get_json_data(): +    """ +    Get bridge data format JSON +    """ +    return cmd(f'bridge --json link show') + + +def _get_raw_data_summary(): +    """Get interested rules +    :returns dict +    """ +    data = _get_json_data() +    data_dict = json.loads(data) +    return data_dict + + +def _get_raw_data_vlan(): +    """ +    :returns dict +    """ +    json_data = cmd('bridge --json --compressvlans vlan show') +    data_dict = json.loads(json_data) +    return data_dict + + +def _get_raw_data_fdb(bridge): +    """Get MAC-address for the bridge brX +    :returns list +    """ +    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: +        raise vyos.opmode.UnconfiguredSubsystem(f"no such bridge device {bridge}") +    data_dict = json.loads(json_data) +    return data_dict + + +def _get_raw_data_mdb(bridge): +    """Get MAC-address multicast gorup for the bridge brX +    :return list +    """ +    json_data = cmd(f'bridge --json  mdb show br {bridge}') +    data_dict = json.loads(json_data) +    return data_dict + + +def _get_bridge_members(bridge: str) -> list: +    """ +    Get list of interface bridge members +    :param bridge: str +    :default: ['n/a'] +    :return: list +    """ +    data = _get_raw_data_summary() +    members = jmespath.search(f'[?master == `{bridge}`].ifname', data) +    return [member for member in members] if members else ['n/a'] + + +def _get_member_options(bridge: str): +    data = _get_raw_data_summary() +    options = jmespath.search(f'[?master == `{bridge}`]', data) +    return options + + +def _get_formatted_output_summary(data): +    data_entries = '' +    bridges = set(jmespath.search('[*].master', data)) +    for bridge in bridges: +        member_options = _get_member_options(bridge) +        member_entries = [] +        for option in member_options: +            interface = option.get('ifname') +            ifindex = option.get('ifindex') +            state = option.get('state') +            mtu = option.get('mtu') +            flags = ','.join(option.get('flags')).lower() +            prio = option.get('priority') +            member_entries.append([interface, state, mtu, flags, prio]) +        member_headers = ["Member", "State", "MTU", "Flags", "Prio"] +        output_members = tabulate(member_entries, member_headers, numalign="left") +        output_bridge = f"""Bridge interface {bridge}: +{output_members} + +""" +        data_entries += output_bridge +    output = data_entries +    return output + + +def _get_formatted_output_vlan(data): +    data_entries = [] +    for entry in data: +        interface = entry.get('ifname') +        vlans = entry.get('vlans') +        for vlan_entry in vlans: +            vlan = vlan_entry.get('vlan') +            if vlan_entry.get('vlanEnd'): +                vlan_end = vlan_entry.get('vlanEnd') +                vlan = f'{vlan}-{vlan_end}' +            flags = ', '.join(vlan_entry.get('flags')).lower() +            data_entries.append([interface, vlan, flags]) + +    headers = ["Interface", "Vlan", "Flags"] +    output = tabulate(data_entries, headers) +    return output + + +def _get_formatted_output_fdb(data): +    data_entries = [] +    for entry in data: +        interface = entry.get('ifname') +        mac = entry.get('mac') +        state = entry.get('state') +        flags = ','.join(entry['flags']) +        data_entries.append([interface, mac, state, flags]) + +    headers = ["Interface", "Mac address", "State", "Flags"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def _get_formatted_output_mdb(data): +    data_entries = [] +    for entry in data: +        for mdb_entry in entry['mdb']: +            interface = mdb_entry.get('port') +            group = mdb_entry.get('grp') +            state = mdb_entry.get('state') +            flags = ','.join(mdb_entry.get('flags')) +            data_entries.append([interface, group, state, flags]) +    headers = ["Interface", "Group", "State", "Flags"] +    output = tabulate(data_entries, headers) +    return output + + +def show(raw: bool): +    bridge_data = _get_raw_data_summary() +    if raw: +        return bridge_data +    else: +        return _get_formatted_output_summary(bridge_data) + + +def show_vlan(raw: bool): +    bridge_vlan = _get_raw_data_vlan() +    if raw: +        return bridge_vlan +    else: +        return _get_formatted_output_vlan(bridge_vlan) + + +def show_fdb(raw: bool, interface: str): +    fdb_data = _get_raw_data_fdb(interface) +    if raw: +        return fdb_data +    else: +        return _get_formatted_output_fdb(fdb_data) + + +def show_mdb(raw: bool, interface: str): +    mdb_data = _get_raw_data_mdb(interface) +    if raw: +        return mdb_data +    else: +        return _get_formatted_output_mdb(mdb_data) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/clear_dhcp_lease.py b/src/op_mode/clear_dhcp_lease.py new file mode 100755 index 000000000..250dbcce1 --- /dev/null +++ b/src/op_mode/clear_dhcp_lease.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import argparse +import re + +from isc_dhcp_leases import Lease +from isc_dhcp_leases import IscDhcpLeases + +from vyos.configquery import ConfigTreeQuery +from vyos.util import ask_yes_no +from vyos.util import call +from vyos.util import commit_in_progress + + +config = ConfigTreeQuery() +base = ['service', 'dhcp-server'] +lease_file = '/config/dhcpd.leases' + + +def del_lease_ip(address): +    """ +    Read lease_file and write data to this file +    without specific section "lease ip" +    Delete section "lease x.x.x.x { x;x;x; }" +    """ +    with open(lease_file, encoding='utf-8') as f: +        data = f.read().rstrip() +        lease_config_ip = '{(?P<config>[\s\S]+?)\n}' +        pattern = rf"lease {address} {lease_config_ip}" +        # Delete lease for ip block +        data = re.sub(pattern, '', data) + +    # Write new data to original lease_file +    with open(lease_file, 'w', encoding='utf-8') as f: +        f.write(data) + +def is_ip_in_leases(address): +    """ +    Return True if address found in the lease file +    """ +    leases = IscDhcpLeases(lease_file) +    lease_ips = [] +    for lease in leases.get(): +        lease_ips.append(lease.ip) +    if address not in lease_ips: +        print(f'Address "{address}" not found in "{lease_file}"') +        return False +    return True + + +if not config.exists(base): +    print('DHCP-server not configured!') +    exit(0) + +if config.exists(base + ['failover']): +    print('Lease cannot be reset in failover mode!') +    exit(0) + + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument('--ip', help='IPv4 address', action='store', required=True) + +    args = parser.parse_args() +    address = args.ip + +    if not is_ip_in_leases(address): +        exit(1) + +    if commit_in_progress(): +        print('Cannot clear DHCP lease while a commit is in progress') +        exit(1) + +    if not ask_yes_no(f'This will restart DHCP server.\nContinue?'): +        exit(1) +    else: +        del_lease_ip(address) +        call('systemctl restart isc-dhcp-server.service') diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index ffc574362..d39e88bf3 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -20,6 +20,7 @@ import argparse  from psutil import process_iter  from vyos.util import call +from vyos.util import commit_in_progress  from vyos.util import DEVNULL  from vyos.util import is_wwan_connected @@ -40,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}'): @@ -61,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 @@ -87,6 +88,9 @@ def main():      args = parser.parse_args()      if args.connect: +        if commit_in_progress(): +            print('Cannot connect while a commit is in progress') +            exit(1)          connect(args.connect)      elif args.disconnect:          disconnect(args.disconnect) diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py new file mode 100755 index 000000000..fff537936 --- /dev/null +++ b/src/op_mode/conntrack.py @@ -0,0 +1,153 @@ +#!/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 xmltodict + +from tabulate import tabulate +from vyos.util import cmd +from vyos.util import run + +import vyos.opmode + + +def _get_xml_data(family): +    """ +    Get conntrack XML output +    """ +    return cmd(f'sudo conntrack --dump --family {family} --output xml') + + +def _xml_to_dict(xml): +    """ +    Convert XML to dictionary +    Return: dictionary +    """ +    parse = xmltodict.parse(xml, attr_prefix='') +    # If only one conntrack entry we must change dict +    if 'meta' in parse['conntrack']['flow']: +        return dict(conntrack={'flow': [parse['conntrack']['flow']]}) +    return parse + + +def _get_raw_data(family): +    """ +    Return: dictionary +    """ +    xml = _get_xml_data(family) +    if len(xml) == 0: +        output = {'conntrack': +            { +                'error': True, +                'reason': 'entries not found' +            } +        } +        return output +    return _xml_to_dict(xml) + + +def _get_raw_statistics(): +    entries = [] +    data = cmd('sudo conntrack -S') +    data = data.replace('  \t', '').split('\n') +    for entry in data: +        entries.append(entry.split()) +    return entries + + +def get_formatted_statistics(entries): +    headers = ["CPU", "Found", "Invalid", "Insert", "Insert fail", "Drop", "Early drop", "Errors", "Search restart"] +    output = tabulate(entries, headers, numalign="left") +    return output + + +def get_formatted_output(dict_data): +    """ +    :param xml: +    :return: formatted output +    """ +    data_entries = [] +    if 'error' in dict_data['conntrack']: +        return 'Entries not found' +    for entry in dict_data['conntrack']['flow']: +        orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {} +        reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {} +        proto = {} +        for meta in entry['meta']: +            direction = meta['direction'] +            if direction in ['original']: +                if 'layer3' in meta: +                    orig_src = meta['layer3']['src'] +                    orig_dst = meta['layer3']['dst'] +                if 'layer4' in meta: +                    if meta.get('layer4').get('sport'): +                        orig_sport = meta['layer4']['sport'] +                    if meta.get('layer4').get('dport'): +                        orig_dport = meta['layer4']['dport'] +                    proto = meta['layer4']['protoname'] +            if direction in ['reply']: +                if 'layer3' in meta: +                    reply_src = meta['layer3']['src'] +                    reply_dst = meta['layer3']['dst'] +                if 'layer4' in meta: +                    if meta.get('layer4').get('sport'): +                        reply_sport = meta['layer4']['sport'] +                    if meta.get('layer4').get('dport'): +                        reply_dport = meta['layer4']['dport'] +                    proto = meta['layer4']['protoname'] +            if direction == 'independent': +                conn_id = meta['id'] +                timeout = meta['timeout'] +                orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src +                orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst +                reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src +                reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst +                state = meta['state'] if 'state' in meta else '' +                mark = meta['mark'] +                zone = meta['zone'] if 'zone' in meta else '' +                data_entries.append( +                    [conn_id, orig_src, orig_dst, reply_src, reply_dst, proto, state, timeout, mark, zone]) +    headers = ["Id", "Original src", "Original dst", "Reply src", "Reply dst", "Protocol", "State", "Timeout", "Mark", +               "Zone"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def show(raw: bool, family: str): +    family = 'ipv6' if family == 'inet6' else 'ipv4' +    conntrack_data = _get_raw_data(family) +    if raw: +        return conntrack_data +    else: +        return get_formatted_output(conntrack_data) + + +def show_statistics(raw: bool): +    conntrack_statistics = _get_raw_statistics() +    if raw: +        return conntrack_statistics +    else: +        return get_formatted_statistics(conntrack_statistics) + + +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/conntrack_sync.py b/src/op_mode/conntrack_sync.py index e45c38f07..54ecd6d0e 100755 --- a/src/op_mode/conntrack_sync.py +++ b/src/op_mode/conntrack_sync.py @@ -22,6 +22,7 @@ from argparse import ArgumentParser  from vyos.configquery import CliShellApiConfigQuery  from vyos.configquery import ConfigTreeQuery  from vyos.util import call +from vyos.util import commit_in_progress  from vyos.util import cmd  from vyos.util import run  from vyos.template import render_to_string @@ -86,6 +87,9 @@ if __name__ == '__main__':      if args.restart:          is_configured() +        if commit_in_progress(): +            print('Cannot restart conntrackd while a commit is in progress') +            exit(1)          syslog.syslog('Restarting conntrack sync service...')          cmd('systemctl restart conntrackd.service') diff --git a/src/op_mode/container.py b/src/op_mode/container.py new file mode 100755 index 000000000..ce466ffc1 --- /dev/null +++ b/src/op_mode/container.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import json +import sys + +from sys import exit + +from vyos.util import cmd + +import vyos.opmode + + +def _get_json_data(command: str) -> list: +    """ +    Get container command format JSON +    """ +    return cmd(f'{command} --format json') + + +def _get_raw_data(command: str) -> list: +    json_data = _get_json_data(command) +    data = json.loads(json_data) +    return data + + +def show_container(raw: bool): +    command = 'sudo podman ps --all' +    container_data = _get_raw_data(command) +    if raw: +        return container_data +    else: +        return cmd(command) + + +def show_image(raw: bool): +    command = 'sudo podman image ls' +    container_data = _get_raw_data('sudo podman image ls') +    if raw: +        return container_data +    else: +        return cmd(command) + + +def show_network(raw: bool): +    command = 'sudo podman network ls' +    container_data = _get_raw_data(command) +    if raw: +        return container_data +    else: +        return cmd(command) + + +def restart(name: str): +    from vyos.util import rc_cmd + +    rc, output = rc_cmd(f'sudo podman restart {name}') +    if rc != 0: +        print(output) +        return None +    print(f'Container name "{name}" restarted!') +    return output + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/cpu.py b/src/op_mode/cpu.py new file mode 100755 index 000000000..d53663c17 --- /dev/null +++ b/src/op_mode/cpu.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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.cpu +import vyos.opmode + +from jinja2 import Template + +cpu_template = Template(""" +{% for cpu in cpus %} +{% if 'physical id' in cpu %}CPU socket: {{cpu['physical id']}}{% endif %} +{% if 'vendor_id' in cpu %}CPU Vendor:       {{cpu['vendor_id']}}{% endif %} +{% if 'model name' in cpu %}Model:            {{cpu['model name']}}{% endif %} +{% if 'cpu cores' in cpu %}Cores:            {{cpu['cpu cores']}}{% endif %} +{% if 'cpu MHz' in cpu %}Current MHz:      {{cpu['cpu MHz']}}{% endif %} +{% endfor %} +""") + +cpu_summary_template = Template(""" +Physical CPU cores: {{count}} +CPU model(s): {{models | join(", ")}} +""") + +def _get_raw_data(): +    return vyos.cpu.get_cpus() + +def _format_cpus(cpu_data): +    env = {'cpus': cpu_data} +    return cpu_template.render(env).strip() + +def _get_summary_data(): +    count = vyos.cpu.get_core_count() +    cpu_data = vyos.cpu.get_cpus() +    models = [c['model name'] for c in cpu_data] +    env = {'count': count, "models": models} + +    return env + +def _format_cpu_summary(summary_data): +    return cpu_summary_template.render(summary_data).strip() + +def show(raw: bool): +    cpu_data = _get_raw_data() + +    if raw: +        return cpu_data +    else: +        return _format_cpus(cpu_data) + +def show_summary(raw: bool): +    cpu_summary_data = _get_summary_data() + +    if raw: +        return cpu_summary_data +    else: +        return _format_cpu_summary(cpu_summary_data) + + +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/cpu_summary.py b/src/op_mode/cpu_summary.py deleted file mode 100755 index 3bdf5a718..000000000 --- a/src/op_mode/cpu_summary.py +++ /dev/null @@ -1,48 +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 -from vyos.util import colon_separated_to_dict - -FILE_NAME = '/proc/cpuinfo' - -def get_raw_data(): -    with open(FILE_NAME, 'r') as f: -        data_raw = f.read() - -    data = colon_separated_to_dict(data_raw) - -    # Accumulate all data in a dict for future support for machine-readable output -    cpu_data = {} -    cpu_data['cpu_number'] = len(data['processor']) -    cpu_data['models'] = list(set(data['model name'])) - -    # Strip extra whitespace from CPU model names, /proc/cpuinfo is prone to that -    cpu_data['models'] = list(map(lambda s: re.sub(r'\s+', ' ', s), cpu_data['models'])) - -    return cpu_data - -def get_formatted_output(): -    cpu_data = get_raw_data() - -    out = "CPU(s): {0}\n".format(cpu_data['cpu_number']) -    out += "CPU model(s): {0}".format(",".join(cpu_data['models'])) - -    return out - -if __name__ == '__main__': -    print(get_formatted_output()) - 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 new file mode 100755 index 000000000..a0e47d7ad --- /dev/null +++ b/src/op_mode/dns.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + + +import sys + +from sys import exit +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery +from vyos.util import cmd + +import vyos.opmode + + +def _data_to_dict(data, sep="\t") -> dict: +    """ +    Return dictionary from plain text +    separated by tab + +    cache-entries	73 +    cache-hits	0 +    uptime	2148 +    user-msec	172 + +    { +      'cache-entries': '73', +      'cache-hits': '0', +      'uptime': '2148', +      'user-msec': '172' +    } +    """ +    dictionary = {} +    mylist = [line for line in data.split('\n')] + +    for line in mylist: +        if sep in line: +            key, value = line.split(sep) +            dictionary[key] = value +    return dictionary + + +def _get_raw_forwarding_statistics() -> dict: +    command = cmd('rec_control --socket-dir=/run/powerdns get-all') +    data = _data_to_dict(command) +    data['cache-size'] = "{0:.2f}".format( int( +        cmd('rec_control --socket-dir=/run/powerdns get cache-bytes')) / 1024 ) +    return data + + +def _get_formatted_forwarding_statistics(data): +    cache_entries = data.get('cache-entries') +    max_cache_entries = data.get('max-cache-entries') +    cache_size = data.get('cache-size') +    data_entries = [[cache_entries, max_cache_entries, f'{cache_size} kbytes']] +    headers = ["Cache entries", "Max cache entries" , "Cache size"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def show_forwarding_statistics(raw: bool): + +    config = ConfigTreeQuery() +    if not config.exists('service dns forwarding'): +        print("DNS forwarding is not configured") +        exit(0) + +    dns_data = _get_raw_forwarding_statistics() +    if raw: +        return dns_data +    else: +        return _get_formatted_forwarding_statistics(dns_data) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index 3146fc357..46bda5f7e 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -24,43 +24,33 @@ from vyos.config import Config  from vyos.util import cmd  from vyos.util import dict_search_args -def get_firewall_interfaces(conf, firewall, name=None, ipv6=False): -    interfaces = conf.get_config_dict(['interfaces'], key_mangling=('-', '_'), -                                      get_first_key=True, no_tag_node_value_mangle=True) - +def get_firewall_interfaces(firewall, name=None, ipv6=False):      directions = ['in', 'out', 'local'] -    def parse_if(ifname, if_conf): -        if 'firewall' in if_conf: +    if 'interface' in firewall: +        for ifname, if_conf in firewall['interface'].items():              for direction in directions: -                if direction in if_conf['firewall']: -                    fw_conf = if_conf['firewall'][direction] -                    name_str = f'({ifname},{direction})' - -                    if 'name' in fw_conf: -                        fw_name = fw_conf['name'] +                if direction not in if_conf: +                    continue -                        if not name: -                            firewall['name'][fw_name]['interface'].append(name_str) -                        elif not ipv6 and name == fw_name: -                            firewall['interface'].append(name_str) +                fw_conf = if_conf[direction] +                name_str = f'({ifname},{direction})' -                    if 'ipv6_name' in fw_conf: -                        fw_name = fw_conf['ipv6_name'] +                if 'name' in fw_conf: +                    fw_name = fw_conf['name'] -                        if not name: -                            firewall['ipv6_name'][fw_name]['interface'].append(name_str) -                        elif ipv6 and name == fw_name: -                            firewall['interface'].append(name_str) +                    if not name: +                        firewall['name'][fw_name]['interface'].append(name_str) +                    elif not ipv6 and name == fw_name: +                        firewall['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) +                if 'ipv6_name' in fw_conf: +                    fw_name = fw_conf['ipv6_name'] -    for iftype, iftype_conf in interfaces.items(): -        for ifname, if_conf in iftype_conf.items(): -            parse_if(ifname, if_conf) +                    if not name: +                        firewall['ipv6_name'][fw_name]['interface'].append(name_str) +                    elif ipv6 and name == fw_name: +                        firewall['interface'].append(name_str)      return firewall @@ -73,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(): @@ -83,13 +73,13 @@ def get_config_firewall(conf, name=None, ipv6=False, interfaces=True):                  for fw_name, name_conf in firewall['ipv6_name'].items():                      name_conf['interface'] = [] -        get_firewall_interfaces(conf, firewall, name, ipv6) +        get_firewall_interfaces(firewall, name, ipv6)      return firewall  def get_nftables_details(name, ipv6=False):      suffix = '6' if ipv6 else ''      name_prefix = 'NAME6_' if ipv6 else 'NAME_' -    command = f'sudo nft list chain ip{suffix} filter {name_prefix}{name}' +    command = f'sudo nft list chain ip{suffix} vyos_filter {name_prefix}{name}'      try:          results = cmd(command)      except: @@ -270,7 +260,7 @@ def show_firewall_group(name=None):              references = find_references(group_type, group_name)              row = [group_name, group_type, '\n'.join(references) or 'N/A']              if 'address' in group_conf: -                row.append("\n".join(sorted(group_conf['address'], key=ipaddress.ip_address))) +                row.append("\n".join(sorted(group_conf['address'])))              elif 'network' in group_conf:                  row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network)))              elif 'mac_address' in group_conf: diff --git a/src/op_mode/flow_accounting_op.py b/src/op_mode/flow_accounting_op.py index 6586cbceb..514143cd7 100755 --- a/src/op_mode/flow_accounting_op.py +++ b/src/op_mode/flow_accounting_op.py @@ -22,7 +22,9 @@ import ipaddress  import os.path  from tabulate import tabulate  from json import loads -from vyos.util import cmd, run +from vyos.util import cmd +from vyos.util import commit_in_progress +from vyos.util import run  from vyos.logger import syslog  # some default values @@ -224,6 +226,9 @@ if not _uacctd_running():  # restart pmacct daemon  if cmd_args.action == 'restart': +    if commit_in_progress(): +        print('Cannot restart flow-accounting while a commit is in progress') +        exit(1)      # run command to restart flow-accounting      cmd('systemctl restart uacctd.service',          message='Failed to restart flow-accounting') 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_ssh_server_key.py b/src/op_mode/generate_ssh_server_key.py index cbc9ef973..43e94048d 100755 --- a/src/op_mode/generate_ssh_server_key.py +++ b/src/op_mode/generate_ssh_server_key.py @@ -17,10 +17,15 @@  from sys import exit  from vyos.util import ask_yes_no  from vyos.util import cmd +from vyos.util import commit_in_progress  if not ask_yes_no('Do you really want to remove the existing SSH host keys?'):      exit(0) +if commit_in_progress(): +    print('Cannot restart SSH while a commit is in progress') +    exit(1) +  cmd('rm -v /etc/ssh/ssh_host_*')  cmd('dpkg-reconfigure openssh-server')  cmd('systemctl restart ssh.service') diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index 21561d16f..a22f04c45 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -119,7 +119,7 @@ config_base = ipsec_base +  ['remote-access', 'connection']  pki_base = ['pki']  conf = ConfigTreeQuery()  if not conf.exists(config_base): -    exit('IPSec remote-access is not configured!') +    exit('IPsec remote-access is not configured!')  profile_name = 'VyOS IKEv2 Profile'  if args.profile: @@ -131,7 +131,7 @@ if args.name:  conn_base = config_base + [args.connection]  if not conf.exists(conn_base): -     exit(f'IPSec remote-access connection "{args.connection}" does not exist!') +     exit(f'IPsec remote-access connection "{args.connection}" does not exist!')  data = conf.get_config_dict(conn_base, key_mangling=('-', '_'),                              get_first_key=True, no_tag_node_value_mangle=True) @@ -178,7 +178,7 @@ for _, proposal in ike_proposal.items():              proposal['hash'] in set(vyos2client_integrity) and              proposal['dh_group'] in set(supported_dh_groups)): -            # We 're-code' from the VyOS IPSec proposals to the Apple naming scheme +            # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme              proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ]              proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] @@ -191,7 +191,7 @@ count = 1  for _, proposal in esp_proposals.items():      if {'encryption', 'hash'} <= set(proposal):          if proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity): -            # We 're-code' from the VyOS IPSec proposals to the Apple naming scheme +            # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme              proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ]              proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py new file mode 100755 index 000000000..e0d204a0a --- /dev/null +++ b/src/op_mode/ipsec.py @@ -0,0 +1,473 @@ +#!/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 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 +from vyos.util import seconds_to_human + +import vyos.opmode + + +SWANCTL_CONF = '/etc/swanctl/swanctl.conf' + + +def _convert(text): +    return int(text) if text.isdigit() else text.lower() + + +def _alphanum_key(key): +    return [_convert(c) for c in re_split('([0-9]+)', str(key))] + + +def _get_vici_sas(): +    from vici import Session as vici_session + +    try: +        session = vici_session() +    except Exception: +        raise vyos.opmode.UnconfiguredSubsystem("IPsec not initialized") +    sas = list(session.list_sas()) +    return sas + + +def _get_raw_data_sas(): +    get_sas = _get_vici_sas() +    sas = convert_data(get_sas) +    return sas + + +def _get_formatted_output_sas(sas): +    sa_data = [] +    for sa in sas: +        for parent_sa in sa.values(): +            # create an item for each child-sa +            for child_sa in parent_sa.get('child-sas', {}).values(): +                # prepare a list for output data +                sa_out_name = sa_out_state = sa_out_uptime = sa_out_bytes = sa_out_packets = sa_out_remote_addr = sa_out_remote_id = sa_out_proposal = 'N/A' + +                # collect raw data +                sa_name = child_sa.get('name') +                sa_state = child_sa.get('state') +                sa_uptime = child_sa.get('install-time') +                sa_bytes_in = child_sa.get('bytes-in') +                sa_bytes_out = child_sa.get('bytes-out') +                sa_packets_in = child_sa.get('packets-in') +                sa_packets_out = child_sa.get('packets-out') +                sa_remote_addr = parent_sa.get('remote-host') +                sa_remote_id = parent_sa.get('remote-id') +                sa_proposal_encr_alg = child_sa.get('encr-alg') +                sa_proposal_integ_alg = child_sa.get('integ-alg') +                sa_proposal_encr_keysize = child_sa.get('encr-keysize') +                sa_proposal_dh_group = child_sa.get('dh-group') + +                # format data to display +                if sa_name: +                    sa_out_name = sa_name +                if sa_state: +                    if sa_state == 'INSTALLED': +                        sa_out_state = 'up' +                    else: +                        sa_out_state = 'down' +                if sa_uptime: +                    sa_out_uptime = seconds_to_human(sa_uptime) +                if sa_bytes_in and sa_bytes_out: +                    bytes_in = filesize.size(int(sa_bytes_in)) +                    bytes_out = filesize.size(int(sa_bytes_out)) +                    sa_out_bytes = f'{bytes_in}/{bytes_out}' +                if sa_packets_in and sa_packets_out: +                    packets_in = filesize.size(int(sa_packets_in), +                                               system=filesize.si) +                    packets_out = filesize.size(int(sa_packets_out), +                                                system=filesize.si) +                    packets_str = f'{packets_in}/{packets_out}' +                    sa_out_packets = re.sub(r'B', r'', packets_str) +                if sa_remote_addr: +                    sa_out_remote_addr = sa_remote_addr +                if sa_remote_id: +                    sa_out_remote_id = sa_remote_id +                # format proposal +                if sa_proposal_encr_alg: +                    sa_out_proposal = sa_proposal_encr_alg +                if sa_proposal_encr_keysize: +                    sa_proposal_encr_keysize_str = sa_proposal_encr_keysize +                    sa_out_proposal = f'{sa_out_proposal}_{sa_proposal_encr_keysize_str}' +                if sa_proposal_integ_alg: +                    sa_proposal_integ_alg_str = sa_proposal_integ_alg +                    sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_integ_alg_str}' +                if sa_proposal_dh_group: +                    sa_proposal_dh_group_str = sa_proposal_dh_group +                    sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_dh_group_str}' + +                # add a new item to output data +                sa_data.append([ +                    sa_out_name, sa_out_state, sa_out_uptime, sa_out_bytes, +                    sa_out_packets, sa_out_remote_addr, sa_out_remote_id, +                    sa_out_proposal +                ]) + +    headers = [ +        "Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", +        "Remote address", "Remote ID", "Proposal" +    ] +    sa_data = sorted(sa_data, key=_alphanum_key) +    output = tabulate(sa_data, headers) +    return output + + +# 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: +                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:typing.Optional[str]): +    conns = get_peer_connections(peer, tunnel) + +    if not conns: +        raise vyos.opmode.IncorrectValue('Peer or tunnel(s) not found, aborting') + +    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: +            raise vyos.opmode.InternalError(f'Timed out while resetting {conn}') + +    print('Peer reset result: success') + + +def show_sa(raw: bool): +    sa_data = _get_raw_data_sas() +    if raw: +        return sa_data +    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__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) 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/show_ram.py b/src/op_mode/memory.py index 2b0be3965..7666de646 100755 --- a/src/op_mode/show_ram.py +++ b/src/op_mode/memory.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -15,7 +15,12 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  # -def get_system_memory(): +import sys + +import vyos.opmode + + +def _get_raw_data():      from re import search as re_search      def find_value(keyword, mem_data): @@ -33,7 +38,7 @@ def get_system_memory():      used = total - available -    res = { +    mem_data = {        "total":   total,        "free":    available,        "used":    used, @@ -41,25 +46,20 @@ 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 +    return mem_data -def get_raw_data(): -    return get_system_memory_human() +def _get_formatted_output(mem): +    from vyos.util import bytes_to_human -def get_formatted_output(): -    mem = get_raw_data() +    # 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"]) @@ -67,5 +67,21 @@ def get_formatted_output():      return out +def show(raw: bool): +    ram_data = _get_raw_data() + +    if raw: +        return ram_data +    else: +        return _get_formatted_output(ram_data) + +  if __name__ == '__main__': -    print(get_formatted_output()) +    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/nat.py b/src/op_mode/nat.py new file mode 100755 index 000000000..f899eb3dc --- /dev/null +++ b/src/op_mode/nat.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import jmespath +import json +import sys +import 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 +    """ +    if direction == 'source': +        opt = '--src-nat' +    if direction == 'destination': +        opt = '--dst-nat' +    return cmd(f'sudo conntrack --dump --family {family} {opt} --output xml') + + +def _xml_to_dict(xml): +    """ +    Convert XML to dictionary +    Return: dictionary +    """ +    parse = xmltodict.parse(xml, attr_prefix='') +    # If only one conntrack entry we must change dict +    if 'meta' in parse['conntrack']['flow']: +        return dict(conntrack={'flow': [parse['conntrack']['flow']]}) +    return parse + + +def _get_json_data(direction, family): +    """ +    Get NAT format JSON +    """ +    if direction == 'source': +        chain = 'POSTROUTING' +    if direction == 'destination': +        chain = 'PREROUTING' +    family = 'ip6' if family == 'inet6' else 'ip' +    return cmd(f'sudo nft --json list chain {family} vyos_nat {chain}') + + +def _get_raw_data_rules(direction, family): +    """Get interested rules +    :returns dict +    """ +    data = _get_json_data(direction, family) +    data_dict = json.loads(data) +    rules = [] +    for rule in data_dict['nftables']: +        if 'rule' in rule and 'comment' in rule['rule']: +            rules.append(rule) +    return rules + + +def _get_raw_translation(direction, family): +    """ +    Return: dictionary +    """ +    xml = _get_xml_translation(direction, family) +    if len(xml) == 0: +        output = {'conntrack': +            { +                'error': True, +                'reason': 'entries not found' +            } +        } +        return output +    return _xml_to_dict(xml) + + +def _get_formatted_output_rules(data, direction, family): +    # Add default values before loop +    sport, dport, proto = 'any', 'any', 'any' +    saddr = '::/0' if family == 'inet6' else '0.0.0.0/0' +    daddr = '::/0' if family == 'inet6' else '0.0.0.0/0' + +    data_entries = [] +    for rule in data: +        if 'comment' in rule['rule']: +            comment = rule.get('rule').get('comment') +            rule_number = comment.split('-')[-1] +            rule_number = rule_number.split(' ')[0] +        if 'expr' in rule['rule']: +            interface = rule.get('rule').get('expr')[0].get('match').get('right') \ +                if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any' +        for index, match in enumerate(jmespath.search('rule.expr[*].match', rule)): +            if 'payload' in match['left']: +                if isinstance(match['right'], dict) and ('prefix' in match['right'] or 'set' in match['right']): +                    # Merge dict src/dst l3_l4 parameters +                    my_dict = {**match['left']['payload'], **match['right']} +                    my_dict['op'] = match['op'] +                    op = '!' if my_dict.get('op') == '!=' else '' +                    proto = my_dict.get('protocol').upper() +                    if my_dict['field'] == 'saddr': +                        saddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' +                    elif my_dict['field'] == 'daddr': +                        daddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' +                    elif my_dict['field'] == 'sport': +                        # Port range or single port +                        if jmespath.search('set[*].range', my_dict): +                            sport = my_dict['set'][0]['range'] +                            sport = '-'.join(map(str, sport)) +                        else: +                            sport = my_dict.get('set') +                            sport = ','.join(map(str, sport)) +                    elif my_dict['field'] == 'dport': +                        # Port range or single port +                        if jmespath.search('set[*].range', my_dict): +                            dport = my_dict["set"][0]["range"] +                            dport = '-'.join(map(str, dport)) +                        else: +                            dport = my_dict.get('set') +                            dport = ','.join(map(str, dport)) +                else: +                    field = jmespath.search('left.payload.field', match) +                    if field == 'saddr': +                        saddr = match.get('right') +                    elif field == 'daddr': +                        daddr = match.get('right') +                    elif field == 'sport': +                        sport = match.get('right') +                    elif field == 'dport': +                        dport = match.get('right') +            else: +                saddr = '::/0' if family == 'inet6' else '0.0.0.0/0' +                daddr = '::/0' if family == 'inet6' else '0.0.0.0/0' +                sport = 'any' +                dport = 'any' +                proto = 'any' + +            source = f'''{saddr} +sport {sport}''' +            destination = f'''{daddr} +dport {dport}''' + +            if jmespath.search('left.payload.field', match) == 'protocol': +                field_proto = match.get('right').upper() + +            for expr in rule.get('rule').get('expr'): +                if 'snat' in expr: +                    translation = dict_search('snat.addr', expr) +                    if expr['snat'] and 'port' in expr['snat']: +                        if jmespath.search('snat.port.range', expr): +                            port = dict_search('snat.port.range', expr) +                            port = '-'.join(map(str, port)) +                        else: +                            port = expr['snat']['port'] +                        translation = f'''{translation} +port {port}''' + +                elif 'masquerade' in expr: +                    translation = 'masquerade' +                    if expr['masquerade'] and 'port' in expr['masquerade']: +                        if jmespath.search('masquerade.port.range', expr): +                            port = dict_search('masquerade.port.range', expr) +                            port = '-'.join(map(str, port)) +                        else: +                            port = expr['masquerade']['port'] + +                        translation = f'''{translation} +port {port}''' +                elif 'dnat' in expr: +                    translation = dict_search('dnat.addr', expr) +                    if expr['dnat'] and 'port' in expr['dnat']: +                        if jmespath.search('dnat.port.range', expr): +                            port = dict_search('dnat.port.range', expr) +                            port = '-'.join(map(str, port)) +                        else: +                            port = expr['dnat']['port'] +                        translation = f'''{translation} +port {port}''' +                else: +                    translation = 'exclude' +        # Overwrite match loop 'proto' if specified filed 'protocol' exist +        if 'protocol' in jmespath.search('rule.expr[*].match.left.payload.field', rule): +            proto = jmespath.search('rule.expr[0].match.right', rule).upper() + +        data_entries.append([rule_number, source, destination, proto, interface, translation]) + +    interface_header = 'Out-Int' if direction == 'source' else 'In-Int' +    headers = ["Rule", "Source", "Destination", "Proto", interface_header, "Translation"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def _get_formatted_output_statistics(data, direction): +    data_entries = [] +    for rule in data: +        if 'comment' in rule['rule']: +            comment = rule.get('rule').get('comment') +            rule_number = comment.split('-')[-1] +            rule_number = rule_number.split(' ')[0] +        if 'expr' in rule['rule']: +            interface = rule.get('rule').get('expr')[0].get('match').get('right') \ +                if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any' +            packets = jmespath.search('rule.expr[*].counter.packets | [0]', rule) +            _bytes = jmespath.search('rule.expr[*].counter.bytes | [0]', rule) +        data_entries.append([rule_number, packets, _bytes, interface]) +    headers = ["Rule", "Packets", "Bytes", "Interface"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def _get_formatted_translation(dict_data, nat_direction, family): +    data_entries = [] +    if 'error' in dict_data['conntrack']: +        return 'Entries not found' +    for entry in dict_data['conntrack']['flow']: +        orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {} +        reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {} +        proto = {} +        for meta in entry['meta']: +            direction = meta['direction'] +            if direction in ['original']: +                if 'layer3' in meta: +                    orig_src = meta['layer3']['src'] +                    orig_dst = meta['layer3']['dst'] +                if 'layer4' in meta: +                    if meta.get('layer4').get('sport'): +                        orig_sport = meta['layer4']['sport'] +                    if meta.get('layer4').get('dport'): +                        orig_dport = meta['layer4']['dport'] +                    proto = meta['layer4']['protoname'] +            if direction in ['reply']: +                if 'layer3' in meta: +                    reply_src = meta['layer3']['src'] +                    reply_dst = meta['layer3']['dst'] +                if 'layer4' in meta: +                    if meta.get('layer4').get('sport'): +                        reply_sport = meta['layer4']['sport'] +                    if meta.get('layer4').get('dport'): +                        reply_dport = meta['layer4']['dport'] +                    proto = meta['layer4']['protoname'] +            if direction == 'independent': +                conn_id = meta['id'] +                timeout = meta['timeout'] +                orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src +                orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst +                reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src +                reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst +                state = meta['state'] if 'state' in meta else '' +                mark = meta['mark'] +                zone = meta['zone'] if 'zone' in meta else '' +                if nat_direction == 'source': +                    data_entries.append( +                        [orig_src, reply_dst, proto, timeout, mark, zone]) +                elif nat_direction == 'destination': +                    data_entries.append( +                        [orig_dst, reply_src, proto, timeout, mark, zone]) + +    headers = ["Pre-NAT", "Post-NAT", "Proto", "Timeout", "Mark", "Zone"] +    output = tabulate(data_entries, headers, numalign="left") +    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: +        return nat_rules +    else: +        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: +        return nat_statistics +    else: +        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) +    if raw: +        return nat_translation +    else: +        return _get_formatted_translation(nat_translation, direction, 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/neighbor.py b/src/op_mode/neighbor.py new file mode 100755 index 000000000..264dbdc72 --- /dev/null +++ b/src/op_mode/neighbor.py @@ -0,0 +1,122 @@ +#!/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/>. + +# Sample output of `ip --json neigh list`: +# +# [ +#   { +#     "dst": "192.168.1.1", +#     "dev": "eth0",                 # Missing if `dev ...` option is used +#     "lladdr": "00:aa:bb:cc:dd:ee", # May be missing for failed entries +#     "state": [ +#       "REACHABLE" +#     ] +#  }, +# ] + +import sys +import typing + +import vyos.opmode + +def interface_exists(interface): +    import os +    return os.path.exists(f'/sys/class/net/{interface}') + +def get_raw_data(family, interface=None, state=None): +    from json import loads +    from vyos.util import cmd + +    if interface: +        if not interface_exists(interface): +            raise ValueError(f"Interface '{interface}' does not exist in the system") +        interface = f"dev {interface}" +    else: +        interface = "" + +    if state: +        state = f"nud {state}" +    else: +        state = "" + +    neigh_cmd = f"ip --family {family} --json neighbor list {interface} {state}" + +    data = loads(cmd(neigh_cmd)) + +    return data + +def format_neighbors(neighs, interface=None): +    from tabulate import tabulate + +    def entry_to_list(e, intf=None): +        dst = e["dst"] + +        # State is always a list in the iproute2 output +        state = ", ".join(e["state"]) + +        # Link layer address is absent from e.g. FAILED entries +        if "lladdr" in e: +            lladdr = e["lladdr"] +        else: +            lladdr = None + +        # Device field is absent from outputs of `ip neigh list dev ...` +        if "dev" in e: +            dev = e["dev"] +        elif interface: +            dev = interface +        else: +            raise ValueError("interface is not defined") + +        return [dst, dev, lladdr, state] + +    neighs = map(entry_to_list, neighs) + +    headers = ["Address", "Interface", "Link layer address",  "State"] +    return tabulate(neighs, headers) + +def show(raw: bool, family: str, interface: typing.Optional[str], state: typing.Optional[str]): +    """ Display neighbor table contents """ +    data = get_raw_data(family, interface, state=state) + +    if raw: +        return data +    else: +        return format_neighbors(data, interface) + +def reset(family: str, interface: typing.Optional[str], address: typing.Optional[str]): +    from vyos.util import run + +    if address and interface: +        raise ValueError("interface and address parameters are mutually exclusive") +    elif address: +        run(f"""ip --family {family} neighbor flush to {address}""") +    elif interface: +        run(f"""ip --family {family} neighbor flush dev {interface}""") +    else: +        # Flush an entire neighbor table +        run(f"""ip --family {family} neighbor flush""") + + +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/openconnect-control.py b/src/op_mode/openconnect-control.py index c3cd25186..20c50e779 100755 --- a/src/op_mode/openconnect-control.py +++ b/src/op_mode/openconnect-control.py @@ -19,7 +19,9 @@ import argparse  import json  from vyos.config import Config -from vyos.util import popen, run, DEVNULL +from vyos.util import popen +from vyos.util import run +from vyos.util import DEVNULL  from tabulate import tabulate  occtl        = '/usr/bin/occtl' diff --git a/src/op_mode/openconnect.py b/src/op_mode/openconnect.py new file mode 100755 index 000000000..b21890728 --- /dev/null +++ b/src/op_mode/openconnect.py @@ -0,0 +1,73 @@ +#!/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 json + +from tabulate import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.util import rc_cmd + +import vyos.opmode + + +occtl        = '/usr/bin/occtl' +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: +        raise vyos.opmode.DataUnavailable(out) + +    sessions = json.loads(out) +    return sessions + + +def _get_formatted_sessions(data): +    headers = ["Interface", "Username", "IP", "Remote IP", "RX", "TX", "State", "Uptime"] +    ses_list = [] +    for ses in data: +        ses_list.append([ +            ses["Device"], ses["Username"], ses["IPv4"], ses["Remote IP"],  +            ses["_RX"], ses["_TX"], ses["State"], ses["_Connected at"] +        ]) +    if len(ses_list) > 0: +        output = tabulate(ses_list, headers) +    else: +        output = 'No active openconnect sessions' +    return output + + +def show_sessions(raw: bool): +    config = ConfigTreeQuery() +    if not config.exists('vpn openconnect'): +        raise vyos.opmode.UnconfiguredSubsystem('Openconnect is not configured') + +    openconnect_data = _get_raw_data_sessions() +    if raw: +        return openconnect_data +    return _get_formatted_sessions(openconnect_data) + + +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/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/reset_openvpn.py b/src/op_mode/reset_openvpn.py index dbd3eb4d1..efbf65083 100755 --- a/src/op_mode/reset_openvpn.py +++ b/src/op_mode/reset_openvpn.py @@ -17,6 +17,7 @@  import os  from sys import argv, exit  from vyos.util import call +from vyos.util import commit_in_progress  if __name__ == '__main__':      if (len(argv) < 1): @@ -25,6 +26,9 @@ if __name__ == '__main__':      interface = argv[1]      if os.path.isfile(f'/run/openvpn/{interface}.conf'): +        if commit_in_progress(): +            print('Cannot restart OpenVPN while a commit is in progress') +            exit(1)          call(f'systemctl restart openvpn@{interface}.service')      else:          print(f'OpenVPN interface "{interface}" does not exist!') diff --git a/src/op_mode/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py index af4fb2d15..9203c009f 100755 --- a/src/op_mode/restart_dhcp_relay.py +++ b/src/op_mode/restart_dhcp_relay.py @@ -24,6 +24,7 @@ import os  import vyos.config  from vyos.util import call +from vyos.util import commit_in_progress  parser = argparse.ArgumentParser() @@ -39,7 +40,10 @@ if __name__ == '__main__':          if not c.exists_effective('service dhcp-relay'):              print("DHCP relay service not configured")          else: -            call('systemctl restart isc-dhcp-server.service') +            if commit_in_progress(): +                print('Cannot restart DHCP relay while a commit is in progress') +                exit(1) +            call('systemctl restart isc-dhcp-relay.service')          sys.exit(0)      elif args.ipv6: @@ -47,7 +51,10 @@ if __name__ == '__main__':          if not c.exists_effective('service dhcpv6-relay'):              print("DHCPv6 relay service not configured")          else: -            call('systemctl restart isc-dhcp-server6.service') +            if commit_in_progress(): +                print('Cannot restart DHCPv6 relay while commit is in progress') +                exit(1) +            call('systemctl restart isc-dhcp-relay6.service')          sys.exit(0)      else: diff --git a/src/op_mode/route.py b/src/op_mode/route.py new file mode 100755 index 000000000..d07a34180 --- /dev/null +++ b/src/op_mode/route.py @@ -0,0 +1,115 @@ +#!/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 routing table information. +#    Used by the "run <ip|ipv6> route *" commands. + +import re +import sys +import typing + +from jinja2 import Template + +import vyos.opmode + +frr_command_template = Template(""" +{% if family == "inet" %} +    show ip route +{% else %} +    show ipv6 route +{% endif %} + +{% if table %} +    table {{table}} +{% endif %} + +{% if vrf %} +    vrf {{table}} +{% endif %} + +{% if tag %} +    tag {{tag}} +{% elif net %} +    {{net}} +{% elif protocol %} +    {{protocol}} +{% endif %} + +{% if raw %} +    json +{% 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], +         table: typing.Optional[int], +         protocol: typing.Optional[str], +         vrf: typing.Optional[str], +         tag: typing.Optional[str]): +    if net and protocol: +        raise ValueError("net and protocol are mutually exclusive") +    elif table and vrf: +        raise ValueError("table and vrf are mutually exclusive") +    elif (family == 'inet6') and (protocol == 'rip'): +        raise ValueError("rip is not a valid protocol for family inet6") +    elif (family == 'inet') and (protocol == 'ripng'): +        raise ValueError("rip is not a valid protocol for family inet6") +    else: +        if (family == 'inet6') and (protocol == 'ospf'): +            protocol = 'ospf6' + +        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 +            d = loads(output) +            collect = [] +            for k,_ in d.items(): +                for l in d[k]: +                    collect.append(l) +            return collect +        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/show_cpu.py b/src/op_mode/show_cpu.py deleted file mode 100755 index 9973d9789..000000000 --- a/src/op_mode/show_cpu.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2016-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 json - -from jinja2 import Template -from sys import exit -from vyos.util import popen, DEVNULL - -OUT_TMPL_SRC = """ -{%- if cpu -%} -{% if 'vendor' in cpu %}CPU Vendor:       {{cpu.vendor}}{% endif %} -{% if 'model' in cpu %}Model:            {{cpu.model}}{% endif %} -{% if 'cpus' in cpu %}Total CPUs:       {{cpu.cpus}}{% endif %} -{% if 'sockets' in cpu %}Sockets:          {{cpu.sockets}}{% endif %} -{% if 'cores' in cpu %}Cores:            {{cpu.cores}}{% endif %} -{% if 'threads' in cpu %}Threads:          {{cpu.threads}}{% endif %} -{% if 'mhz' in cpu %}Current MHz:      {{cpu.mhz}}{% endif %} -{% if 'mhz_min' in cpu %}Minimum MHz:      {{cpu.mhz_min}}{% endif %} -{% if 'mhz_max' in cpu %}Maximum MHz:      {{cpu.mhz_max}}{% endif %} -{%- endif -%} -""" - -def get_raw_data(): -    cpu = {} -    cpu_json, code = popen('lscpu -J', stderr=DEVNULL) - -    if code == 0: -        cpu_info = json.loads(cpu_json) -        if len(cpu_info) > 0 and 'lscpu' in cpu_info: -            for prop in cpu_info['lscpu']: -                if (prop['field'].find('Thread(s)') > -1): cpu['threads'] = prop['data'] -                if (prop['field'].find('Core(s)')) > -1: cpu['cores'] = prop['data'] -                if (prop['field'].find('Socket(s)')) > -1: cpu['sockets'] = prop['data'] -                if (prop['field'].find('CPU(s):')) > -1: cpu['cpus'] = prop['data'] -                if (prop['field'].find('CPU MHz')) > -1: cpu['mhz'] = prop['data'] -                if (prop['field'].find('CPU min MHz')) > -1: cpu['mhz_min'] = prop['data'] -                if (prop['field'].find('CPU max MHz')) > -1: cpu['mhz_max'] = prop['data'] -                if (prop['field'].find('Vendor ID')) > -1: cpu['vendor'] = prop['data'] -                if (prop['field'].find('Model name')) > -1: cpu['model'] = prop['data'] - -    return cpu - -def get_formatted_output(): -    cpu = get_raw_data() - -    tmp = {'cpu':cpu} -    tmpl = Template(OUT_TMPL_SRC) -    return tmpl.render(tmp) - -if __name__ == '__main__': -    cpu = get_raw_data() - -    if len(cpu) > 0: -        print(get_formatted_output()) -    else: -        print('CPU information could not be determined\n') -        exit(1) - diff --git a/src/op_mode/show_nat66_rules.py b/src/op_mode/show_nat66_rules.py deleted file mode 100755 index 967ec9d37..000000000 --- a/src/op_mode/show_nat66_rules.py +++ /dev/null @@ -1,102 +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 jmespath -import json - -from argparse import ArgumentParser -from jinja2 import Template -from sys import exit -from vyos.util import cmd -from vyos.util import dict_search - -parser = ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("--source", help="Show statistics for configured source NAT rules", action="store_true") -group.add_argument("--destination", help="Show statistics for configured destination NAT rules", action="store_true") -args = parser.parse_args() - -if args.source or args.destination: -    tmp = cmd('sudo nft -j list table ip6 nat') -    tmp = json.loads(tmp) -     -    format_nat66_rule = '{0: <10} {1: <50} {2: <50} {3: <10}' -    print(format_nat66_rule.format("Rule", "Source" if args.source else "Destination", "Translation", "Outbound Interface" if args.source else "Inbound Interface")) -    print(format_nat66_rule.format("----", "------" if args.source else "-----------", "-----------", "------------------" if args.source else "-----------------")) -     -    data_json = jmespath.search('nftables[?rule].rule[?chain]', tmp) -    for idx in range(0, len(data_json)): -        data = data_json[idx] -         -        # The following key values must exist -        # When the rule JSON does not have some keys, this is not a rule we can work with -        continue_rule = False -        for key in ['comment', 'chain', 'expr']: -            if key not in data: -                continue_rule = True -                continue -        if continue_rule: -            continue -         -        comment = data['comment'] -         -        # Check the annotation to see if the annotation format is created by VYOS -        continue_rule = True -        for comment_prefix in ['SRC-NAT66-', 'DST-NAT66-']: -            if comment_prefix in comment: -                continue_rule = False -        if continue_rule: -            continue -         -        # When log is detected from the second index of expr, then this rule should be ignored -        if 'log' in data['expr'][2]: -            continue -         -        rule = comment.replace('SRC-NAT66-','') -        rule = rule.replace('DST-NAT66-','') -        chain = data['chain'] -        if not ((args.source and chain == 'POSTROUTING') or (not args.source and chain == 'PREROUTING')): -            continue -        interface = dict_search('match.right', data['expr'][0]) -        srcdest = dict_search('match.right.prefix.addr', data['expr'][2]) -        if srcdest: -            addr_tmp = dict_search('match.right.prefix.len', data['expr'][2]) -            if addr_tmp: -                srcdest = srcdest + '/' + str(addr_tmp) -        else: -            srcdest = dict_search('match.right', data['expr'][2]) -         -        tran_addr_json = dict_search('snat.addr' if args.source else 'dnat.addr', data['expr'][3]) -        if tran_addr_json: -            if isinstance(srcdest_json,str): -                tran_addr = tran_addr_json - -            if 'prefix' in tran_addr_json: -                addr_tmp = dict_search('snat.addr.prefix.addr' if args.source else 'dnat.addr.prefix.addr', data['expr'][3]) -                len_tmp = dict_search('snat.addr.prefix.len' if args.source else 'dnat.addr.prefix.len', data['expr'][3]) -                if addr_tmp: -                    tran_addr = addr_tmp + '/' + str(len_tmp) -        else: -            if 'masquerade' in data['expr'][3]: -                tran_addr = 'masquerade' -         -        print(format_nat66_rule.format(rule, srcdest, tran_addr, interface)) -     -    exit(0) -else: -    parser.print_help() -    exit(1) - diff --git a/src/op_mode/show_nat66_statistics.py b/src/op_mode/show_nat66_statistics.py index bc81692ae..cb10aed9f 100755 --- a/src/op_mode/show_nat66_statistics.py +++ b/src/op_mode/show_nat66_statistics.py @@ -44,7 +44,7 @@ group.add_argument("--destination", help="Show statistics for configured destina  args = parser.parse_args()  if args.source or args.destination: -    tmp = cmd('sudo nft -j list table ip6 nat') +    tmp = cmd('sudo nft -j list table ip6 vyos_nat')      tmp = json.loads(tmp)      source = r"nftables[?rule.chain=='POSTROUTING'].rule.{chain: chain, handle: handle, comment: comment, counter: expr[].counter | [0], interface: expr[].match.right | [0] }" diff --git a/src/op_mode/show_nat_rules.py b/src/op_mode/show_nat_rules.py deleted file mode 100755 index 98adb31dd..000000000 --- a/src/op_mode/show_nat_rules.py +++ /dev/null @@ -1,123 +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 jmespath -import json - -from argparse import ArgumentParser -from jinja2 import Template -from sys import exit -from vyos.util import cmd -from vyos.util import dict_search - -parser = ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("--source", help="Show statistics for configured source NAT rules", action="store_true") -group.add_argument("--destination", help="Show statistics for configured destination NAT rules", action="store_true") -args = parser.parse_args() - -if args.source or args.destination: -    tmp = cmd('sudo nft -j list table ip nat') -    tmp = json.loads(tmp) - -    format_nat_rule = '{0: <10} {1: <50} {2: <50} {3: <10}' -    print(format_nat_rule.format("Rule", "Source" if args.source else "Destination", "Translation", "Outbound Interface" if args.source else "Inbound Interface")) -    print(format_nat_rule.format("----", "------" if args.source else "-----------", "-----------", "------------------" if args.source else "-----------------")) - -    data_json = jmespath.search('nftables[?rule].rule[?chain]', tmp) -    for idx in range(0, len(data_json)): -        data = data_json[idx] - -        # The following key values must exist -        # When the rule JSON does not have some keys, this is not a rule we can work with -        continue_rule = False -        for key in ['comment', 'chain', 'expr']: -            if key not in data: -                continue_rule = True -                continue -        if continue_rule: -            continue - -        comment = data['comment'] - -        # Check the annotation to see if the annotation format is created by VYOS -        continue_rule = True -        for comment_prefix in ['SRC-NAT-', 'DST-NAT-']: -            if comment_prefix in comment: -                continue_rule = False -        if continue_rule: -            continue - -        rule = int(''.join(list(filter(str.isdigit, comment)))) -        chain = data['chain'] -        if not ((args.source and chain == 'POSTROUTING') or (not args.source and chain == 'PREROUTING')): -            continue -        interface = dict_search('match.right', data['expr'][0]) -        srcdest = '' -        srcdests = [] -        tran_addr = '' -        for i in range(1,len(data['expr']) ): -            srcdest_json = dict_search('match.right', data['expr'][i]) -            if srcdest_json: -                if isinstance(srcdest_json,str): -                    if srcdest != '': -                        srcdests.append(srcdest) -                        srcdest = '' -                    srcdest = srcdest_json + ' ' -                elif 'prefix' in srcdest_json: -                    addr_tmp = dict_search('match.right.prefix.addr', data['expr'][i]) -                    len_tmp = dict_search('match.right.prefix.len', data['expr'][i]) -                    if addr_tmp and len_tmp: -                        srcdest = addr_tmp + '/' + str(len_tmp) + ' ' -                elif 'set' in srcdest_json: -                    if isinstance(srcdest_json['set'][0],int): -                        srcdest += 'port ' + str(srcdest_json['set'][0]) + ' ' -                    else: -                        port_range = srcdest_json['set'][0]['range'] -                        srcdest += 'port ' + str(port_range[0]) + '-' + str(port_range[1]) + ' ' - -            tran_addr_json = dict_search('snat' if args.source else 'dnat', data['expr'][i]) -            if tran_addr_json: -                if isinstance(tran_addr_json['addr'],str): -                    tran_addr += tran_addr_json['addr'] + ' ' -                elif 'prefix' in tran_addr_json['addr']: -                    addr_tmp = dict_search('snat.addr.prefix.addr' if args.source else 'dnat.addr.prefix.addr', data['expr'][3]) -                    len_tmp = dict_search('snat.addr.prefix.len' if args.source else 'dnat.addr.prefix.len', data['expr'][3]) -                    if addr_tmp and len_tmp: -                        tran_addr += addr_tmp + '/' + str(len_tmp) + ' ' - -                if isinstance(tran_addr_json['port'],int): -                    tran_addr += 'port ' + str(tran_addr_json['port']) - -            else: -                if 'masquerade' in data['expr'][i]: -                    tran_addr = 'masquerade' -                elif 'log' in data['expr'][i]: -                    continue - -        if srcdest != '': -            srcdests.append(srcdest) -            srcdest = '' -        print(format_nat_rule.format(rule, srcdests[0], tran_addr, interface)) - -        for i in range(1, len(srcdests)): -            print(format_nat_rule.format(' ', srcdests[i], ' ', ' ')) - -    exit(0) -else: -    parser.print_help() -    exit(1) - diff --git a/src/op_mode/show_nat_statistics.py b/src/op_mode/show_nat_statistics.py index c568c8305..be41e083b 100755 --- a/src/op_mode/show_nat_statistics.py +++ b/src/op_mode/show_nat_statistics.py @@ -44,7 +44,7 @@ group.add_argument("--destination", help="Show statistics for configured destina  args = parser.parse_args()  if args.source or args.destination: -    tmp = cmd('sudo nft -j list table ip nat') +    tmp = cmd('sudo nft -j list table ip vyos_nat')      tmp = json.loads(tmp)      source = r"nftables[?rule.chain=='POSTROUTING'].rule.{chain: chain, handle: handle, comment: comment, counter: expr[].counter | [0], interface: expr[].match.right | [0] }" diff --git a/src/op_mode/show_nat_translations.py b/src/op_mode/show_nat_translations.py index 25091e9fc..508845e23 100755 --- a/src/op_mode/show_nat_translations.py +++ b/src/op_mode/show_nat_translations.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-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 @@ -83,11 +83,23 @@ def pipe():      return xml +def xml_to_dict(xml): +    """ +    Convert XML to dictionary +    Return: dictionary +    """ +    parse = xmltodict.parse(xml) +    # If only one NAT entry we must change dict T4499 +    if 'meta' in parse['conntrack']['flow']: +        return dict(conntrack={'flow': [parse['conntrack']['flow']]}) +    return parse + +  def process(data, stats, protocol, pipe, verbose, flowtype=''):      if not data:          return -    parsed = xmltodict.parse(data) +    parsed = xml_to_dict(data)      print(headers(verbose, pipe)) diff --git a/src/op_mode/show_neigh.py b/src/op_mode/show_neigh.py deleted file mode 100755 index 94e745493..000000000 --- a/src/op_mode/show_neigh.py +++ /dev/null @@ -1,96 +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/>. - -#ip -j -f inet neigh list | jq -#[ -  #{ -    #"dst": "192.168.101.8", -    #"dev": "enp0s25", -    #"lladdr": "78:d2:94:72:77:7e", -    #"state": [ -      #"STALE" -    #] -  #}, -  #{ -    #"dst": "192.168.101.185", -    #"dev": "enp0s25", -    #"lladdr": "34:46:ec:76:f8:9b", -    #"state": [ -      #"STALE" -    #] -  #}, -  #{ -    #"dst": "192.168.101.225", -    #"dev": "enp0s25", -    #"lladdr": "c2:cb:fa:bf:a0:35", -    #"state": [ -      #"STALE" -    #] -  #}, -  #{ -    #"dst": "192.168.101.1", -    #"dev": "enp0s25", -    #"lladdr": "00:98:2b:f8:3f:11", -    #"state": [ -      #"REACHABLE" -    #] -  #}, -  #{ -    #"dst": "192.168.101.181", -    #"dev": "enp0s25", -    #"lladdr": "d8:9b:3b:d5:88:22", -    #"state": [ -      #"STALE" -    #] -  #} -#] - -import sys -import argparse -import json -from vyos.util import cmd - -def main(): -    #parese args -    parser = argparse.ArgumentParser() -    parser.add_argument('--family', help='Protocol family', required=True) -    args = parser.parse_args() -     -    neigh_raw_json = cmd(f'ip -j -f {args.family} neigh list') -    neigh_raw_json = neigh_raw_json.lower() -    neigh_json = json.loads(neigh_raw_json) -     -    format_neigh = '%-50s %-10s %-20s %s' -    print(format_neigh % ("IP Address", "Device", "State", "LLADDR")) -    print(format_neigh % ("----------", "------", "-----", "------")) -     -    if neigh_json is not None: -        for neigh_item in neigh_json: -            dev = neigh_item['dev'] -            dst = neigh_item['dst'] -            lladdr = neigh_item['lladdr'] if 'lladdr' in neigh_item else '' -            state = neigh_item['state'] -             -            i = 0 -            for state_item in  state: -                if i == 0: -                    print(format_neigh % (dst, dev, state_item, lladdr)) -                else: -                    print(format_neigh % ('', '', state_item, '')) -                i+=1 -             -if __name__ == '__main__': -    main() 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/show_vrf.py b/src/op_mode/show_vrf.py deleted file mode 100755 index 3c7a90205..000000000 --- a/src/op_mode/show_vrf.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import argparse -import jinja2 -from json import loads - -from vyos.util import cmd - -vrf_out_tmpl = """VRF name          state     mac address        flags                     interfaces ---------          -----     -----------        -----                     ---------- -{%- for v in vrf %} -{{"%-16s"|format(v.ifname)}}  {{ "%-8s"|format(v.operstate | lower())}}  {{"%-17s"|format(v.address | lower())}}  {{ v.flags|join(',')|lower()}}  {{v.members|join(',')|lower()}} -{%- endfor %} - -""" - -def list_vrfs(): -    command = 'ip -j -br link show type vrf' -    answer = loads(cmd(command)) -    return [_ for _ in answer if _] - -def list_vrf_members(vrf): -    command = f'ip -j -br link show master {vrf}' -    answer = loads(cmd(command)) -    return [_ for _ in answer if _] - -parser = argparse.ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("-e", "--extensive", action="store_true", -                   help="provide detailed vrf informatio") -parser.add_argument('interface', metavar='I', type=str, nargs='?', -                    help='interface to display') - -args = parser.parse_args() - -if args.extensive: -    data = { 'vrf': [] } -    for vrf in list_vrfs(): -        name = vrf['ifname'] -        if args.interface and name != args.interface: -            continue - -        vrf['members'] = [] -        for member in list_vrf_members(name): -            vrf['members'].append(member['ifname']) -        data['vrf'].append(vrf) - -    tmpl = jinja2.Template(vrf_out_tmpl) -    print(tmpl.render(data)) - -else: -    print(" ".join([vrf['ifname'] for vrf in list_vrfs()])) diff --git a/src/op_mode/storage.py b/src/op_mode/storage.py new file mode 100755 index 000000000..d16e271bd --- /dev/null +++ b/src/op_mode/storage.py @@ -0,0 +1,78 @@ +#!/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.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: +        cmd_str = 'df -h -x squashf' +    else: +        cmd_str = 'df -h -t ext4 --output=source,size,used,avail,pcent' + +    res = cmd(cmd_str) + +    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(): +    return _get_system_storage() + +def show(raw: bool): +    if raw: +        return _get_raw_data() + +    return _get_formatted_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/system.py b/src/op_mode/system.py new file mode 100755 index 000000000..11a3a8730 --- /dev/null +++ b/src/op_mode/system.py @@ -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/>. + +import jmespath +import json +import sys +import requests +import typing + +from sys import exit + +from vyos.configquery import ConfigTreeQuery + +import vyos.opmode +import vyos.version + +config = ConfigTreeQuery() +base = ['system', 'update-check'] + + +def _compare_version_raw(): +    url = config.value(base + ['url']) +    local_data = vyos.version.get_full_version_data() +    remote_data = vyos.version.get_remote_version(url) +    if not remote_data: +        return {"error": True, +                "reason": "Unable to get remote version"} +    if local_data.get('version') and remote_data: +        local_version = local_data.get('version') +        remote_version = jmespath.search('[0].version', remote_data) +        image_url = jmespath.search('[0].url', remote_data) +        if local_data.get('version') != remote_version: +            return {"error": False, +                    "update_available": True, +                    "local_version": local_version, +                    "remote_version": remote_version, +                    "url": image_url} +        return {"update_available": False, +                "local_version": local_version, +                "remote_version": remote_version} + + +def _formatted_compare_version(data): +    local_version = data.get('local_version') +    remote_version = data.get('remote_version') +    url = data.get('url') +    if {'update_available','local_version', 'remote_version', 'url'} <= set(data): +        return f'Current version: {local_version}\n\nUpdate available: {remote_version}\nUpdate URL: {url}' +    elif local_version == remote_version and remote_version is not None: +        return f'No available updates for your system \n' \ +               f'current version: {local_version}\nremote version: {remote_version}' +    else: +        return 'Update not found' + + +def _verify(): +    if not config.exists(base): +        return False +    return True + + +def show_update(raw: bool): +    if not _verify(): +        raise vyos.opmode.UnconfiguredSubsystem("system update-check not configured") +    data = _compare_version_raw() +    if raw: +        return data +    else: +        return _formatted_compare_version(data) + + +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/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/show_uptime.py b/src/op_mode/uptime.py index b70c60cf8..2ebe6783b 100755 --- a/src/op_mode/show_uptime.py +++ b/src/op_mode/uptime.py @@ -14,7 +14,11 @@  # You should have received a copy of the GNU General Public License  # along with this program.  If not, see <http://www.gnu.org/licenses/>. -def get_uptime_seconds(): +import sys + +import vyos.opmode + +def _get_uptime_seconds():    from re import search    from vyos.util import read_file @@ -23,7 +27,7 @@ def get_uptime_seconds():    return int(float(seconds)) -def get_load_averages(): +def _get_load_averages():      from re import search      from vyos.util import cmd      from vyos.cpu import get_core_count @@ -40,19 +44,17 @@ def get_load_averages():      return res -def get_raw_data(): +def _get_raw_data():      from vyos.util import seconds_to_human      res = {} -    res["uptime_seconds"] = get_uptime_seconds() -    res["uptime"] = seconds_to_human(get_uptime_seconds()) -    res["load_average"] = get_load_averages() +    res["uptime_seconds"] = _get_uptime_seconds() +    res["uptime"] = seconds_to_human(_get_uptime_seconds()) +    res["load_average"] = _get_load_averages()      return res -def get_formatted_output(): -    data = get_raw_data() - +def _get_formatted_output(data):      out = "Uptime: {}\n\n".format(data["uptime"])      avgs = data["load_average"]      out += "Load averages:\n" @@ -62,5 +64,19 @@ def get_formatted_output():      return out +def show(raw: bool): +    uptime_data = _get_raw_data() + +    if raw: +        return uptime_data +    else: +        return _get_formatted_output(uptime_data) +  if __name__ == '__main__': -    print(get_formatted_output()) +    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/show_version.py b/src/op_mode/version.py index b82ab6eca..ad0293aca 100755 --- a/src/op_mode/show_version.py +++ b/src/op_mode/version.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2016-2020 VyOS maintainers and contributors +# Copyright (C) 2016-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 @@ -18,13 +18,14 @@  #    Displays image version and system information.  #    Used by the "run show version" command. -import argparse +import sys +import typing + +import vyos.opmode  import vyos.version  import vyos.limericks  from jinja2 import Template -from sys import exit -from vyos.util import call  version_output_tmpl = """  Version:          VyOS {{version}} @@ -45,32 +46,39 @@ Hardware S/N:     {{hardware_serial}}  Hardware UUID:    {{hardware_uuid}}  Copyright:        VyOS maintainers and contributors +{%- if limerick %} +{{limerick}} +{% endif -%}  """ -def get_raw_data(): +def _get_raw_data(funny=False):      version_data = vyos.version.get_full_version_data() + +    if funny: +        version_data["limerick"] = vyos.limericks.get_random() +      return version_data -def get_formatted_output(): -    version_data = get_raw_data() +def _get_formatted_output(version_data):      tmpl = Template(version_output_tmpl) -    return tmpl.render(version_data) +    return tmpl.render(version_data).strip() -if __name__ == '__main__': -    parser = argparse.ArgumentParser() -    parser.add_argument("-f", "--funny", action="store_true", help="Add something funny to the output") -    parser.add_argument("-j", "--json", action="store_true", help="Produce JSON output") +def show(raw: bool, funny: typing.Optional[bool]): +    """ Display neighbor table contents """ +    version_data = _get_raw_data(funny=funny) -    args = parser.parse_args() +    if raw: +        return version_data +    else: +        return _get_formatted_output(version_data) -    version_data = vyos.version.get_full_version_data() -    if args.json: -        import json -        print(json.dumps(version_data)) -        exit(0) -    else: -        print(get_formatted_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) -    if args.funny: -        print(vyos.limericks.get_random()) diff --git a/src/op_mode/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py index 00f34564a..4b44c5c15 100755 --- a/src/op_mode/vpn_ike_sa.py +++ b/src/op_mode/vpn_ike_sa.py @@ -71,7 +71,7 @@ if __name__ == '__main__':      args = parser.parse_args()      if not process_named_running('charon'): -        print("IPSec Process NOT Running") +        print("IPsec Process NOT Running")          sys.exit(0)      ike_sa(args.peer, args.nat) diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index 8955e5a59..68dc5bc45 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -87,6 +87,7 @@ def reset_profile(profile, tunnel):      print('Profile reset result: ' + ('success' if result == 0 else 'failed'))  def debug_peer(peer, tunnel): +    peer = peer.replace(':', '-')      if not peer or peer == "all":          debug_commands = [              "sudo ipsec statusall", @@ -109,7 +110,7 @@ def debug_peer(peer, tunnel):      if not tunnel or tunnel == 'all':          tunnel = '' -    conn = get_peer_connections(peer, tunnel) +    conns = get_peer_connections(peer, tunnel, return_all = (tunnel == '' or tunnel == 'all'))      if not conns:          print('Peer not found, aborting') diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py new file mode 100755 index 000000000..a9a416761 --- /dev/null +++ b/src/op_mode/vrf.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import json +import jmespath +import sys +import typing + +from tabulate import tabulate +from vyos.util import cmd + +import vyos.opmode + + +def _get_raw_data(name=None): +    """ +    If vrf name is not set - get all VRFs +    If vrf name is set - get only this name data +    If vrf name set and not found - return [] +    """ +    output = cmd('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'ip --json --brief link show dev {name}') +            data = json.loads(output) +            return data +        return [] +    return data + + +def _get_vrf_members(vrf: str) -> list: +    """ +    Get list of interface VRF members +    :param vrf: str +    :return: list +    """ +    output = cmd(f'ip --json --brief link show master {vrf}') +    answer = json.loads(output) +    interfaces = [] +    for data in answer: +        if 'ifname' in data: +            interfaces.append(data.get('ifname')) +    return interfaces if len(interfaces) > 0 else ['n/a'] + + +def _get_formatted_output(raw_data): +    data_entries = [] +    for vrf in raw_data: +        name = vrf.get('ifname') +        state = vrf.get('operstate').lower() +        hw_address = vrf.get('address') +        flags = ','.join(vrf.get('flags')).lower() +        members = ','.join(_get_vrf_members(name)) +        data_entries.append([name, state, hw_address, flags, members]) + +    headers = ["Name", "State", "MAC address", "Flags", "Interfaces"] +    output = tabulate(data_entries, headers, numalign="left") +    return output + + +def show(raw: bool, name: typing.Optional[str]): +    vrf_data = _get_raw_data(name=name) +    if not jmespath.search('[*].ifname', vrf_data): +        return "VRF is not configured" +    if raw: +        return vrf_data +    else: +        return _get_formatted_output(vrf_data) + + +if __name__ == "__main__": +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/vtysh_wrapper.sh b/src/op_mode/vtysh_wrapper.sh index 09980e14f..25d09ce77 100755 --- a/src/op_mode/vtysh_wrapper.sh +++ b/src/op_mode/vtysh_wrapper.sh @@ -1,5 +1,6 @@  #!/bin/sh  declare -a tmp -# FRR uses ospf6 where we use ospfv3, thus alter the command -tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/") +# FRR uses ospf6 where we use ospfv3, and we use reset over clear for BGP, +# thus alter the commands +tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/")  vtysh -c "$tmp" diff --git a/src/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh index 43a4b79fc..d5f301b75 100755 --- a/src/op_mode/webproxy_update_blacklist.sh +++ b/src/op_mode/webproxy_update_blacklist.sh @@ -88,7 +88,7 @@ if [[ -n $update ]] && [[ $update -eq "yes" ]]; then      # fix permissions      chown -R proxy:proxy ${db_dir} -    chmod 2770 ${db_dir} +    chmod 755 ${db_dir}      logger --priority WARNING "webproxy blacklist entries updated (${count_before}/${count_after})" diff --git a/src/services/api/graphql/recipes/__init__.py b/src/services/api/graphql/__init__.py index e69de29bb..e69de29bb 100644 --- a/src/services/api/graphql/recipes/__init__.py +++ b/src/services/api/graphql/__init__.py diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 84d719fda..aa1ba0eb0 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -17,13 +17,27 @@ import vyos.defaults  from . graphql.queries import query  from . graphql.mutations import mutation  from . graphql.directives import directives_dict +from . graphql.errors import op_mode_error +from . 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, mutation, snake_case_fallback_resolvers, directives=directives_dict) +    schema = make_executable_schema(type_defs, query, op_mode_error, mutation, auth_token_mutation, snake_case_fallback_resolvers, directives=directives_dict)      return schema diff --git a/src/services/api/graphql/generate/composite_function.py b/src/services/api/graphql/generate/composite_function.py new file mode 100644 index 000000000..bc9d80fbb --- /dev/null +++ b/src/services/api/graphql/generate/composite_function.py @@ -0,0 +1,11 @@ +# typing information for composite functions: those that invoke several +# elementary requests, and return the result as a single dict +import typing + +def system_status(): +    pass + +queries = {'system_status': system_status} + +mutations = {} + diff --git a/src/services/api/graphql/generate/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py new file mode 100644 index 000000000..fc0dd7a87 --- /dev/null +++ b/src/services/api/graphql/generate/config_session_function.py @@ -0,0 +1,28 @@ +# typing information for native configsession functions; used to generate +# schema definition files +import typing + +def show_config(path: list[str], configFormat: typing.Optional[str]): +    pass + +def show(path: list[str]): +    pass + +queries = {'show_config': show_config, +           'show': show} + +def save_config_file(fileName: typing.Optional[str]): +    pass +def load_config_file(fileName: str): +    pass +def add_system_image(location: str): +    pass +def delete_system_image(name: str): +    pass + +mutations = {'save_config_file': save_config_file, +             'load_config_file': load_config_file, +             'add_system_image': add_system_image, +             'delete_system_image': delete_system_image} + + diff --git a/src/services/api/graphql/generate/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py new file mode 100755 index 000000000..61a08cb2f --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_composite.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# +# A utility to generate GraphQL schema defintions from typing information of +# composite functions comprising several requests. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': +    sys.path.append("/usr/libexec/vyos/services/api") +    from graphql.libs.op_mode import snake_to_pascal_case, map_type_name +    from composite_function import queries, mutations +    from vyos.config import Config +    from vyos.configdict import dict_merge +    from vyos.xml import defaults +else: +    from .. libs.op_mode import snake_to_pascal_case, map_type_name +    from . composite_function import queries, mutations +    from .. import state + +SCHEMA_PATH = directories['api_schema'] + +if __package__ is None or __package__ == '': +    # allow running stand-alone +    conf = Config() +    base = ['service', 'https', 'api'] +    graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) +    if 'graphql' not in graphql_dict: +        exit("graphql is not configured") + +    graphql_dict = dict_merge(defaults(base), graphql_dict) +    auth_type = graphql_dict['graphql']['authentication']['type'] +else: +    auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, +                     'schema_name': '', +                     'schema_fields': []} + +query_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Query { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositequery +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @compositequery +{%- endif %} +} +""" + +mutation_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositemutation +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @compositemutation +{%- endif %} +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = snake_to_pascal_case(func_name) +    schema_data['schema_fields'] = schema_fields + +    j2_template = Template(template) +    res = j2_template.render(schema_data) + +    return res + +def generate_composite_definitions(): +    results = [] +    for name,func in queries.items(): +        res = create_schema(name, func, query_template) +        results.append(res) + +    for name,func in mutations.items(): +        res = create_schema(name, func, mutation_template) +        results.append(res) + +    out = '\n'.join(results) +    with open(f'{SCHEMA_PATH}/composite.graphql', 'w') as f: +        f.write(out) + +if __name__ == '__main__': +    generate_composite_definitions() diff --git a/src/services/api/graphql/generate/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py new file mode 100755 index 000000000..49bf2440e --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# +# A utility to generate GraphQL schema defintions from typing information of +# (wrappers of) native configsession functions. + +import os +import sys +import json +from inspect import signature, getmembers, isfunction, isclass, getmro +from jinja2 import Template + +from vyos.defaults import directories +if __package__ is None or __package__ == '': +    sys.path.append("/usr/libexec/vyos/services/api") +    from graphql.libs.op_mode import snake_to_pascal_case, map_type_name +    from config_session_function import queries, mutations +    from vyos.config import Config +    from vyos.configdict import dict_merge +    from vyos.xml import defaults +else: +    from .. libs.op_mode import snake_to_pascal_case, map_type_name +    from . config_session_function import queries, mutations +    from .. import state + +SCHEMA_PATH = directories['api_schema'] + +if __package__ is None or __package__ == '': +    # allow running stand-alone +    conf = Config() +    base = ['service', 'https', 'api'] +    graphql_dict = conf.get_config_dict(base, key_mangling=('-', '_'), +                                          no_tag_node_value_mangle=True, +                                          get_first_key=True) +    if 'graphql' not in graphql_dict: +        exit("graphql is not configured") + +    graphql_dict = dict_merge(defaults(base), graphql_dict) +    auth_type = graphql_dict['graphql']['authentication']['type'] +else: +    auth_type = state.settings['app'].state.vyos_auth_type + +schema_data: dict = {'auth_type': auth_type, +                     'schema_name': '', +                     'schema_fields': []} + +query_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Query { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionquery +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @configsessionquery +{%- endif %} +} +""" + +mutation_template  = """ +{%- if auth_type == 'key' %} +input {{ schema_name }}Input { +    key: String! +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- elif schema_fields %} +input {{ schema_name }}Input { +    {%- for field_entry in schema_fields %} +    {{ field_entry }} +    {%- endfor %} +} +{%- endif %} + +type {{ schema_name }} { +    result: Generic +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +{%- if auth_type == 'key' or schema_fields %} +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionmutation +{%- else %} +    {{ schema_name }} : {{ schema_name }}Result @configsessionmutation +{%- endif %} +} +""" + +def create_schema(func_name: str, func: callable, template: str) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = snake_to_pascal_case(func_name) +    schema_data['schema_fields'] = schema_fields + +    j2_template = Template(template) +    res = j2_template.render(schema_data) + +    return res + +def generate_config_session_definitions(): +    results = [] +    for name,func in queries.items(): +        res = create_schema(name, func, query_template) +        results.append(res) + +    for name,func in mutations.items(): +        res = create_schema(name, func, mutation_template) +        results.append(res) + +    out = '\n'.join(results) +    with open(f'{SCHEMA_PATH}/configsession.graphql', 'w') as f: +        f.write(out) + +if __name__ == '__main__': +    generate_config_session_definitions() diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py new file mode 100755 index 000000000..fc63b0100 --- /dev/null +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +# +# +# A utility to generate GraphQL schema defintions from standardized op-mode +# scripts. + +import os +import 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__ == '': +    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 .. 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'] +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' + +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 }} +    op_mode_error: OpModeError +    success: Boolean! +    errors: [String] +} + +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 +} + +type {{ schema_name }}Result { +    data: {{ schema_name }} +    op_mode_error: OpModeError +    success: Boolean! +    errors: [String] +} + +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 %} +} +""" + +error_template = """ +interface OpModeError { +    name: String! +    message: String! +    vyos_code: Int! +} +{% for name in error_names %} +type {{ name }} implements OpModeError { +    name: String! +    message: String! +    vyos_code: Int! +} +{%- endfor %} +""" + +def create_schema(func_name: str, base_name: str, func: callable) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation) + +    # It is assumed that if one is generating a schema for a 'show_*' +    # function, that 'get_raw_data' is present and 'raw' is desired. +    if 'raw' in list(field_dict): +        del field_dict['raw'] + +    schema_fields = [] +    for k,v in field_dict.items(): +        schema_fields.append(k+': '+v) + +    schema_data['schema_name'] = snake_to_pascal_case(func_name + '_' + base_name) +    schema_data['schema_fields'] = schema_fields + +    if is_show_function_name(func_name): +        j2_template = Template(query_template) +    else: +        j2_template = Template(mutation_template) + +    res = j2_template.render(schema_data) + +    return res + +def create_error_schema(): +    from vyos import opmode + +    e = Exception +    err_types = getmembers(opmode, isclass) +    err_types = [k for k in err_types if issubclass(k[1], e)] +    # drop base class, to be replaced by interface type. Find the class +    # programmatically, in case the base class name changes. +    for i in range(len(err_types)): +        if err_types[i][1] in getmro(err_types[i-1][1]): +            del err_types[i] +            break +    err_names = [k[0] for k in err_types] +    error_data = {'error_names': err_names} +    j2_template = Template(error_template) +    res = j2_template.render(error_data) + +    return res + +def generate_op_mode_definitions(): +    out = create_error_schema() +    with open(f'{SCHEMA_PATH}/{op_mode_error_schema}', 'w') as f: +        f.write(out) + +    with open(op_mode_include_file) as f: +        op_mode_files = json.load(f) + +    for file in op_mode_files: +        basename = os.path.splitext(file)[0].replace('-', '_') +        module = load_as_module(basename, os.path.join(OP_MODE_PATH, file)) + +        funcs = getmembers(module, isfunction) +        funcs = list(filter(lambda ft: is_op_mode_function_name(ft[0]), funcs)) + +        funcs_dict = {} +        for (name, thunk) in funcs: +            funcs_dict[name] = thunk + +        results = [] +        for name,func in funcs_dict.items(): +            res = create_schema(name, basename, func) +            results.append(res) + +        out = '\n'.join(results) +        with open(f'{SCHEMA_PATH}/{basename}.graphql', 'w') as f: +            f.write(out) + +if __name__ == '__main__': +    generate_op_mode_definitions() diff --git a/src/services/api/graphql/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 0a9298f55..a7919854a 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -31,49 +31,57 @@ class VyosDirective(SchemaDirectiveVisitor):          field.resolve = func          return field +class ConfigSessionQueryDirective(VyosDirective): +    """ +    Class providing implementation of 'configsessionquery' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_config_session_query_resolver) -class ConfigureDirective(VyosDirective): +class ConfigSessionMutationDirective(VyosDirective):      """ -    Class providing implementation of 'configure' directive in schema. +    Class providing implementation of 'configsessionmutation' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_configure_resolver) +                                       make_resolver=make_config_session_mutation_resolver) -class ShowConfigDirective(VyosDirective): +class GenOpQueryDirective(VyosDirective):      """ -    Class providing implementation of 'show' directive in schema. +    Class providing implementation of 'genopquery' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_show_config_resolver) +                                       make_resolver=make_gen_op_query_resolver) -class ConfigFileDirective(VyosDirective): +class GenOpMutationDirective(VyosDirective):      """ -    Class providing implementation of 'configfile' directive in schema. +    Class providing implementation of 'genopmutation' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_config_file_resolver) +                                       make_resolver=make_gen_op_mutation_resolver) -class ShowDirective(VyosDirective): +class CompositeQueryDirective(VyosDirective):      """ -    Class providing implementation of 'show' directive in schema. +    Class providing implementation of 'system_status' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_show_resolver) +                                       make_resolver=make_composite_query_resolver) -class ImageDirective(VyosDirective): +class CompositeMutationDirective(VyosDirective):      """ -    Class providing implementation of 'image' directive in schema. +    Class providing implementation of 'system_status' directive in schema.      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type, -                                       make_resolver=make_image_resolver) +                                       make_resolver=make_composite_mutation_resolver) -directives_dict = {"configure": ConfigureDirective, -                   "showconfig": ShowConfigDirective, -                   "configfile": ConfigFileDirective, -                   "show": ShowDirective, -                   "image": ImageDirective} +directives_dict = {"configsessionquery": ConfigSessionQueryDirective, +                   "configsessionmutation": ConfigSessionMutationDirective, +                   "genopquery": GenOpQueryDirective, +                   "genopmutation": GenOpMutationDirective, +                   "compositequery": CompositeQueryDirective, +                   "compositemutation": CompositeMutationDirective} diff --git a/src/services/api/graphql/graphql/errors.py b/src/services/api/graphql/graphql/errors.py new file mode 100644 index 000000000..1066300e0 --- /dev/null +++ b/src/services/api/graphql/graphql/errors.py @@ -0,0 +1,8 @@ + +from ariadne import InterfaceType + +op_mode_error = InterfaceType("OpModeError") + +@op_mode_error.type_resolver +def resolve_op_mode_error(obj, *_): +    return obj['name'] diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 0c3eb702a..87ea59c43 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -14,13 +14,16 @@  # 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 api.graphql.recipes.session import Session +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  mutation = ObjectType("Mutation") @@ -39,29 +42,62 @@ 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'] -                } +            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 -            data = kwargs['data']              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass              try: -                mod = import_module(f'api.graphql.recipes.{func_base_name}') +                mod = import_module(f'api.graphql.session.override.{func_base_name}')                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -72,28 +108,31 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                  "success": True,                  "data": data              } +        except OpModeError as e: +            typename = type(e).__name__ +            msg = str(e) +            return { +                "success": False, +                "errore": ['op_mode_error'], +                "op_mode_error": {"name": f"{typename}", +                                 "message": msg if msg else op_mode_err_msg.get(typename, "Unknown"), +                                 "vyos_code": op_mode_err_code.get(typename, 9999)} +            }          except Exception as error:              return {                  "success": False, -                "errors": [str(error)] +                "errors": [repr(error)]              }      return func_impl -def make_prefix_resolver(mutation_name, prefix=[]): -    for pre in prefix: -        Pre = pre.capitalize() -        if Pre in mutation_name: -            class_name = mutation_name.replace(Pre, '', 1) -            return make_mutation_resolver(mutation_name, class_name, pre) -    raise Exception - -def make_configure_resolver(mutation_name): -    class_name = mutation_name -    return make_mutation_resolver(mutation_name, class_name, 'configure') +def make_config_session_mutation_resolver(mutation_name): +    return make_mutation_resolver(mutation_name, mutation_name, +                                  convert_camel_case_to_snake(mutation_name)) -def make_config_file_resolver(mutation_name): -    return make_prefix_resolver(mutation_name, prefix=['save', 'load']) +def make_gen_op_mutation_resolver(mutation_name): +    return make_mutation_resolver(mutation_name, mutation_name, 'gen_op_mutation') -def make_image_resolver(mutation_name): -    return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) +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 e1868091e..1ad586428 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -14,13 +14,16 @@  # 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 api.graphql.recipes.session import Session +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  query = ObjectType("Query") @@ -39,29 +42,62 @@ 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'] -                } +            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 -            data = kwargs['data']              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass              try: -                mod = import_module(f'api.graphql.recipes.{func_base_name}') +                mod = import_module(f'api.graphql.session.override.{func_base_name}')                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -72,18 +108,31 @@ def make_query_resolver(query_name, class_name, session_func):                  "success": True,                  "data": data              } +        except OpModeError as e: +            typename = type(e).__name__ +            msg = str(e) +            return { +                "success": False, +                "errors": ['op_mode_error'], +                "op_mode_error": {"name": f"{typename}", +                                 "message": msg if msg else op_mode_err_msg.get(typename, "Unknown"), +                                 "vyos_code": op_mode_err_code.get(typename, 9999)} +            }          except Exception as error:              return {                  "success": False, -                "errors": [str(error)] +                "errors": [repr(error)]              }      return func_impl -def make_show_config_resolver(query_name): -    class_name = query_name -    return make_query_resolver(query_name, class_name, 'show_config') +def make_config_session_query_resolver(query_name): +    return make_query_resolver(query_name, query_name, +                               convert_camel_case_to_snake(query_name)) + +def make_gen_op_query_resolver(query_name): +    return make_query_resolver(query_name, query_name, 'gen_op_query') -def make_show_resolver(query_name): -    class_name = query_name -    return make_query_resolver(query_name, class_name, 'show') +def make_composite_query_resolver(query_name): +    return make_query_resolver(query_name, query_name, +                               convert_camel_case_to_snake(query_name)) diff --git a/src/services/api/graphql/graphql/schema/auth_token.graphql b/src/services/api/graphql/graphql/schema/auth_token.graphql new file mode 100644 index 000000000..af53a293a --- /dev/null +++ b/src/services/api/graphql/graphql/schema/auth_token.graphql @@ -0,0 +1,19 @@ + +input AuthTokenInput { +    username: String! +    password: String! +} + +type AuthToken { +    result: Generic +} + +type AuthTokenResult { +    data: AuthToken +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +    AuthToken(data: AuthTokenInput) : AuthTokenResult +} diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql deleted file mode 100644 index 31ab26b9e..000000000 --- a/src/services/api/graphql/graphql/schema/config_file.graphql +++ /dev/null @@ -1,27 +0,0 @@ -input SaveConfigFileInput { -    fileName: String -} - -type SaveConfigFile { -    fileName: String -} - -type SaveConfigFileResult { -    data: SaveConfigFile -    success: Boolean! -    errors: [String] -} - -input LoadConfigFileInput { -    fileName: String! -} - -type LoadConfigFile { -    fileName: String! -} - -type LoadConfigFileResult { -    data: LoadConfigFile -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql deleted file mode 100644 index 25f091bfa..000000000 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ /dev/null @@ -1,35 +0,0 @@ -input DhcpServerConfigInput { -    sharedNetworkName: String -    subnet: String -    defaultRouter: String -    nameServer: String -    domainName: String -    lease: Int -    range: Int -    start: String -    stop: String -    dnsForwardingAllowFrom: String -    dnsForwardingCacheSize: Int -    dnsForwardingListenAddress: String -} - -type DhcpServerConfig { -    sharedNetworkName: String -    subnet: String -    defaultRouter: String -    nameServer: String -    domainName: String -    lease: Int -    range: Int -    start: String -    stop: String -    dnsForwardingAllowFrom: String -    dnsForwardingCacheSize: Int -    dnsForwardingListenAddress: String -} - -type CreateDhcpServerResult { -    data: DhcpServerConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql deleted file mode 100644 index d89904b9e..000000000 --- a/src/services/api/graphql/graphql/schema/firewall_group.graphql +++ /dev/null @@ -1,95 +0,0 @@ -input CreateFirewallAddressGroupInput { -    name: String! -    address: [String] -} - -type CreateFirewallAddressGroup { -    name: String! -    address: [String] -} - -type CreateFirewallAddressGroupResult { -    data: CreateFirewallAddressGroup -    success: Boolean! -    errors: [String] -} - -input UpdateFirewallAddressGroupMembersInput { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressGroupMembers { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressGroupMembersResult { -    data: UpdateFirewallAddressGroupMembers -    success: Boolean! -    errors: [String] -} - -input RemoveFirewallAddressGroupMembersInput { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressGroupMembers { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressGroupMembersResult { -    data: RemoveFirewallAddressGroupMembers -    success: Boolean! -    errors: [String] -} - -input CreateFirewallAddressIpv6GroupInput { -    name: String! -    address: [String] -} - -type CreateFirewallAddressIpv6Group { -    name: String! -    address: [String] -} - -type CreateFirewallAddressIpv6GroupResult { -    data: CreateFirewallAddressIpv6Group -    success: Boolean! -    errors: [String] -} - -input UpdateFirewallAddressIpv6GroupMembersInput { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressIpv6GroupMembers { -    name: String! -    address: [String!]! -} - -type UpdateFirewallAddressIpv6GroupMembersResult { -    data: UpdateFirewallAddressIpv6GroupMembers -    success: Boolean! -    errors: [String] -} - -input RemoveFirewallAddressIpv6GroupMembersInput { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressIpv6GroupMembers { -    name: String! -    address: [String!]! -} - -type RemoveFirewallAddressIpv6GroupMembersResult { -    data: RemoveFirewallAddressIpv6GroupMembers -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/image.graphql b/src/services/api/graphql/graphql/schema/image.graphql deleted file mode 100644 index 7d1b4f9d0..000000000 --- a/src/services/api/graphql/graphql/schema/image.graphql +++ /dev/null @@ -1,29 +0,0 @@ -input AddSystemImageInput { -    location: String! -} - -type AddSystemImage { -    location: String -    result: String -} - -type AddSystemImageResult { -    data: AddSystemImage -    success: Boolean! -    errors: [String] -} - -input DeleteSystemImageInput { -    name: String! -} - -type DeleteSystemImage { -    name: String -    result: String -} - -type DeleteSystemImageResult { -    data: DeleteSystemImage -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql deleted file mode 100644 index 32438b315..000000000 --- a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql +++ /dev/null @@ -1,18 +0,0 @@ -input InterfaceEthernetConfigInput { -    interface: String -    address: String -    replace: Boolean = true -    description: String -} - -type InterfaceEthernetConfig { -    interface: String -    address: String -    description: String -} - -type CreateInterfaceEthernetResult { -    data: InterfaceEthernetConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 952e46f34..62b0d30bb 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -3,28 +3,14 @@ schema {      mutation: Mutation  } -directive @configure on FIELD_DEFINITION -directive @configfile on FIELD_DEFINITION -directive @show on FIELD_DEFINITION -directive @showconfig on FIELD_DEFINITION -directive @image on FIELD_DEFINITION +directive @compositequery on FIELD_DEFINITION +directive @compositemutation on FIELD_DEFINITION +directive @configsessionquery on FIELD_DEFINITION +directive @configsessionmutation on FIELD_DEFINITION +directive @genopquery on FIELD_DEFINITION +directive @genopmutation on FIELD_DEFINITION -type Query { -    Show(data: ShowInput) : ShowResult @show -    ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig -} +scalar Generic -type Mutation { -    CreateDhcpServer(data: DhcpServerConfigInput) : CreateDhcpServerResult @configure -    CreateInterfaceEthernet(data: InterfaceEthernetConfigInput) : CreateInterfaceEthernetResult @configure -    CreateFirewallAddressGroup(data: CreateFirewallAddressGroupInput) : CreateFirewallAddressGroupResult @configure -    UpdateFirewallAddressGroupMembers(data: UpdateFirewallAddressGroupMembersInput) : UpdateFirewallAddressGroupMembersResult @configure -    RemoveFirewallAddressGroupMembers(data: RemoveFirewallAddressGroupMembersInput) : RemoveFirewallAddressGroupMembersResult @configure -    CreateFirewallAddressIpv6Group(data: CreateFirewallAddressIpv6GroupInput) : CreateFirewallAddressIpv6GroupResult @configure -    UpdateFirewallAddressIpv6GroupMembers(data: UpdateFirewallAddressIpv6GroupMembersInput) : UpdateFirewallAddressIpv6GroupMembersResult @configure -    RemoveFirewallAddressIpv6GroupMembers(data: RemoveFirewallAddressIpv6GroupMembersInput) : RemoveFirewallAddressIpv6GroupMembersResult @configure -    SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configfile -    LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configfile -    AddSystemImage(data: AddSystemImageInput) : AddSystemImageResult @image -    DeleteSystemImage(data: DeleteSystemImageInput) : DeleteSystemImageResult @image -} +type Query +type Mutation diff --git a/src/services/api/graphql/graphql/schema/show.graphql b/src/services/api/graphql/graphql/schema/show.graphql deleted file mode 100644 index c7709e48b..000000000 --- a/src/services/api/graphql/graphql/schema/show.graphql +++ /dev/null @@ -1,14 +0,0 @@ -input ShowInput { -    path: [String!]! -} - -type Show { -    path: [String] -    result: String -} - -type ShowResult { -    data: Show -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/graphql/schema/show_config.graphql b/src/services/api/graphql/graphql/schema/show_config.graphql deleted file mode 100644 index 34afd2aa9..000000000 --- a/src/services/api/graphql/graphql/schema/show_config.graphql +++ /dev/null @@ -1,21 +0,0 @@ -""" -Use 'scalar Generic' for show config output, to avoid attempts to -JSON-serialize in case of JSON output. -""" -scalar Generic - -input ShowConfigInput { -    path: [String!]! -    configFormat: String -} - -type ShowConfig { -    path: [String] -    result: Generic -} - -type ShowConfigResult { -    data: ShowConfig -    success: Boolean! -    errors: [String] -} diff --git a/src/services/api/graphql/libs/key_auth.py b/src/services/api/graphql/libs/key_auth.py new file mode 100644 index 000000000..2db0f7d48 --- /dev/null +++ b/src/services/api/graphql/libs/key_auth.py @@ -0,0 +1,18 @@ + +from .. import state + +def check_auth(key_list, key): +    if not key_list: +        return None +    key_id = None +    for k in key_list: +        if k['key'] == key: +            key_id = k['id'] +    return key_id + +def auth_required(key): +    api_keys = None +    api_keys = state.settings['app'].state.vyos_keys +    key_id = check_auth(api_keys, key) +    state.settings['app'].state.vyos_id = key_id +    return key_id diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py new file mode 100644 index 000000000..6939ed5d6 --- /dev/null +++ b/src/services/api/graphql/libs/op_mode.py @@ -0,0 +1,101 @@ +# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import typing +import importlib.util +from typing import Union +from humps import decamelize + +from vyos.defaults import directories +from vyos.util import load_as_module +from vyos.opmode import _normalize_field_names + +def load_op_mode_as_module(name: str): +    path = os.path.join(directories['op_mode'], name) +    name = os.path.splitext(name)[0].replace('-', '_') +    return load_as_module(name, path) + +def is_op_mode_function_name(name): +    if re.match(r"^(show|clear|reset|restart)", name): +        return True +    return False + +def is_show_function_name(name): +    if re.match(r"^show", name): +        return True +    return False + +def _nth_split(delim: str, n: int, s: str): +    groups = s.split(delim) +    l = len(groups) +    if n > l-1 or n < 1: +        return (s, '') +    return (delim.join(groups[:n]), delim.join(groups[n:])) + +def _nth_rsplit(delim: str, n: int, s: str): +    groups = s.split(delim) +    l = len(groups) +    if n > l-1 or n < 1: +        return (s, '') +    return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) + +# Since we have mangled possible hyphens in the file name while constructing +# the snake case of the query/mutation name, we will need to recover the +# file name by searching with mangling: +def _filter_on_mangled(test): +    def func(elem): +        mangle = os.path.splitext(elem)[0].replace('-', '_') +        return test == mangle +    return func + +# Find longest name in concatenated string that matches the basename of an +# op-mode script. Should one prefer to concatenate in the reverse order +# (script_name + '_' + function_name), use _nth_rsplit. +def split_compound_op_mode_name(name: str, files: list): +    for i in range(1, name.count('_') + 1): +        pair = _nth_split('_', i, name) +        f = list(filter(_filter_on_mangled(pair[1]), files)) +        if f: +            pair = (pair[0], f[0]) +            return pair +    return (name, '') + +def snake_to_pascal_case(name: str) -> str: +    res = ''.join(map(str.title, name.split('_'))) +    return res + +def map_type_name(type_name: type, optional: bool = False) -> str: +    if type_name == str: +        return 'String!' if not optional else 'String = null' +    if type_name == int: +        return 'Int!' if not optional else 'Int = null' +    if type_name == bool: +        return 'Boolean!' if not optional else 'Boolean = false' +    if typing.get_origin(type_name) == list: +        if not optional: +            return f'[{map_type_name(typing.get_args(type_name)[0])}]!' +        return f'[{map_type_name(typing.get_args(type_name)[0])}]' +    # typing.Optional is typing.Union[_, NoneType] +    if (typing.get_origin(type_name) is typing.Union and +            typing.get_args(type_name)[1] == type(None)): +        return f'{map_type_name(typing.get_args(type_name)[0], optional=True)}' + +    # scalar 'Generic' is defined in schema.graphql +    return 'Generic' + +def normalize_output(result: Union[dict, list]) -> Union[dict, list]: +    return _normalize_field_names(decamelize(result)) diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py new file mode 100644 index 000000000..2100eba7f --- /dev/null +++ b/src/services/api/graphql/libs/token_auth.py @@ -0,0 +1,71 @@ +import jwt +import uuid +import pam +from secrets import token_hex + +from .. import state + +def _check_passwd_pam(username: str, passwd: str) -> bool: +    if pam.authenticate(username, passwd): +        return True +    return False + +def init_secret(): +    length = int(state.settings['app'].state.vyos_secret_len) +    secret = token_hex(length) +    state.settings['secret'] = secret + +def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict: +    if user is None or passwd is None: +        return {} +    if _check_passwd_pam(user, passwd): +        app = state.settings['app'] +        try: +            users = app.state.vyos_token_users +        except AttributeError: +            app.state.vyos_token_users = {} +            users = app.state.vyos_token_users +        user_id = uuid.uuid1().hex +        payload_data = {'iss': user, 'sub': user_id, 'exp': exp} +        secret = state.settings.get('secret') +        if secret is None: +            return { +                    "success": False, +                    "errors": ['failed secret generation'] +                   } +        token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256") + +        users |= {user_id: user} +        return {'token': token} + +def get_user_context(request): +    context = {} +    context['request'] = request +    context['user'] = None +    if 'Authorization' in request.headers: +        auth = request.headers['Authorization'] +        scheme, token = auth.split() +        if scheme.lower() != 'bearer': +            return context + +        try: +            secret = state.settings.get('secret') +            payload = jwt.decode(token, secret, algorithms=["HS256"]) +            user_id: str = payload.get('sub') +            if user_id is None: +                return context +        except jwt.exceptions.ExpiredSignatureError: +            context['error'] = 'expired token' +            return context +        except jwt.PyJWTError: +            return context +        try: +            users = state.settings['app'].state.vyos_token_users +        except AttributeError: +            return context + +        user = users.get(user_id) +        if user is not None: +            context['user'] = user + +    return context diff --git a/src/services/api/graphql/session/__init__.py b/src/services/api/graphql/session/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/services/api/graphql/session/__init__.py diff --git a/src/validators/interface-name b/src/services/api/graphql/session/composite/system_status.py index 105815eee..d809f32e3 100755 --- a/src/validators/interface-name +++ b/src/services/api/graphql/session/composite/system_status.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# 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 @@ -13,22 +13,26 @@  #  # 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 json +import importlib.util + +from vyos.defaults import directories -from sys import argv -from sys import exit +from api.graphql.libs.op_mode import load_op_mode_as_module -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$' +def get_system_version() -> dict: +    show_version = load_op_mode_as_module('version.py') +    return show_version.show(raw=True, funny=False) -if __name__ == '__main__': -    if len(argv) != 2: -        exit(1) -    interface = argv[1] +def get_system_uptime() -> dict: +    show_uptime = load_op_mode_as_module('uptime.py') +    return show_uptime._get_raw_data() -    if re.match(pattern, interface): -        exit(0) -    if os.path.exists(f'/sys/class/net/{interface}'): -        exit(0) -    exit(1) +def get_system_ram_usage() -> dict: +    show_ram = load_op_mode_as_module('memory.py') +    return show_ram.show(raw=True) diff --git a/src/services/api/graphql/session/errors/op_mode_errors.py b/src/services/api/graphql/session/errors/op_mode_errors.py new file mode 100644 index 000000000..7bc1d1d81 --- /dev/null +++ b/src/services/api/graphql/session/errors/op_mode_errors.py @@ -0,0 +1,15 @@ + + +op_mode_err_msg = { +    "UnconfiguredSubsystem": "subsystem is not configured or not running", +    "DataUnavailable": "data currently unavailable", +    "PermissionDenied": "client does not have permission", +    "IncorrectValue": "argument value is incorrect" +} + +op_mode_err_code = { +    "UnconfiguredSubsystem": 2000, +    "DataUnavailable": 2001, +    "PermissionDenied": 1003, +    "IncorrectValue": 1002 +} diff --git a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py index b91932e14..b91932e14 100644 --- a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py +++ b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py diff --git a/src/services/api/graphql/recipes/session.py b/src/services/api/graphql/session/session.py index 1f844ff70..0b77b1433 100644 --- a/src/services/api/graphql/recipes/session.py +++ b/src/services/api/graphql/session/session.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -13,14 +13,21 @@  # You should have received a copy of the GNU Lesser General Public License  # along with this library.  If not, see <http://www.gnu.org/licenses/>. +import os  import json  from ariadne import convert_camel_case_to_snake -import vyos.defaults  from vyos.config import Config  from vyos.configtree import ConfigTree +from vyos.defaults import directories  from vyos.template import render +from vyos.opmode import Error as OpModeError + +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')  class Session:      """ @@ -33,39 +40,11 @@ class Session:          self._data = data          self._name = convert_camel_case_to_snake(type(self).__name__) -    def configure(self): -        session = self._session -        data = self._data -        func_base_name = self._name - -        tmpl_file = f'{func_base_name}.tmpl' -        cmd_file = f'/tmp/{func_base_name}.cmds' -        tmpl_dir = vyos.defaults.directories['api_templates'] -          try: -            render(cmd_file, tmpl_file, data, location=tmpl_dir) -            commands = [] -            with open(cmd_file) as f: -                lines = f.readlines() -            for line in lines: -                commands.append(line.split()) -            for cmd in commands: -                if cmd[0] == 'set': -                    session.set(cmd[1:]) -                elif cmd[0] == 'delete': -                    session.delete(cmd[1:]) -                else: -                    raise ValueError('Operation must be "set" or "delete"') -            session.commit() -        except Exception as error: -            raise error - -    def delete_path_if_childless(self, path): -        session = self._session -        config = Config(session.get_session_env()) -        if not config.list_nodes(path): -            session.delete(path) -            session.commit() +            with open(op_mode_include_file) as f: +                self._op_mode_list = json.loads(f.read()) +        except Exception: +            self._op_mode_list = None      def show_config(self):          session = self._session @@ -75,14 +54,14 @@ class Session:          try:              out = session.show_config(data['path'])              if data.get('config_format', '') == 'json': -                config_tree = vyos.configtree.ConfigTree(out) +                config_tree = ConfigTree(out)                  out = json.loads(config_tree.to_json())          except Exception as error:              raise error          return out -    def save(self): +    def save_config_file(self):          session = self._session          data = self._data          if 'file_name' not in data or not data['file_name']: @@ -93,7 +72,7 @@ class Session:          except Exception as error:              raise error -    def load(self): +    def load_config_file(self):          session = self._session          data = self._data @@ -115,7 +94,7 @@ class Session:          return out -    def add(self): +    def add_system_image(self):          session = self._session          data = self._data @@ -126,7 +105,7 @@ class Session:          return res -    def delete(self): +    def delete_system_image(self):          session = self._session          data = self._data @@ -136,3 +115,63 @@ class Session:              raise error          return res + +    def system_status(self): +        import api.graphql.session.composite.system_status as system_status + +        session = self._session +        data = self._data + +        status = {} +        status['host_name'] = session.show(['host', 'name']).strip() +        status['version'] = system_status.get_system_version() +        status['uptime'] = system_status.get_system_uptime() +        status['ram'] = system_status.get_system_ram_usage() + +        return status + +    def gen_op_query(self): +        session = self._session +        data = self._data +        name = self._name +        op_mode_list = self._op_mode_list + +        # handle the case that the op-mode file contains underscores: +        if op_mode_list is None: +            raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") +        (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list) +        if scriptname == '': +            raise FileNotFoundError(f"No op-mode file named in string '{name}'") + +        mod = load_op_mode_as_module(f'{scriptname}') +        func = getattr(mod, func_name) +        try: +            res = func(True, **data) +        except OpModeError as e: +            raise e + +        res = normalize_output(res) + +        return res + +    def gen_op_mutation(self): +        session = self._session +        data = self._data +        name = self._name +        op_mode_list = self._op_mode_list + +        # handle the case that the op-mode file name contains underscores: +        if op_mode_list is None: +            raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'") +        (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list) +        if scriptname == '': +            raise FileNotFoundError(f"No op-mode file named in string '{name}'") + +        mod = load_op_mode_as_module(f'{scriptname}') +        func = getattr(mod, func_name) +        try: +            res = func(**data) +        except OpModeError as e: +            raise e + +        return res diff --git a/src/services/api/graphql/recipes/templates/create_dhcp_server.tmpl b/src/services/api/graphql/session/templates/create_dhcp_server.tmpl index 70de43183..70de43183 100644 --- a/src/services/api/graphql/recipes/templates/create_dhcp_server.tmpl +++ b/src/services/api/graphql/session/templates/create_dhcp_server.tmpl diff --git a/src/services/api/graphql/recipes/templates/create_firewall_address_group.tmpl b/src/services/api/graphql/session/templates/create_firewall_address_group.tmpl index a890d0086..a890d0086 100644 --- a/src/services/api/graphql/recipes/templates/create_firewall_address_group.tmpl +++ b/src/services/api/graphql/session/templates/create_firewall_address_group.tmpl diff --git a/src/services/api/graphql/recipes/templates/create_firewall_address_ipv_6_group.tmpl b/src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl index e9b660722..e9b660722 100644 --- a/src/services/api/graphql/recipes/templates/create_firewall_address_ipv_6_group.tmpl +++ b/src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl diff --git a/src/services/api/graphql/recipes/templates/create_interface_ethernet.tmpl b/src/services/api/graphql/session/templates/create_interface_ethernet.tmpl index d9d7ed691..d9d7ed691 100644 --- a/src/services/api/graphql/recipes/templates/create_interface_ethernet.tmpl +++ b/src/services/api/graphql/session/templates/create_interface_ethernet.tmpl diff --git a/src/services/api/graphql/recipes/templates/remove_firewall_address_group_members.tmpl b/src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl index 458f3e5fc..458f3e5fc 100644 --- a/src/services/api/graphql/recipes/templates/remove_firewall_address_group_members.tmpl +++ b/src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl diff --git a/src/services/api/graphql/recipes/templates/remove_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl index 0efa0b226..0efa0b226 100644 --- a/src/services/api/graphql/recipes/templates/remove_firewall_address_ipv_6_group_members.tmpl +++ b/src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl diff --git a/src/services/api/graphql/recipes/templates/update_firewall_address_group_members.tmpl b/src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl index f56c61231..f56c61231 100644 --- a/src/services/api/graphql/recipes/templates/update_firewall_address_group_members.tmpl +++ b/src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl diff --git a/src/services/api/graphql/recipes/templates/update_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl index f98a5517c..f98a5517c 100644 --- a/src/services/api/graphql/recipes/templates/update_firewall_address_ipv_6_group_members.tmpl +++ b/src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl 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 e9b904ba8..60ea9a5ee 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -647,19 +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), 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)) - +        app.add_route('/graphql', GraphQL(schema, +                                          context_value=get_user_context, +                                          debug=True, +                                          introspection=in_spec))  ###  if __name__ == '__main__': @@ -676,6 +687,7 @@ if __name__ == '__main__':          server_config = load_server_config()      except Exception as err:          logger.critical(f"Failed to load the HTTP API server config: {err}") +        sys.exit(1)      config_session = ConfigSession(os.getpid()) @@ -683,11 +695,23 @@ if __name__ == '__main__':      app.state.vyos_keys = server_config['api_keys']      app.state.vyos_debug = server_config['debug'] -    app.state.vyos_gql = server_config['gql']      app.state.vyos_strict = server_config['strict'] -    app.state.vyos_origins = server_config.get('cors', {}).get('origins', []) +    app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', []) +    if 'graphql' in server_config: +        app.state.vyos_graphql = True +        if isinstance(server_config['graphql'], dict): +            if 'introspection' in server_config['graphql']: +                app.state.vyos_introspection = True +            else: +                app.state.vyos_introspection = False +            # default value is merged in conf_mode http-api.py, if not set +            app.state.vyos_auth_type = server_config['graphql']['authentication']['type'] +            app.state.vyos_token_exp = server_config['graphql']['authentication']['expiration'] +            app.state.vyos_secret_len = server_config['graphql']['authentication']['secret_length'] +    else: +        app.state.vyos_graphql = False -    if app.state.vyos_gql: +    if app.state.vyos_graphql:          graphql_init(app)      try: diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index a8df232ae..864ee8419 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -30,6 +30,7 @@ from vyos.ifconfig.vrrp import VRRP  from vyos.configquery import ConfigTreeQuery  from vyos.util import cmd  from vyos.util import dict_search +from vyos.util import commit_in_progress  # configure logging  logger = logging.getLogger(__name__) @@ -63,6 +64,17 @@ class KeepalivedFifo:      # load configuration      def _config_load(self): +        # For VRRP configuration to be read, the commit must be finished +        count = 1 +        while commit_in_progress(): +            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'Forced keepalived configuration loading despite a commit in progress ({count} wait time expired, not waiting further)') +                break +            count += 1 +            time.sleep(1) +          try:              base = ['high-availability', 'vrrp']              conf = ConfigTreeQuery() diff --git a/src/system/vyos-event-handler.py b/src/system/vyos-event-handler.py new file mode 100755 index 000000000..1c85380bc --- /dev/null +++ b/src/system/vyos-event-handler.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import argparse +import json +import re +import select +from copy import deepcopy +from os import getpid, environ +from pathlib import Path +from signal import signal, SIGTERM, SIGINT +from sys import exit +from systemd import journal + +from vyos.util import run, dict_search + +# Identify this script +my_pid = getpid() +my_name = Path(__file__).stem + + +# handle termination signal +def handle_signal(signal_type, frame): +    if signal_type == SIGTERM: +        journal.send('Received SIGTERM signal, stopping normally', +                     SYSLOG_IDENTIFIER=my_name) +    if signal_type == SIGINT: +        journal.send('Received SIGINT signal, stopping normally', +                     SYSLOG_IDENTIFIER=my_name) +    exit(0) + + +# Class for analyzing and process messages +class Analyzer: +    # Initialize settings +    def __init__(self, config: dict) -> None: +        self.config = {} +        # Prepare compiled regex objects +        for event_id, event_config in config.items(): +            script = dict_search('script.path', event_config) +            # Check for arguments +            if dict_search('script.arguments', event_config): +                script_arguments = dict_search('script.arguments', event_config) +                script = f'{script} {script_arguments}' +            # Prepare environment +            environment = deepcopy(environ) +            # Check for additional environment options +            if dict_search('script.environment', event_config): +                for env_variable, env_value in dict_search( +                        'script.environment', event_config).items(): +                    environment[env_variable] = env_value.get('value') +            # Create final config dictionary +            pattern_raw = event_config['filter']['pattern'] +            pattern_compiled = re.compile( +                rf'{event_config["filter"]["pattern"]}') +            pattern_config = { +                pattern_compiled: { +                    'pattern_raw': +                        pattern_raw, +                    'syslog_id': +                        dict_search('filter.syslog-identifier', event_config), +                    'pattern_script': { +                        'path': script, +                        'environment': environment +                    } +                } +            } +            self.config.update(pattern_config) + +    # Execute script safely +    def script_run(self, pattern: str, script_path: str, +                   script_env: dict) -> None: +        try: +            run(script_path, env=script_env) +            journal.send( +                f'Pattern found: "{pattern}", script executed: "{script_path}"', +                SYSLOG_IDENTIFIER=my_name) +        except Exception as err: +            journal.send( +                f'Pattern found: "{pattern}", failed to execute script "{script_path}": {err}', +                SYSLOG_IDENTIFIER=my_name) + +    # Analyze a message +    def process_message(self, message: dict) -> None: +        for pattern_compiled, pattern_config in self.config.items(): +            # Check if syslog id is presented in config and matches +            syslog_id = pattern_config.get('syslog_id') +            if syslog_id and message['SYSLOG_IDENTIFIER'] != syslog_id: +                continue +            if pattern_compiled.fullmatch(message['MESSAGE']): +                # Add message to environment variables +                pattern_config['pattern_script']['environment'][ +                    'message'] = message['MESSAGE'] +                # Run script +                self.script_run( +                    pattern=pattern_config['pattern_raw'], +                    script_path=pattern_config['pattern_script']['path'], +                    script_env=pattern_config['pattern_script']['environment']) + + +if __name__ == '__main__': +    # Parse command arguments and get config +    parser = argparse.ArgumentParser() +    parser.add_argument('-c', +                        '--config', +                        action='store', +                        help='Path to even-handler configuration', +                        required=True, +                        type=Path) + +    args = parser.parse_args() +    try: +        config_path = Path(args.config) +        config = json.loads(config_path.read_text()) +        # Create an object for analazyng messages +        analyzer = Analyzer(config) +    except Exception as err: +        print( +            f'Configuration file "{config_path}" does not exist or malformed: {err}' +        ) +        exit(1) + +    # Prepare for proper exitting +    signal(SIGTERM, handle_signal) +    signal(SIGINT, handle_signal) + +    # Set up journal connection +    data = journal.Reader() +    data.seek_tail() +    data.get_previous() +    p = select.poll() +    p.register(data, data.get_events()) + +    journal.send(f'Started with configuration: {config}', +                 SYSLOG_IDENTIFIER=my_name) + +    while p.poll(): +        if data.process() != journal.APPEND: +            continue +        for entry in data: +            message = entry['MESSAGE'] +            pid = entry['_PID'] +            # Skip empty messages and messages from this process +            if message and pid != my_pid: +                try: +                    analyzer.process_message(entry) +                except Exception as err: +                    journal.send(f'Unable to process message: {err}', +                                 SYSLOG_IDENTIFIER=my_name) diff --git a/src/system/vyos-system-update-check.py b/src/system/vyos-system-update-check.py new file mode 100755 index 000000000..c9597721b --- /dev/null +++ b/src/system/vyos-system-update-check.py @@ -0,0 +1,70 @@ +#!/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 jmespath + +from pathlib import Path +from sys import exit +from time import sleep + +from vyos.util import call + +import vyos.version + +motd_file = Path('/run/motd.d/10-vyos-update') + + +if __name__ == '__main__': +    # Parse command arguments and get config +    parser = argparse.ArgumentParser() +    parser.add_argument('-c', +                        '--config', +                        action='store', +                        help='Path to system-update-check 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) + +    url_json = config.get('url') +    local_data = vyos.version.get_full_version_data() +    local_version = local_data.get('version') + +    while True: +        remote_data = vyos.version.get_remote_version(url_json) +        if remote_data: +            url = jmespath.search('[0].url', remote_data) +            remote_version = jmespath.search('[0].version', remote_data) +            if local_version != remote_version and remote_version: +                call(f'wall -n "Update available: {remote_version} \nUpdate URL: {url}"') +                # MOTD used in /run/motd.d/10-update +                motd_file.parent.mkdir(exist_ok=True) +                motd_file.write_text(f'---\n' +                                     f'Current version: {local_version}\n' +                                     f'Update available: \033[1;34m{remote_version}\033[0m\n' +                                     f'---\n') +        # Check every 12 hours +        sleep(43200) diff --git a/src/systemd/dhclient@.service b/src/systemd/dhclient@.service index 2ced1038a..23cd4cfc3 100644 --- a/src/systemd/dhclient@.service +++ b/src/systemd/dhclient@.service @@ -13,6 +13,9 @@ PIDFile=/var/lib/dhcp/dhclient_%i.pid  ExecStart=/sbin/dhclient -4 $DHCLIENT_OPTS  ExecStop=/sbin/dhclient -4 $DHCLIENT_OPTS -r  Restart=always +TimeoutStopSec=20 +SendSIGKILL=true +FinalKillSignal=SIGABRT  [Install]  WantedBy=multi-user.target diff --git a/src/systemd/telegraf.service b/src/systemd/telegraf.service new file mode 100644 index 000000000..553942ac6 --- /dev/null +++ b/src/systemd/telegraf.service @@ -0,0 +1,15 @@ +[Unit] +Description=The plugin-driven server agent for reporting metrics into InfluxDB +Documentation=https://github.com/influxdata/telegraf +After=network.target + +[Service] +EnvironmentFile=-/etc/default/telegraf +ExecStart=/usr/bin/telegraf --config /run/telegraf/vyos-telegraf.conf --config-directory /etc/telegraf/telegraf.d +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartForceExitStatus=SIGPIPE +KillMode=control-group + +[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/systemd/vyos-event-handler.service b/src/systemd/vyos-event-handler.service new file mode 100644 index 000000000..6afe4f95b --- /dev/null +++ b/src/systemd/vyos-event-handler.service @@ -0,0 +1,11 @@ +[Unit] +Description=VyOS event handler +After=network.target vyos-router.service + +[Service] +Type=simple +Restart=always +ExecStart=/usr/bin/python3 /usr/libexec/vyos/system/vyos-event-handler.py --config /run/vyos-event-handler.conf + +[Install] +WantedBy=multi-user.target diff --git a/src/systemd/vyos-system-update.service b/src/systemd/vyos-system-update.service new file mode 100644 index 000000000..032e5a14c --- /dev/null +++ b/src/systemd/vyos-system-update.service @@ -0,0 +1,11 @@ +[Unit] +Description=VyOS system udpate-check service +After=network.target vyos-router.service + +[Service] +Type=simple +Restart=always +ExecStart=/usr/bin/python3 /usr/libexec/vyos/system/vyos-system-update-check.py --config /run/vyos-system-update.conf + +[Install] +WantedBy=multi-user.target diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 7e0bee8e1..ffb4fe32c 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -1,17 +1,18 @@  [Unit] -Description=WPA supplicant daemon (macsec-specific version) +Description=WPA supplicant daemon (MACsec-specific version)  Requires=sys-subsystem-net-devices-%i.device  ConditionPathExists=/run/wpa_supplicant/%I.conf  After=vyos-router.service  RequiresMountsFor=/run -# NetworkManager users will probably want the dbus version instead. -  [Service]  Type=simple  WorkingDirectory=/run/wpa_supplicant  PIDFile=/run/wpa_supplicant/%I.pid -ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -i%I +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=2  [Install]  WantedBy=multi-user.target diff --git a/src/tests/test_op_mode.py b/src/tests/test_op_mode.py new file mode 100644 index 000000000..90963b3c5 --- /dev/null +++ b/src/tests/test_op_mode.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +from unittest import TestCase + +import vyos.opmode + +class TestVyOSOpMode(TestCase): +    def test_field_name_normalization(self): +        from vyos.opmode import _normalize_field_name + +        self.assertEqual(_normalize_field_name(" foo bar "), "foo_bar") +        self.assertEqual(_normalize_field_name("foo-bar"), "foo_bar") +        self.assertEqual(_normalize_field_name("foo (bar) baz"), "foo_bar_baz") +        self.assertEqual(_normalize_field_name("load%"), "load_percentage") + +    def test_dict_fields_normalization_non_unique(self): +        from vyos.opmode import _normalize_field_names + +        # Space and dot are both replaced by an underscore, +        # so dicts like this cannor be normalized uniquely +        data = {"foo bar": True, "foo.bar": False} + +        with self.assertRaises(vyos.opmode.InternalError): +            _normalize_field_names(data) + +    def test_dict_fields_normalization_simple_dict(self): +        from vyos.opmode import _normalize_field_names + +        data = {"foo bar": True, "Bar-Baz": False} +        self.assertEqual(_normalize_field_names(data), {"foo_bar": True, "bar_baz": False}) + +    def test_dict_fields_normalization_nested_dict(self): +        from vyos.opmode import _normalize_field_names + +        data = {"foo bar": True, "bar-baz": {"baz-quux": {"quux-xyzzy": False}}} +        self.assertEqual(_normalize_field_names(data), +          {"foo_bar": True, "bar_baz": {"baz_quux": {"quux_xyzzy": False}}}) + +    def test_dict_fields_normalization_mixed(self): +        from vyos.opmode import _normalize_field_names + +        data = [{"foo bar": True, "bar-baz": [{"baz-quux": {"quux-xyzzy": [False]}}]}] +        self.assertEqual(_normalize_field_names(data), +          [{"foo_bar": True, "bar_baz": [{"baz_quux": {"quux_xyzzy": [False]}}]}]) + +    def test_dict_fields_normalization_primitive(self): +        from vyos.opmode import _normalize_field_names + +        data = [1, False, "foo"] +        self.assertEqual(_normalize_field_names(data), [1, False, "foo"]) + diff --git a/src/tests/test_util.py b/src/tests/test_util.py index 8ac9a500a..d8b2b7940 100644 --- a/src/tests/test_util.py +++ b/src/tests/test_util.py @@ -26,3 +26,17 @@ class TestVyOSUtil(TestCase):      def test_sysctl_read(self):          self.assertEqual(sysctl_read('net.ipv4.conf.lo.forwarding'), '1') + +    def test_camel_to_snake_case(self): +        self.assertEqual(camel_to_snake_case('ConnectionTimeout'), +                                             'connection_timeout') +        self.assertEqual(camel_to_snake_case('connectionTimeout'), +                                             'connection_timeout') +        self.assertEqual(camel_to_snake_case('TCPConnectionTimeout'), +                                             'tcp_connection_timeout') +        self.assertEqual(camel_to_snake_case('TCPPort'), +                                             'tcp_port') +        self.assertEqual(camel_to_snake_case('UseHTTPProxy'), +                                             'use_http_proxy') +        self.assertEqual(camel_to_snake_case('CustomerID'), +                                             'customer_id') diff --git a/src/validators/accel-radius-dictionary b/src/validators/accel-radius-dictionary new file mode 100755 index 000000000..05287e770 --- /dev/null +++ b/src/validators/accel-radius-dictionary @@ -0,0 +1,13 @@ +#!/bin/sh + +DICT_PATH=/usr/share/accel-ppp/radius +NAME=$1 + +if [ -n "$NAME" -a -e $DICT_PATH/dictionary.$NAME ]; then +    exit 0 +else +    echo "$NAME is not a valid RADIUS dictionary name" +    echo "Please make sure that $DICT_PATH/dictionary.$NAME file exists" +    exit 1 +fi + diff --git a/src/validators/allowed-vlan b/src/validators/allowed-vlan deleted file mode 100755 index 11389390b..000000000 --- a/src/validators/allowed-vlan +++ /dev/null @@ -1,19 +0,0 @@ -#! /usr/bin/python3 - -import sys -import re - -if __name__ == '__main__': -    if len(sys.argv)>1: -        allowed_vlan = sys.argv[1] -        if re.search('[0-9]{1,4}-[0-9]{1,4}', allowed_vlan): -            for tmp in allowed_vlan.split('-'): -                if int(tmp) not in range(1, 4095): -                    sys.exit(1) -        else: -            if int(allowed_vlan) not in range(1, 4095): -                sys.exit(1) -    else: -        sys.exit(2) -     -    sys.exit(0) diff --git a/src/validators/bgp-extended-community b/src/validators/bgp-extended-community new file mode 100755 index 000000000..b69ae3449 --- /dev/null +++ b/src/validators/bgp-extended-community @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library.  If not, see <http://www.gnu.org/licenses/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_2_OCTET: int = 65535 +COMM_MAX_4_OCTET: int = 4294967295 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 1: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_left: str = community.split(':')[0] +        comm_right: int = int(community.split(':')[1]) + +        # check if left part is an IPv4 address +        if is_ipv4(comm_left) and 0 <= comm_right <= COMM_MAX_2_OCTET: +            exit() +        # check if a left part is a number +        if 0 <= int(comm_left) <= COMM_MAX_2_OCTET \ +                and 0 <= comm_right <= COMM_MAX_4_OCTET: +            exit() + +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/bgp-large-community b/src/validators/bgp-large-community new file mode 100755 index 000000000..386398308 --- /dev/null +++ b/src/validators/bgp-large-community @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library.  If not, see <http://www.gnu.org/licenses/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_4_OCTET: int = 4294967295 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 2: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_part1: int = int(community.split(':')[0]) +        comm_part2: int = int(community.split(':')[1]) +        comm_part3: int = int(community.split(':')[2]) + +        # check compatibilities of left and right parts +        if 0 <= comm_part1 <= COMM_MAX_4_OCTET \ +                and 0 <= comm_part2 <= COMM_MAX_4_OCTET \ +                and 0 <= comm_part3 <= COMM_MAX_4_OCTET: +            exit(0) + +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/bgp-regular-community b/src/validators/bgp-regular-community new file mode 100755 index 000000000..d43a71eae --- /dev/null +++ b/src/validators/bgp-regular-community @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library.  If not, see <http://www.gnu.org/licenses/>. + +from argparse import ArgumentParser +from sys import exit + +from vyos.template import is_ipv4 + +COMM_MAX_2_OCTET: int = 65535 + +if __name__ == '__main__': +    # add an argument with community +    parser: ArgumentParser = ArgumentParser() +    parser.add_argument('community', type=str) +    args = parser.parse_args() +    community: str = args.community +    if community.count(':') != 1: +        print("Invalid community format") +        exit(1) +    try: +        # try to extract community parts from an argument +        comm_left: int = int(community.split(':')[0]) +        comm_right: int = int(community.split(':')[1]) + +        # check compatibilities of left and right parts +        if 0 <= comm_left <= COMM_MAX_2_OCTET \ +                and 0 <= comm_right <= COMM_MAX_2_OCTET: +            exit(0) +    except Exception: +        # fail if something was wrong +        print("Invalid community format") +        exit(1) + +    # fail if none of validators catched the value +    print("Invalid community format") +    exit(1)
\ No newline at end of file diff --git a/src/validators/dotted-decimal b/src/validators/dotted-decimal deleted file mode 100755 index 652110346..000000000 --- a/src/validators/dotted-decimal +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program.  If not, see <http://www.gnu.org/licenses/>. - -import re -import sys - -area = sys.argv[1] - -res = re.match(r'^(\d+)\.(\d+)\.(\d+)\.(\d+)$', area) -if not res: -    print("\'{0}\' is not a valid dotted decimal value".format(area)) -    sys.exit(1) -else: -    components = res.groups() -    for n in range(0, 4): -        if (int(components[n]) > 255): -            print("Invalid component of a dotted decimal value: {0} exceeds 255".format(components[n])) -            sys.exit(1) - -sys.exit(0) diff --git a/src/validators/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/ipv6-address-exclude b/src/validators/ipv6-address-exclude new file mode 100755 index 000000000..be1d3db25 --- /dev/null +++ b/src/validators/ipv6-address-exclude @@ -0,0 +1,7 @@ +#!/bin/sh +arg="$1" +if [ "${arg:0:1}" != "!" ]; then +  exit 1 +fi +path=$(dirname "$0") +${path}/ipv6-address "${arg:1}" diff --git a/src/validators/ipv6-prefix-exclude b/src/validators/ipv6-prefix-exclude new file mode 100755 index 000000000..6fa4f1d8d --- /dev/null +++ b/src/validators/ipv6-prefix-exclude @@ -0,0 +1,7 @@ +#!/bin/sh +arg="$1" +if [ "${arg:0:1}" != "!" ]; then +  exit 1 +fi +path=$(dirname "$0") +${path}/ipv6-prefix "${arg:1}" 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/range b/src/validators/range deleted file mode 100755 index d4c25f3c4..000000000 --- a/src/validators/range +++ /dev/null @@ -1,56 +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 re -import sys -import argparse - -class MalformedRange(Exception): -    pass - -def validate_range(value, min=None, max=None): -    try: -        lower, upper = re.match(r'^(\d+)-(\d+)$', value).groups() - -        lower, upper = int(lower), int(upper) - -        if int(lower) > int(upper): -            raise MalformedRange("the lower bound exceeds the upper bound".format(value)) - -        if min is not None: -            if lower < min: -                raise MalformedRange("the lower bound must not be less than {}".format(min)) - -        if max is not None: -            if upper > max: -                raise MalformedRange("the upper bound must not be greater than {}".format(max)) - -    except (AttributeError, ValueError): -        raise MalformedRange("range syntax error") - -parser = argparse.ArgumentParser(description='Range validator.') -parser.add_argument('--min', type=int, action='store') -parser.add_argument('--max', type=int, action='store') -parser.add_argument('value', action='store') - -if __name__ == '__main__': -    args = parser.parse_args() - -    try: -        validate_range(args.value, min=args.min, max=args.max) -    except MalformedRange as e: -        print("Incorrect range '{}': {}".format(args.value, e)) -        sys.exit(1) 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) {  | 
