diff options
Diffstat (limited to 'src')
206 files changed, 7908 insertions, 2612 deletions
diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py index aac07bd80..1cd8f5451 100755 --- a/src/conf_mode/arp.py +++ b/src/conf_mode/arp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018 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 @@ -13,92 +13,62 @@  #  # 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 os -import re -import syslog as sl +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 vyos import airbag  airbag.enable() -arp_cmd = '/usr/sbin/arp' - -def get_config(): -  c = Config() -  if not c.exists('protocols static arp'): -    return None - -  c.set_level('protocols static') -  config_data = {} -   -  for ip_addr in c.list_nodes('arp'): -    config_data.update( -        { -          ip_addr : c.return_value('arp ' + ip_addr + ' hwaddr') -        } -    ) +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() -  return config_data +    base = ['protocols', 'static', 'arp'] +    arp = conf.get_config_dict(base, get_first_key=True) -def generate(c): -  c_eff = Config() -  c_eff.set_level('protocols static') -  c_eff_cnf = {} -  for ip_addr in c_eff.list_effective_nodes('arp'): -    c_eff_cnf.update( -        { -          ip_addr : c_eff.return_effective_value('arp ' + ip_addr + ' hwaddr') -        } -    ) +    if 'interface' in arp: +        for interface in arp['interface']: +            tmp = node_changed(conf, base + ['interface', interface, 'address'], recursive=True) +            if tmp: arp['interface'][interface].update({'address_old' : tmp}) -  config_data = { -    'remove'  : [], -    'update'  : {} -  } -  ### removal -  if c == None: -    for ip_addr in c_eff_cnf: -      config_data['remove'].append(ip_addr) -  else: -    for ip_addr in c_eff_cnf: -      if not ip_addr in c or c[ip_addr] == None: -        config_data['remove'].append(ip_addr) +    return arp -  ### add/update -  if c != None: -    for ip_addr in c: -      if not ip_addr in c_eff_cnf: -        config_data['update'][ip_addr] = c[ip_addr] -      if  ip_addr in c_eff_cnf: -        if c[ip_addr] != c_eff_cnf[ip_addr] and c[ip_addr] != None: -          config_data['update'][ip_addr] = c[ip_addr] +def verify(arp): +    pass -  return config_data +def generate(arp): +    pass -def apply(c): -  for ip_addr in c['remove']: -    sl.syslog(sl.LOG_NOTICE, "arp -d " + ip_addr) -    call(f'{arp_cmd} -d {ip_addr} >/dev/null 2>&1') +def apply(arp): +    if not arp: +        return None -  for ip_addr in c['update']: -    sl.syslog(sl.LOG_NOTICE, "arp -s " + ip_addr + " " + c['update'][ip_addr]) -    updated = c['update'][ip_addr] -    call(f'{arp_cmd} -s {ip_addr} {updated}') +    if 'interface' in arp: +        for interface, interface_config in arp['interface'].items(): +            # Delete old static ARP assignments first +            if 'address_old' in interface_config: +                for address in interface_config['address_old']: +                    call(f'ip neigh del {address} dev {interface}') +            # Add new static ARP entries to interface +            if 'address' not in interface_config: +                continue +            for address, address_config in interface_config['address'].items(): +                mac = address_config['mac'] +                call(f'ip neigh add {address} lladdr {mac} dev {interface}')  if __name__ == '__main__': -  try: -    c = get_config() -    ## syntax verification is done via cli -    config = generate(c) -    apply(config) -  except ConfigError as e: -    print(e) -    sys.exit(1) +    try: +        c = get_config() +        verify(c) +        generate(c) +        apply(c) +    except ConfigError as e: +        print(e) +        exit(1) diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py index d93a2a8f4..39a2971ce 100755 --- a/src/conf_mode/bcast_relay.py +++ b/src/conf_mode/bcast_relay.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2017-2020 VyOS maintainers and contributors +# Copyright (C) 2017-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 @@ -78,7 +78,7 @@ def generate(relay):              continue          config['instance'] = instance -        render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.tmpl', +        render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.j2',                 config)      return None diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py index 68877f794..82289526f 100755 --- a/src/conf_mode/conntrack.py +++ b/src/conf_mode/conntrack.py @@ -15,11 +15,14 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os +import re  from sys import exit  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.firewall import find_nftables_rule +from vyos.firewall import remove_nftables_rule  from vyos.util import cmd  from vyos.util import run  from vyos.util import process_named_running @@ -32,6 +35,7 @@ airbag.enable()  conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf'  sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' +nftables_ct_file = r'/run/nftables-ct.conf'  # Every ALG (Application Layer Gateway) consists of either a Kernel Object  # also called a Kernel Module/Driver or some rules present in iptables @@ -43,8 +47,8 @@ module_map = {          'ko' : ['nf_nat_h323', 'nf_conntrack_h323'],      },      'nfs' : { -        'iptables' : ['VYATTA_CT_HELPER --table raw --proto tcp --dport 111 --jump CT --helper rpc', -                      'VYATTA_CT_HELPER --table raw --proto udp --dport 111 --jump CT --helper rpc'], +        'nftables' : ['ct helper set "rpc_tcp" tcp dport "{111}" return', +                      'ct helper set "rpc_udp" udp dport "{111}" return']      },      'pptp' : {          'ko' : ['nf_nat_pptp', 'nf_conntrack_pptp'], @@ -53,9 +57,7 @@ module_map = {          'ko' : ['nf_nat_sip', 'nf_conntrack_sip'],       },      'sqlnet' : { -        'iptables' : ['VYATTA_CT_HELPER --table raw --proto tcp --dport 1521 --jump CT --helper tns', -                      'VYATTA_CT_HELPER --table raw --proto tcp --dport 1525 --jump CT --helper tns', -                      'VYATTA_CT_HELPER --table raw --proto tcp --dport 1536 --jump CT --helper tns'], +        'nftables' : ['ct helper set "tns_tcp" tcp dport "{1521,1525,1536}" return']      },      'tftp' : {          'ko' : ['nf_nat_tftp', 'nf_conntrack_tftp'], @@ -80,19 +82,49 @@ def get_config(config=None):      # 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) +    # 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 'timeout' in default_values and 'custom' in default_values['timeout']: +        del default_values['timeout']['custom']      conntrack = dict_merge(default_values, conntrack)      return conntrack  def verify(conntrack): +    if dict_search('ignore.rule', conntrack) != None: +        for rule, rule_config in conntrack['ignore']['rule'].items(): +            if dict_search('destination.port', rule_config) or \ +               dict_search('source.port', rule_config): +               if 'protocol' not in rule_config or rule_config['protocol'] not in ['tcp', 'udp']: +                   raise ConfigError(f'Port requires tcp or udp as protocol in rule {rule}') +      return None  def generate(conntrack): -    render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.tmpl', conntrack) -    render(sysctl_file, 'conntrack/sysctl.conf.tmpl', conntrack) +    render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) +    render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) +    render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) + +    # dry-run newly generated configuration +    tmp = run(f'nft -c -f {nftables_ct_file}') +    if tmp > 0: +        if os.path.exists(nftables_ct_file): +            os.unlink(nftables_ct_file) +        raise ConfigError('Configuration file errors encountered!')      return None +def find_nftables_ct_rule(rule): +    helper_search = re.search('ct helper set "(\w+)"', rule) +    if helper_search: +        rule = helper_search[1] +    return find_nftables_rule('raw', 'VYOS_CT_HELPER', [rule]) + +def find_remove_rule(rule): +    handle = find_nftables_ct_rule(rule) +    if handle: +        remove_nftables_rule('raw', 'VYOS_CT_HELPER', handle) +  def apply(conntrack):      # Depending on the enable/disable state of the ALG (Application Layer Gateway)      # modules we need to either insmod or rmmod the helpers. @@ -103,20 +135,20 @@ def apply(conntrack):                      # Only remove the module if it's loaded                      if os.path.exists(f'/sys/module/{mod}'):                          cmd(f'rmmod {mod}') -            if 'iptables' in module_config: -                for rule in module_config['iptables']: -                    # Only install iptables rule if it does not exist -                    tmp = run(f'iptables --check {rule}') -                    if tmp == 0: cmd(f'iptables --delete {rule}') +            if 'nftables' in module_config: +                for rule in module_config['nftables']: +                    find_remove_rule(rule)          else:              if 'ko' in module_config:                  for mod in module_config['ko']:                      cmd(f'modprobe {mod}') -            if 'iptables' in module_config: -                for rule in module_config['iptables']: -                    # Only install iptables rule if it does not exist -                    tmp = run(f'iptables --check {rule}') -                    if tmp > 0: cmd(f'iptables --insert {rule}') +            if 'nftables' in module_config: +                for rule in module_config['nftables']: +                    if not find_nftables_ct_rule(rule): +                        cmd(f'nft insert rule ip raw VYOS_CT_HELPER {rule}') + +    # Load new nftables ruleset +    cmd(f'nft -f {nftables_ct_file}')      if process_named_running('conntrackd'):          # Reload conntrack-sync daemon to fetch new sysctl values diff --git a/src/conf_mode/conntrack_sync.py b/src/conf_mode/conntrack_sync.py index f82a077e6..c4b2bb488 100755 --- a/src/conf_mode/conntrack_sync.py +++ b/src/conf_mode/conntrack_sync.py @@ -36,7 +36,7 @@ airbag.enable()  config_file = '/run/conntrackd/conntrackd.conf'  def resync_vrrp(): -    tmp = run('/usr/libexec/vyos/conf_mode/vrrp.py') +    tmp = run('/usr/libexec/vyos/conf_mode/high-availability.py')      if tmp > 0:          print('ERROR: error restarting VRRP daemon!') @@ -93,9 +93,9 @@ def verify(conntrack):              raise ConfigError('Can not configure expect-sync "all" with other protocols!')      if 'listen_address' in conntrack: -        address = conntrack['listen_address'] -        if not is_addr_assigned(address): -            raise ConfigError(f'Specified listen-address {address} not assigned to any interface!') +        for address in conntrack['listen_address']: +            if not is_addr_assigned(address): +                raise ConfigError(f'Specified listen-address {address} not assigned to any interface!')      vrrp_group = dict_search('failover_mechanism.vrrp.sync_group', conntrack)      if vrrp_group == None: @@ -111,11 +111,12 @@ def generate(conntrack):              os.unlink(config_file)          return None -    render(config_file, 'conntrackd/conntrackd.conf.tmpl', conntrack) +    render(config_file, 'conntrackd/conntrackd.conf.j2', conntrack)      return None  def apply(conntrack): +    systemd_service = 'conntrackd.service'      if not conntrack:          # Failover mechanism daemon should be indicated that it no longer needs          # to execute conntrackd actions on transition. This is only required @@ -123,7 +124,7 @@ def apply(conntrack):          if process_named_running('conntrackd'):              resync_vrrp() -        call('systemctl stop conntrackd.service') +        call(f'systemctl stop {systemd_service}')          return None      # Failover mechanism daemon should be indicated that it needs to execute @@ -132,7 +133,7 @@ def apply(conntrack):      if not process_named_running('conntrackd'):          resync_vrrp() -    call('systemctl restart conntrackd.service') +    call(f'systemctl reload-or-restart {systemd_service}')      return None  if __name__ == '__main__': diff --git a/src/conf_mode/containers.py b/src/conf_mode/container.py index 2e14e0b25..2110fd9e0 100755 --- a/src/conf_mode/containers.py +++ b/src/conf_mode/container.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 @@ -15,20 +15,19 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os -import json  from ipaddress import ip_address  from ipaddress import ip_network  from time import sleep  from json import dumps as json_write +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import node_changed  from vyos.util import call  from vyos.util import cmd  from vyos.util import run -from vyos.util import read_file  from vyos.util import write_file  from vyos.template import inc_ip  from vyos.template import is_ipv4 @@ -42,6 +41,20 @@ 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 +  def _cmd(command):      if os.path.exists('/tmp/vyos.container.debug'):          print(command) @@ -77,10 +90,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, ['container', 'network']) +    tmp = node_changed(conf, base + ['container', 'network'])      if tmp: container.update({'network_remove' : tmp}) -    tmp = node_changed(conf, ['container', 'name']) +    tmp = node_changed(conf, base + ['container', 'name'])      if tmp: container.update({'container_remove' : tmp})      return container @@ -93,6 +106,26 @@ def verify(container):      # Add new container      if 'name' in container:          for name, container_config in container['name'].items(): +            # Container image is a mandatory option +            if 'image' not in container_config: +                raise ConfigError(f'Container image for "{name}" is mandatory!') + +            # Check if requested container image exists locally. If it does not +            # exist locally - inform the user. This is required as there is a +            # shared container image storage accross all VyOS images. A user can +            # delete a container image from the system, boot into another version +            # of VyOS and then it would fail to boot. This is to prevent any +            # configuration error when container images are deleted from the +            # global storage. A per image local storage would be a super waste +            # of diskspace as there will be a full copy (up tu several GB/image) +            # on upgrade. This is the "cheapest" and fastest solution in terms +            # 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 '\ +                        f'locally. Please use "add container image {image}" to add it '\ +                        f'to the system! Container "{name}" will not be started!') +              if 'network' in container_config:                  if len(container_config['network']) > 1:                      raise ConfigError(f'Only one network can be specified for container "{name}"!') @@ -122,6 +155,18 @@ def verify(container):                          raise ConfigError(f'IP address "{address}" can not be used for a container, '\                                            'reserved for the container engine!') +            if 'device' in container_config: +                for dev, dev_config in container_config['device'].items(): +                    if 'source' not in dev_config: +                        raise ConfigError(f'Device "{dev}" has no source path configured!') + +                    if 'destination' not in dev_config: +                        raise ConfigError(f'Device "{dev}" has no destination path configured!') + +                    source = dev_config['source'] +                    if not os.path.exists(source): +                        raise ConfigError(f'Device "{dev}" source path "{source}" does not exist!') +              if 'environment' in container_config:                  for var, cfg in container_config['environment'].items():                      if 'value' not in cfg: @@ -139,10 +184,6 @@ def verify(container):                      if not os.path.exists(source):                          raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') -            # Container image is a mandatory option -            if 'image' not in container_config: -                raise ConfigError(f'Container image for "{name}" is mandatory!') -              # If 'allow-host-networks' or 'network' not set.              if 'allow_host_networks' not in container_config and 'network' not in container_config:                  raise ConfigError(f'Must either set "network" or "allow-host-networks" for container "{name}"!') @@ -182,6 +223,10 @@ def verify(container):  def generate(container):      # bail out early - looks like removal from running config      if not container: +        if os.path.exists(config_containers_registry): +            os.unlink(config_containers_registry) +        if os.path.exists(config_containers_storage): +            os.unlink(config_containers_storage)          return None      if 'network' in container: @@ -215,8 +260,8 @@ def generate(container):              write_file(f'/etc/cni/net.d/{network}.conflist', json_write(tmp, indent=2)) -    render(config_containers_registry, 'containers/registry.tmpl', container) -    render(config_containers_storage, 'containers/storage.tmpl', container) +    render(config_containers_registry, 'container/registries.conf.j2', container) +    render(config_containers_storage, 'container/storage.conf.j2', container)      return None @@ -240,6 +285,11 @@ def apply(container):          for name, container_config in container['name'].items():              image = container_config['image'] +            if run(f'podman image exists {image}') != 0: +                # container image does not exist locally - user already got +                # informed by a WARNING in verfiy() - bail out early +                continue +              if 'disable' in container_config:                  # check if there is a container by that name running                  tmp = _cmd('podman ps -a --format "{{.Names}}"') @@ -251,13 +301,6 @@ def apply(container):              memory = container_config['memory']              restart = container_config['restart'] -            # Check if requested container image exists locally. If it does not, we -            # pull it. print() is the best way to have a good response from the -            # polling process to the user to display progress. If the image exists -            # locally, a user can update it running `update container image <name>` -            tmp = run(f'podman image exists {image}') -            if tmp != 0: print(os.system(f'podman pull {image}')) -              # Add capability options. Should be in uppercase              cap_add = ''              if 'cap_add' in container_config: @@ -266,6 +309,14 @@ def apply(container):                      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: @@ -296,9 +347,9 @@ def apply(container):              container_base_cmd = f'podman run --detach --interactive --tty --replace {cap_add} ' \                                   f'--memory {memory}m --memory-swap 0 --restart {restart} ' \ -                                 f'--name {name} {port} {volume} {env_opt}' +                                 f'--name {name} {device} {port} {volume} {env_opt}'              if 'allow_host_networks' in container_config: -                _cmd(f'{container_base_cmd} --net host {image}') +                _run_rerun(f'{container_base_cmd} --net host {image}')              else:                  for network in container_config['network']:                      ipparam = '' @@ -306,16 +357,7 @@ def apply(container):                          address = container_config['network'][network]['address']                          ipparam = f'--ip {address}' -                    counter = 0 -                    while True: -                        if counter >= 10: -                            break -                        try: -                            _cmd(f'{container_base_cmd} --net {network} {ipparam} {image}') -                            break -                        except: -                            counter = counter +1 -                            sleep(0.5) +                    _run_rerun(f'{container_base_cmd} --net {network} {ipparam} {image}')      return None diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/dhcp_relay.py index 6352e0b4a..4de2ca2f3 100755 --- a/src/conf_mode/dhcp_relay.py +++ b/src/conf_mode/dhcp_relay.py @@ -66,18 +66,19 @@ def generate(relay):      if not relay:          return None -    render(config_file, 'dhcp-relay/dhcrelay.conf.tmpl', relay) +    render(config_file, 'dhcp-relay/dhcrelay.conf.j2', relay)      return None  def apply(relay):      # bail out early - looks like removal from running config +    service_name = 'isc-dhcp-relay.service'      if not relay: -        call('systemctl stop isc-dhcp-relay.service') +        call(f'systemctl stop {service_name}')          if os.path.exists(config_file):              os.unlink(config_file)          return None -    call('systemctl restart isc-dhcp-relay.service') +    call(f'systemctl restart {service_name}')      return None diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py index a8cef5ebf..52b682d6d 100755 --- a/src/conf_mode/dhcp_server.py +++ b/src/conf_mode/dhcp_server.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 @@ -109,7 +109,7 @@ def get_config(config=None):      if not conf.exists(base):          return None -    dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True)      # T2665: defaults include lease time per TAG node which need to be added to      # individual subnet definitions      default_values = defaults(base + ['shared-network-name', 'subnet']) @@ -286,7 +286,7 @@ def generate(dhcp):      # Please see: https://phabricator.vyos.net/T1129 for quoting of the raw      # parameters we can pass to ISC DHCPd      tmp_file = '/tmp/dhcpd.conf' -    render(tmp_file, 'dhcp-server/dhcpd.conf.tmpl', dhcp, +    render(tmp_file, 'dhcp-server/dhcpd.conf.j2', dhcp,             formater=lambda _: _.replace(""", '"'))      # XXX: as we have the ability for a user to pass in "raw" options via VyOS      # CLI (see T3544) we now ask ISC dhcpd to test the newly rendered @@ -299,7 +299,7 @@ def generate(dhcp):      # Now that we know that the newly rendered configuration is "good" we can      # render the "real" configuration -    render(config_file, 'dhcp-server/dhcpd.conf.tmpl', dhcp, +    render(config_file, 'dhcp-server/dhcpd.conf.j2', dhcp,             formater=lambda _: _.replace(""", '"'))      return None diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py index aea2c3b73..c1bd51f62 100755 --- a/src/conf_mode/dhcpv6_relay.py +++ b/src/conf_mode/dhcpv6_relay.py @@ -82,19 +82,20 @@ def generate(relay):      if not relay:          return None -    render(config_file, 'dhcp-relay/dhcrelay6.conf.tmpl', relay) +    render(config_file, 'dhcp-relay/dhcrelay6.conf.j2', relay)      return None  def apply(relay):      # bail out early - looks like removal from running config +    service_name = 'isc-dhcp-relay6.service'      if not relay:          # DHCPv6 relay support is removed in the commit -        call('systemctl stop isc-dhcp-relay6.service') +        call(f'systemctl stop {service_name}')          if os.path.exists(config_file):              os.unlink(config_file)          return None -    call('systemctl restart isc-dhcp-relay6.service') +    call(f'systemctl restart {service_name}')      return None diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py index e6a2e4486..078ff327c 100755 --- a/src/conf_mode/dhcpv6_server.py +++ b/src/conf_mode/dhcpv6_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 @@ -41,7 +41,9 @@ def get_config(config=None):      if not conf.exists(base):          return None -    dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), +                                  get_first_key=True, +                                  no_tag_node_value_mangle=True)      return dhcpv6  def verify(dhcpv6): @@ -51,7 +53,7 @@ def verify(dhcpv6):      # If DHCP is enabled we need one share-network      if 'shared_network_name' not in dhcpv6: -        raise ConfigError('No DHCPv6 shared networks configured. At least\n' \ +        raise ConfigError('No DHCPv6 shared networks configured. At least '\                            'one DHCPv6 shared network must be configured.')      # Inspect shared-network/subnet @@ -60,8 +62,9 @@ def verify(dhcpv6):      for network, network_config in dhcpv6['shared_network_name'].items():          # A shared-network requires a subnet definition          if 'subnet' not in network_config: -            raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". At least one\n' \ -                              'lease subnet must be configured for each shared network!') +            raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\ +                              'At least one lease subnet must be configured for '\ +                              'each shared network!')          for subnet, subnet_config in network_config['subnet'].items():              if 'address_range' in subnet_config: @@ -83,20 +86,20 @@ def verify(dhcpv6):                          # Stop address must be greater or equal to start address                          if not ip_address(stop) >= ip_address(start): -                            raise ConfigError(f'address-range stop address "{stop}" must be greater or equal\n' \ +                            raise ConfigError(f'address-range stop address "{stop}" must be greater then or equal ' \                                                f'to the range start address "{start}"!')                          # DHCPv6 range start address must be unique - two ranges can't                          # start with the same address - makes no sense                          if start in range6_start: -                            raise ConfigError(f'Conflicting DHCPv6 lease range:\n' \ +                            raise ConfigError(f'Conflicting DHCPv6 lease range: '\                                                f'Pool start address "{start}" defined multipe times!')                          range6_start.append(start)                          # DHCPv6 range stop address must be unique - two ranges can't                          # end with the same address - makes no sense                          if stop in range6_stop: -                            raise ConfigError(f'Conflicting DHCPv6 lease range:\n' \ +                            raise ConfigError(f'Conflicting DHCPv6 lease range: '\                                                f'Pool stop address "{stop}" defined multipe times!')                          range6_stop.append(stop) @@ -112,7 +115,7 @@ def verify(dhcpv6):                  for prefix, prefix_config in subnet_config['prefix_delegation']['start'].items():                      if 'stop' not in prefix_config: -                        raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}"\n' +                        raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}" '\                                            f'must be configured')                      if 'prefix_length' not in prefix_config: @@ -126,6 +129,10 @@ def verify(dhcpv6):                          if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet):                              raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') +            if 'vendor_option' in subnet_config: +                if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: +                    raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') +              # Subnets must be unique              if subnet in subnets:                  raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') @@ -149,8 +156,8 @@ def verify(dhcpv6):                          raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2))      if not listen_ok: -        raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on\n' \ -                          'this machine. At least one subnet6 must be connected such that\n' \ +        raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ +                          'this machine. At least one subnet6 must be connected such that '\                            'DHCPv6 listens on an interface!') @@ -161,20 +168,20 @@ def generate(dhcpv6):      if not dhcpv6 or 'disable' in dhcpv6:          return None -    render(config_file, 'dhcp-server/dhcpdv6.conf.tmpl', dhcpv6) +    render(config_file, 'dhcp-server/dhcpdv6.conf.j2', dhcpv6)      return None  def apply(dhcpv6):      # bail out early - looks like removal from running config +    service_name = 'isc-dhcp-server6.service'      if not dhcpv6 or 'disable' in dhcpv6:          # DHCP server is removed in the commit -        call('systemctl stop isc-dhcp-server6.service') +        call(f'systemctl stop {service_name}')          if os.path.exists(config_file):              os.unlink(config_file) -          return None -    call('systemctl restart isc-dhcp-server6.service') +    call(f'systemctl restart {service_name}')      return None  if __name__ == '__main__': diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index 06366362a..f1c2d1f43 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.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 @@ -16,7 +16,9 @@  import os +from netifaces import interfaces  from sys import exit +from glob import glob  from vyos.config import Config  from vyos.configdict import dict_merge @@ -50,10 +52,12 @@ def get_config(config=None):      if not conf.exists(base):          return None -    dns = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    dns = 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. +    # options which we need to update into the dictionary retrieved.      default_values = defaults(base) +    # T2665 due to how defaults under tag nodes work, we must clear these out before we merge +    del default_values['authoritative_domain']      dns = dict_merge(default_values, dns)      # some additions to the default dictionary @@ -62,9 +66,182 @@ def get_config(config=None):          if conf.exists(base_nameservers):              dns.update({'system_name_server': conf.return_values(base_nameservers)}) -        base_nameservers_dhcp = ['system', 'name-servers-dhcp'] -        if conf.exists(base_nameservers_dhcp): -            dns.update({'system_name_server_dhcp': conf.return_values(base_nameservers_dhcp)}) +    if 'authoritative_domain' in dns: +        dns['authoritative_zones'] = [] +        dns['authoritative_zone_errors'] = [] +        for node in dns['authoritative_domain']: +            zonedata = dns['authoritative_domain'][node] +            if ('disable' in zonedata) or (not 'records' in zonedata): +                continue +            zone = { +                'name': node, +                'file': "{}/zone.{}.conf".format(pdns_rec_run_dir, node), +                'records': [], +            } + +            recorddata = zonedata['records'] + +            for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: +                if rtype not in recorddata: +                    continue +                for subnode in recorddata[rtype]: +                    if 'disable' in recorddata[rtype][subnode]: +                        continue + +                    rdata = recorddata[rtype][subnode] + +                    if rtype in [ 'a', 'aaaa' ]: +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'address' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: at least one address is required'.format(subnode, node)) +                            continue + +                        for address in rdata['address']: +                            zone['records'].append({ +                                'name': subnode, +                                'type': rtype.upper(), +                                'ttl': rdata['ttl'], +                                'value': address +                            }) +                    elif rtype in ['cname', 'ptr']: +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'target' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: target is required'.format(subnode, node)) +                            continue + +                        zone['records'].append({ +                            'name': subnode, +                            'type': rtype.upper(), +                            'ttl': rdata['ttl'], +                            'value': '{}.'.format(rdata['target']) +                        }) +                    elif rtype == 'mx': +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        del rdefaults['server'] +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'server' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: at least one server is required'.format(subnode, node)) +                            continue + +                        for servername in rdata['server']: +                            serverdata = rdata['server'][servername] +                            serverdefaults = defaults(base + ['authoritative-domain', 'records', rtype, 'server']) # T2665 +                            serverdata = dict_merge(serverdefaults, serverdata) +                            zone['records'].append({ +                                'name': subnode, +                                'type': rtype.upper(), +                                'ttl': rdata['ttl'], +                                'value': '{} {}.'.format(serverdata['priority'], servername) +                            }) +                    elif rtype == 'txt': +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'value' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: at least one value is required'.format(subnode, node)) +                            continue + +                        for value in rdata['value']: +                            zone['records'].append({ +                                'name': subnode, +                                'type': rtype.upper(), +                                'ttl': rdata['ttl'], +                                'value': "\"{}\"".format(value.replace("\"", "\\\"")) +                            }) +                    elif rtype == 'spf': +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'value' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: value is required'.format(subnode, node)) +                            continue + +                        zone['records'].append({ +                            'name': subnode, +                            'type': rtype.upper(), +                            'ttl': rdata['ttl'], +                            'value': '"{}"'.format(rdata['value'].replace("\"", "\\\"")) +                        }) +                    elif rtype == 'srv': +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        del rdefaults['entry'] +                        rdata = dict_merge(rdefaults, rdata) + +                        if not 'entry' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: at least one entry is required'.format(subnode, node)) +                            continue + +                        for entryno in rdata['entry']: +                            entrydata = rdata['entry'][entryno] +                            entrydefaults = defaults(base + ['authoritative-domain', 'records', rtype, 'entry']) # T2665 +                            entrydata = dict_merge(entrydefaults, entrydata) + +                            if not 'hostname' in entrydata: +                                dns['authoritative_zone_errors'].append('{}.{}: hostname is required for entry {}'.format(subnode, node, entryno)) +                                continue + +                            if not 'port' in entrydata: +                                dns['authoritative_zone_errors'].append('{}.{}: port is required for entry {}'.format(subnode, node, entryno)) +                                continue + +                            zone['records'].append({ +                                'name': subnode, +                                'type': rtype.upper(), +                                'ttl': rdata['ttl'], +                                'value': '{} {} {} {}.'.format(entrydata['priority'], entrydata['weight'], entrydata['port'], entrydata['hostname']) +                            }) +                    elif rtype == 'naptr': +                        rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 +                        del rdefaults['rule'] +                        rdata = dict_merge(rdefaults, rdata) + + +                        if not 'rule' in rdata: +                            dns['authoritative_zone_errors'].append('{}.{}: at least one rule is required'.format(subnode, node)) +                            continue + +                        for ruleno in rdata['rule']: +                            ruledata = rdata['rule'][ruleno] +                            ruledefaults = defaults(base + ['authoritative-domain', 'records', rtype, 'rule']) # T2665 +                            ruledata = dict_merge(ruledefaults, ruledata) +                            flags = "" +                            if 'lookup-srv' in ruledata: +                                flags += "S" +                            if 'lookup-a' in ruledata: +                                flags += "A" +                            if 'resolve-uri' in ruledata: +                                flags += "U" +                            if 'protocol-specific' in ruledata: +                                flags += "P" + +                            if 'order' in ruledata: +                                order = ruledata['order'] +                            else: +                                order = ruleno + +                            if 'regexp' in ruledata: +                                regexp= ruledata['regexp'].replace("\"", "\\\"") +                            else: +                                regexp = '' + +                            if ruledata['replacement']: +                                replacement = '{}.'.format(ruledata['replacement']) +                            else: +                                replacement = '' + +                            zone['records'].append({ +                                'name': subnode, +                                'type': rtype.upper(), +                                'ttl': rdata['ttl'], +                                'value': '{} {} "{}" "{}" "{}" {}'.format(order, ruledata['preference'], flags, ruledata['service'], regexp, replacement) +                            }) + +            dns['authoritative_zones'].append(zone)      return dns @@ -86,10 +263,14 @@ def verify(dns):              if 'server' not in dns['domain'][domain]:                  raise ConfigError(f'No server configured for domain {domain}!') +    if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']: +        for error in dns['authoritative_zone_errors']: +            print(error) +        raise ConfigError('Invalid authoritative records have been defined') +      if 'system' in dns: -        if not ('system_name_server' in dns or 'system_name_server_dhcp' in dns): -            print("Warning: No 'system name-server' or 'system " \ -                  "name-servers-dhcp' configured") +        if not 'system_name_server' in dns: +            print('Warning: No "system name-server" configured')      return None @@ -98,12 +279,21 @@ def generate(dns):      if not dns:          return None -    render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.tmpl', +    render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2',              dns, user=pdns_rec_user, group=pdns_rec_group) -    render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.tmpl', +    render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2',              dns, user=pdns_rec_user, group=pdns_rec_group) +    for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): +        os.unlink(zone_filename) + +    if 'authoritative_zones' in dns: +        for zone in dns['authoritative_zones']: +            render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', +                    zone, user=pdns_rec_user, group=pdns_rec_group) + +      # if vyos-hostsd didn't create its files yet, create them (empty)      for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]:          with open(file, 'a'): @@ -119,6 +309,9 @@ def apply(dns):          if os.path.isfile(pdns_rec_config_file):              os.unlink(pdns_rec_config_file) + +        for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): +            os.unlink(zone_filename)      else:          ### first apply vyos-hostsd config          hc = hostsd_client() @@ -142,10 +335,15 @@ def apply(dns):              hc.delete_name_server_tags_recursor(['system'])          # add dhcp nameserver tags for configured interfaces -        if 'system_name_server_dhcp' in dns: -            for interface in dns['system_name_server_dhcp']: -                hc.add_name_server_tags_recursor(['dhcp-' + interface, -                                                  'dhcpv6-' + interface ]) +        if 'system_name_server' in dns: +            for interface in dns['system_name_server']: +                # system_name_server key contains both IP addresses and interface +                # names (DHCP) to use DNS servers. We need to check if the +                # value is an interface name - only if this is the case, add the +                # interface based DNS forwarder. +                if interface in interfaces(): +                    hc.add_name_server_tags_recursor(['dhcp-' + interface, +                                                      'dhcpv6-' + interface ])          # hostsd will generate the forward-zones file          # the list and keys() are required as get returns a dict, not list @@ -153,6 +351,12 @@ def apply(dns):          if 'domain' in dns:              hc.add_forward_zones(dns['domain']) +        # hostsd generates NTAs for the authoritative zones +        # the list and keys() are required as get returns a dict, not list +        hc.delete_authoritative_zones(list(hc.get_authoritative_zones())) +        if 'authoritative_zones' in dns: +            hc.add_authoritative_zones(list(map(lambda zone: zone['name'], dns['authoritative_zones']))) +          # call hostsd to generate forward-zones and its lua-config-file          hc.apply() diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py index a31e5ed75..06a2f7e15 100755 --- a/src/conf_mode/dynamic_dns.py +++ b/src/conf_mode/dynamic_dns.py @@ -131,7 +131,7 @@ def generate(dyndns):      if not dyndns:          return None -    render(config_file, 'dynamic-dns/ddclient.conf.tmpl', dyndns) +    render(config_file, 'dynamic-dns/ddclient.conf.j2', dyndns)      return None  def apply(dyndns): diff --git a/src/conf_mode/firewall-interface.py b/src/conf_mode/firewall-interface.py new file mode 100755 index 000000000..9a5d278e9 --- /dev/null +++ b/src/conf_mode/firewall-interface.py @@ -0,0 +1,175 @@ +#!/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 8e6ce5b14..6924bf555 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -15,51 +15,413 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os +import re +from glob import glob +from json import loads  from sys import exit +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import node_changed -from vyos.configdict import leaf_node_changed +from vyos.configdiff import get_config_diff, Diff  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 process_named_running +from vyos.util import run +from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag -from pprint import pprint  airbag.enable() +policy_route_conf_script = '/usr/libexec/vyos/conf_mode/policy-route.py' -def get_config(config=None): +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'}, +    'broadcast_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_broadcasts', 'enable': '0', 'disable': '1'}, +    'ip_src_route': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_source_route'}, +    'ipv6_receive_redirects': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_redirects'}, +    'ipv6_src_route': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_source_route', 'enable': '0', 'disable': '-1'}, +    'log_martians': {'sysfs': '/proc/sys/net/ipv4/conf/all/log_martians'}, +    'receive_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_redirects'}, +    'send_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/send_redirects'}, +    'source_validation': {'sysfs': '/proc/sys/net/ipv4/conf/*/rp_filter', 'disable': '0', 'strict': '1', 'loose': '2'}, +    'syn_cookies': {'sysfs': '/proc/sys/net/ipv4/tcp_syncookies'}, +    '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', +    'network_group', +    'port_group' +] + +snmp_change_type = { +    'unknown': 0, +    'add': 1, +    'delete': 2, +    'change': 3 +} +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 get_config(config=None):      if config:          conf = config      else:          conf = Config() -    base = ['nfirewall'] +    base = ['firewall'] +      firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,                                      no_tag_node_value_mangle=True) -    pprint(firewall) +    default_values = defaults(base) +    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) + +    if 'config_trap' in firewall and firewall['config_trap'] == 'enable': +        diff = get_config_diff(conf) +        firewall['trap_diff'] = diff.get_child_nodes_diff_str(base) +        firewall['trap_targets'] = conf.get_config_dict(['service', 'snmp', 'trap-target'], +                                        key_mangling=('-', '_'), get_first_key=True, +                                        no_tag_node_value_mangle=True) +      return firewall +def verify_rule(firewall, rule_conf, ipv6): +    if 'action' not in rule_conf: +        raise ConfigError('Rule action must be defined') + +    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"') + +    if 'limit' in rule_conf: +        if 'rate' in rule_conf['limit']: +            rate_int = re.sub(r'\D', '', rule_conf['limit']['rate']) +            if int(rate_int) < 1: +                raise ConfigError('Limit rate integer cannot be less than 1') + +    if 'ipsec' in rule_conf: +        if {'match_ipsec', 'match_non_ipsec'} <= set(rule_conf['ipsec']): +            raise ConfigError('Cannot specify both "match-ipsec" and "match-non-ipsec"') + +    if 'recent' in rule_conf: +        if not {'count', 'time'} <= set(rule_conf['recent']): +            raise ConfigError('Recent "count" and "time" values must be defined') + +    tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') +    if tcp_flags: +        if dict_search_args(rule_conf, 'protocol') != 'tcp': +            raise ConfigError('Protocol must be tcp when specifying tcp flags') + +        not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') +        if not_flags: +            duplicates = [flag for flag in tcp_flags if flag in not_flags] +            if duplicates: +                raise ConfigError(f'Cannot match a tcp flag as set and not set') + +    if 'protocol' in rule_conf: +        if rule_conf['protocol'] == 'icmp' and ipv6: +            raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') +        if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: +            raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') + +    for side in ['destination', 'source']: +        if side in rule_conf: +            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') + +                for group in valid_groups: +                    if group in side_conf['group']: +                        group_name = side_conf['group'][group] + +                        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: +                            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 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): +                if 'protocol' not in rule_conf: +                    raise ConfigError('Protocol must be defined if specifying a port or port-group') + +                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(firewall): -    # bail out early - looks like removal from running config -    if not firewall: -        return None +    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') + +    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 '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 name and dict_search_args(firewall, 'name', name) == None: +                raise ConfigError(f'Firewall name "{name}" is still referenced on interface {ifname}') + +            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}') + +    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')      return None +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 +  def generate(firewall): -    if not firewall: -        return None +    if not os.path.exists(nftables_conf): +        firewall['first_install'] = True +    else: +        firewall['cleanup_commands'] = cleanup_commands(firewall) +    render(nftables_conf, 'firewall/nftables.j2', firewall) +    render(nftables_defines_conf, 'firewall/nftables-defines.j2', firewall)      return None -def apply(firewall): -    if not firewall: +def apply_sysfs(firewall): +    for name, conf in sysfs_config.items(): +        paths = glob(conf['sysfs']) +        value = None + +        if name in firewall: +            conf_value = firewall[name] + +            if conf_value in conf: +                value = conf[conf_value] +            elif conf_value == 'enable': +                value = '1' +            elif conf_value == 'disable': +                value = '0' + +        if value: +            for path in paths: +                with open(path, 'w') as f: +                    f.write(value) + +def post_apply_trap(firewall): +    if 'first_install' in firewall: +        return None + +    if 'config_trap' not in firewall or firewall['config_trap'] != 'enable':          return None +    if not process_named_running('snmpd'): +        return None + +    trap_username = os.getlogin() + +    for host, target_conf in firewall['trap_targets'].items(): +        community = target_conf['community'] if 'community' in target_conf else 'public' +        port = int(target_conf['port']) if 'port' in target_conf else 162 + +        base_cmd = f'snmptrap -v2c -c {community} {host}:{port} 0 {snmp_trap_mib}::{snmp_trap_name} ' + +        for change_type, changes in firewall['trap_diff'].items(): +            for path_str, value in changes.items(): +                objects = [ +                    f'mgmtEventUser s "{trap_username}"', +                    f'mgmtEventSource i {snmp_event_source}', +                    f'mgmtEventType i {snmp_change_type[change_type]}' +                ] + +                if change_type == 'add': +                    objects.append(f'mgmtEventCurrCfg s "{path_str} {value}"') +                elif change_type == 'delete': +                    objects.append(f'mgmtEventPrevCfg s "{path_str} {value}"') +                elif change_type == 'change': +                    objects.append(f'mgmtEventPrevCfg s "{path_str} {value[0]}"') +                    objects.append(f'mgmtEventCurrCfg s "{path_str} {value[1]}"') + +                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}') +    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') + +    apply_sysfs(firewall) + +    if firewall['policy_resync']: +        resync_policy_route() + +    post_apply_trap(firewall) +      return None  if __name__ == '__main__': diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 0a4559ade..7750c1247 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.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 @@ -16,121 +16,87 @@  import os  import re +  from sys import exit  import ipaddress  from ipaddress import ip_address -from jinja2 import FileSystemLoader, Environment +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import dict_merge  from vyos.ifconfig import Section  from vyos.ifconfig import Interface -from vyos.config import Config -from vyos import ConfigError -from vyos.util import cmd  from vyos.template import render - +from vyos.util import call +from vyos.util import cmd +from vyos.validate import is_addr_assigned +from vyos.xml import defaults +from vyos import ConfigError  from vyos import airbag  airbag.enable() -# default values -default_sflow_server_port = 6343 -default_netflow_server_port = 2055 -default_plugin_pipe_size = 10 -default_captured_packet_size = 128 -default_netflow_version = '9' -default_sflow_agentip = 'auto' -uacctd_conf_path = '/etc/pmacct/uacctd.conf' -iptables_nflog_table = 'raw' -iptables_nflog_chain = 'VYATTA_CT_PREROUTING_HOOK' -egress_iptables_nflog_table = 'mangle' -egress_iptables_nflog_chain = 'FORWARD' - -# helper functions -# check if node exists and return True if this is true -def _node_exists(path): -    vyos_config = Config() -    if vyos_config.exists(path): -        return True - -# get sFlow agent-ip if agent-address is "auto" (default behaviour) -def _sflow_default_agentip(config): -    # check if any of BGP, OSPF, OSPFv3 protocols are configured and use router-id from there -    if config.exists('protocols bgp'): -        bgp_router_id = config.return_value("protocols bgp {} parameters router-id".format(config.list_nodes('protocols bgp')[0])) -        if bgp_router_id: -            return bgp_router_id -    if config.return_value('protocols ospf parameters router-id'): -        return config.return_value('protocols ospf parameters router-id') -    if config.return_value('protocols ospfv3 parameters router-id'): -        return config.return_value('protocols ospfv3 parameters router-id') - -    # if router-id was not found, use first available ip of any interface -    for iface in Section.interfaces(): -        for address in Interface(iface).get_addr(): -            # return an IP, if this is not loopback -            regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') -            if regex_filter.search(address): -                return regex_filter.search(address).group('ipaddr') - -    # return nothing by default -    return None - -# get iptables rule dict for chain in table -def _iptables_get_nflog(chain, table): +uacctd_conf_path = '/run/pmacct/uacctd.conf' +systemd_service = 'uacctd.service' +systemd_override = f'/etc/systemd/system/{systemd_service}.d/override.conf' +nftables_nflog_table = 'raw' +nftables_nflog_chain = 'VYOS_CT_PREROUTING_HOOK' +egress_nftables_nflog_table = 'inet mangle' +egress_nftables_nflog_chain = 'FORWARD' + +# get nftables rule dict for chain in table +def _nftables_get_nflog(chain, table):      # define list with rules      rules = []      # prepare regex for parsing rules -    rule_pattern = "^-A (?P<rule_definition>{0} (\-i|\-o) (?P<interface>[\w\.\*\-]+).*--comment FLOW_ACCOUNTING_RULE.* -j NFLOG.*$)".format(chain) +    rule_pattern = '[io]ifname "(?P<interface>[\w\.\*\-]+)".*handle (?P<handle>[\d]+)'      rule_re = re.compile(rule_pattern) -    for iptables_variant in ['iptables', 'ip6tables']: -        # run iptables, save output and split it by lines -        iptables_command = f'{iptables_variant} -t {table} -S {chain}' -        tmp = cmd(iptables_command, message='Failed to get flows list') - -        # parse each line and add information to list -        for current_rule in tmp.splitlines(): -            current_rule_parsed = rule_re.search(current_rule) -            if current_rule_parsed: -                rules.append({ 'interface': current_rule_parsed.groupdict()["interface"], 'iptables_variant': iptables_variant, 'table': table, 'rule_definition': current_rule_parsed.groupdict()["rule_definition"] }) +    # run nftables, save output and split it by lines +    nftables_command = f'nft -a list chain {table} {chain}' +    tmp = cmd(nftables_command, message='Failed to get flows list') +    # parse each line and add information to list +    for current_rule in tmp.splitlines(): +        if 'FLOW_ACCOUNTING_RULE' not in current_rule: +            continue +        current_rule_parsed = rule_re.search(current_rule) +        if current_rule_parsed: +            groups = current_rule_parsed.groupdict() +            rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] })      # return list with rules      return rules -# modify iptables rules -def _iptables_config(configured_ifaces, direction): -    # define list of iptables commands to modify settings -    iptable_commands = [] -    iptables_chain = iptables_nflog_chain -    iptables_table = iptables_nflog_table +def _nftables_config(configured_ifaces, direction, length=None): +    # define list of nftables commands to modify settings +    nftable_commands = [] +    nftables_chain = nftables_nflog_chain +    nftables_table = nftables_nflog_table      if direction == "egress": -        iptables_chain = egress_iptables_nflog_chain -        iptables_table = egress_iptables_nflog_table +        nftables_chain = egress_nftables_nflog_chain +        nftables_table = egress_nftables_nflog_table      # prepare extended list with configured interfaces      configured_ifaces_extended = []      for iface in configured_ifaces: -        configured_ifaces_extended.append({ 'iface': iface, 'iptables_variant': 'iptables' }) -        configured_ifaces_extended.append({ 'iface': iface, 'iptables_variant': 'ip6tables' }) +        configured_ifaces_extended.append({ 'iface': iface }) -    # get currently configured interfaces with iptables rules -    active_nflog_rules = _iptables_get_nflog(iptables_chain, iptables_table) +    # get currently configured interfaces with nftables rules +    active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table)      # compare current active list with configured one and delete excessive interfaces, add missed      active_nflog_ifaces = []      for rule in active_nflog_rules: -        iptables = rule['iptables_variant']          interface = rule['interface']          if interface not in configured_ifaces:              table = rule['table'] -            rule = rule['rule_definition'] -            iptable_commands.append(f'{iptables} -t {table} -D {rule}') +            handle = rule['handle'] +            nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}')          else:              active_nflog_ifaces.append({                  'iface': interface, -                'iptables_variant': iptables,              })      # do not create new rules for already configured interfaces @@ -141,244 +107,178 @@ def _iptables_config(configured_ifaces, direction):      # create missed rules      for iface_extended in configured_ifaces_extended:          iface = iface_extended['iface'] -        iptables = iface_extended['iptables_variant'] -        iptables_op = "-i" -        if direction == "egress": -            iptables_op = "-o" - -        rule_definition = f'{iptables_chain} {iptables_op} {iface} -m comment --comment FLOW_ACCOUNTING_RULE -j NFLOG --nflog-group 2 --nflog-size {default_captured_packet_size} --nflog-threshold 100' -        iptable_commands.append(f'{iptables} -t {iptables_table} -I {rule_definition}') - -    # change iptables -    for command in iptable_commands: +        iface_prefix = "o" if direction == "egress" else "i" +        rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' +        nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') +        # Also add IPv6 ingres logging +        if nftables_table == nftables_nflog_table: +            nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') + +    # change nftables +    for command in nftable_commands:          cmd(command, raising=ConfigError) -def get_config(): -    vc = Config() -    vc.set_level('') -    # Convert the VyOS config to an abstract internal representation -    flow_config = { -        'flow-accounting-configured': vc.exists('system flow-accounting'), -        'buffer-size': vc.return_value('system flow-accounting buffer-size'), -        'enable-egress': _node_exists('system flow-accounting enable-egress'), -        'disable-imt': _node_exists('system flow-accounting disable-imt'), -        'syslog-facility': vc.return_value('system flow-accounting syslog-facility'), -        'interfaces': None, -        'sflow': { -            'configured': vc.exists('system flow-accounting sflow'), -            'agent-address': vc.return_value('system flow-accounting sflow agent-address'), -            'sampling-rate': vc.return_value('system flow-accounting sflow sampling-rate'), -            'servers': None -        }, -        'netflow': { -            'configured': vc.exists('system flow-accounting netflow'), -            'engine-id': vc.return_value('system flow-accounting netflow engine-id'), -            'max-flows': vc.return_value('system flow-accounting netflow max-flows'), -            'sampling-rate': vc.return_value('system flow-accounting netflow sampling-rate'), -            'source-ip': vc.return_value('system flow-accounting netflow source-ip'), -            'version': vc.return_value('system flow-accounting netflow version'), -            'timeout': { -                'expint': vc.return_value('system flow-accounting netflow timeout expiry-interval'), -                'general': vc.return_value('system flow-accounting netflow timeout flow-generic'), -                'icmp': vc.return_value('system flow-accounting netflow timeout icmp'), -                'maxlife': vc.return_value('system flow-accounting netflow timeout max-active-life'), -                'tcp.fin': vc.return_value('system flow-accounting netflow timeout tcp-fin'), -                'tcp': vc.return_value('system flow-accounting netflow timeout tcp-generic'), -                'tcp.rst': vc.return_value('system flow-accounting netflow timeout tcp-rst'), -                'udp': vc.return_value('system flow-accounting netflow timeout udp') -            }, -            'servers': None -        } -    } - -    # get interfaces list -    if vc.exists('system flow-accounting interface'): -        flow_config['interfaces'] = vc.return_values('system flow-accounting interface') - -    # get sFlow collectors list -    if vc.exists('system flow-accounting sflow server'): -        flow_config['sflow']['servers'] = [] -        sflow_collectors = vc.list_nodes('system flow-accounting sflow server') -        for collector in sflow_collectors: -            port = default_sflow_server_port -            if vc.return_value("system flow-accounting sflow server {} port".format(collector)): -                port = vc.return_value("system flow-accounting sflow server {} port".format(collector)) -            flow_config['sflow']['servers'].append({ 'address': collector, 'port': port }) - -    # get NetFlow collectors list -    if vc.exists('system flow-accounting netflow server'): -        flow_config['netflow']['servers'] = [] -        netflow_collectors = vc.list_nodes('system flow-accounting netflow server') -        for collector in netflow_collectors: -            port = default_netflow_server_port -            if vc.return_value("system flow-accounting netflow server {} port".format(collector)): -                port = vc.return_value("system flow-accounting netflow server {} port".format(collector)) -            flow_config['netflow']['servers'].append({ 'address': collector, 'port': port }) - -    # get sflow agent-id -    if flow_config['sflow']['agent-address'] == None or flow_config['sflow']['agent-address'] == 'auto': -        flow_config['sflow']['agent-address'] = _sflow_default_agentip(vc) - -    # get NetFlow version -    if not flow_config['netflow']['version']: -        flow_config['netflow']['version'] = default_netflow_version - -    # convert NetFlow engine-id format, if this is necessary -    if flow_config['netflow']['engine-id'] and flow_config['netflow']['version'] == '5': -        regex_filter = re.compile('^\d+$') -        if regex_filter.search(flow_config['netflow']['engine-id']): -            flow_config['netflow']['engine-id'] = "{}:0".format(flow_config['netflow']['engine-id']) - -    # return dict with flow-accounting configuration -    return flow_config - -def verify(config): -    # Verify that configuration is valid -    # skip all checks if flow-accounting was removed -    if not config['flow-accounting-configured']: -        return True +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() +    base = ['system', 'flow-accounting'] +    if not conf.exists(base): +        return None + +    flow_accounting = 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) + +    # delete individual flow type default - should only be added if user uses +    # this feature +    for flow_type in ['sflow', 'netflow']: +        if flow_type in default_values: +            del default_values[flow_type] +    flow_accounting = dict_merge(default_values, flow_accounting) + +    for flow_type in ['sflow', 'netflow']: +        if flow_type in flow_accounting: +            default_values = defaults(base + [flow_type]) +            # we need to merge individual server configurations +            if 'server' in default_values: +                del default_values['server'] +            flow_accounting[flow_type] = dict_merge(default_values, flow_accounting[flow_type]) + +            if 'server' in flow_accounting[flow_type]: +                default_values = defaults(base + [flow_type, 'server']) +                for server in flow_accounting[flow_type]['server']: +                    flow_accounting[flow_type]['server'][server] = dict_merge( +                        default_values,flow_accounting[flow_type]['server'][server]) + +    return flow_accounting + +def verify(flow_config): +    if not flow_config: +        return None      # check if at least one collector is enabled -    if not (config['sflow']['configured'] or config['netflow']['configured'] or not config['disable-imt']): -        raise ConfigError("You need to configure at least one sFlow or NetFlow protocol, or not set \"disable-imt\" for flow-accounting") +    if 'sflow' not in flow_config and 'netflow' not in flow_config and 'disable_imt' in flow_config: +        raise ConfigError('You need to configure at least sFlow or NetFlow, ' \ +                          'or not set "disable-imt" for flow-accounting!')      # Check if at least one interface is configured -    if not config['interfaces']: -        raise ConfigError("You need to configure at least one interface for flow-accounting") +    if 'interface' not in flow_config: +        raise ConfigError('Flow accounting requires at least one interface to ' \ +                          'be configured!')      # check that all configured interfaces exists in the system -    for iface in config['interfaces']: -        if not iface in Section.interfaces(): -            # chnged from error to warning to allow adding dynamic interfaces and interface templates -            # raise ConfigError("The {} interface is not presented in the system".format(iface)) -            print("Warning: the {} interface is not presented in the system".format(iface)) +    for interface in flow_config['interface']: +        if interface not in Section.interfaces(): +            # Changed from error to warning to allow adding dynamic interfaces +            # and interface templates +            Warning(f'Interface "{interface}" is not presented in the system')      # check sFlow configuration -    if config['sflow']['configured']: -        # check if at least one sFlow collector is configured if sFlow configuration is presented -        if not config['sflow']['servers']: -            raise ConfigError("You need to configure at least one sFlow server") +    if 'sflow' in flow_config: +        # check if at least one sFlow collector is configured +        if 'server' not in flow_config['sflow']: +            raise ConfigError('You need to configure at least one sFlow server!')          # check that all sFlow collectors use the same IP protocol version          sflow_collector_ipver = None -        for sflow_collector in config['sflow']['servers']: +        for server in flow_config['sflow']['server']:              if sflow_collector_ipver: -                if sflow_collector_ipver != ip_address(sflow_collector['address']).version: +                if sflow_collector_ipver != ip_address(server).version:                      raise ConfigError("All sFlow servers must use the same IP protocol")              else: -                sflow_collector_ipver = ip_address(sflow_collector['address']).version - +                sflow_collector_ipver = ip_address(server).version          # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa -        for sflow_collector in config['sflow']['servers']: -            if ip_address(sflow_collector['address']).version != ip_address(config['sflow']['agent-address']).version: -                raise ConfigError("Different IP address versions cannot be mixed in \"sflow agent-address\" and \"sflow server\". You need to set manually the same IP version for \"agent-address\" as for all sFlow servers") - -        # check if configured sFlow agent-id exist in the system -        agent_id_presented = None -        for iface in Section.interfaces(): -            for address in Interface(iface).get_addr(): -                # check an IP, if this is not loopback -                regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') -                if regex_filter.search(address): -                    if regex_filter.search(address).group('ipaddr') == config['sflow']['agent-address']: -                        agent_id_presented = True -                        break -        if not agent_id_presented: -            raise ConfigError("Your \"sflow agent-address\" does not exist in the system") +        for server in flow_config['sflow']['server']: +            if 'agent_address' in flow_config['sflow']: +                if ip_address(server).version != ip_address(flow_config['sflow']['agent_address']).version: +                    raise ConfigError('IPv4 and IPv6 addresses can not be mixed in "sflow agent-address" and "sflow '\ +                                      'server". You need to set the same IP version for both "agent-address" and '\ +                                      'all sFlow servers') + +        if 'agent_address' in flow_config['sflow']: +            tmp = flow_config['sflow']['agent_address'] +            if not is_addr_assigned(tmp): +                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']): +                tmp = flow_config['sflow']['source_address'] +                raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!')      # check NetFlow configuration -    if config['netflow']['configured']: +    if 'netflow' in flow_config:          # check if at least one NetFlow collector is configured if NetFlow configuration is presented -        if not config['netflow']['servers']: -            raise ConfigError("You need to configure at least one NetFlow server") - -        # check if configured netflow source-ip exist in the system -        if config['netflow']['source-ip']: -            source_ip_presented = None -            for iface in Section.interfaces(): -                for address in Interface(iface).get_addr(): -                    # check an IP -                    regex_filter = re.compile('^(?!(127)|(::1)|(fe80))(?P<ipaddr>[a-f\d\.:]+)/\d+$') -                    if regex_filter.search(address): -                        if regex_filter.search(address).group('ipaddr') == config['netflow']['source-ip']: -                            source_ip_presented = True -                            break -            if not source_ip_presented: -                print("Warning: your \"netflow source-ip\" does not exist in the system") - -        # check if engine-id compatible with selected protocol version -        if config['netflow']['engine-id']: +        if 'server' not in flow_config['netflow']: +            raise ConfigError('You need to configure at least one NetFlow server!') + +        # Check if configured netflow source-address exist in the system +        if 'source_address' in flow_config['netflow']: +            if not is_addr_assigned(flow_config['netflow']['source_address']): +                tmp = flow_config['netflow']['source_address'] +                raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') + +        # Check if engine-id compatible with selected protocol version +        if 'engine_id' in flow_config['netflow']:              v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$'              v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' -            if config['netflow']['version'] == '5': +            engine_id = flow_config['netflow']['engine_id'] +            version = flow_config['netflow']['version'] + +            if flow_config['netflow']['version'] == '5':                  regex_filter = re.compile(v5_filter) -                if not regex_filter.search(config['netflow']['engine-id']): -                    raise ConfigError("You cannot use NetFlow engine-id {} together with NetFlow protocol version {}".format(config['netflow']['engine-id'], config['netflow']['version'])) +                if not regex_filter.search(engine_id): +                    raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\ +                                      f'together with NetFlow protocol version "{version}"!')              else:                  regex_filter = re.compile(v9v10_filter) -                if not regex_filter.search(config['netflow']['engine-id']): -                    raise ConfigError("You cannot use NetFlow engine-id {} together with NetFlow protocol version {}".format(config['netflow']['engine-id'], config['netflow']['version'])) +                if not regex_filter.search(flow_config['netflow']['engine_id']): +                    raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\ +                                      f'with NetFlow protocol version "{version}"!')      # return True if all checks were passed      return True -def generate(config): -    # skip all checks if flow-accounting was removed -    if not config['flow-accounting-configured']: -        return True - -    # Calculate all necessary values -    if config['buffer-size']: -        # circular queue size -        config['plugin_pipe_size'] = int(config['buffer-size']) * 1024**2 -    else: -        config['plugin_pipe_size'] = default_plugin_pipe_size * 1024**2 -    # transfer buffer size -    # recommended value from pmacct developers 1/1000 of pipe size -    config['plugin_buffer_size'] = int(config['plugin_pipe_size'] / 1000) - -    # Prepare a timeouts string -    timeout_string = '' -    for timeout_type, timeout_value in config['netflow']['timeout'].items(): -        if timeout_value: -            if timeout_string == '': -                timeout_string = "{}{}={}".format(timeout_string, timeout_type, timeout_value) -            else: -                timeout_string = "{}:{}={}".format(timeout_string, timeout_type, timeout_value) -    config['netflow']['timeout_string'] = timeout_string - -    render(uacctd_conf_path, 'netflow/uacctd.conf.tmpl', { -        'templatecfg': config, -        'snaplen': default_captured_packet_size, -    }) +def generate(flow_config): +    if not flow_config: +        return None +    render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config) +    render(systemd_override, 'pmacct/override.conf.j2', flow_config) +    # Reload systemd manager configuration +    call('systemctl daemon-reload') -def apply(config): -    # define variables -    command = None +def apply(flow_config): +    action = 'restart'      # Check if flow-accounting was removed and define command -    if not config['flow-accounting-configured']: -        command = 'systemctl stop uacctd.service' -    else: -        command = 'systemctl restart uacctd.service' +    if not flow_config: +        _nftables_config([], 'ingress') +        _nftables_config([], 'egress') + +        # Stop flow-accounting daemon and remove configuration file +        call(f'systemctl stop {systemd_service}') +        if os.path.exists(uacctd_conf_path): +            os.unlink(uacctd_conf_path) +        return -    # run command to start or stop flow-accounting -    cmd(command, raising=ConfigError, message='Failed to start/stop flow-accounting') +    # Start/reload flow-accounting daemon +    call(f'systemctl restart {systemd_service}') -    # configure iptables rules for defined interfaces -    if config['interfaces']: -        _iptables_config(config['interfaces'], 'ingress') +    # configure nftables rules for defined interfaces +    if 'interface' in flow_config: +        _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length'])          # configure egress the same way if configured otherwise remove it -        if config['enable-egress']: -            _iptables_config(config['interfaces'], 'egress') +        if 'enable_egress' in flow_config: +            _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length'])          else: -            _iptables_config([], 'egress') -    else: -        _iptables_config([], 'ingress') -        _iptables_config([], 'egress') +            _nftables_config([], 'egress')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/vrrp.py b/src/conf_mode/high-availability.py index c72efc61f..e14050dd3 100755 --- a/src/conf_mode/vrrp.py +++ b/src/conf_mode/high-availability.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 @@ -28,7 +28,6 @@ from vyos.template import render  from vyos.template import is_ipv4  from vyos.template import is_ipv6  from vyos.util import call -from vyos.util import is_systemd_service_running  from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag @@ -40,33 +39,41 @@ def get_config(config=None):      else:          conf = Config() -    base = ['high-availability', 'vrrp'] +    base = ['high-availability'] +    base_vrrp = ['high-availability', 'vrrp']      if not conf.exists(base):          return None -    vrrp = conf.get_config_dict(base, key_mangling=('-', '_'), +    ha = 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. -    if 'group' in vrrp: -        default_values = defaults(base + ['group']) -        for group in vrrp['group']: -            vrrp['group'][group] = dict_merge(default_values, vrrp['group'][group]) +    if 'vrrp' in ha: +        if 'group' in ha['vrrp']: +            default_values_vrrp = defaults(base_vrrp + ['group']) +            for group in ha['vrrp']['group']: +                ha['vrrp']['group'][group] = dict_merge(default_values_vrrp, ha['vrrp']['group'][group]) + +    # Merge per virtual-server default values +    if 'virtual_server' in ha: +        default_values = defaults(base + ['virtual-server']) +        for vs in ha['virtual_server']: +            ha['virtual_server'][vs] = dict_merge(default_values, ha['virtual_server'][vs])      ## Get the sync group used for conntrack-sync      conntrack_path = ['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group']      if conf.exists(conntrack_path): -        vrrp['conntrack_sync_group'] = conf.return_value(conntrack_path) +        ha['conntrack_sync_group'] = conf.return_value(conntrack_path) -    return vrrp +    return ha -def verify(vrrp): -    if not vrrp: +def verify(ha): +    if not ha:          return None      used_vrid_if = [] -    if 'group' in vrrp: -        for group, group_config in vrrp['group'].items(): +    if 'vrrp' in ha and 'group' in ha['vrrp']: +        for group, group_config in ha['vrrp']['group'].items():              # Check required fields              if 'vrid' not in group_config:                  raise ConfigError(f'VRID is required but not set in VRRP group "{group}"') @@ -119,33 +126,41 @@ def verify(vrrp):                      if is_ipv4(group_config['peer_address']):                          raise ConfigError(f'VRRP group "{group}" uses IPv6 but peer-address is IPv4!')      # Check sync groups -    if 'sync_group' in vrrp: -        for sync_group, sync_config in vrrp['sync_group'].items(): +    if 'vrrp' in ha and 'sync_group' in ha['vrrp']: +        for sync_group, sync_config in ha['vrrp']['sync_group'].items():              if 'member' in sync_config:                  for member in sync_config['member']: -                    if member not in vrrp['group']: +                    if member not in ha['vrrp']['group']:                          raise ConfigError(f'VRRP sync-group "{sync_group}" refers to VRRP group "{member}", '\                                            'but it does not exist!') -def generate(vrrp): -    if not vrrp: +    # Virtual-server +    if 'virtual_server' in ha: +        for vs, vs_config in ha['virtual_server'].items(): +            if 'port' not in vs_config: +                raise ConfigError(f'Port is required but not set for virtual-server "{vs}"') +            if 'real_server' not in vs_config: +                raise ConfigError(f'Real-server ip is required but not set for virtual-server "{vs}"') +        # Real-server +        for rs, rs_config in vs_config['real_server'].items(): +            if 'port' not in rs_config: +                raise ConfigError(f'Port is required but not set for virtual-server "{vs}" real-server "{rs}"') + + +def generate(ha): +    if not ha:          return None -    render(VRRP.location['config'], 'vrrp/keepalived.conf.tmpl', vrrp) +    render(VRRP.location['config'], 'high-availability/keepalived.conf.j2', ha)      return None -def apply(vrrp): +def apply(ha):      service_name = 'keepalived.service' -    if not vrrp: +    if not ha:          call(f'systemctl stop {service_name}')          return None -    # XXX: T3944 - reload keepalived configuration if service is already running -    # to not cause any service disruption when applying changes. -    if is_systemd_service_running(service_name): -        call(f'systemctl reload {service_name}') -    else: -        call(f'systemctl restart {service_name}') +    call(f'systemctl reload-or-restart {service_name}')      return None  if __name__ == '__main__': diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index 87bad0dc6..93f244f42 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/host_name.py @@ -21,13 +21,14 @@ import copy  import vyos.util  import vyos.hostsd_client -from vyos import ConfigError +from vyos.base import Warning  from vyos.config import Config  from vyos.ifconfig import Section  from vyos.template import is_ip  from vyos.util import cmd  from vyos.util import call  from vyos.util import process_named_running +from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -113,7 +114,7 @@ def verify(hosts):      for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items():          # Warnin user if interface does not have DHCP or DHCPv6 configured          if not set(interface_config).intersection(['dhcp', 'dhcpv6']): -            print(f'WARNING: "{interface}" is not a DHCP interface but uses DHCP name-server option!') +            Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!')      return None diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 7e4b117c8..4a7906c17 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -13,25 +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 sys  import os  import json -import time + +from time import sleep  from copy import deepcopy  import vyos.defaults +  from vyos.config import Config -from vyos import ConfigError +from vyos.template import render  from vyos.util import cmd  from vyos.util import call - +from vyos import ConfigError  from vyos import airbag  airbag.enable() -config_file = '/etc/vyos/http-api.conf' +api_conf_file = '/etc/vyos/http-api.conf' +systemd_service = '/run/systemd/system/vyos-http-api.service'  vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] @@ -49,21 +50,44 @@ def get_config(config=None):      else:          conf = Config() -    if not conf.exists('service https api'): +    base = ['service', 'https', 'api'] +    if not conf.exists(base):          return None -    else: -        conf.set_level('service https api') +    # 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' +        http_api['strict'] = True      if conf.exists('debug'): -        http_api['debug'] = 'true' +        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)): @@ -83,24 +107,31 @@ def verify(http_api):  def generate(http_api):      if http_api is None: +        if os.path.exists(systemd_service): +            os.unlink(systemd_service)          return None      if not os.path.exists('/etc/vyos'):          os.mkdir('/etc/vyos') -    with open(config_file, 'w') as f: +    with open(api_conf_file, 'w') as f:          json.dump(http_api, f, indent=2) +    render(systemd_service, 'https/vyos-http-api.service.j2', http_api)      return None  def apply(http_api): +    # Reload systemd manager configuration +    call('systemctl daemon-reload') +    service_name = 'vyos-http-api.service' +      if http_api is not None: -        call('systemctl restart vyos-http-api.service') +        call(f'systemctl restart {service_name}')      else: -        call('systemctl stop vyos-http-api.service') +        call(f'systemctl stop {service_name}')      # Let uvicorn settle before restarting Nginx -    time.sleep(2) +    sleep(1)      cmd(f'{vyos_conf_scripts_dir}/https.py', raising=ConfigError) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index 92dc4a410..3057357fc 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -23,6 +23,7 @@ import vyos.defaults  import vyos.certbot_util  from vyos.config import Config +from vyos.configverify import verify_vrf  from vyos import ConfigError  from vyos.pki import wrap_certificate  from vyos.pki import wrap_private_key @@ -34,6 +35,7 @@ from vyos import airbag  airbag.enable()  config_file = '/etc/nginx/sites-available/default' +systemd_override = r'/etc/systemd/system/nginx.service.d/override.conf'  cert_dir = '/etc/ssl/certs'  key_dir = '/etc/ssl/private'  certbot_dir = vyos.defaults.directories['certbot'] @@ -59,10 +61,11 @@ def get_config(config=None):      else:          conf = Config() -    if not conf.exists('service https'): +    base = ['service', 'https'] +    if not conf.exists(base):          return None -    https = conf.get_config_dict('service https', get_first_key=True) +    https = conf.get_config_dict(base, get_first_key=True)      if https:          https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), @@ -103,6 +106,8 @@ def verify(https):              if not domains_found:                  raise ConfigError("At least one 'virtual-host <id> server-name' "                                "matching the 'certbot domain-name' is required.") + +    verify_vrf(https)      return None  def generate(https): @@ -143,7 +148,6 @@ def generate(https):          server_cert = str(wrap_certificate(pki_cert['certificate']))          if 'ca-certificate' in cert_dict:              ca_cert = cert_dict['ca-certificate'] -            print(ca_cert)              server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate']))          write_file(cert_path, server_cert) @@ -188,6 +192,8 @@ def generate(https):          vhosts = https.get('api-restrict', {}).get('virtual-host', [])          if vhosts:              api_data['vhost'] = vhosts[:] +        if 'socket' in list(api_settings): +            api_data['socket'] = True      if api_data:          vhost_list = api_data.get('vhost', []) @@ -208,11 +214,13 @@ def generate(https):          'certbot': certbot      } -    render(config_file, 'https/nginx.default.tmpl', data) - +    render(config_file, 'https/nginx.default.j2', data) +    render(systemd_override, 'https/override.conf.j2', https)      return None  def apply(https): +    # Reload systemd manager configuration +    call('systemctl daemon-reload')      if https is not None:          call('systemctl restart nginx.service')      else: diff --git a/src/conf_mode/igmp_proxy.py b/src/conf_mode/igmp_proxy.py index fb030c9f3..de6a51c64 100755 --- a/src/conf_mode/igmp_proxy.py +++ b/src/conf_mode/igmp_proxy.py @@ -19,6 +19,7 @@ import os  from sys import exit  from netifaces import interfaces +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.template import render @@ -92,10 +93,10 @@ def generate(igmp_proxy):      # bail out early - service is disabled, but inform user      if 'disable' in igmp_proxy: -        print('WARNING: IGMP Proxy will be deactivated because it is disabled') +        Warning('IGMP Proxy will be deactivated because it is disabled')          return None -    render(config_file, 'igmp-proxy/igmpproxy.conf.tmpl', igmp_proxy) +    render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy)      return None diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 431d65f1f..4167594e3 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -27,8 +27,9 @@ from vyos.configdict import is_source_interface  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_source_interface +from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_source_interface  from vyos.configverify import verify_vlan_config  from vyos.configverify import verify_vrf  from vyos.ifconfig import BondIf @@ -67,7 +68,7 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'bonding'] -    bond = get_interface_dict(conf, base) +    ifname, bond = get_interface_dict(conf, base)      # To make our own life easier transfor the list of member interfaces      # into a dictionary - we will use this to add additional information @@ -80,14 +81,14 @@ def get_config(config=None):      if 'mode' in bond:          bond['mode'] = get_bond_mode(bond['mode']) -    tmp = leaf_node_changed(conf, ['mode']) +    tmp = leaf_node_changed(conf, base + [ifname, 'mode'])      if tmp: bond.update({'shutdown_required': {}}) -    tmp = leaf_node_changed(conf, ['lacp-rate']) +    tmp = leaf_node_changed(conf, base + [ifname, 'lacp-rate'])      if tmp: bond.update({'shutdown_required': {}})      # determine which members have been removed -    interfaces_removed = leaf_node_changed(conf, ['member', 'interface']) +    interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface'])      if interfaces_removed:          bond.update({'shutdown_required': {}})          if 'member' not in bond: @@ -132,10 +133,10 @@ def verify(bond):          return None      if 'arp_monitor' in bond: -        if 'target' in bond['arp_monitor'] and len(int(bond['arp_monitor']['target'])) > 16: +        if 'target' in bond['arp_monitor'] and len(bond['arp_monitor']['target']) > 16:              raise ConfigError('The maximum number of arp-monitor targets is 16') -        if 'interval' in bond['arp_monitor'] and len(int(bond['arp_monitor']['interval'])) > 0: +        if 'interval' in bond['arp_monitor'] and int(bond['arp_monitor']['interval']) > 0:              if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']:                  raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \                                    'transmit-load-balance or adaptive-load-balance') @@ -149,6 +150,7 @@ def verify(bond):      verify_address(bond)      verify_dhcpv6(bond)      verify_vrf(bond) +    verify_mirror_redirect(bond)      # use common function to verify VLAN configuration      verify_vlan_config(bond) diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index 4d3ebc587..38ae727c1 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -22,12 +22,12 @@ from netifaces import interfaces  from vyos.config import Config  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_member  from vyos.configdict import is_source_interface  from vyos.configdict import has_vlan_subinterface_configured  from vyos.configdict import dict_merge  from vyos.configverify import verify_dhcpv6 +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 @@ -50,15 +50,15 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'bridge'] -    bridge = get_interface_dict(conf, base) +    ifname, bridge = get_interface_dict(conf, base)      # determine which members have been removed -    tmp = node_changed(conf, ['member', 'interface'], key_mangling=('-', '_')) +    tmp = node_changed(conf, base + [ifname, 'member', 'interface'], key_mangling=('-', '_'))      if tmp:          if 'member' in bridge: -            bridge['member'].update({'interface_remove': tmp }) +            bridge['member'].update({'interface_remove' : tmp })          else: -            bridge.update({'member': {'interface_remove': tmp }}) +            bridge.update({'member' : {'interface_remove' : tmp }})      if dict_search('member.interface', bridge):          # XXX: T2665: we need a copy of the dict keys for iteration, else we will get: @@ -106,6 +106,7 @@ def verify(bridge):      verify_dhcpv6(bridge)      verify_vrf(bridge) +    verify_mirror_redirect(bridge)      ifname = bridge['ifname'] diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py index 55c783f38..e771581e1 100755 --- a/src/conf_mode/interfaces-dummy.py +++ b/src/conf_mode/interfaces-dummy.py @@ -21,6 +21,7 @@ from vyos.configdict import get_interface_dict  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect  from vyos.ifconfig import DummyIf  from vyos import ConfigError  from vyos import airbag @@ -36,7 +37,7 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'dummy'] -    dummy = get_interface_dict(conf, base) +    _, dummy = get_interface_dict(conf, base)      return dummy  def verify(dummy): @@ -46,6 +47,7 @@ def verify(dummy):      verify_vrf(dummy)      verify_address(dummy) +    verify_mirror_redirect(dummy)      return None diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index e7250fb49..fec4456fb 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -19,20 +19,23 @@ import os  from glob import glob  from sys import exit +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import get_interface_dict  from vyos.configverify import verify_address  from vyos.configverify import verify_dhcpv6  from vyos.configverify import verify_eapol  from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mirror +from vyos.configverify import verify_mirror_redirect  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.ethtool import Ethtool  from vyos.ifconfig import EthernetIf -from vyos.pki import wrap_certificate +from vyos.pki import find_chain +from vyos.pki import encode_certificate +from vyos.pki import load_certificate  from vyos.pki import wrap_private_key  from vyos.template import render  from vyos.util import call @@ -62,7 +65,7 @@ def get_config(config=None):                                 get_first_key=True, no_tag_node_value_mangle=True)      base = ['interfaces', 'ethernet'] -    ethernet = get_interface_dict(conf, base) +    _, ethernet = get_interface_dict(conf, base)      if 'deleted' not in ethernet:         if pki: ethernet['pki'] = pki @@ -81,7 +84,7 @@ def verify(ethernet):      verify_address(ethernet)      verify_vrf(ethernet)      verify_eapol(ethernet) -    verify_mirror(ethernet) +    verify_mirror_redirect(ethernet)      ethtool = Ethtool(ifname)      # No need to check speed and duplex keys as both have default values. @@ -140,8 +143,8 @@ def verify(ethernet):              raise ConfigError('XDP requires additional TX queues, too few available!')      if {'is_bond_member', 'mac'} <= set(ethernet): -        print(f'WARNING: changing mac address "{mac}" will be ignored as "{ifname}" ' -              f'is a member of bond "{is_bond_member}"'.format(**ethernet)) +        Warning(f'changing mac address "{mac}" will be ignored as "{ifname}" ' \ +                f'is a member of bond "{is_bond_member}"'.format(**ethernet))      # use common function to verify VLAN configuration      verify_vlan_config(ethernet) @@ -150,7 +153,7 @@ def verify(ethernet):  def generate(ethernet):      if 'eapol' in ethernet:          render(wpa_suppl_conf.format(**ethernet), -               'ethernet/wpa_supplicant.conf.tmpl', ethernet) +               'ethernet/wpa_supplicant.conf.j2', ethernet)          ifname = ethernet['ifname']          cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') @@ -159,16 +162,26 @@ def generate(ethernet):          cert_name = ethernet['eapol']['certificate']          pki_cert = ethernet['pki']['certificate'][cert_name] -        write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) +        loaded_pki_cert = load_certificate(pki_cert['certificate']) +        loaded_ca_certs = {load_certificate(c['certificate']) +            for c in ethernet['pki']['ca'].values()} + +        cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) + +        write_file(cert_file_path, +                   '\n'.join(encode_certificate(c) for c in cert_full_chain))          write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))          if 'ca_certificate' in ethernet['eapol']:              ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem')              ca_cert_name = ethernet['eapol']['ca_certificate'] -            pki_ca_cert = ethernet['pki']['ca'][cert_name] +            pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] + +            loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) +            ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)              write_file(ca_cert_file_path, -                       wrap_certificate(pki_ca_cert['certificate'])) +                       '\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)): diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index 2a63b60aa..b9cf2fa3c 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.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 @@ -21,9 +21,12 @@ from netifaces import interfaces  from vyos.config import Config  from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed  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.ifconfig import GeneveIf  from vyos import ConfigError @@ -40,7 +43,18 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'geneve'] -    geneve = get_interface_dict(conf, base) +    ifname, geneve = get_interface_dict(conf, base) + +    # GENEVE interfaces are picky and require recreation if certain parameters +    # change. But a GENEVE interface should - of course - not be re-created if +    # it's description or IP address is adjusted. Feels somehow logic doesn't it? +    for cli_option in ['remote', 'vni']: +        if leaf_node_changed(conf, base + [ifname, cli_option]): +            geneve.update({'rebuild_required': {}}) + +    if is_node_changed(conf, base + [ifname, 'parameters']): +        geneve.update({'rebuild_required': {}}) +      return geneve  def verify(geneve): @@ -50,6 +64,7 @@ def verify(geneve):      verify_mtu_ipv6(geneve)      verify_address(geneve) +    verify_mirror_redirect(geneve)      if 'remote' not in geneve:          raise ConfigError('Remote side must be configured') @@ -65,11 +80,12 @@ def generate(geneve):  def apply(geneve):      # Check if GENEVE interface already exists -    if geneve['ifname'] in interfaces(): -        g = GeneveIf(geneve['ifname']) -        # GENEVE is super picky and the tunnel always needs to be recreated, -        # thus we can simply always delete it first. -        g.remove() +    if 'rebuild_required' in geneve or 'delete' in geneve: +        if geneve['ifname'] in interfaces(): +            g = GeneveIf(geneve['ifname']) +            # GENEVE is super picky and the tunnel always needs to be recreated, +            # thus we can simply always delete it first. +            g.remove()      if 'deleted' not in geneve:          # Finally create the new interface diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index 9b6ddd5aa..6a486f969 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -25,6 +25,7 @@ from vyos.configdict import leaf_node_changed  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.ifconfig import L2TPv3If  from vyos.util import check_kmod  from vyos.validate import is_addr_assigned @@ -44,15 +45,15 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'l2tpv3'] -    l2tpv3 = get_interface_dict(conf, base) +    ifname, l2tpv3 = get_interface_dict(conf, base)      # To delete an l2tpv3 interface we need the current tunnel and session-id      if 'deleted' in l2tpv3: -        tmp = leaf_node_changed(conf, ['tunnel-id']) +        tmp = leaf_node_changed(conf, base + [ifname, 'tunnel-id'])          # leaf_node_changed() returns a list          l2tpv3.update({'tunnel_id': tmp[0]}) -        tmp = leaf_node_changed(conf, ['session-id']) +        tmp = leaf_node_changed(conf, base + [ifname, 'session-id'])          l2tpv3.update({'session_id': tmp[0]})      return l2tpv3 @@ -76,6 +77,7 @@ def verify(l2tpv3):      verify_mtu_ipv6(l2tpv3)      verify_address(l2tpv3) +    verify_mirror_redirect(l2tpv3)      return None  def generate(l2tpv3): diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces-loopback.py index 193334443..08d34477a 100755 --- a/src/conf_mode/interfaces-loopback.py +++ b/src/conf_mode/interfaces-loopback.py @@ -20,6 +20,7 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect  from vyos.ifconfig import LoopbackIf  from vyos import ConfigError  from vyos import airbag @@ -35,10 +36,11 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'loopback'] -    loopback = get_interface_dict(conf, base) +    _, loopback = get_interface_dict(conf, base)      return loopback  def verify(loopback): +    verify_mirror_redirect(loopback)      return None  def generate(loopback): diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index eab69f36e..279dd119b 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -29,6 +29,7 @@ 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 import ConfigError  from vyos import airbag @@ -47,7 +48,7 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'macsec'] -    macsec = get_interface_dict(conf, base) +    ifname, macsec = get_interface_dict(conf, base)      # Check if interface has been removed      if 'deleted' in macsec: @@ -66,6 +67,7 @@ def verify(macsec):      verify_vrf(macsec)      verify_mtu_ipv6(macsec)      verify_address(macsec) +    verify_mirror_redirect(macsec)      if not (('security' in macsec) and              ('cipher' in macsec['security'])): @@ -96,7 +98,7 @@ def verify(macsec):  def generate(macsec):      render(wpa_suppl_conf.format(**macsec), -           'macsec/wpa_supplicant.conf.tmpl', macsec) +           'macsec/wpa_supplicant.conf.j2', macsec)      return None diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 3b8fae710..4750ca3e8 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.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 @@ -32,8 +32,10 @@ from shutil import rmtree  from vyos.config import Config  from vyos.configdict import get_interface_dict +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.ifconfig import VTunIf  from vyos.pki import load_dh_parameters  from vyos.pki import load_private_key @@ -47,6 +49,7 @@ from vyos.template import is_ipv4  from vyos.template import is_ipv6  from vyos.util import call  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 makedir @@ -82,10 +85,12 @@ def get_config(config=None):      tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'),                                  get_first_key=True, no_tag_node_value_mangle=True) -    openvpn = get_interface_dict(conf, base) +    ifname, openvpn = get_interface_dict(conf, base)      if 'deleted' not in openvpn:          openvpn['pki'] = tmp_pki +        if is_node_changed(conf, base + [ifname, 'openvpn-option']): +            openvpn.update({'restart_required': {}})          # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict'          # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there. @@ -225,11 +230,12 @@ def verify(openvpn):          if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn:              raise ConfigError('Must specify "local-address" or add interface to bridge') -        if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: -            raise ConfigError('Only one IPv4 local-address can be specified') +        if 'local_address' in openvpn: +            if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: +                raise ConfigError('Only one IPv4 local-address can be specified') -        if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: -            raise ConfigError('Only one IPv6 local-address can be specified') +            if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: +                raise ConfigError('Only one IPv6 local-address can be specified')          if openvpn['device_type'] == 'tun':              if 'remote_address' not in openvpn: @@ -268,7 +274,7 @@ def verify(openvpn):              if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn):                  raise ConfigError('"remote-address" and "remote-host" can not be the same') -        if openvpn['device_type'] == 'tap': +        if openvpn['device_type'] == 'tap' and 'local_address' in openvpn:              # we can only have one local_address, this is ensured above              v4addr = None              for laddr in openvpn['local_address']: @@ -423,8 +429,8 @@ def verify(openvpn):      # verify specified IP address is present on any interface on this system      if 'local_host' in openvpn:          if not is_addr_assigned(openvpn['local_host']): -            raise ConfigError('local-host IP address "{local_host}" not assigned' \ -                              ' to any interface'.format(**openvpn)) +            print('local-host IP address "{local_host}" not assigned' \ +                  ' to any interface'.format(**openvpn))      # TCP active      if openvpn['protocol'] == 'tcp-active': @@ -489,6 +495,7 @@ def verify(openvpn):              raise ConfigError('Username for authentication is missing')      verify_vrf(openvpn) +    verify_mirror_redirect(openvpn)      return None @@ -600,7 +607,7 @@ def generate(openvpn):      # Generate User/Password authentication file      if 'authentication' in openvpn: -        render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.tmpl', openvpn, +        render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.j2', openvpn,                 user=user, group=group, permission=0o600)      else:          # delete old auth file if present @@ -616,16 +623,16 @@ def generate(openvpn):              # Our client need's to know its subnet mask ...              client_config['server_subnet'] = dict_search('server.subnet', openvpn) -            render(client_file, 'openvpn/client.conf.tmpl', client_config, +            render(client_file, 'openvpn/client.conf.j2', client_config,                     user=user, group=group)      # we need to support quoting of raw parameters from OpenVPN CLI      # see https://phabricator.vyos.net/T1632 -    render(cfg_file.format(**openvpn), 'openvpn/server.conf.tmpl', openvpn, +    render(cfg_file.format(**openvpn), 'openvpn/server.conf.j2', openvpn,             formater=lambda _: _.replace(""", '"'), user=user, group=group)      # Render 20-override.conf for OpenVPN service -    render(service_file.format(**openvpn), 'openvpn/service-override.conf.tmpl', openvpn, +    render(service_file.format(**openvpn), 'openvpn/service-override.conf.j2', openvpn,             formater=lambda _: _.replace(""", '"'), user=user, group=group)      # Reload systemd services config to apply an override      call(f'systemctl daemon-reload') @@ -647,9 +654,19 @@ def apply(openvpn):          return None +    # verify specified IP address is present on any interface on this system +    # Allow to bind service to nonlocal address, if it virtaual-vrrp address +    # or if address will be assign later +    if 'local_host' in openvpn: +        if not is_addr_assigned(openvpn['local_host']): +            cmd('sysctl -w net.ipv4.ip_nonlocal_bind=1') +      # No matching OpenVPN process running - maybe it got killed or none      # existed - nevertheless, spawn new OpenVPN process -    call(f'systemctl reload-or-restart openvpn@{interface}.service') +    action = 'reload-or-restart' +    if 'restart_required' in openvpn: +        action = 'restart' +    call(f'systemctl {action} openvpn@{interface}.service')      o = VTunIf(**openvpn)      o.update(openvpn) diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 584adc75e..e2fdc7a42 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -22,12 +22,15 @@ 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  from vyos.configverify import verify_interface_exists  from vyos.configverify import verify_vrf  from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect  from vyos.ifconfig import PPPoEIf  from vyos.template import render  from vyos.util import call @@ -46,33 +49,17 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'pppoe'] -    pppoe = get_interface_dict(conf, base) +    ifname, pppoe = get_interface_dict(conf, base)      # We should only terminate the PPPoE session if critical parameters change.      # All parameters that can be changed on-the-fly (like interface description)      # should not lead to a reconnect! -    tmp = leaf_node_changed(conf, ['access-concentrator']) -    if tmp: pppoe.update({'shutdown_required': {}}) - -    tmp = leaf_node_changed(conf, ['connect-on-demand']) -    if tmp: pppoe.update({'shutdown_required': {}}) - -    tmp = leaf_node_changed(conf, ['service-name']) -    if tmp: pppoe.update({'shutdown_required': {}}) - -    tmp = leaf_node_changed(conf, ['source-interface']) -    if tmp: pppoe.update({'shutdown_required': {}}) - -    tmp = leaf_node_changed(conf, ['vrf']) -    # leaf_node_changed() returns a list, as VRF is a non-multi node, there -    # will be only one list element -    if tmp: pppoe.update({'vrf_old': tmp[0]}) - -    tmp = leaf_node_changed(conf, ['authentication', 'user']) -    if tmp: pppoe.update({'shutdown_required': {}}) - -    tmp = leaf_node_changed(conf, ['authentication', 'password']) -    if tmp: pppoe.update({'shutdown_required': {}}) +    for options in ['access-concentrator', 'connect-on-demand', 'service-name', +                    'source-interface', 'vrf', 'no-default-route', 'authentication']: +        if is_node_changed(conf, base + [ifname, options]): +            pppoe.update({'shutdown_required': {}}) +            # bail out early - no need to further process other nodes +            break      return pppoe @@ -85,6 +72,7 @@ def verify(pppoe):      verify_authentication(pppoe)      verify_vrf(pppoe)      verify_mtu_ipv6(pppoe) +    verify_mirror_redirect(pppoe)      if {'connect_on_demand', 'vrf'} <= set(pppoe):          raise ConfigError('On-demand dialing and VRF can not be used at the same time') @@ -104,7 +92,7 @@ def generate(pppoe):          return None      # Create PPP configuration files -    render(config_pppoe, 'pppoe/peer.tmpl', pppoe, permission=0o640) +    render(config_pppoe, 'pppoe/peer.j2', pppoe, permission=0o640)      return None @@ -118,7 +106,7 @@ def apply(pppoe):          return None      # reconnect should only be necessary when certain config options change, -    # like ACS name, authentication, no-peer-dns, source-interface +    # like ACS name, authentication ... (see get_config() for details)      if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or          'shutdown_required' in pppoe): @@ -128,6 +116,9 @@ def apply(pppoe):              p.remove()          call(f'systemctl restart ppp@{ifname}.service') +        # When interface comes "live" a hook is called: +        # /etc/ppp/ip-up.d/99-vyos-pppoe-callback +        # which triggers PPPoEIf.update()      else:          if os.path.isdir(f'/sys/class/net/{ifname}'):              p = PPPoEIf(ifname) diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 945a2ea9c..1cd3fe276 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -18,13 +18,14 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict -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_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.ifconfig import MACVLANIf  from vyos import ConfigError @@ -41,14 +42,14 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'pseudo-ethernet'] -    peth = get_interface_dict(conf, base) +    ifname, peth = get_interface_dict(conf, base) -    mode = leaf_node_changed(conf, ['mode']) -    if mode: peth.update({'mode_old' : mode}) +    mode = is_node_changed(conf, ['mode']) +    if mode: peth.update({'shutdown_required' : {}})      if 'source_interface' in peth: -        peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], -                                            peth['source_interface']) +        _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], +                                               peth['source_interface'])      return peth  def verify(peth): @@ -60,6 +61,7 @@ def verify(peth):      verify_vrf(peth)      verify_address(peth)      verify_mtu_parent(peth, peth['parent']) +    verify_mirror_redirect(peth)      # use common function to verify VLAN configuration      verify_vlan_config(peth) diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index 30f57ec0c..eff7f373c 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 yOS 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,24 +18,20 @@ import os  from sys import exit  from netifaces import interfaces -from ipaddress import IPv4Address  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.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_interface_exists  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.ifconfig import Interface  from vyos.ifconfig import Section  from vyos.ifconfig import TunnelIf -from vyos.template import is_ipv4 -from vyos.template import is_ipv6  from vyos.util import get_interface_config  from vyos.util import dict_search  from vyos import ConfigError @@ -52,10 +48,26 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'tunnel'] -    tunnel = get_interface_dict(conf, base) - -    tmp = leaf_node_changed(conf, ['encapsulation']) -    if tmp: tunnel.update({'encapsulation_changed': {}}) +    ifname, tunnel = get_interface_dict(conf, base) + +    if 'deleted' not in tunnel: +        tmp = leaf_node_changed(conf, base + [ifname, 'encapsulation']) +        if tmp: tunnel.update({'encapsulation_changed': {}}) + +        # We also need to inspect other configured tunnels as there are Kernel +        # restrictions where we need to comply. E.g. GRE tunnel key can't be used +        # twice, or with multiple GRE tunnels to the same location we must specify +        # a GRE key +        conf.set_level(base) +        tunnel['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), +                                                      get_first_key=True, +                                                      no_tag_node_value_mangle=True) +        # delete our own instance from this dict +        ifname = tunnel['ifname'] +        del tunnel['other_tunnels'][ifname] +        # if only one tunnel is present on the system, no need to keep this key +        if len(tunnel['other_tunnels']) == 0: +            del tunnel['other_tunnels']      # We must check if our interface is configured to be a DMVPN member      nhrp_base = ['protocols', 'nhrp', 'tunnel'] @@ -96,35 +108,47 @@ def verify(tunnel):              if 'direction' not in tunnel['parameters']['erspan']:                  raise ConfigError('ERSPAN version 2 requires direction to be set!') -    # If tunnel source address any and key not set +    # If tunnel source is any and gre key is not set +    interface = tunnel['ifname']      if tunnel['encapsulation'] in ['gre'] and \         dict_search('source_address', tunnel) == '0.0.0.0' and \         dict_search('parameters.ip.key', tunnel) == None: -        raise ConfigError('Tunnel parameters ip key must be set!') - -    if tunnel['encapsulation'] in ['gre', 'gretap']: -        if dict_search('parameters.ip.key', tunnel) != None: -            # Check pairs tunnel source-address/encapsulation/key with exists tunnels. -            # Prevent the same key for 2 tunnels with same source-address/encap. T2920 -            for tunnel_if in Section.interfaces('tunnel'): -                # It makes no sense to run the test for re-used GRE keys on our -                # own interface we are currently working on -                if tunnel['ifname'] == tunnel_if: -                    continue -                tunnel_cfg = get_interface_config(tunnel_if) -                # no match on encapsulation - bail out -                if dict_search('linkinfo.info_kind', tunnel_cfg) != tunnel['encapsulation']: -                    continue -                new_source_address = dict_search('source_address', tunnel) -                # Convert tunnel key to ip key, format "ip -j link show" -                # 1 => 0.0.0.1, 999 => 0.0.3.231 -                orig_new_key = dict_search('parameters.ip.key', tunnel) -                new_key = IPv4Address(int(orig_new_key)) -                new_key = str(new_key) -                if dict_search('address', tunnel_cfg) == new_source_address and \ -                   dict_search('linkinfo.info_data.ikey', tunnel_cfg) == new_key: -                    raise ConfigError(f'Key "{orig_new_key}" for source-address "{new_source_address}" ' \ +        raise ConfigError(f'"parameters ip key" must be set for {interface} when '\ +                           'encapsulation is GRE!') + +    gre_encapsulations = ['gre', 'gretap'] +    if tunnel['encapsulation'] in gre_encapsulations and 'other_tunnels' in tunnel: +        # Check pairs tunnel source-address/encapsulation/key with exists tunnels. +        # Prevent the same key for 2 tunnels with same source-address/encap. T2920 +        for o_tunnel, o_tunnel_conf in tunnel['other_tunnels'].items(): +            # no match on encapsulation - bail out +            our_encapsulation = tunnel['encapsulation'] +            their_encapsulation = o_tunnel_conf['encapsulation'] +            if our_encapsulation in gre_encapsulations and their_encapsulation \ +                not in gre_encapsulations: +                continue + +            our_address = dict_search('source_address', tunnel) +            our_key = dict_search('parameters.ip.key', tunnel) +            their_address = dict_search('source_address', o_tunnel_conf) +            their_key = dict_search('parameters.ip.key', o_tunnel_conf) +            if our_key != None: +                if their_address == our_address and their_key == our_key: +                    raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" ' \                                        f'is already used for tunnel "{tunnel_if}"!') +            else: +                our_source_if = dict_search('source_interface', tunnel) +                their_source_if = dict_search('source_interface', o_tunnel_conf) +                our_remote = dict_search('remote', tunnel) +                their_remote = dict_search('remote', o_tunnel_conf) +                # If no IP GRE key is defined we can not have more then one GRE tunnel +                # bound to any one interface/IP address and the same remote. This will +                # result in a OS  PermissionError: add tunnel "gre0" failed: File exists +                if (their_address == our_address or our_source_if == their_source_if) and \ +                    our_remote == their_remote: +                    raise ConfigError(f'Missing required "ip key" parameter when '\ +                                       'running more then one GRE based tunnel on the '\ +                                       'same source-interface/source-address')      # Keys are not allowed with ipip and sit tunnels      if tunnel['encapsulation'] in ['ipip', 'sit']: @@ -134,6 +158,7 @@ def verify(tunnel):      verify_mtu_ipv6(tunnel)      verify_address(tunnel)      verify_vrf(tunnel) +    verify_mirror_redirect(tunnel)      if 'source_interface' in tunnel:          verify_interface_exists(tunnel['source_interface']) diff --git a/src/conf_mode/interfaces-vti.py b/src/conf_mode/interfaces-vti.py index 57950ffea..f4b0436af 100755 --- a/src/conf_mode/interfaces-vti.py +++ b/src/conf_mode/interfaces-vti.py @@ -19,6 +19,7 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect  from vyos.ifconfig import VTIIf  from vyos.util import dict_search  from vyos import ConfigError @@ -35,10 +36,11 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'vti'] -    vti = get_interface_dict(conf, base) +    _, vti = get_interface_dict(conf, base)      return vti  def verify(vti): +    verify_mirror_redirect(vti)      return None  def generate(vti): diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index 804f2d14f..f44d754ba 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.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 @@ -19,11 +19,15 @@ import os  from sys import exit  from netifaces import interfaces +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed  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.ifconfig import Interface  from vyos.ifconfig import VXLANIf @@ -34,15 +38,40 @@ 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 +    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', 'vxlan'] -    vxlan = get_interface_dict(conf, base) +    ifname, vxlan = get_interface_dict(conf, base) + +    # VXLAN interfaces are picky and require recreation if certain parameters +    # change. But a VXLAN interface should - of course - not be re-created if +    # it's description or IP address is adjusted. Feels somehow logic doesn't it? +    for cli_option in ['external', 'gpe', 'group', 'port', 'remote', +                       'source-address', 'source-interface', 'vni']: +        if leaf_node_changed(conf, base + [ifname, cli_option]): +            vxlan.update({'rebuild_required': {}}) + +    if is_node_changed(conf, base + [ifname, 'parameters']): +        vxlan.update({'rebuild_required': {}}) + +    # We need to verify that no other VXLAN tunnel is configured when external +    # mode is in use - Linux Kernel limitation +    conf.set_level(base) +    vxlan['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), +                                                  get_first_key=True, +                                                  no_tag_node_value_mangle=True) + +    # This if-clause is just to be sure - it will always evaluate to true +    ifname = vxlan['ifname'] +    if ifname in vxlan['other_tunnels']: +        del vxlan['other_tunnels'][ifname] +    if len(vxlan['other_tunnels']) == 0: +        del vxlan['other_tunnels']      return vxlan @@ -52,19 +81,31 @@ def verify(vxlan):          return None      if int(vxlan['mtu']) < 1500: -        print('WARNING: RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU') +        Warning('RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU')      if 'group' in vxlan:          if 'source_interface' not in vxlan: -            raise ConfigError('Multicast VXLAN requires an underlaying interface ') - +            raise ConfigError('Multicast VXLAN requires an underlaying interface')          verify_source_interface(vxlan)      if not any(tmp in ['group', 'remote', 'source_address'] for tmp in vxlan):          raise ConfigError('Group, remote or source-address must be configured') -    if 'vni' not in vxlan: -        raise ConfigError('Must configure VNI for VXLAN') +    if 'vni' not in vxlan and 'external' not in vxlan: +        raise ConfigError( +            'Must either configure VXLAN "vni" or use "external" CLI option!') + +    if {'external', 'vni'} <= set(vxlan): +        raise ConfigError('Can not specify both "external" and "VNI"!') + +    if {'external', 'other_tunnels'} <= set(vxlan): +        other_tunnels = ', '.join(vxlan['other_tunnels']) +        raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ +                          f'CLI option is used. Additional tunnels: {other_tunnels}') + +    if 'gpe' in vxlan and 'external' not in vxlan: +        raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ +                          f'CLI option is used.')      if 'source_interface' in vxlan:          # VXLAN adds at least an overhead of 50 byte - we need to check the @@ -81,22 +122,42 @@ def verify(vxlan):              raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\                                f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') +    # Check for mixed IPv4 and IPv6 addresses +    protocol = None +    if 'source_address' in vxlan: +        if is_ipv6(vxlan['source_address']): +            protocol = 'ipv6' +        else: +            protocol = 'ipv4' + +    if 'remote' in vxlan: +        error_msg = 'Can not mix both IPv4 and IPv6 for VXLAN underlay' +        for remote in vxlan['remote']: +            if is_ipv6(remote): +                if protocol == 'ipv4': +                    raise ConfigError(error_msg) +                protocol = 'ipv6' +            else: +                if protocol == 'ipv6': +                    raise ConfigError(error_msg) +                protocol = 'ipv4' +      verify_mtu_ipv6(vxlan)      verify_address(vxlan) +    verify_mirror_redirect(vxlan)      return None -  def generate(vxlan):      return None -  def apply(vxlan):      # Check if the VXLAN interface already exists -    if vxlan['ifname'] in interfaces(): -        v = VXLANIf(vxlan['ifname']) -        # VXLAN is super picky and the tunnel always needs to be recreated, -        # thus we can simply always delete it first. -        v.remove() +    if 'rebuild_required' in vxlan or 'delete' in vxlan: +        if vxlan['ifname'] in interfaces(): +            v = VXLANIf(vxlan['ifname']) +            # VXLAN is super picky and the tunnel always needs to be recreated, +            # thus we can simply always delete it first. +            v.remove()      if 'deleted' not in vxlan:          # Finally create the new interface @@ -105,7 +166,6 @@ def apply(vxlan):      return None -  if __name__ == '__main__':      try:          c = get_config() diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index da64dd076..180ffa507 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.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 @@ -28,6 +28,7 @@ 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.ifconfig import WireGuardIf  from vyos.util import check_kmod  from vyos.util import check_port_availability @@ -45,17 +46,17 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'wireguard'] -    wireguard = get_interface_dict(conf, base) +    ifname, wireguard = get_interface_dict(conf, base)      # Check if a port was changed -    wireguard['port_changed'] = leaf_node_changed(conf, ['port']) +    wireguard['port_changed'] = leaf_node_changed(conf, base + [ifname, 'port'])      # Determine which Wireguard peer has been removed.      # Peers can only be removed with their public key!      dict = {} -    tmp = node_changed(conf, ['peer'], key_mangling=('-', '_')) +    tmp = node_changed(conf, base + [ifname, 'peer'], key_mangling=('-', '_'))      for peer in (tmp or []): -        public_key = leaf_node_changed(conf, ['peer', peer, 'public_key']) +        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) @@ -70,6 +71,7 @@ def verify(wireguard):      verify_mtu_ipv6(wireguard)      verify_address(wireguard)      verify_vrf(wireguard) +    verify_mirror_redirect(wireguard)      if 'private_key' not in wireguard:          raise ConfigError('Wireguard private-key not defined') diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index af35b5f03..d34297063 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -27,6 +27,7 @@ from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete  from vyos.configverify import verify_dhcpv6  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.ifconfig import WiFiIf @@ -75,15 +76,19 @@ def get_config(config=None):          conf = Config()      base = ['interfaces', 'wireless'] -    wifi = get_interface_dict(conf, base) +    ifname, wifi = get_interface_dict(conf, base)      # Cleanup "delete" default values when required user selectable values are      # not defined at all -    tmp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) +    tmp = conf.get_config_dict(base + [ifname], key_mangling=('-', '_'), +                               get_first_key=True)      if not (dict_search('security.wpa.passphrase', tmp) or              dict_search('security.wpa.radius', tmp)):          if 'deleted' not in wifi:              del wifi['security']['wpa'] +            # if 'security' key is empty, drop it too +            if len(wifi['security']) == 0: +                del wifi['security']      # defaults include RADIUS server specifics per TAG node which need to be      # added to individual RADIUS servers instead - so we can simply delete them @@ -189,6 +194,7 @@ def verify(wifi):      verify_address(wifi)      verify_vrf(wifi) +    verify_mirror_redirect(wifi)      # use common function to verify VLAN configuration      verify_vlan_config(wifi) @@ -242,11 +248,11 @@ def generate(wifi):      # render appropriate new config files depending on access-point or station mode      if wifi['type'] == 'access-point': -        render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.tmpl', +        render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.j2',                 wifi)      elif wifi['type'] == 'station': -        render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.tmpl', +        render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.j2',                 wifi)      return None diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index f013e5411..e275ace84 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -17,21 +17,29 @@  import os  from sys import exit +from time import sleep  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_interface_exists +from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_vrf  from vyos.ifconfig import WWANIf  from vyos.util import cmd  from vyos.util import call  from vyos.util import dict_search  from vyos.util import DEVNULL +from vyos.util import is_systemd_service_active +from vyos.util import write_file  from vyos import ConfigError  from vyos import airbag  airbag.enable() +service_name = 'ModemManager.service' +cron_script = '/etc/cron.d/vyos-wwan' +  def get_config(config=None):      """      Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -42,7 +50,41 @@ def get_config(config=None):      else:          conf = Config()      base = ['interfaces', 'wwan'] -    wwan = get_interface_dict(conf, base) +    ifname, wwan = get_interface_dict(conf, base) + +    # We should only terminate the WWAN session if critical parameters change. +    # All parameters that can be changed on-the-fly (like interface description) +    # should not lead to a reconnect! +    tmp = is_node_changed(conf, base + [ifname, 'address']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    tmp = is_node_changed(conf, base + [ifname, 'apn']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    tmp = is_node_changed(conf, base + [ifname, 'disable']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    tmp = is_node_changed(conf, base + [ifname, 'vrf']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    tmp = is_node_changed(conf, base + [ifname, 'authentication']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    tmp = is_node_changed(conf, base + [ifname, 'ipv6', 'address', 'autoconf']) +    if tmp: wwan.update({'shutdown_required': {}}) + +    # 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=('-', '_'), +                                                       get_first_key=True, +                                                       no_tag_node_value_mangle=True) + +    # This if-clause is just to be sure - it will always evaluate to true +    if ifname in wwan['other_interfaces']: +        del wwan['other_interfaces'][ifname] +    if len(wwan['other_interfaces']) == 0: +        del wwan['other_interfaces']      return wwan @@ -57,42 +99,84 @@ def verify(wwan):      verify_interface_exists(ifname)      verify_authentication(wwan)      verify_vrf(wwan) +    verify_mirror_redirect(wwan)      return None  def generate(wwan): +    if 'deleted' in wwan: +        # We are the last WWAN interface - there are no other ones remaining +        # thus the cronjob needs to go away, too +        if 'other_interfaces' not in wwan: +            if os.path.exists(cron_script): +                os.unlink(cron_script) +        return None + +    # Install cron triggered helper script to re-dial WWAN interfaces on +    # 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') +      return None  def apply(wwan): -    # we only need the modem number. wwan0 -> 0, wwan1 -> 1 -    modem = wwan['ifname'].lstrip('wwan') -    base_cmd = f'mmcli --modem {modem}' -    # Number of bearers is limited - always disconnect first -    cmd(f'{base_cmd} --simple-disconnect') +    # ModemManager is required to dial WWAN connections - one instance is +    # required to serve all modems. Activate ModemManager on first invocation +    # of any WWAN interface. +    if not is_systemd_service_active(service_name): +        cmd(f'systemctl start {service_name}') + +        counter = 100 +        # Wait until a modem is detected and then we can continue +        while counter > 0: +            counter -= 1 +            tmp = cmd('mmcli -L') +            if tmp != 'No modems were found': +                break +            sleep(0.250) + +    if 'shutdown_required' in wwan: +        # we only need the modem number. wwan0 -> 0, wwan1 -> 1 +        modem = wwan['ifname'].lstrip('wwan') +        base_cmd = f'mmcli --modem {modem}' +        # Number of bearers is limited - always disconnect first +        cmd(f'{base_cmd} --simple-disconnect')      w = WWANIf(wwan['ifname'])      if 'deleted' in wwan or 'disable' in wwan:          w.remove() + +        # We are the last WWAN interface - there are no other WWAN interfaces +        # remaining, thus we can stop ModemManager and free resources. +        if 'other_interfaces' not in wwan: +            cmd(f'systemctl stop {service_name}') +            # Clean CRON helper script which is used for to re-connect when +            # RF signal is lost +            if os.path.exists(cron_script): +                os.unlink(cron_script) +          return None -    ip_type = 'ipv4' -    slaac = dict_search('ipv6.address.autoconf', wwan) != None -    if 'address' in wwan: -        if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): -            ip_type = 'ipv4v6' -        elif 'dhcpv6' in wwan['address'] or slaac: -            ip_type = 'ipv6' -        elif 'dhcp' in wwan['address']: -            ip_type = 'ipv4' - -    options = f'ip-type={ip_type},apn=' + wwan['apn'] -    if 'authentication' in wwan: -        options += ',user={user},password={password}'.format(**wwan['authentication']) - -    command = f'{base_cmd} --simple-connect="{options}"' -    call(command, stdout=DEVNULL) -    w.update(wwan) +    if 'shutdown_required' in wwan: +        ip_type = 'ipv4' +        slaac = dict_search('ipv6.address.autoconf', wwan) != None +        if 'address' in wwan: +            if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): +                ip_type = 'ipv4v6' +            elif 'dhcpv6' in wwan['address'] or slaac: +                ip_type = 'ipv6' +            elif 'dhcp' in wwan['address']: +                ip_type = 'ipv4' + +        options = f'ip-type={ip_type},apn=' + wwan['apn'] +        if 'authentication' in wwan: +            options += ',user={user},password={password}'.format(**wwan['authentication']) +        command = f'{base_cmd} --simple-connect="{options}"' +        call(command, stdout=DEVNULL) + +    w.update(wwan)      return None  if __name__ == '__main__': diff --git a/src/conf_mode/lldp.py b/src/conf_mode/lldp.py index 082c3e128..c703c1fe0 100755 --- a/src/conf_mode/lldp.py +++ b/src/conf_mode/lldp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2017-2020 VyOS maintainers and contributors +# Copyright (C) 2017-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,19 +15,20 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os -import re -from copy import deepcopy  from sys import exit +from vyos.base import Warning  from vyos.config import Config +from vyos.configdict import dict_merge  from vyos.validate import is_addr_assigned  from vyos.validate import is_loopback_addr  from vyos.version import get_version_data -from vyos import ConfigError  from vyos.util import call +from vyos.util import dict_search +from vyos.xml import defaults  from vyos.template import render - +from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -35,178 +36,73 @@ config_file = "/etc/default/lldpd"  vyos_config_file = "/etc/lldpd.d/01-vyos.conf"  base = ['service', 'lldp'] -default_config_data = { -    "options": '', -    "interface_list": '', -    "location": '' -} - -def get_options(config): -    options = {} -    config.set_level(base) - -    options['listen_vlan'] = config.exists('listen-vlan') -    options['mgmt_addr'] = [] -    for addr in config.return_values('management-address'): -        if is_addr_assigned(addr) and not is_loopback_addr(addr): -            options['mgmt_addr'].append(addr) -        else: -            message = 'WARNING: LLDP management address {0} invalid - '.format(addr) -            if is_loopback_addr(addr): -                message += '(loopback address).' -            else: -                message += 'address not found.' -            print(message) - -    snmp = config.exists('snmp enable') -    options["snmp"] = snmp -    if snmp: -        config.set_level('') -        options["sys_snmp"] = config.exists('service snmp') -        config.set_level(base) - -    config.set_level(base + ['legacy-protocols']) -    options['cdp'] = config.exists('cdp') -    options['edp'] = config.exists('edp') -    options['fdp'] = config.exists('fdp') -    options['sonmp'] = config.exists('sonmp') - -    # start with an unknown version information -    version_data = get_version_data() -    options['description'] = version_data['version'] -    options['listen_on'] = [] - -    return options - -def get_interface_list(config): -    config.set_level(base) -    intfs_names = config.list_nodes(['interface']) -    if len(intfs_names) < 0: -        return 0 - -    interface_list = [] -    for name in intfs_names: -        config.set_level(base + ['interface', name]) -        disable = config.exists(['disable']) -        intf = { -            'name': name, -            'disable': disable -        } -        interface_list.append(intf) -    return interface_list - - -def get_location_intf(config, name): -    path = base + ['interface', name] -    config.set_level(path) - -    config.set_level(path + ['location']) -    elin = '' -    coordinate_based = {} - -    if config.exists('elin'): -        elin = config.return_value('elin') - -    if config.exists('coordinate-based'): -        config.set_level(path + ['location', 'coordinate-based']) - -        coordinate_based['latitude'] = config.return_value(['latitude']) -        coordinate_based['longitude'] = config.return_value(['longitude']) - -        coordinate_based['altitude'] = '0' -        if config.exists(['altitude']): -            coordinate_based['altitude'] = config.return_value(['altitude']) - -        coordinate_based['datum'] = 'WGS84' -        if config.exists(['datum']): -            coordinate_based['datum'] = config.return_value(['datum']) - -    intf = { -        'name': name, -        'elin': elin, -        'coordinate_based': coordinate_based - -    } -    return intf - - -def get_location(config): -    config.set_level(base) -    intfs_names = config.list_nodes(['interface']) -    if len(intfs_names) < 0: -        return 0 - -    if config.exists('disable'): -        return 0 - -    intfs_location = [] -    for name in intfs_names: -        intf = get_location_intf(config, name) -        intfs_location.append(intf) - -    return intfs_location - -  def get_config(config=None): -    lldp = deepcopy(default_config_data)      if config:          conf = config      else:          conf = Config() +      if not conf.exists(base): -        return None -    else: -        lldp['options'] = get_options(conf) -        lldp['interface_list'] = get_interface_list(conf) -        lldp['location'] = get_location(conf) +        return {} -        return lldp +    lldp = conf.get_config_dict(base, key_mangling=('-', '_'), +                                get_first_key=True, no_tag_node_value_mangle=True) +    if conf.exists(['service', 'snmp']): +        lldp['system_snmp_enabled'] = '' + +    version_data = get_version_data() +    lldp['version'] = version_data['version'] + +    # We have gathered the dict representation of the CLI, but there are default +    # options which we need to update into the dictionary retrived. +    # location coordinates have a default value +    if 'interface' in lldp: +        for interface, interface_config in lldp['interface'].items(): +            default_values = defaults(base + ['interface']) +            if dict_search('location.coordinate_based', interface_config) == None: +                # no location specified - no need to add defaults +                del default_values['location']['coordinate_based']['datum'] +                del default_values['location']['coordinate_based']['altitude'] + +            # cleanup default_values dictionary from inner to outer +            # this might feel overkill here, but it does support easy extension +            # in the future with additional default values +            if len(default_values['location']['coordinate_based']) == 0: +                del default_values['location']['coordinate_based'] +            if len(default_values['location']) == 0: +                del default_values['location'] + +            lldp['interface'][interface] = dict_merge(default_values, +                                                   lldp['interface'][interface]) + +    return lldp  def verify(lldp):      # bail out early - looks like removal from running config      if lldp is None:          return -    # check location -    for location in lldp['location']: -        # check coordinate-based -        if len(location['coordinate_based']) > 0: -            # check longitude and latitude -            if not location['coordinate_based']['longitude']: -                raise ConfigError('Must define longitude for interface {0}'.format(location['name'])) - -            if not location['coordinate_based']['latitude']: -                raise ConfigError('Must define latitude for interface {0}'.format(location['name'])) - -            if not re.match(r'^(\d+)(\.\d+)?[nNsS]$', location['coordinate_based']['latitude']): -                raise ConfigError('Invalid location for interface {0}:\n' \ -                                  'latitude should be a number followed by S or N'.format(location['name'])) - -            if not re.match(r'^(\d+)(\.\d+)?[eEwW]$', location['coordinate_based']['longitude']): -                raise ConfigError('Invalid location for interface {0}:\n' \ -                                  'longitude should be a number followed by E or W'.format(location['name'])) - -            # check altitude and datum if exist -            if location['coordinate_based']['altitude']: -                if not re.match(r'^[-+0-9\.]+$', location['coordinate_based']['altitude']): -                    raise ConfigError('Invalid location for interface {0}:\n' \ -                                      'altitude should be a positive or negative number'.format(location['name'])) - -            if location['coordinate_based']['datum']: -                if not re.match(r'^(WGS84|NAD83|MLLW)$', location['coordinate_based']['datum']): -                    raise ConfigError("Invalid location for interface {0}:\n' \ -                                      'datum should be WGS84, NAD83, or MLLW".format(location['name'])) - -        # check elin -        elif location['elin']: -            if not re.match(r'^[0-9]{10,25}$', location['elin']): -                raise ConfigError('Invalid location for interface {0}:\n' \ -                                  'ELIN number must be between 10-25 numbers'.format(location['name'])) +    if 'management_address' in lldp: +        for address in lldp['management_address']: +            message = f'LLDP management address "{address}" is invalid' +            if is_loopback_addr(address): +                Warning(f'{message} - loopback address') +            elif not is_addr_assigned(address): +                Warning(f'{message} - not assigned to any interface') + +    if 'interface' in lldp: +        for interface, interface_config in lldp['interface'].items(): +            # bail out early if no location info present in interface config +            if 'location' not in interface_config: +                continue +            if 'coordinate_based' in interface_config['location']: +                if not {'latitude', 'latitude'} <= set(interface_config['location']['coordinate_based']): +                    raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!')      # check options -    if lldp['options']['snmp']: -        if not lldp['options']['sys_snmp']: +    if 'snmp' in lldp and 'enable' in lldp['snmp']: +        if 'system_snmp_enabled' not in lldp:              raise ConfigError('SNMP must be configured to enable LLDP SNMP') @@ -215,29 +111,17 @@ def generate(lldp):      if lldp is None:          return -    # generate listen on interfaces -    for intf in lldp['interface_list']: -        tmp = '' -        # add exclamation mark if interface is disabled -        if intf['disable']: -            tmp = '!' - -        tmp += intf['name'] -        lldp['options']['listen_on'].append(tmp) - -    # generate /etc/default/lldpd -    render(config_file, 'lldp/lldpd.tmpl', lldp) -    # generate /etc/lldpd.d/01-vyos.conf -    render(vyos_config_file, 'lldp/vyos.conf.tmpl', lldp) - +    render(config_file, 'lldp/lldpd.j2', lldp) +    render(vyos_config_file, 'lldp/vyos.conf.j2', lldp)  def apply(lldp): +    systemd_service = 'lldpd.service'      if lldp:          # start/restart lldp service -        call('systemctl restart lldpd.service') +        call(f'systemctl restart {systemd_service}')      else:          # LLDP service has been terminated -        call('systemctl stop lldpd.service') +        call(f'systemctl stop {systemd_service}')          if os.path.isfile(config_file):              os.unlink(config_file)          if os.path.isfile(vyos_config_file): diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 59939d0fb..85819a77e 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -23,11 +23,13 @@ from platform import release as kernel_version  from sys import exit  from netifaces import interfaces +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.template import render  from vyos.template import is_ip_network  from vyos.util import cmd +from vyos.util import run  from vyos.util import check_kmod  from vyos.util import dict_search  from vyos.validate import is_addr_assigned @@ -42,7 +44,7 @@ if LooseVersion(kernel_version()) > LooseVersion('5.1'):  else:      k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] -iptables_nat_config = '/tmp/vyos-nat-rules.nft' +nftables_nat_config = '/tmp/vyos-nat-rules.nft'  def get_handler(json, chain, target):      """ Get nftable rule handler number of given chain/target combination. @@ -93,7 +95,6 @@ def get_config(config=None):                  nat[direction]['rule'][rule] = dict_merge(default_values,                      nat[direction]['rule'][rule]) -      # read in current nftable (once) for further processing      tmp = cmd('nft -j list table raw')      nftable_json = json.loads(tmp) @@ -106,9 +107,9 @@ def get_config(config=None):          nat['helper_functions'] = 'remove'          # Retrieve current table handler positions -        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_HELPER') +        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', 'VYATTA_CT_HELPER') +        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 @@ -119,10 +120,10 @@ def get_config(config=None):          nat['helper_functions'] = 'add'          # Retrieve current table handler positions -        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_IGNORE') -        nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_PREROUTING_HOOK') -        nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_IGNORE') -        nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_OUTPUT_HOOK') +        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_IGNORE') +        nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_PREROUTING_HOOK') +        nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_IGNORE') +        nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_OUTPUT_HOOK')      return nat @@ -142,14 +143,14 @@ def verify(nat):                  raise ConfigError(f'{err_msg} outbound-interface not specified')              if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): -                print(f'WARNING: rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') +                Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system')              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): -                            print(f'WARNING: IP address {ip} does not exist on the system!') +                            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') @@ -167,7 +168,7 @@ def verify(nat):                                    'inbound-interface not specified')              else:                  if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): -                    print(f'WARNING: rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') +                    Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system')              if dict_search('translation.address', config) == None and 'exclude' not in config: @@ -180,14 +181,21 @@ def verify(nat):      return None  def generate(nat): -    render(iptables_nat_config, 'firewall/nftables-nat.tmpl', nat, -           permission=0o755) +    render(nftables_nat_config, 'firewall/nftables-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!') +      return None  def apply(nat): -    cmd(f'{iptables_nat_config}') -    if os.path.isfile(iptables_nat_config): -        os.unlink(iptables_nat_config) +    cmd(f'nft -f {nftables_nat_config}') +    if os.path.isfile(nftables_nat_config): +        os.unlink(nftables_nat_config)      return None diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index fb376a434..0972151a0 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -21,6 +21,7 @@ import os  from sys import exit  from netifaces import interfaces +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.template import render @@ -35,7 +36,7 @@ airbag.enable()  k_mod = ['nft_nat', 'nft_chain_nat'] -iptables_nat_config = '/tmp/vyos-nat66-rules.nft' +nftables_nat66_config = '/tmp/vyos-nat66-rules.nft'  ndppd_config = '/run/ndppd/ndppd.conf'  def get_handler(json, chain, target): @@ -79,9 +80,9 @@ def get_config(config=None):      if not conf.exists(base):          nat['helper_functions'] = 'remove' -        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_HELPER') +        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', 'VYATTA_CT_HELPER') +        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 @@ -92,10 +93,10 @@ def get_config(config=None):          nat['helper_functions'] = 'add'          # Retrieve current table handler positions -        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_IGNORE') -        nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_PREROUTING_HOOK') -        nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_IGNORE') -        nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_OUTPUT_HOOK') +        nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_IGNORE') +        nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_PREROUTING_HOOK') +        nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_IGNORE') +        nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_OUTPUT_HOOK')      else:          nat['helper_functions'] = 'has' @@ -117,12 +118,12 @@ def verify(nat):                  raise ConfigError(f'{err_msg} outbound-interface not specified')              if config['outbound_interface'] not in interfaces(): -                raise ConfigError(f'WARNING: rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') +                raise ConfigError(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system')              addr = dict_search('translation.address', config)              if addr != None:                  if addr != 'masquerade' and not is_ipv6(addr): -                    raise ConfigError(f'Warning: IPv6 address {addr} is not a valid address') +                    raise ConfigError(f'IPv6 address {addr} is not a valid address')              else:                  raise ConfigError(f'{err_msg} translation address not specified') @@ -140,27 +141,27 @@ def verify(nat):                                    'inbound-interface not specified')              else:                  if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): -                    print(f'WARNING: rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') +                    Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system')      return None  def generate(nat): -    render(iptables_nat_config, 'firewall/nftables-nat66.tmpl', nat, permission=0o755) -    render(ndppd_config, 'ndppd/ndppd.conf.tmpl', nat, permission=0o755) +    render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755) +    render(ndppd_config, 'ndppd/ndppd.conf.j2', nat, permission=0o755)      return None  def apply(nat):      if not nat:          return None -    cmd(f'{iptables_nat_config}') +    cmd(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(iptables_nat_config): -        os.unlink(iptables_nat_config) +    if os.path.isfile(nftables_nat66_config): +        os.unlink(nftables_nat66_config)      return None diff --git a/src/conf_mode/netns.py b/src/conf_mode/netns.py new file mode 100755 index 000000000..0924eb616 --- /dev/null +++ b/src/conf_mode/netns.py @@ -0,0 +1,118 @@ +#!/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 + +from sys import exit +from tempfile import NamedTemporaryFile + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.ifconfig import Interface +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 +airbag.enable() + + +def netns_interfaces(c, match): +    """ +    get NETNS bound interfaces +    """ +    matched = [] +    old_level = c.get_level() +    c.set_level(['interfaces']) +    section = c.get_config_dict([], get_first_key=True) +    for type in section: +        interfaces = section[type] +        for name in interfaces: +            interface = interfaces[name] +            if 'netns' in interface: +                v = interface.get('netns', '') +                if v == match: +                    matched.append(name) + +    c.set_level(old_level) +    return matched + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['netns'] +    netns = conf.get_config_dict(base, get_first_key=True, +                                       no_tag_node_value_mangle=True) + +    # determine which NETNS has been removed +    for name in node_changed(conf, base + ['name']): +        if 'netns_remove' not in netns: +            netns.update({'netns_remove' : {}}) + +        netns['netns_remove'][name] = {} +        # get NETNS bound interfaces +        interfaces = netns_interfaces(conf, name) +        if interfaces: netns['netns_remove'][name]['interface'] = interfaces + +    return netns + +def verify(netns): +    # ensure NETNS is not assigned to any interface +    if 'netns_remove' in netns: +        for name, config in netns['netns_remove'].items(): +            if 'interface' in config: +                raise ConfigError(f'Can not remove NETNS "{name}", it still has '\ +                                  f'member interfaces!') + +    if 'name' in netns: +        for name, config in netns['name'].items(): +            print(name) + +    return None + + +def generate(netns): +    if not netns: +        return None + +    return None + + +def apply(netns): + +    for tmp in (dict_search('netns_remove', netns) or []): +        if os.path.isfile(f'/run/netns/{tmp}'): +            call(f'ip netns del {tmp}') + +    if 'name' in netns: +        for name, config in netns['name'].items(): +            if not os.path.isfile(f'/run/netns/{name}'): +                call(f'ip netns add {name}') + +    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/ntp.py b/src/conf_mode/ntp.py index 52070aabc..0d6ec9ace 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/ntp.py @@ -56,8 +56,8 @@ def generate(ntp):      if not ntp:          return None -    render(config_file, 'ntp/ntpd.conf.tmpl', ntp) -    render(systemd_override, 'ntp/override.conf.tmpl', ntp) +    render(config_file, 'ntp/ntpd.conf.j2', ntp) +    render(systemd_override, 'ntp/override.conf.j2', ntp)      return None diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy-local-route.py index 539189442..3f834f55c 100755 --- a/src/conf_mode/policy-local-route.py +++ b/src/conf_mode/policy-local-route.py @@ -18,6 +18,7 @@ import os  from sys import exit +from netifaces import interfaces  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import node_changed @@ -35,35 +36,92 @@ def get_config(config=None):          conf = config      else:          conf = Config() -    base = ['policy', 'local-route'] +    base = ['policy'] +      pbr = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) -    # delete policy local-route -    dict = {} -    tmp = node_changed(conf, ['policy', 'local-route', 'rule'], key_mangling=('-', '_')) -    if tmp: -        for rule in (tmp or []): -            src = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'source']) -            fwmk = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'fwmark']) -            if src: -                dict = dict_merge({'rule_remove' : {rule : {'source' : src}}}, dict) -                pbr.update(dict) -            if fwmk: -                dict = dict_merge({'rule_remove' : {rule : {'fwmark' : fwmk}}}, dict) +    for route in ['local_route', 'local_route6']: +        dict_id = 'rule_remove' if route == 'local_route' else 'rule6_remove' +        route_key = 'local-route' if route == 'local_route' else 'local-route6' +        base_rule = base + [route_key, 'rule'] + +        # delete policy local-route +        dict = {} +        tmp = node_changed(conf, base_rule, key_mangling=('-', '_')) +        if tmp: +            for rule in (tmp or []): +                src = leaf_node_changed(conf, base_rule + [rule, 'source']) +                fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) +                iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) +                dst = leaf_node_changed(conf, base_rule + [rule, 'destination']) +                rule_def = {} +                if src: +                    rule_def = dict_merge({'source' : src}, rule_def) +                if fwmk: +                    rule_def = dict_merge({'fwmark' : fwmk}, rule_def) +                if iif: +                    rule_def = dict_merge({'inbound_interface' : iif}, rule_def) +                if dst: +                    rule_def = dict_merge({'destination' : dst}, rule_def) +                dict = dict_merge({dict_id : {rule : rule_def}}, dict)                  pbr.update(dict) -    # delete policy local-route rule x source x.x.x.x -    # delete policy local-route rule x fwmark x -    if 'rule' in pbr: -        for rule in pbr['rule']: -            src = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'source']) -            fwmk = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'fwmark']) -            if src: -                dict = dict_merge({'rule_remove' : {rule : {'source' : src}}}, dict) -                pbr.update(dict) -            if fwmk: -                dict = dict_merge({'rule_remove' : {rule : {'fwmark' : fwmk}}}, dict) -                pbr.update(dict) +        if not route in pbr: +            continue + +        # delete policy local-route rule x source x.x.x.x +        # delete policy local-route rule x fwmark x +        # delete policy local-route rule x destination x.x.x.x +        if 'rule' in pbr[route]: +            for rule, rule_config in pbr[route]['rule'].items(): +                src = leaf_node_changed(conf, base_rule + [rule, 'source']) +                fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) +                iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) +                dst = leaf_node_changed(conf, base_rule + [rule, 'destination']) +                # keep track of changes in configuration +                # otherwise we might remove an existing node although nothing else has changed +                changed = False + +                rule_def = {} +                # src is None if there are no changes to src +                if src is None: +                    # if src hasn't changed, include it in the removal selector +                    # if a new selector is added, we have to remove all previous rules without this selector +                    # to make sure we remove all previous rules with this source(s), it will be included +                    if 'source' in rule_config: +                        rule_def = dict_merge({'source': rule_config['source']}, rule_def) +                else: +                    # if src is not None, it's previous content will be returned +                    # this can be an empty array if it's just being set, or the previous value +                    # either way, something has to be changed and we only want to remove previous values +                    changed = True +                    # set the old value for removal if it's not empty +                    if len(src) > 0: +                        rule_def = dict_merge({'source' : src}, rule_def) +                if fwmk is None: +                    if 'fwmark' in rule_config: +                        rule_def = dict_merge({'fwmark': rule_config['fwmark']}, rule_def) +                else: +                    changed = True +                    if len(fwmk) > 0: +                        rule_def = dict_merge({'fwmark' : fwmk}, rule_def) +                if iif is None: +                    if 'inbound_interface' in rule_config: +                        rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def) +                else: +                    changed = True +                    if len(iif) > 0: +                        rule_def = dict_merge({'inbound_interface' : iif}, rule_def) +                if dst is None: +                    if 'destination' in rule_config: +                        rule_def = dict_merge({'destination': rule_config['destination']}, rule_def) +                else: +                    changed = True +                    if len(dst) > 0: +                        rule_def = dict_merge({'destination' : dst}, rule_def) +                if changed: +                    dict = dict_merge({dict_id : {rule : rule_def}}, dict) +                    pbr.update(dict)      return pbr @@ -72,13 +130,25 @@ def verify(pbr):      if not pbr:          return None -    if 'rule' in pbr: -        for rule in pbr['rule']: -            if 'source' not in pbr['rule'][rule] and 'fwmark' not in pbr['rule'][rule]: -                raise ConfigError('Source address or fwmark is required!') -            else: -                if 'set' not in pbr['rule'][rule] or 'table' not in pbr['rule'][rule]['set']: -                    raise ConfigError('Table set is required!') +    for route in ['local_route', 'local_route6']: +        if not route in pbr: +            continue + +        pbr_route = pbr[route] +        if 'rule' in pbr_route: +            for rule in pbr_route['rule']: +                if 'source' not in pbr_route['rule'][rule] \ +                        and 'destination' not in pbr_route['rule'][rule] \ +                        and 'fwmark' not in pbr_route['rule'][rule] \ +                        and 'inbound_interface' not in pbr_route['rule'][rule]: +                    raise ConfigError('Source or destination address or fwmark or inbound-interface is required!') +                else: +                    if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']: +                        raise ConfigError('Table set is required!') +                    if 'inbound_interface' in pbr_route['rule'][rule]: +                        interface = pbr_route['rule'][rule]['inbound_interface'] +                        if interface not in interfaces(): +                            raise ConfigError(f'Interface "{interface}" does not exist')      return None @@ -93,36 +163,51 @@ def apply(pbr):          return None      # Delete old rule if needed -    if 'rule_remove' in pbr: -        for rule in pbr['rule_remove']: -            if 'source' in pbr['rule_remove'][rule]: -                for src in pbr['rule_remove'][rule]['source']: -                    call(f'ip rule del prio {rule} from {src}') -            if 'fwmark' in  pbr['rule_remove'][rule]: -                for fwmk in pbr['rule_remove'][rule]['fwmark']: -                    call(f'ip rule del prio {rule} from all fwmark {fwmk}') +    for rule_rm in ['rule_remove', 'rule6_remove']: +        if rule_rm in pbr: +            v6 = " -6" if rule_rm == 'rule6_remove' else "" +            for rule, rule_config in pbr[rule_rm].items(): +                rule_config['source'] = rule_config['source'] if 'source' in rule_config else [''] +                for src in rule_config['source']: +                    f_src = '' if src == '' else f' from {src} ' +                    rule_config['destination'] = rule_config['destination'] if 'destination' in rule_config else [''] +                    for dst in rule_config['destination']: +                        f_dst = '' if dst == '' else f' to {dst} ' +                        rule_config['fwmark'] = rule_config['fwmark'] if 'fwmark' in rule_config else [''] +                        for fwmk in rule_config['fwmark']: +                            f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' +                            rule_config['inbound_interface'] = rule_config['inbound_interface'] if 'inbound_interface' in rule_config else [''] +                            for iif in rule_config['inbound_interface']: +                                f_iif = '' if iif == '' else f' iif {iif} ' +                                call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_fwmk}{f_iif}')      # Generate new config -    if 'rule' in pbr: -        for rule in pbr['rule']: -            table = pbr['rule'][rule]['set']['table'] -            # Only source in the rule -            # set policy local-route rule 100 source '203.0.113.1' -            if 'source' in pbr['rule'][rule] and not 'fwmark' in pbr['rule'][rule]: -                for src in pbr['rule'][rule]['source']: -                    call(f'ip rule add prio {rule} from {src} lookup {table}') -            # Only fwmark in the rule -            # set policy local-route rule 101 fwmark '23' -            if 'fwmark' in pbr['rule'][rule] and not 'source' in pbr['rule'][rule]: -                fwmk = pbr['rule'][rule]['fwmark'] -                call(f'ip rule add prio {rule} from all fwmark {fwmk} lookup {table}') -            # Source and fwmark in the rule -            # set policy local-route rule 100 source '203.0.113.1' -            # set policy local-route rule 100 fwmark '23' -            if 'source' in pbr['rule'][rule] and 'fwmark' in pbr['rule'][rule]: -                fwmk = pbr['rule'][rule]['fwmark'] -                for src in pbr['rule'][rule]['source']: -                    call(f'ip rule add prio {rule} from {src} fwmark {fwmk} lookup {table}') +    for route in ['local_route', 'local_route6']: +        if not route in pbr: +            continue + +        v6 = " -6" if route == 'local_route6' else "" + +        pbr_route = pbr[route] +        if 'rule' in pbr_route: +            for rule, rule_config in pbr_route['rule'].items(): +                table = rule_config['set']['table'] + +                rule_config['source'] = rule_config['source'] if 'source' in rule_config else ['all'] +                for src in rule_config['source'] or ['all']: +                    f_src = '' if src == '' else f' from {src} ' +                    rule_config['destination'] = rule_config['destination'] if 'destination' in rule_config else ['all'] +                    for dst in rule_config['destination']: +                        f_dst = '' if dst == '' else f' to {dst} ' +                        f_fwmk = '' +                        if 'fwmark' in rule_config: +                            fwmk = rule_config['fwmark'] +                            f_fwmk = f' fwmark {fwmk} ' +                        f_iif = '' +                        if 'inbound_interface' in rule_config: +                            iif = rule_config['inbound_interface'] +                            f_iif = f' iif {iif} ' +                        call(f'ip{v6} rule add prio {rule} {f_src}{f_dst}{f_fwmk}{f_iif} lookup {table}')      return None diff --git a/src/conf_mode/policy-route-interface.py b/src/conf_mode/policy-route-interface.py new file mode 100755 index 000000000..1108aebe6 --- /dev/null +++ b/src/conf_mode/policy-route-interface.py @@ -0,0 +1,120 @@ +#!/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 new file mode 100755 index 000000000..5de341beb --- /dev/null +++ b/src/conf_mode/policy-route.py @@ -0,0 +1,262 @@ +#!/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 json import loads +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.template import render +from vyos.util import cmd +from vyos.util import dict_search_args +from vyos.util import run +from vyos import ConfigError +from vyos import airbag +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', +    '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 +    else: +        conf = Config() +    base = ['policy'] + +    policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +                                    no_tag_node_value_mangle=True) + +    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): +    icmp = 'icmp' if not ipv6 else 'icmpv6' +    if icmp in rule_conf: +        icmp_defined = False +        if 'type_name' in rule_conf[icmp]: +            icmp_defined = True +            if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]: +                raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name') +        if 'code' in rule_conf[icmp]: +            icmp_defined = True +            if 'type' not in rule_conf[icmp]: +                raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined') +        if 'type' in rule_conf[icmp]: +            icmp_defined = True + +        if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp: +            raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP') + +    if 'set' in rule_conf: +        if 'tcp_mss' in rule_conf['set']: +            tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') +            if not tcp_flags or 'syn' not in tcp_flags: +                raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + +    tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') +    if tcp_flags: +        if dict_search_args(rule_conf, 'protocol') != 'tcp': +            raise ConfigError('Protocol must be tcp when specifying tcp flags') + +        not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') +        if not_flags: +            duplicates = [flag for flag in tcp_flags if flag in not_flags] +            if duplicates: +                raise ConfigError(f'Cannot match a tcp flag as set and not set') + +    for side in ['destination', 'source']: +        if side in rule_conf: +            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') + +                for group in valid_groups: +                    if group in side_conf['group']: +                        group_name = side_conf['group'][group] + +                        if group_name.startswith('!'): +                            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(policy['firewall_group'], fw_group, group_name) + +                        if group_obj is None: +                            raise ConfigError(f'Invalid {error_group} "{group_name}" on policy route rule') + +                        if not group_obj: +                            Warning(f'{error_group} "{group_name}" has no members') + +            if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): +                if 'protocol' not in rule_conf: +                    raise ConfigError('Protocol must be defined if specifying a port or port-group') + +                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(policy): +    for route in ['route', 'route6']: +        ipv6 = route == 'route6' +        if route in 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}') + +    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 + +def apply_table_marks(policy): +    for route in ['route', 'route6']: +        if route in policy: +            cmd_str = 'ip' if route == 'route' else 'ip -6' +            tables = [] +            for name, pol_conf in policy[route].items(): +                if 'rule' in pol_conf: +                    for rule_id, rule_conf in pol_conf['rule'].items(): +                        set_table = dict_search_args(rule_conf, 'set', 'table') +                        if set_table: +                            if set_table == 'main': +                                set_table = '254' +                            if set_table in tables: +                                continue +                            tables.append(set_table) +                            table_mark = mark_offset - int(set_table) +                            cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') + +def cleanup_table_marks(): +    for cmd_str in ['ip', 'ip -6']: +        json_rules = cmd(f'{cmd_str} -j -N rule list') +        rules = loads(json_rules) +        for rule in rules: +            if 'fwmark' not in rule or 'table' not in rule: +                continue +            fwmark = rule['fwmark'] +            table = int(rule['table']) +            if fwmark[:2] == '0x': +                fwmark = int(fwmark, 16) +            if (int(fwmark) == (mark_offset - table)): +                cmd(f'{cmd_str} rule del fwmark {fwmark} table {table}') + +def apply(policy): +    install_result = run(f'nft -f {nftables_conf}') +    if install_result == 1: +        raise ConfigError('Failed to apply policy based routing') + +    if 'first_install' not in policy: +        cleanup_table_marks() + +    apply_table_marks(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/conf_mode/policy.py b/src/conf_mode/policy.py index 1a03d520b..ef6008140 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.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 verify(policy):              # human readable instance name (hypen instead of underscore)              policy_hr = policy_type.replace('_', '-') +            entries = []              for rule, rule_config in instance_config['rule'].items():                  mandatory_error = f'must be specified for "{policy_hr} {instance} rule {rule}"!'                  if 'action' not in rule_config: @@ -113,6 +114,10 @@ def verify(policy):                      if 'prefix' not in rule_config:                          raise ConfigError(f'A prefix {mandatory_error}') +                    if rule_config in entries: +                        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: @@ -171,10 +176,8 @@ def verify(policy):  def generate(policy):      if not policy: -        policy['new_frr_config'] = ''          return None - -    policy['new_frr_config'] = render_to_string('frr/policy.frr.tmpl', policy) +    policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy)      return None  def apply(policy): @@ -190,8 +193,9 @@ 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 .*') -    frr_cfg.add_before('^line vty', policy['new_frr_config']) +    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)      # The route-map used for the FIB (zebra) is part of the zebra daemon @@ -200,19 +204,11 @@ 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 .*') -    frr_cfg.add_before('^line vty', policy['new_frr_config']) +    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) -    # If FRR config is blank, rerun the blank commit x times due to frr-reload -    # behavior/bug not properly clearing out on one commit. -    if policy['new_frr_config'] == '': -        for a in range(5): -            frr_cfg.commit_configuration(zebra_daemon) - -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() -      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index 539fd7b8e..0436abaf9 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -16,10 +16,9 @@  import os -from sys import exit -  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configverify import verify_vrf  from vyos.template import is_ipv6  from vyos.template import render_to_string  from vyos.validate import is_ipv6_link_local @@ -35,8 +34,9 @@ def get_config(config=None):      else:          conf = Config()      base = ['protocols', 'bfd'] -    bfd = conf.get_config_dict(base, get_first_key=True) - +    bfd = conf.get_config_dict(base, key_mangling=('-', '_'), +                               get_first_key=True, +                               no_tag_node_value_mangle=True)      # Bail out early if configuration tree does not exist      if not conf.exists(base):          return bfd @@ -79,28 +79,37 @@ def verify(bfd):                  # multihop and echo-mode cannot be used together                  if 'echo_mode' in peer_config: -                    raise ConfigError('Multihop and echo-mode cannot be used together') +                    raise ConfigError('BFD multihop and echo-mode cannot be used together')                  # multihop doesn't accept interface names                  if 'source' in peer_config and 'interface' in peer_config['source']: -                    raise ConfigError('Multihop and source interface cannot be used together') +                    raise ConfigError('BFD multihop and source interface cannot be used together') + +            if 'profile' in peer_config: +                profile_name = peer_config['profile'] +                if 'profile' not in bfd or profile_name not in bfd['profile']: +                    raise ConfigError(f'BFD profile "{profile_name}" does not exist!') + +            if 'vrf' in peer_config: +                verify_vrf(peer_config)      return None  def generate(bfd):      if not bfd: -        bfd['new_frr_config'] = ''          return None - -    bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.tmpl', bfd) +    bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.j2', bfd)  def apply(bfd): +    bfd_daemon = 'bfdd' +      # Save original configuration prior to starting any commit actions      frr_cfg = frr.FRRConfig() -    frr_cfg.load_configuration() -    frr_cfg.modify_section('^bfd', '') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', bfd['new_frr_config']) -    frr_cfg.commit_configuration() +    frr_cfg.load_configuration(bfd_daemon) +    frr_cfg.modify_section('^bfd', stop_pattern='^exit', remove_stop_mark=True) +    if 'new_frr_config' in bfd: +        frr_cfg.add_before(frr.default_add_before, bfd['new_frr_config']) +    frr_cfg.commit_configuration(bfd_daemon)      return None diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 68284e0f9..cd46cbcb4 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -138,13 +138,20 @@ def verify(bgp):                  if asn == bgp['local_as']:                      raise ConfigError('Cannot have local-as same as BGP AS number') +                # Neighbor AS specified for local-as and remote-as can not be the same +                if dict_search('remote_as', peer_config) == asn: +                     raise ConfigError(f'Neighbor "{peer}" has local-as specified which is '\ +                                        'the same as remote-as, this is not allowed!') +              # ttl-security and ebgp-multihop can't be used in the same configration              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') -            # Check if neighbor has both override capability and strict capability match configured at the same time. +            # 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: -                raise ConfigError(f'Neighbor "{peer}" cannot have both override-capability and strict-capability-match configured at the same time!') +                raise ConfigError(f'Neighbor "{peer}" cannot have both override-capability and '\ +                                  'strict-capability-match configured at the same time!')              # Check spaces in the password              if 'password' in peer_config and ' ' in peer_config['password']: @@ -157,15 +164,39 @@ def verify(bgp):                  if not verify_remote_as(peer_config, bgp):                      raise ConfigError(f'Neighbor "{peer}" remote-as must be set!') +                # Peer-group member cannot override remote-as of peer-group +                if 'peer_group' in peer_config: +                    peer_group = peer_config['peer_group'] +                    if 'remote_as' in peer_config and 'remote_as' in bgp['peer_group'][peer_group]: +                        raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!') +                if 'interface' in peer_config: +                    if 'peer_group' in peer_config['interface']: +                        peer_group = peer_config['interface']['peer_group'] +                        if 'remote_as' in peer_config['interface'] and 'remote_as' in bgp['peer_group'][peer_group]: +                            raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!') +                    if 'v6only' in peer_config['interface']: +                        if 'peer_group' in peer_config['interface']['v6only']: +                            peer_group = peer_config['interface']['v6only']['peer_group'] +                            if 'remote_as' in peer_config['interface']['v6only'] and 'remote_as' in bgp['peer_group'][peer_group]: +                                raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!') +                  # Only checks for ipv4 and ipv6 neighbors                  # Check if neighbor address is assigned as system interface address -                if is_ip(peer) and is_addr_assigned(peer): -                    raise ConfigError(f'Can not configure a local address as neighbor "{peer}"') +                vrf = None +                vrf_error_msg = f' in default VRF!' +                if 'vrf' in bgp: +                    vrf = bgp['vrf'] +                    vrf_error_msg = f' in VRF "{vrf}"!' + +                if is_ip(peer) and is_addr_assigned(peer, vrf): +                    raise ConfigError(f'Can not configure local address as neighbor "{peer}"{vrf_error_msg}')                  elif is_interface(peer):                      if 'peer_group' in peer_config:                          raise ConfigError(f'peer-group must be set under the interface node of "{peer}"')                      if 'remote_as' in peer_config:                          raise ConfigError(f'remote-as must be set under the interface node of "{peer}"') +                    if 'source_interface' in peer_config['interface']: +                        raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"')              for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec',                          'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', @@ -183,6 +214,33 @@ def verify(bgp):                      raise ConfigError(f'Neighbor "{peer}" cannot have both ipv6-unicast and ipv6-labeled-unicast configured at the same time!')                  afi_config = peer_config['address_family'][afi] + +                if 'conditionally_advertise' in afi_config: +                    if 'advertise_map' not in afi_config['conditionally_advertise']: +                        raise ConfigError('Must speficy advertise-map when conditionally-advertise is in use!') +                    # Verify advertise-map (which is a route-map) exists +                    verify_route_map(afi_config['conditionally_advertise']['advertise_map'], bgp) + +                    if ('exist_map' not in afi_config['conditionally_advertise'] and +                        'non_exist_map' not in afi_config['conditionally_advertise']): +                        raise ConfigError('Must either speficy exist-map or non-exist-map when ' \ +                                          'conditionally-advertise is in use!') + +                    if {'exist_map', 'non_exist_map'} <= set(afi_config['conditionally_advertise']): +                        raise ConfigError('Can not specify both exist-map and non-exist-map for ' \ +                                          'conditionally-advertise!') + +                    if 'exist_map' in afi_config['conditionally_advertise']: +                        verify_route_map(afi_config['conditionally_advertise']['exist_map'], bgp) + +                    if 'non_exist_map' in afi_config['conditionally_advertise']: +                        verify_route_map(afi_config['conditionally_advertise']['non_exist_map'], bgp) + +                # T4332: bgp deterministic-med cannot be disabled while addpath-tx-bestpath-per-AS is in use +                if 'addpath_tx_per_as' in afi_config: +                    if dict_search('parameters.deterministic_med', bgp) == None: +                        raise ConfigError('addpath-tx-per-as requires BGP deterministic-med paramtere to be set!') +                  # Validate if configured Prefix list exists                  if 'prefix_list' in afi_config:                      for tmp in ['import', 'export']: @@ -255,26 +313,16 @@ def verify(bgp):                      tmp = dict_search(f'route_map.vpn.{export_import}', afi_config)                      if tmp: verify_route_map(tmp, bgp) -            if afi in ['l2vpn_evpn'] and 'vrf' not in bgp: -                # Some L2VPN EVPN AFI options are only supported under VRF -                if 'vni' in afi_config: -                    for vni, vni_config in afi_config['vni'].items(): -                        if 'rd' in vni_config: -                            raise ConfigError('VNI route-distinguisher is only supported under EVPN VRF') -                        if 'route_target' in vni_config: -                            raise ConfigError('VNI route-target is only supported under EVPN VRF')      return None  def generate(bgp):      if not bgp or 'deleted' in bgp: -        bgp['frr_bgpd_config'] = '' -        bgp['frr_zebra_config'] = ''          return None -    bgp['protocol'] = 'bgp' # required for frr/vrf.route-map.frr.tmpl -    bgp['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.tmpl', bgp) -    bgp['frr_bgpd_config']  = render_to_string('frr/bgpd.frr.tmpl', bgp) +    bgp['protocol'] = 'bgp' # required for frr/vrf.route-map.frr.j2 +    bgp['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.j2', bgp) +    bgp['frr_bgpd_config']  = render_to_string('frr/bgpd.frr.j2', bgp)      return None @@ -287,8 +335,9 @@ def apply(bgp):      # The route-map used for the FIB (zebra) is part of the zebra daemon      frr_cfg.load_configuration(zebra_daemon) -    frr_cfg.modify_section(r'(\s+)?ip protocol bgp route-map [-a-zA-Z0-9.]+$', '', '(\s|!)') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', bgp['frr_zebra_config']) +    frr_cfg.modify_section(r'(\s+)?ip protocol bgp route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') +    if 'frr_zebra_config' in bgp: +        frr_cfg.add_before(frr.default_add_before, bgp['frr_zebra_config'])      frr_cfg.commit_configuration(zebra_daemon)      # Generate empty helper string which can be ammended to FRR commands, it @@ -298,13 +347,11 @@ def apply(bgp):          vrf = ' vrf ' + bgp['vrf']      frr_cfg.load_configuration(bgp_daemon) -    frr_cfg.modify_section(f'^router bgp \d+{vrf}$', '') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', bgp['frr_bgpd_config']) +    frr_cfg.modify_section(f'^router bgp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True) +    if 'frr_bgpd_config' in bgp: +        frr_cfg.add_before(frr.default_add_before, bgp['frr_bgpd_config'])      frr_cfg.commit_configuration(bgp_daemon) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() -      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_igmp.py b/src/conf_mode/protocols_igmp.py index 28d560d03..65cc2beba 100755 --- a/src/conf_mode/protocols_igmp.py +++ b/src/conf_mode/protocols_igmp.py @@ -108,7 +108,7 @@ def generate(igmp):      if igmp is None:          return None -    render(config_file, 'frr/igmp.frr.tmpl', igmp) +    render(config_file, 'frr/igmp.frr.j2', igmp)      return None  def apply(igmp): diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 4505e2496..5dafd26d0 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -56,10 +56,10 @@ def get_config(config=None):      # instead of the VRF instance.      if vrf: isis['vrf'] = vrf -    # As we no re-use this Python handler for both VRF and non VRF instances for -    # IS-IS we need to find out if any interfaces changed so properly adjust -    # the FRR configuration and not by acctident change interfaces from a -    # different VRF. +    # FRR has VRF support for different routing daemons. As interfaces belong +    # to VRFs - or the global VRF, we need to check for changed interfaces so +    # that they will be properly rendered for the FRR config. Also this eases +    # removal of interfaces from the running configuration.      interfaces_removed = node_changed(conf, base + ['interface'])      if interfaces_removed:          isis['interface_removed'] = list(interfaces_removed) @@ -169,40 +169,50 @@ def verify(isis):      # Segment routing checks      if dict_search('segment_routing.global_block', isis): -        high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) -        low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) +        g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) +        g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) -        # If segment routing global block high value is blank, throw error -        if (low_label_value and not high_label_value) or (high_label_value and not low_label_value): -            raise ConfigError('Segment routing global block requires both low and high value!') +        # 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(low_label_value) > int(high_label_value): -            raise ConfigError('Segment routing global block low value must be lower than high value') +        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', isis): -        high_label_value = dict_search('segment_routing.local_block.high_label_value', isis) -        low_label_value = dict_search('segment_routing.local_block.low_label_value', isis) +        if dict_search('segment_routing.global_block', isis) == None: +            raise ConfigError('Segment routing local-block requires global-block to be configured!') -        # If segment routing local block high value is blank, throw error -        if (low_label_value and not high_label_value) or (high_label_value and not low_label_value): -            raise ConfigError('Segment routing local block requires both high and low value!') +        l_high_label_value = dict_search('segment_routing.local_block.high_label_value', isis) +        l_low_label_value = dict_search('segment_routing.local_block.low_label_value', isis) -        # If segment routing local block low value is higher than the high value, throw error -        if int(low_label_value) > int(high_label_value): -            raise ConfigError('Segment routing local block low value must be lower than high value') +        # 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})!')      return None  def generate(isis):      if not isis or 'deleted' in isis: -        isis['frr_isisd_config'] = '' -        isis['frr_zebra_config'] = ''          return None -    isis['protocol'] = 'isis' # required for frr/vrf.route-map.frr.tmpl -    isis['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.tmpl', isis) -    isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.tmpl', isis) +    isis['protocol'] = 'isis' # required for frr/vrf.route-map.frr.j2 +    isis['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.j2', isis) +    isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.j2', isis)      return None  def apply(isis): @@ -214,8 +224,9 @@ def apply(isis):      # The route-map used for the FIB (zebra) is part of the zebra daemon      frr_cfg.load_configuration(zebra_daemon) -    frr_cfg.modify_section(r'(\s+)?ip protocol isis route-map [-a-zA-Z0-9.]+$', '', '(\s|!)') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', isis['frr_zebra_config']) +    frr_cfg.modify_section('(\s+)?ip protocol isis route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') +    if 'frr_zebra_config' in isis: +        frr_cfg.add_before(frr.default_add_before, isis['frr_zebra_config'])      frr_cfg.commit_configuration(zebra_daemon)      # Generate empty helper string which can be ammended to FRR commands, it @@ -225,19 +236,18 @@ def apply(isis):          vrf = ' vrf ' + isis['vrf']      frr_cfg.load_configuration(isis_daemon) -    frr_cfg.modify_section(f'^router isis VyOS{vrf}$', '') +    frr_cfg.modify_section(f'^router isis VyOS{vrf}', stop_pattern='^exit', remove_stop_mark=True)      for key in ['interface', 'interface_removed']:          if key not in isis:              continue          for interface in isis[key]: -            frr_cfg.modify_section(f'^interface {interface}{vrf}$', '') +            frr_cfg.modify_section(f'^interface {interface}{vrf}', stop_pattern='^exit', remove_stop_mark=True) -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', isis['frr_isisd_config']) -    frr_cfg.commit_configuration(isis_daemon) +    if 'frr_isisd_config' in isis: +        frr_cfg.add_before(frr.default_add_before, isis['frr_isisd_config']) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() +    frr_cfg.commit_configuration(isis_daemon)      return None diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 3b27608da..5da8e7b06 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.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 @@ -20,11 +20,10 @@ from sys import exit  from glob import glob  from vyos.config import Config -from vyos.configdict import node_changed  from vyos.template import render_to_string -from vyos.util import call  from vyos.util import dict_search  from vyos.util import read_file +from vyos.util import sysctl_write  from vyos import ConfigError  from vyos import frr  from vyos import airbag @@ -66,82 +65,70 @@ def verify(mpls):  def generate(mpls):      # If there's no MPLS config generated, create dictionary key with no value. -    if not mpls: -        mpls['new_frr_config'] = '' +    if not mpls or 'deleted' in mpls:          return None -    mpls['new_frr_config'] = render_to_string('frr/ldpd.frr.tmpl', mpls) +    mpls['frr_ldpd_config'] = render_to_string('frr/ldpd.frr.j2', mpls)      return None  def apply(mpls): -    # Define dictionary that will load FRR config -    frr_cfg = {} +    ldpd_damon = 'ldpd' +      # Save original configuration prior to starting any commit actions -    frr_cfg['original_config'] = frr.get_configuration(daemon='ldpd') -    frr_cfg['modified_config'] = frr.replace_section(frr_cfg['original_config'], mpls['new_frr_config'], from_re='mpls.*') - -    # If FRR config is blank, rerun the blank commit three times due to frr-reload -    # behavior/bug not properly clearing out on one commit. -    if mpls['new_frr_config'] == '': -        for x in range(3): -            frr.reload_configuration(frr_cfg['modified_config'], daemon='ldpd') -    elif not 'ldp' in mpls: -        for x in range(3): -            frr.reload_configuration(frr_cfg['modified_config'], daemon='ldpd') -    else: -        # FRR mark configuration will test for syntax errors and throws an -        # exception if any syntax errors is detected -        frr.mark_configuration(frr_cfg['modified_config']) +    frr_cfg = frr.FRRConfig() + +    frr_cfg.load_configuration(ldpd_damon) +    frr_cfg.modify_section(f'^mpls ldp', stop_pattern='^exit', remove_stop_mark=True) -        # Commit resulting configuration to FRR, this will throw CommitError -        # on failure -        frr.reload_configuration(frr_cfg['modified_config'], daemon='ldpd') +    if 'frr_ldpd_config' in mpls: +        frr_cfg.add_before(frr.default_add_before, mpls['frr_ldpd_config']) +    frr_cfg.commit_configuration(ldpd_damon)      # Set number of entries in the platform label tables      labels = '0'      if 'interface' in mpls:          labels = '1048575' -    call(f'sysctl -wq net.mpls.platform_labels={labels}') +    sysctl_write('net.mpls.platform_labels', labels)      # Check for changes in global MPLS options      if 'parameters' in mpls:              # Choose whether to copy IP TTL to MPLS header TTL          if 'no_propagate_ttl' in mpls['parameters']: -            call('sysctl -wq net.mpls.ip_ttl_propagate=0') +            sysctl_write('net.mpls.ip_ttl_propagate', 0)              # Choose whether to limit maximum MPLS header TTL          if 'maximum_ttl' in mpls['parameters']:              ttl = mpls['parameters']['maximum_ttl'] -            call(f'sysctl -wq net.mpls.default_ttl={ttl}') +            sysctl_write('net.mpls.default_ttl', ttl)      else:          # Set default global MPLS options if not defined. -        call('sysctl -wq net.mpls.ip_ttl_propagate=1') -        call('sysctl -wq net.mpls.default_ttl=255') +        sysctl_write('net.mpls.ip_ttl_propagate', 1) +        sysctl_write('net.mpls.default_ttl', 255)      # Enable and disable MPLS processing on interfaces per configuration      if 'interface' in mpls:          system_interfaces = []          # Populate system interfaces list with local MPLS capable interfaces          for interface in glob('/proc/sys/net/mpls/conf/*'): -            system_interfaces.append(os.path.basename(interface))    +            system_interfaces.append(os.path.basename(interface))          # This is where the comparison is done on if an interface needs to be enabled/disabled.          for system_interface in system_interfaces:              interface_state = read_file(f'/proc/sys/net/mpls/conf/{system_interface}/input')              if '1' in interface_state:                  if system_interface not in mpls['interface']:                      system_interface = system_interface.replace('.', '/') -                    call(f'sysctl -wq net.mpls.conf.{system_interface}.input=0') +                    sysctl_write(f'net.mpls.conf.{system_interface}.input', 0)              elif '0' in interface_state:                  if system_interface in mpls['interface']:                      system_interface = system_interface.replace('.', '/') -                    call(f'sysctl -wq net.mpls.conf.{system_interface}.input=1') +                    sysctl_write(f'net.mpls.conf.{system_interface}.input', 1)      else:          system_interfaces = []          # If MPLS interfaces are not configured, set MPLS processing disabled          for interface in glob('/proc/sys/net/mpls/conf/*'): -            system_interfaces.append(os.path.basename(interface))  +            system_interfaces.append(os.path.basename(interface))          for system_interface in system_interfaces:              system_interface = system_interface.replace('.', '/') -            call(f'sysctl -wq net.mpls.conf.{system_interface}.input=0') +            sysctl_write(f'net.mpls.conf.{system_interface}.input', 0)      return None diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 12dacdba0..56939955d 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 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,6 +16,8 @@  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 @@ -79,36 +81,36 @@ 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): -    render(opennhrp_conf, 'nhrp/opennhrp.conf.tmpl', nhrp) +    render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp)      return None  def apply(nhrp):      if 'tunnel' in nhrp:          for tunnel, tunnel_conf in nhrp['tunnel'].items(): -            if 'source_address' in tunnel_conf: -                chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' -                source_address = tunnel_conf['source_address'] +            if 'source_address' in nhrp['if_tunnel'][tunnel]: +                comment = f'VYOS_NHRP_{tunnel}' +                source_address = nhrp['if_tunnel'][tunnel]['source_address'] -                chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 -                if not chain_exists: -                    run(f'sudo iptables --new {chain}') -                    run(f'sudo iptables --append {chain} -p gre -s {source_address} -d 224.0.0.0/4 -j DROP') -                    run(f'sudo iptables --append {chain} -j RETURN') -                    run(f'sudo iptables --insert OUTPUT 2 -j {chain}') +                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']: -        chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' -        chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 -        if chain_exists: -            run(f'sudo iptables --delete OUTPUT -j {chain}') -            run(f'sudo iptables --flush {chain}') -            run(f'sudo iptables --delete-chain {chain}') +        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)      action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' -    run(f'systemctl {action} opennhrp') +    run(f'systemctl {action} opennhrp.service')      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 6ccda2e5a..5b4874ba2 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -25,6 +25,7 @@ from vyos.configdict import node_changed  from vyos.configverify import verify_common_route_maps  from vyos.configverify import verify_route_map  from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_access_list  from vyos.template import render_to_string  from vyos.util import dict_search  from vyos.util import get_interface_config @@ -56,10 +57,10 @@ def get_config(config=None):      # instead of the VRF instance.      if vrf: ospf['vrf'] = vrf -    # As we no re-use this Python handler for both VRF and non VRF instances for -    # OSPF we need to find out if any interfaces changed so properly adjust -    # the FRR configuration and not by acctident change interfaces from a -    # different VRF. +    # FRR has VRF support for different routing daemons. As interfaces belong +    # to VRFs - or the global VRF, we need to check for changed interfaces so +    # that they will be properly rendered for the FRR config. Also this eases +    # removal of interfaces from the running configuration.      interfaces_removed = node_changed(conf, base + ['interface'])      if interfaces_removed:          ospf['interface_removed'] = list(interfaces_removed) @@ -159,6 +160,16 @@ def verify(ospf):      route_map_name = dict_search('default_information.originate.route_map', ospf)      if route_map_name: verify_route_map(route_map_name, ospf) +    # Validate if configured Access-list exists +    if 'area' in ospf: +          for area, area_config in ospf['area'].items(): +              if 'import_list' in area_config: +                  acl_import = area_config['import_list'] +                  if acl_import: verify_access_list(acl_import, ospf) +              if 'export_list' in area_config: +                  acl_export = area_config['export_list'] +                  if acl_export: verify_access_list(acl_export, ospf) +      if 'interface' in ospf:          for interface, interface_config in ospf['interface'].items():              verify_interface_exists(interface) @@ -177,11 +188,11 @@ def verify(ospf):                          raise ConfigError('Can not use OSPF interface area and area ' \                                            'network configuration at the same time!') -            if 'vrf' in ospf:              # If interface specific options are set, we must ensure that the              # interface is bound to our requesting VRF. Due to the VyOS              # priorities the interface is bound to the VRF after creation of              # the VRF itself, and before any routing protocol is configured. +            if 'vrf' in ospf:                  vrf = ospf['vrf']                  tmp = get_interface_config(interface)                  if 'master' not in tmp or tmp['master'] != vrf: @@ -191,13 +202,11 @@ def verify(ospf):  def generate(ospf):      if not ospf or 'deleted' in ospf: -        ospf['frr_ospfd_config'] = '' -        ospf['frr_zebra_config'] = ''          return None -    ospf['protocol'] = 'ospf' # required for frr/vrf.route-map.frr.tmpl -    ospf['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.tmpl', ospf) -    ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.tmpl', ospf) +    ospf['protocol'] = 'ospf' # required for frr/vrf.route-map.frr.j2 +    ospf['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.j2', ospf) +    ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.j2', ospf)      return None  def apply(ospf): @@ -209,8 +218,9 @@ def apply(ospf):      # The route-map used for the FIB (zebra) is part of the zebra daemon      frr_cfg.load_configuration(zebra_daemon) -    frr_cfg.modify_section(r'(\s+)?ip protocol ospf route-map [-a-zA-Z0-9.]+$', '', '(\s|!)') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ospf['frr_zebra_config']) +    frr_cfg.modify_section('(\s+)?ip protocol ospf route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') +    if 'frr_zebra_config' in ospf: +        frr_cfg.add_before(frr.default_add_before, ospf['frr_zebra_config'])      frr_cfg.commit_configuration(zebra_daemon)      # Generate empty helper string which can be ammended to FRR commands, it @@ -220,20 +230,18 @@ def apply(ospf):          vrf = ' vrf ' + ospf['vrf']      frr_cfg.load_configuration(ospf_daemon) -    frr_cfg.modify_section(f'^router ospf{vrf}$', '') +    frr_cfg.modify_section(f'^router ospf{vrf}', stop_pattern='^exit', remove_stop_mark=True)      for key in ['interface', 'interface_removed']:          if key not in ospf:              continue          for interface in ospf[key]: -            frr_cfg.modify_section(f'^interface {interface}{vrf}$', '') +            frr_cfg.modify_section(f'^interface {interface}{vrf}', stop_pattern='^exit', remove_stop_mark=True) -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ospf['frr_ospfd_config']) +    if 'frr_ospfd_config' in ospf: +        frr_cfg.add_before(frr.default_add_before, ospf['frr_ospfd_config'])      frr_cfg.commit_configuration(ospf_daemon) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() -      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index 536ffa690..ee4eaf59d 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -17,32 +17,80 @@  import os  from sys import exit +from sys import argv  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdict import node_changed  from vyos.configverify import verify_common_route_maps +from vyos.configverify import verify_route_map +from vyos.configverify import verify_interface_exists  from vyos.template import render_to_string  from vyos.ifconfig import Interface +from vyos.util import dict_search +from vyos.util import get_interface_config  from vyos.xml import defaults  from vyos import ConfigError  from vyos import frr  from vyos import airbag  airbag.enable() -frr_daemon = 'ospf6d' -  def get_config(config=None):      if config:          conf = config      else:          conf = Config() -    base = ['protocols', 'ospfv3'] + +    vrf = None +    if len(argv) > 1: +        vrf = argv[1] + +    base_path = ['protocols', 'ospfv3'] + +    # eqivalent of the C foo ? 'a' : 'b' statement +    base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospfv3'] or base_path      ospfv3 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    # Assign the name of our VRF context. This MUST be done before the return +    # statement below, else on deletion we will delete the default instance +    # instead of the VRF instance. +    if vrf: ospfv3['vrf'] = vrf + +    # FRR has VRF support for different routing daemons. As interfaces belong +    # to VRFs - or the global VRF, we need to check for changed interfaces so +    # that they will be properly rendered for the FRR config. Also this eases +    # removal of interfaces from the running configuration. +    interfaces_removed = node_changed(conf, base + ['interface']) +    if interfaces_removed: +        ospfv3['interface_removed'] = list(interfaces_removed) +      # Bail out early if configuration tree does not exist      if not conf.exists(base): +        ospfv3.update({'deleted' : ''})          return ospfv3 +    # We have gathered the dict representation of the CLI, but there are default +    # options which we need to update into the dictionary retrived. +    # XXX: Note that we can not call defaults(base), as defaults does not work +    # on an instance of a tag node. As we use the exact same CLI definition for +    # both the non-vrf and vrf version this is absolutely safe! +    default_values = defaults(base_path) + +    # We have to cleanup the default dict, as default values could enable features +    # which are not explicitly enabled on the CLI. Example: default-information +    # originate comes with a default metric-type of 2, which will enable the +    # entire default-information originate tree, even when not set via CLI so we +    # need to check this first and probably drop that key. +    if dict_search('default_information.originate', ospfv3) is None: +        del default_values['default_information'] + +    # XXX: T2665: we currently have no nice way for defaults under tag nodes, +    # clean them out and add them manually :( +    del default_values['interface'] + +    # merge in remaining default values +    ospfv3 = dict_merge(default_values, ospfv3) +      # We also need some additional information from the config, prefix-lists      # and route-maps for instance. They will be used in verify().      # @@ -60,34 +108,68 @@ def verify(ospfv3):      verify_common_route_maps(ospfv3) +    # As we can have a default-information route-map, we need to validate it! +    route_map_name = dict_search('default_information.originate.route_map', ospfv3) +    if route_map_name: verify_route_map(route_map_name, ospfv3) + +    if 'area' in ospfv3: +        for area, area_config in ospfv3['area'].items(): +            if 'area_type' in area_config: +                if len(area_config['area_type']) > 1: +                    raise ConfigError(f'Can only configure one area-type for OSPFv3 area "{area}"!') +      if 'interface' in ospfv3: -        for ifname, if_config in ospfv3['interface'].items(): -            if 'ifmtu' in if_config: -                mtu = Interface(ifname).get_mtu() -                if int(if_config['ifmtu']) > int(mtu): +        for interface, interface_config in ospfv3['interface'].items(): +            verify_interface_exists(interface) +            if 'ifmtu' in interface_config: +                mtu = Interface(interface).get_mtu() +                if int(interface_config['ifmtu']) > int(mtu):                      raise ConfigError(f'OSPFv3 ifmtu can not exceed physical MTU of "{mtu}"') +            # If interface specific options are set, we must ensure that the +            # interface is bound to our requesting VRF. Due to the VyOS +            # priorities the interface is bound to the VRF after creation of +            # the VRF itself, and before any routing protocol is configured. +            if 'vrf' in ospfv3: +                vrf = ospfv3['vrf'] +                tmp = get_interface_config(interface) +                if 'master' not in tmp or tmp['master'] != vrf: +                    raise ConfigError(f'Interface {interface} is not a member of VRF {vrf}!') +      return None  def generate(ospfv3): -    if not ospfv3: -        ospfv3['new_frr_config'] = '' +    if not ospfv3 or 'deleted' in ospfv3:          return None -    ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.tmpl', ospfv3) +    ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.j2', ospfv3)      return None  def apply(ospfv3): +    ospf6_daemon = 'ospf6d' +      # Save original configuration prior to starting any commit actions      frr_cfg = frr.FRRConfig() -    frr_cfg.load_configuration(frr_daemon) -    frr_cfg.modify_section(r'^interface \S+', '') -    frr_cfg.modify_section('^router ospf6$', '') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ospfv3['new_frr_config']) -    frr_cfg.commit_configuration(frr_daemon) - -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() + +    # Generate empty helper string which can be ammended to FRR commands, it +    # will be either empty (default VRF) or contain the "vrf <name" statement +    vrf = '' +    if 'vrf' in ospfv3: +        vrf = ' vrf ' + ospfv3['vrf'] + +    frr_cfg.load_configuration(ospf6_daemon) +    frr_cfg.modify_section(f'^router ospf6{vrf}', stop_pattern='^exit', remove_stop_mark=True) + +    for key in ['interface', 'interface_removed']: +        if key not in ospfv3: +            continue +        for interface in ospfv3[key]: +            frr_cfg.modify_section(f'^interface {interface}{vrf}', stop_pattern='^exit', remove_stop_mark=True) + +    if 'new_frr_config' in ospfv3: +        frr_cfg.add_before(frr.default_add_before, ospfv3['new_frr_config']) + +    frr_cfg.commit_configuration(ospf6_daemon)      return None diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index df2e6f941..78df9b6f8 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -135,7 +135,7 @@ def generate(pim):      if pim is None:          return None -    render(config_file, 'frr/pimd.frr.tmpl', pim) +    render(config_file, 'frr/pimd.frr.j2', pim)      return None  def apply(pim): diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index 6b78f6f2d..a76c1ce76 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -20,6 +20,7 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdict import node_changed  from vyos.configverify import verify_common_route_maps  from vyos.configverify import verify_access_list  from vyos.configverify import verify_prefix_list @@ -39,8 +40,17 @@ def get_config(config=None):      base = ['protocols', 'rip']      rip = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    # FRR has VRF support for different routing daemons. As interfaces belong +    # to VRFs - or the global VRF, we need to check for changed interfaces so +    # that they will be properly rendered for the FRR config. Also this eases +    # removal of interfaces from the running configuration. +    interfaces_removed = node_changed(conf, base + ['interface']) +    if interfaces_removed: +        rip['interface_removed'] = list(interfaces_removed) +      # Bail out early if configuration tree does not exist      if not conf.exists(base): +        rip.update({'deleted' : ''})          return rip      # We have gathered the dict representation of the CLI, but there are default @@ -89,12 +99,10 @@ def verify(rip):                                        f'with "split-horizon disable" for "{interface}"!')  def generate(rip): -    if not rip: -        rip['new_frr_config'] = '' +    if not rip or 'deleted' in rip:          return None -    rip['new_frr_config'] = render_to_string('frr/ripd.frr.tmpl', rip) - +    rip['new_frr_config'] = render_to_string('frr/ripd.frr.j2', rip)      return None  def apply(rip): @@ -106,19 +114,22 @@ def apply(rip):      # The route-map used for the FIB (zebra) is part of the zebra daemon      frr_cfg.load_configuration(zebra_daemon) -    frr_cfg.modify_section(r'^ip protocol rip route-map [-a-zA-Z0-9.]+$', '') +    frr_cfg.modify_section('^ip protocol rip route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')      frr_cfg.commit_configuration(zebra_daemon)      frr_cfg.load_configuration(rip_daemon) -    frr_cfg.modify_section(r'key chain \S+', '') -    frr_cfg.modify_section(r'interface \S+', '') -    frr_cfg.modify_section('^router rip$', '') +    frr_cfg.modify_section('^key chain \S+', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section('^router rip', stop_pattern='^exit', remove_stop_mark=True) -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', rip['new_frr_config']) -    frr_cfg.commit_configuration(rip_daemon) +    for key in ['interface', 'interface_removed']: +        if key not in rip: +            continue +        for interface in rip[key]: +            frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() +    if 'new_frr_config' in rip: +        frr_cfg.add_before(frr.default_add_before, rip['new_frr_config']) +    frr_cfg.commit_configuration(rip_daemon)      return None diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index bc4954f63..21ff710b3 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -31,8 +31,6 @@ from vyos import frr  from vyos import airbag  airbag.enable() -frr_daemon = 'ripngd' -  def get_config(config=None):      if config:          conf = config @@ -95,21 +93,28 @@ def generate(ripng):          ripng['new_frr_config'] = ''          return None -    ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.tmpl', ripng) +    ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.j2', ripng)      return None  def apply(ripng): +    ripng_daemon = 'ripngd' +    zebra_daemon = 'zebra' +      # Save original configuration prior to starting any commit actions      frr_cfg = frr.FRRConfig() -    frr_cfg.load_configuration(frr_daemon) -    frr_cfg.modify_section(r'key chain \S+', '') -    frr_cfg.modify_section(r'interface \S+', '') -    frr_cfg.modify_section('router ripng', '') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ripng['new_frr_config']) -    frr_cfg.commit_configuration(frr_daemon) - -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() + +    # The route-map used for the FIB (zebra) is part of the zebra daemon +    frr_cfg.load_configuration(zebra_daemon) +    frr_cfg.modify_section('^ipv6 protocol ripng route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') +    frr_cfg.commit_configuration(zebra_daemon) + +    frr_cfg.load_configuration(ripng_daemon) +    frr_cfg.modify_section('key chain \S+', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section('interface \S+', stop_pattern='^exit', remove_stop_mark=True) +    frr_cfg.modify_section('^router ripng', stop_pattern='^exit', remove_stop_mark=True) +    if 'new_frr_config' in ripng: +        frr_cfg.add_before(frr.default_add_before, ripng['new_frr_config']) +    frr_cfg.commit_configuration(ripng_daemon)      return None diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index 947c8ab7a..62ea9c878 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -28,8 +28,6 @@ from vyos import frr  from vyos import airbag  airbag.enable() -frr_daemon = 'bgpd' -  def get_config(config=None):      if config:          conf = config @@ -38,7 +36,9 @@ def get_config(config=None):      base = ['protocols', 'rpki']      rpki = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    # Bail out early if configuration tree does not exist      if not conf.exists(base): +        rpki.update({'deleted' : ''})          return rpki      # We have gathered the dict representation of the CLI, but there are default @@ -79,17 +79,22 @@ def verify(rpki):      return None  def generate(rpki): -    rpki['new_frr_config'] = render_to_string('frr/rpki.frr.tmpl', rpki) +    if not rpki: +        return +    rpki['new_frr_config'] = render_to_string('frr/rpki.frr.j2', rpki)      return None  def apply(rpki): +    bgp_daemon = 'bgpd' +      # Save original configuration prior to starting any commit actions      frr_cfg = frr.FRRConfig() -    frr_cfg.load_configuration(frr_daemon) -    frr_cfg.modify_section('rpki', '') -    frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', rpki['new_frr_config']) -    frr_cfg.commit_configuration(frr_daemon) +    frr_cfg.load_configuration(bgp_daemon) +    frr_cfg.modify_section('^rpki', stop_pattern='^exit', remove_stop_mark=True) +    if 'new_frr_config' in rpki: +        frr_cfg.add_before(frr.default_add_before, rpki['new_frr_config']) +    frr_cfg.commit_configuration(bgp_daemon)      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index f010141e9..58e202928 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -22,6 +22,7 @@ from sys import argv  from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.configdict import get_dhcp_interfaces +from vyos.configdict import get_pppoe_interfaces  from vyos.configverify import verify_common_route_maps  from vyos.configverify import verify_vrf  from vyos.template import render_to_string @@ -59,7 +60,9 @@ def get_config(config=None):      # T3680 - get a list of all interfaces currently configured to use DHCP      tmp = get_dhcp_interfaces(conf, vrf) -    if tmp: static['dhcp'] = tmp +    if tmp: static.update({'dhcp' : tmp}) +    tmp = get_pppoe_interfaces(conf, vrf) +    if tmp: static.update({'pppoe' : tmp})      return static @@ -82,10 +85,16 @@ def verify(static):                      for interface, interface_config in prefix_options[type].items():                          verify_vrf(interface_config) +            if {'blackhole', 'reject'} <= set(prefix_options): +                raise ConfigError(f'Can not use both blackhole and reject for '\ +                                  'prefix "{prefix}"!') +      return None  def generate(static): -    static['new_frr_config'] = render_to_string('frr/staticd.frr.tmpl', static) +    if not static: +        return None +    static['new_frr_config'] = render_to_string('frr/staticd.frr.j2', static)      return None  def apply(static): @@ -97,24 +106,21 @@ def apply(static):      # The route-map used for the FIB (zebra) is part of the zebra daemon      frr_cfg.load_configuration(zebra_daemon) -    frr_cfg.modify_section(r'^ip protocol static route-map [-a-zA-Z0-9.]+$', '') +    frr_cfg.modify_section(r'^ip protocol static route-map [-a-zA-Z0-9.]+', '')      frr_cfg.commit_configuration(zebra_daemon) -      frr_cfg.load_configuration(static_daemon)      if 'vrf' in static:          vrf = static['vrf'] -        frr_cfg.modify_section(f'^vrf {vrf}$', '') +        frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit', remove_stop_mark=True)      else: -        frr_cfg.modify_section(r'^ip route .*', '') -        frr_cfg.modify_section(r'^ipv6 route .*', '') +        frr_cfg.modify_section(r'^ip route .*') +        frr_cfg.modify_section(r'^ipv6 route .*') -    frr_cfg.add_before(r'(interface .*|line vty)', static['new_frr_config']) +    if 'new_frr_config' in static: +        frr_cfg.add_before(frr.default_add_before, static['new_frr_config'])      frr_cfg.commit_configuration(static_daemon) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() -      return None  if __name__ == '__main__': diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py index 99157835a..6afdf31f3 100755 --- a/src/conf_mode/protocols_static_multicast.py +++ b/src/conf_mode/protocols_static_multicast.py @@ -96,7 +96,7 @@ def generate(mroute):      if mroute is None:          return None -    render(config_file, 'frr/static_mcast.frr.tmpl', mroute) +    render(config_file, 'frr/static_mcast.frr.j2', mroute)      return None  def apply(mroute): diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py new file mode 100755 index 000000000..dbe3be225 --- /dev/null +++ b/src/conf_mode/qos.py @@ -0,0 +1,87 @@ +#!/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 dict_merge +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() +    base = ['qos'] +    if not conf.exists(base): +        return None + +    qos = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + +    if 'policy' in qos: +        for policy in qos['policy']: +            # CLI mangles - to _ for better Jinja2 compatibility - do we need +            # Jinja2 here? +            policy = policy.replace('-','_') + +            default_values = defaults(base + ['policy', policy]) + +            # class is another tag node which requires individual handling +            class_default_values = defaults(base + ['policy', policy, 'class']) +            if 'class' in default_values: +                del default_values['class'] + +            for p_name, p_config in qos['policy'][policy].items(): +                qos['policy'][policy][p_name] = dict_merge( +                    default_values, qos['policy'][policy][p_name]) + +                if 'class' in p_config: +                    for p_class in p_config['class']: +                        qos['policy'][policy][p_name]['class'][p_class] = dict_merge( +                            class_default_values, qos['policy'][policy][p_name]['class'][p_class]) + +    import pprint +    pprint.pprint(qos) +    return qos + +def verify(qos): +    if not qos: +        return None + +    # network policy emulator +    # reorder rerquires delay to be set + +    raise ConfigError('123') +    return None + +def generate(qos): +    return None + +def apply(qos): +    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/salt-minion.py b/src/conf_mode/salt-minion.py index 841bf6a39..00b889a11 100755 --- a/src/conf_mode/salt-minion.py +++ b/src/conf_mode/salt-minion.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 @@ -16,14 +16,18 @@  import os -from copy import deepcopy  from socket import gethostname  from sys import exit  from urllib3 import PoolManager +from vyos.base import Warning  from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configverify import verify_interface_exists  from vyos.template import render -from vyos.util import call, chown +from vyos.util import call +from vyos.util import chown +from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag @@ -32,20 +36,10 @@ airbag.enable()  config_file = r'/etc/salt/minion'  master_keyfile = r'/opt/vyatta/etc/config/salt/pki/minion/master_sign.pub' -default_config_data = { -    'hash': 'sha256', -    'log_level': 'warning', -    'master' : 'salt', -    'user': 'minion', -    'group': 'vyattacfg', -    'salt_id': gethostname(), -    'mine_interval': '60', -    'verify_master_pubkey_sign': 'false', -    'master_key': '' -} +user='minion' +group='vyattacfg'  def get_config(config=None): -    salt = deepcopy(default_config_data)      if config:          conf = config      else: @@ -54,44 +48,44 @@ def get_config(config=None):      if not conf.exists(base):          return None -    else: -        conf.set_level(base) -    if conf.exists(['hash']): -        salt['hash'] = conf.return_value(['hash']) +    salt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) +    # ID default is dynamic thus we can not use defaults() +    if 'id' not in salt: +        salt['id'] = gethostname() +    # 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) +    salt = dict_merge(default_values, salt) -    if conf.exists(['master']): -        salt['master'] = conf.return_values(['master']) - -    if conf.exists(['id']): -        salt['salt_id'] = conf.return_value(['id']) +    if not conf.exists(base): +        return None +    else: +        conf.set_level(base) -    if conf.exists(['user']): -        salt['user'] = conf.return_value(['user']) +    return salt -    if conf.exists(['interval']): -        salt['interval'] = conf.return_value(['interval']) +def verify(salt): +    if not salt: +        return None -    if conf.exists(['master-key']): -        salt['master_key'] = conf.return_value(['master-key']) -        salt['verify_master_pubkey_sign'] = 'true' +    if 'hash' in salt and salt['hash'] == 'sha1': +        Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!') -    return salt +    if 'source_interface' in salt: +        verify_interface_exists(salt['source_interface']) -def verify(salt):      return None  def generate(salt):      if not salt:          return None -    render(config_file, 'salt-minion/minion.tmpl', salt, -           user=salt['user'], group=salt['group']) +    render(config_file, 'salt-minion/minion.j2', salt, user=user, group=group)      if not os.path.exists(master_keyfile): -        if salt['master_key']: +        if 'master_key' in salt:              req = PoolManager().request('GET', salt['master_key'], preload_content=False) -              with open(master_keyfile, 'wb') as f:                  while True:                      data = req.read(1024) @@ -100,18 +94,19 @@ def generate(salt):                      f.write(data)              req.release_conn() -            chown(master_keyfile, salt['user'], salt['group']) +            chown(master_keyfile, user, group)      return None  def apply(salt): +    service_name = 'salt-minion.service'      if not salt:          # Salt removed from running config -        call('systemctl stop salt-minion.service') +        call(f'systemctl stop {service_name}')          if os.path.exists(config_file):              os.unlink(config_file)      else: -        call('systemctl restart salt-minion.service') +        call(f'systemctl restart {service_name}')      return None diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py index 51050e702..a2e411e49 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -81,7 +81,7 @@ def generate(proxy):      if not proxy:          return None -    render(config_file, 'conserver/conserver.conf.tmpl', proxy) +    render(config_file, 'conserver/conserver.conf.j2', proxy)      if 'device' in proxy:          for device, device_config in proxy['device'].items():              if 'ssh' not in device_config: @@ -92,7 +92,7 @@ def generate(proxy):                  'port' : device_config['ssh']['port'],              }              render(dropbear_systemd_file.format(**tmp), -                   'conserver/dropbear@.service.tmpl', tmp) +                   'conserver/dropbear@.service.j2', tmp)      return None diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index 67edeb630..ae7e582ec 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -67,8 +67,8 @@ def generate(fastnetmon):          return -    render(config_file, 'ids/fastnetmon.tmpl', fastnetmon) -    render(networks_list, 'ids/fastnetmon_networks_list.tmpl', fastnetmon) +    render(config_file, 'ids/fastnetmon.j2', fastnetmon) +    render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon)      return None diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index f676fdbbe..559d1bcd5 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-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 @@ -41,6 +41,7 @@ default_config_data = {      'interfaces': [],      'dnsv4': [],      'dnsv6': [], +    'client_named_ip_pool': [],      'client_ipv6_pool': [],      'client_ipv6_delegate_prefix': [],      'radius_server': [], @@ -219,6 +220,22 @@ def get_config(config=None):      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 = { @@ -254,10 +271,6 @@ def verify(ipoe):      if not ipoe['interfaces']:          raise ConfigError('No IPoE interface configured') -    for interface in ipoe['interfaces']: -        if not interface['range']: -            raise ConfigError(f'No IPoE client subnet defined on interface "{ interface }"') -      if len(ipoe['dnsv4']) > 2:          raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') @@ -283,10 +296,10 @@ def generate(ipoe):      if not ipoe:          return None -    render(ipoe_conf, 'accel-ppp/ipoe.config.tmpl', ipoe) +    render(ipoe_conf, 'accel-ppp/ipoe.config.j2', ipoe)      if ipoe['auth_mode'] == 'local': -        render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.tmpl', ipoe) +        render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', ipoe)          os.chmod(ipoe_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP)      else: diff --git a/src/conf_mode/service_mdns-repeater.py b/src/conf_mode/service_mdns-repeater.py index d31a0c49e..2383a53fb 100755 --- a/src/conf_mode/service_mdns-repeater.py +++ b/src/conf_mode/service_mdns-repeater.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2017-2020 VyOS maintainers and contributors +# Copyright (C) 2017-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 @@ -92,7 +92,7 @@ def generate(mdns):          if len(mdns['interface']) < 2:              return None -    render(config_file, 'mdns-repeater/avahi-daemon.tmpl', mdns) +    render(config_file, 'mdns-repeater/avahi-daemon.j2', mdns)      return None  def apply(mdns): diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py new file mode 100755 index 000000000..daf75d740 --- /dev/null +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -0,0 +1,229 @@ +#!/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 +import json + +from sys import exit +from shutil import rmtree + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.ifconfig import Section +from vyos.template import render +from vyos.util import call +from vyos.util import chown +from vyos.util import cmd +from vyos.xml import defaults +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' +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 + +def get_nft_filter_chains(): +    """ +    Get nft chains for table filter +    """ +    nft = cmd('nft --json list table ip filter') +    nft = json.loads(nft) +    chain_list = [] + +    for output in nft['nftables']: +        if 'chain' in output: +            chain = output['chain']['name'] +            chain_list.append(chain) + +    return chain_list + + +def get_config(config=None): + +    if config: +        conf = config +    else: +        conf = Config() +    base = ['service', 'monitoring', 'telegraf'] +    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) + +    # 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) +    monitoring = dict_merge(default_values, monitoring) + +    monitoring['custom_scripts_dir'] = custom_scripts_dir +    monitoring['interfaces_ethernet'] = get_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']: +            monitoring['azure_data_explorer']['group_metrics'] = 'SingleTable' +        else: +            monitoring['azure_data_explorer']['group_metrics'] = 'TablePerMetric' +        # Set azure env +        if 'authentication' in monitoring['azure_data_explorer']: +            auth_config = monitoring['azure_data_explorer']['authentication'] +            if {'client_id', 'client_secret', 'tenant_id'} <= set(auth_config): +                os.environ['AZURE_CLIENT_ID'] = auth_config['client_id'] +                os.environ['AZURE_CLIENT_SECRET'] = auth_config['client_secret'] +                os.environ['AZURE_TENANT_ID'] = auth_config['tenant_id'] + +    # Ignore default XML values if config doesn't exists +    # Delete key from dict +    if not conf.exists(base + ['prometheus-client']): +        del monitoring['prometheus_client'] + +    if not conf.exists(base + ['azure-data-explorer']): +        del monitoring['azure_data_explorer'] + +    return monitoring + +def verify(monitoring): +    # bail out early - looks like removal from running config +    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!') + +        if 'url' not in monitoring: +            raise ConfigError(f'Monitoring "url" is mandatory!') + +    # Verify azure-data-explorer +    if 'azure_data_explorer' in monitoring: +        if 'authentication' not in monitoring['azure_data_explorer'] or \ +           'client_id' not in monitoring['azure_data_explorer']['authentication'] or \ +           'client_secret' not in monitoring['azure_data_explorer']['authentication'] or \ +           'tenant_id' not in monitoring['azure_data_explorer']['authentication']: +            raise ConfigError(f'Authentication "client-id, client-secret and tenant-id" are mandatory!') + +        if 'database' not in monitoring['azure_data_explorer']: +            raise ConfigError(f'Monitoring "database" is mandatory!') + +        if 'url' not in monitoring['azure_data_explorer']: +            raise ConfigError(f'Monitoring "url" is mandatory!') + +        if monitoring['azure_data_explorer']['group_metrics'] == 'SingleTable' and \ +            'table' not in monitoring['azure_data_explorer']: +             raise ConfigError(f'Monitoring "table" name for single-table mode is mandatory!') + +    # Verify Splunk +    if 'splunk' in monitoring: +        if 'authentication' not in monitoring['splunk'] or \ +           'token' not in monitoring['splunk']['authentication']: +            raise ConfigError(f'Authentication "organization and token" are mandatory!') + +        if 'url' not in monitoring['splunk']: +            raise ConfigError(f'Monitoring splunk "url" is mandatory!') + +    return None + +def generate(monitoring): +    if not monitoring: +        # Delete config and systemd files +        config_files = [config_telegraf, systemd_telegraf_service, systemd_override, syslog_telegraf] +        for file in config_files: +            if os.path.isfile(file): +                os.unlink(file) + +        # Delete old directories +        if os.path.isdir(cache_dir): +            rmtree(cache_dir, ignore_errors=True) + +        return None + +    # Create telegraf cache dir +    if not os.path.exists(cache_dir): +        os.makedirs(cache_dir) + +    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') + +    return None + +def apply(monitoring): +    # Reload systemd manager configuration +    call('systemctl daemon-reload') +    if monitoring: +        call('systemctl restart vyos-telegraf.service') +    else: +        call('systemctl stop vyos-telegraf.service') +    # Telegraf include custom rsyslog config changes +    call('systemctl restart rsyslog') + +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_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 9fbd531da..6086ef859 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -24,8 +24,11 @@ from vyos.configverify import verify_accel_ppp_base_service  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' @@ -56,6 +59,11 @@ def verify(pppoe):      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!') +      # local ippool and gateway settings config checks      if not (dict_search('client_ip_pool.subnet', pppoe) or             (dict_search('client_ip_pool.start', pppoe) and @@ -73,10 +81,17 @@ def generate(pppoe):      if not pppoe:          return None -    render(pppoe_conf, 'accel-ppp/pppoe.config.tmpl', pppoe) +    # 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.tmpl', +        render(pppoe_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',                 pppoe, permission=0o640)      else:          if os.path.exists(pppoe_chap_secrets): diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 9afcdd63e..71b758399 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -101,7 +101,7 @@ def generate(rtradv):      if not rtradv:          return None -    render(config_file, 'router-advert/radvd.conf.tmpl', rtradv, permission=0o644) +    render(config_file, 'router-advert/radvd.conf.j2', rtradv, permission=0o644)      return None  def apply(rtradv): diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py new file mode 100755 index 000000000..36f3e18a7 --- /dev/null +++ b/src/conf_mode/service_upnp.py @@ -0,0 +1,157 @@ +#!/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 sys import exit +import uuid +import netifaces +from ipaddress import IPv4Network +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 +from vyos.template import is_ipv6 +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/upnp/miniupnp.conf' + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['service', 'upnp'] +    upnpd = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + +    if not upnpd: +        return None + +    if 'rule' in upnpd: +        default_member_values = defaults(base + ['rule']) +        for rule,rule_config in upnpd['rule'].items(): +            upnpd['rule'][rule] = dict_merge(default_member_values, upnpd['rule'][rule]) + +    uuidgen = uuid.uuid1() +    upnpd.update({'uuid': uuidgen}) + +    return upnpd + +def get_all_interface_addr(prefix, filter_dev, filter_family): +    list_addr = [] +    interfaces = netifaces.interfaces() + +    for interface in interfaces: +        if filter_dev and interface in filter_dev: +            continue +        addrs = netifaces.ifaddresses(interface) +        if netifaces.AF_INET in addrs.keys(): +            if netifaces.AF_INET in filter_family: +                for addr in addrs[netifaces.AF_INET]: +                    if prefix: +                        # we need to manually assemble a list of IPv4 address/prefix +                        prefix = '/' + \ +                            str(IPv4Network('0.0.0.0/' + addr['netmask']).prefixlen) +                        list_addr.append(addr['addr'] + prefix) +                    else: +                        list_addr.append(addr['addr']) +        if netifaces.AF_INET6 in addrs.keys(): +            if netifaces.AF_INET6 in filter_family: +                for addr in addrs[netifaces.AF_INET6]: +                    if prefix: +                        # we need to manually assemble a list of IPv4 address/prefix +                        bits = bin(int(addr['netmask'].replace(':', '').split('/')[0], 16)).count('1') +                        prefix = '/' + str(bits) +                        list_addr.append(addr['addr'] + prefix) +                    else: +                        list_addr.append(addr['addr']) + +    return list_addr + +def verify(upnpd): +    if not upnpd: +        return None + +    if 'wan_interface' not in upnpd: +        raise ConfigError('To enable UPNP, you must have the "wan-interface" option!') + +    if 'rule' in upnpd: +        for rule, rule_config in upnpd['rule'].items(): +            for option in ['external_port_range', 'internal_port_range', 'ip', 'action']: +                if option not in rule_config: +                    tmp = option.replace('_', '-') +                    raise ConfigError(f'Every UPNP rule requires "{tmp}" to be set!') + +    if 'stun' in upnpd: +        for option in ['host', 'port']: +            if option not in upnpd['stun']: +                raise ConfigError(f'A UPNP stun support must have an "{option}" option!') + +    # Check the validity of the IP address +    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]) +    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 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!') +            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!') + +    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): +            raise ConfigError(f'{listen_if_or_addr} must listen on the interface of the network card') + +def generate(upnpd): +    if not upnpd: +        return None + +    if os.path.isfile(config_file): +        os.unlink(config_file) + +    render(config_file, 'firewall/upnpd.conf.j2', upnpd) + +def apply(upnpd): +    systemd_service_name = 'miniupnpd.service' +    if not upnpd: +        # Stop the UPNP service +        call(f'systemctl stop {systemd_service_name}') +    else: +        # Start the UPNP service +        call(f'systemctl restart {systemd_service_name}') + +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_webproxy.py b/src/conf_mode/service_webproxy.py index a16cc4aeb..32af31bde 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -61,7 +61,7 @@ def generate_sg_localdb(category, list_type, role, proxy):                     user=user_group, group=user_group)          # temporary config file, deleted after generation -        render(sg_tmp_file, 'squid/sg_acl.conf.tmpl', tmp, +        render(sg_tmp_file, 'squid/sg_acl.conf.j2', tmp,                 user=user_group, group=user_group)          call(f'su - {user_group} -c "squidGuard -d -c {sg_tmp_file} -C {db_file}"') @@ -166,8 +166,8 @@ def generate(proxy):      if not proxy:          return None -    render(squid_config_file, 'squid/squid.conf.tmpl', proxy) -    render(squidguard_config_file, 'squid/squidGuard.conf.tmpl', proxy) +    render(squid_config_file, 'squid/squid.conf.j2', proxy) +    render(squidguard_config_file, 'squid/squidGuard.conf.j2', proxy)      cat_dict = {          'local-block' : 'domains', diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 6c6367045..5cd24db32 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -18,17 +18,20 @@ import os  from sys import exit +from vyos.base import Warning  from vyos.config import Config +from vyos.configdict import dict_merge  from vyos.configverify import verify_vrf  from vyos.snmpv3_hashgen import plaintext_to_md5  from vyos.snmpv3_hashgen import plaintext_to_sha1  from vyos.snmpv3_hashgen import random  from vyos.template import render -from vyos.template import is_ipv4  from vyos.util import call  from vyos.util import chmod_755 +from vyos.util import dict_search  from vyos.validate import is_addr_assigned  from vyos.version import get_version_data +from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -37,57 +40,26 @@ config_file_client  = r'/etc/snmp/snmp.conf'  config_file_daemon  = r'/etc/snmp/snmpd.conf'  config_file_access  = r'/usr/share/snmp/snmpd.conf'  config_file_user    = r'/var/lib/snmp/snmpd.conf' -default_script_dir  = r'/config/user-data/'  systemd_override    = r'/etc/systemd/system/snmpd.service.d/override.conf' +systemd_service     = 'snmpd.service' -# SNMP OIDs used to mark auth/priv type -OIDs = { -    'md5' : '.1.3.6.1.6.3.10.1.1.2', -    'sha' : '.1.3.6.1.6.3.10.1.1.3', -    'aes' : '.1.3.6.1.6.3.10.1.2.4', -    'des' : '.1.3.6.1.6.3.10.1.2.2', -    'none': '.1.3.6.1.6.3.10.1.2.1' -} - -default_config_data = { -    'listen_on': [], -    'listen_address': [], -    'ipv6_enabled': 'True', -    'communities': [], -    'smux_peers': [], -    'location' : '', -    'protocol' : 'udp', -    'description' : '', -    'contact' : '', -    'route_table': 'False', -    'trap_source': '', -    'trap_targets': [], -    'vyos_user': '', -    'vyos_user_pass': '', -    'version': '', -    'v3_enabled': 'False', -    'v3_engineid': '', -    'v3_groups': [], -    'v3_traps': [], -    'v3_users': [], -    'v3_views': [], -    'script_ext': [] -} - -def rmfile(file): -    if os.path.isfile(file): -        os.unlink(file) - -def get_config(): -    snmp = default_config_data -    conf = Config() -    if not conf.exists('service snmp'): -        return None +def get_config(config=None): +    if config: +        conf = config      else: -        if conf.exists('system ipv6 disable'): -            snmp['ipv6_enabled'] = False +        conf = Config() +    base = ['service', 'snmp'] -        conf.set_level('service snmp') +    snmp = conf.get_config_dict(base, key_mangling=('-', '_'), +                                get_first_key=True, no_tag_node_value_mangle=True) +    if not conf.exists(base): +        snmp.update({'deleted' : ''}) + +    if conf.exists(['service', 'lldp', 'snmp', 'enable']): +        snmp.update({'lldp_snmp' : ''}) + +    if 'deleted' in snmp: +        return snmp      version_data = get_version_data()      snmp['version'] = version_data['version'] @@ -96,477 +68,217 @@ def get_config():      snmp['vyos_user'] = 'vyos' + random(8)      snmp['vyos_user_pass'] = random(16) -    if conf.exists('community'): -        for name in conf.list_nodes('community'): -            community = { -                'name': name, -                'authorization': 'ro', -                'network_v4': [], -                'network_v6': [], -                'has_source' : False -            } - -            if conf.exists('community {0} authorization'.format(name)): -                community['authorization'] = conf.return_value('community {0} authorization'.format(name)) - -            # Subnet of SNMP client(s) allowed to contact system -            if conf.exists('community {0} network'.format(name)): -                for addr in conf.return_values('community {0} network'.format(name)): -                    if is_ipv4(addr): -                        community['network_v4'].append(addr) -                    else: -                        community['network_v6'].append(addr) - -            # IP address of SNMP client allowed to contact system -            if conf.exists('community {0} client'.format(name)): -                for addr in conf.return_values('community {0} client'.format(name)): -                    if is_ipv4(addr): -                        community['network_v4'].append(addr) -                    else: -                        community['network_v6'].append(addr) - -            if (len(community['network_v4']) > 0) or (len(community['network_v6']) > 0): -                 community['has_source'] = True - -            snmp['communities'].append(community) - -    if conf.exists('contact'): -        snmp['contact'] = conf.return_value('contact') - -    if conf.exists('description'): -        snmp['description'] = conf.return_value('description') - -    if conf.exists('listen-address'): -        for addr in conf.list_nodes('listen-address'): -            port = '161' -            if conf.exists('listen-address {0} port'.format(addr)): -                port = conf.return_value('listen-address {0} port'.format(addr)) - -            snmp['listen_address'].append((addr, port)) +    # 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) + +    # We can not merge defaults for tagNodes - those need to be blended in +    # per tagNode instance +    if 'listen_address' in default_values: +        del default_values['listen_address'] +    if 'community' in default_values: +        del default_values['community'] +    if 'trap_target' in default_values: +        del default_values['trap_target'] +    if 'v3' in default_values: +        del default_values['v3'] +    snmp = dict_merge(default_values, snmp) + +    if 'listen_address' in snmp: +        default_values = defaults(base + ['listen-address']) +        for address in snmp['listen_address']: +            snmp['listen_address'][address] = dict_merge( +                default_values, snmp['listen_address'][address])          # Always listen on localhost if an explicit address has been configured          # This is a safety measure to not end up with invalid listen addresses          # that are not configured on this system. See https://phabricator.vyos.net/T850 -        if not '127.0.0.1' in conf.list_nodes('listen-address'): -            snmp['listen_address'].append(('127.0.0.1', '161')) - -        if not '::1' in conf.list_nodes('listen-address'): -            snmp['listen_address'].append(('::1', '161')) - -    if conf.exists('location'): -        snmp['location'] = conf.return_value('location') - -    if conf.exists('protocol'): -        snmp['protocol'] = conf.return_value('protocol') - -    if conf.exists('smux-peer'): -        snmp['smux_peers'] = conf.return_values('smux-peer') - -    if conf.exists('trap-source'): -        snmp['trap_source'] = conf.return_value('trap-source') - -    if conf.exists('trap-target'): -        for target in conf.list_nodes('trap-target'): -            trap_tgt = { -                'target': target, -                'community': '', -                'port': '' -            } - -            if conf.exists('trap-target {0} community'.format(target)): -               trap_tgt['community'] = conf.return_value('trap-target {0} community'.format(target)) - -            if conf.exists('trap-target {0} port'.format(target)): -                trap_tgt['port'] = conf.return_value('trap-target {0} port'.format(target)) - -            snmp['trap_targets'].append(trap_tgt) - -    if conf.exists('script-extensions'): -        for extname in conf.list_nodes('script-extensions extension-name'): -            conf_script = conf.return_value('script-extensions extension-name {} script'.format(extname)) -            # if script has not absolute path, use pre configured path -            if "/" not in conf_script: -                conf_script = default_script_dir + conf_script - -            extension = { -                'name': extname, -                'script' : conf_script -            } - -            snmp['script_ext'].append(extension) - -    if conf.exists('oid-enable route-table'): -        snmp['route_table'] = True - -    if conf.exists('vrf'): -        # Append key to dict but don't place it in the default dictionary. -        # This is required to make the override.conf.tmpl work until we -        # migrate to get_config_dict(). -        snmp['vrf'] = conf.return_value('vrf') - - -    ######################################################################### -    #                ____  _   _ __  __ ____          _____                 # -    #               / ___|| \ | |  \/  |  _ \  __   _|___ /                 # -    #               \___ \|  \| | |\/| | |_) | \ \ / / |_ \                 # -    #                ___) | |\  | |  | |  __/   \ V / ___) |                # -    #               |____/|_| \_|_|  |_|_|       \_/ |____/                 # -    #                                                                       # -    #     now take care about the fancy SNMP v3 stuff, or bail out eraly    # -    ######################################################################### -    if not conf.exists('v3'): -        return snmp -    else: -        snmp['v3_enabled'] = True - -    # 'set service snmp v3 engineid' -    if conf.exists('v3 engineid'): -        snmp['v3_engineid'] = conf.return_value('v3 engineid') - -    # 'set service snmp v3 group' -    if conf.exists('v3 group'): -        for group in conf.list_nodes('v3 group'): -            v3_group = { -                'name': group, -                'mode': 'ro', -                'seclevel': 'auth', -                'view': '' -            } - -            if conf.exists('v3 group {0} mode'.format(group)): -                v3_group['mode'] = conf.return_value('v3 group {0} mode'.format(group)) - -            if conf.exists('v3 group {0} seclevel'.format(group)): -                v3_group['seclevel'] = conf.return_value('v3 group {0} seclevel'.format(group)) - -            if conf.exists('v3 group {0} view'.format(group)): -                v3_group['view'] = conf.return_value('v3 group {0} view'.format(group)) - -            snmp['v3_groups'].append(v3_group) - -    # 'set service snmp v3 trap-target' -    if conf.exists('v3 trap-target'): -        for trap in conf.list_nodes('v3 trap-target'): -            trap_cfg = { -                'ipAddr': trap, -                'secName': '', -                'authProtocol': 'md5', -                'authPassword': '', -                'authMasterKey': '', -                'privProtocol': 'des', -                'privPassword': '', -                'privMasterKey': '', -                'ipProto': 'udp', -                'ipPort': '162', -                'type': '', -                'secLevel': 'noAuthNoPriv' -            } - -            if conf.exists('v3 trap-target {0} user'.format(trap)): -                # Set the securityName used for authenticated SNMPv3 messages. -                trap_cfg['secName'] = conf.return_value('v3 trap-target {0} user'.format(trap)) - -            if conf.exists('v3 trap-target {0} auth type'.format(trap)): -                # Set the authentication protocol (MD5 or SHA) used for authenticated SNMPv3 messages -                # cmdline option '-a' -                trap_cfg['authProtocol'] = conf.return_value('v3 trap-target {0} auth type'.format(trap)) - -            if conf.exists('v3 trap-target {0} auth plaintext-password'.format(trap)): -                # Set the authentication pass phrase used for authenticated SNMPv3 messages. -                # cmdline option '-A' -                trap_cfg['authPassword'] = conf.return_value('v3 trap-target {0} auth plaintext-password'.format(trap)) - -            if conf.exists('v3 trap-target {0} auth encrypted-password'.format(trap)): -                # Sets the keys to be used for SNMPv3 transactions. These options allow you to set the master authentication keys. -                # cmdline option '-3m' -                trap_cfg['authMasterKey'] = conf.return_value('v3 trap-target {0} auth encrypted-password'.format(trap)) - -            if conf.exists('v3 trap-target {0} privacy type'.format(trap)): -                # Set the privacy protocol (DES or AES) used for encrypted SNMPv3 messages. -                # cmdline option '-x' -                trap_cfg['privProtocol'] = conf.return_value('v3 trap-target {0} privacy type'.format(trap)) - -            if conf.exists('v3 trap-target {0} privacy plaintext-password'.format(trap)): -                # Set the privacy pass phrase used for encrypted SNMPv3 messages. -                # cmdline option '-X' -                trap_cfg['privPassword'] = conf.return_value('v3 trap-target {0} privacy plaintext-password'.format(trap)) - -            if conf.exists('v3 trap-target {0} privacy encrypted-password'.format(trap)): -                # Sets the keys to be used for SNMPv3 transactions. These options allow you to set the master encryption keys. -                # cmdline option '-3M' -                trap_cfg['privMasterKey'] = conf.return_value('v3 trap-target {0} privacy encrypted-password'.format(trap)) - -            if conf.exists('v3 trap-target {0} protocol'.format(trap)): -                trap_cfg['ipProto'] = conf.return_value('v3 trap-target {0} protocol'.format(trap)) - -            if conf.exists('v3 trap-target {0} port'.format(trap)): -                trap_cfg['ipPort'] = conf.return_value('v3 trap-target {0} port'.format(trap)) - -            if conf.exists('v3 trap-target {0} type'.format(trap)): -                trap_cfg['type'] = conf.return_value('v3 trap-target {0} type'.format(trap)) - -            # Determine securityLevel used for SNMPv3 messages (noAuthNoPriv|authNoPriv|authPriv). -            # Appropriate pass phrase(s) must provided when using any level higher than noAuthNoPriv. -            if trap_cfg['authPassword'] or trap_cfg['authMasterKey']: -                if trap_cfg['privProtocol'] or trap_cfg['privPassword']: -                    trap_cfg['secLevel'] = 'authPriv' -                else: -                    trap_cfg['secLevel'] = 'authNoPriv' - -            snmp['v3_traps'].append(trap_cfg) - -    # 'set service snmp v3 user' -    if conf.exists('v3 user'): -        for user in conf.list_nodes('v3 user'): -            user_cfg = { -                'name': user, -                'authMasterKey': '', -                'authPassword': '', -                'authProtocol': 'md5', -                'authOID': 'none', -                'group': '', -                'mode': 'ro', -                'privMasterKey': '', -                'privPassword': '', -                'privOID': '', -                'privProtocol': 'des' -            } - -            # v3 user {0} auth -            if conf.exists('v3 user {0} auth encrypted-password'.format(user)): -                user_cfg['authMasterKey'] = conf.return_value('v3 user {0} auth encrypted-password'.format(user)) - -            if conf.exists('v3 user {0} auth plaintext-password'.format(user)): -                user_cfg['authPassword'] = conf.return_value('v3 user {0} auth plaintext-password'.format(user)) - -            # load default value -            type = user_cfg['authProtocol'] -            if conf.exists('v3 user {0} auth type'.format(user)): -                type = conf.return_value('v3 user {0} auth type'.format(user)) - -            # (re-)update with either default value or value from CLI -            user_cfg['authProtocol'] = type -            user_cfg['authOID'] = OIDs[type] - -            # v3 user {0} group -            if conf.exists('v3 user {0} group'.format(user)): -                user_cfg['group'] = conf.return_value('v3 user {0} group'.format(user)) - -            # v3 user {0} mode -            if conf.exists('v3 user {0} mode'.format(user)): -                user_cfg['mode'] = conf.return_value('v3 user {0} mode'.format(user)) - -            # v3 user {0} privacy -            if conf.exists('v3 user {0} privacy encrypted-password'.format(user)): -                user_cfg['privMasterKey'] = conf.return_value('v3 user {0} privacy encrypted-password'.format(user)) - -            if conf.exists('v3 user {0} privacy plaintext-password'.format(user)): -                user_cfg['privPassword'] = conf.return_value('v3 user {0} privacy plaintext-password'.format(user)) - -            # load default value -            type = user_cfg['privProtocol'] -            if conf.exists('v3 user {0} privacy type'.format(user)): -                type = conf.return_value('v3 user {0} privacy type'.format(user)) - -            # (re-)update with either default value or value from CLI -            user_cfg['privProtocol'] = type -            user_cfg['privOID'] = OIDs[type] - -            snmp['v3_users'].append(user_cfg) - -    # 'set service snmp v3 view' -    if conf.exists('v3 view'): -        for view in conf.list_nodes('v3 view'): -            view_cfg = { -                'name': view, -                'oids': [] -            } - -            if conf.exists('v3 view {0} oid'.format(view)): -                for oid in conf.list_nodes('v3 view {0} oid'.format(view)): -                    oid_cfg = { -                        'oid': oid -                    } -                    view_cfg['oids'].append(oid_cfg) -            snmp['v3_views'].append(view_cfg) +        if '127.0.0.1' not in snmp['listen_address']: +            tmp = {'127.0.0.1': {'port': '161'}} +            snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + +        if '::1' not in snmp['listen_address']: +            tmp = {'::1': {'port': '161'}} +            snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + +    if 'community' in snmp: +        default_values = defaults(base + ['community']) +        for community in snmp['community']: +            snmp['community'][community] = dict_merge( +                default_values, snmp['community'][community]) + +    if 'trap_target' in snmp: +        default_values = defaults(base + ['trap-target']) +        for trap in snmp['trap_target']: +            snmp['trap_target'][trap] = dict_merge( +                default_values, snmp['trap_target'][trap]) + +    if 'v3' in snmp: +        default_values = defaults(base + ['v3']) +        # tagNodes need to be merged in individually later on +        for tmp in ['user', 'group', 'trap_target']: +            del default_values[tmp] +        snmp['v3'] = dict_merge(default_values, snmp['v3']) + +        for user_group in ['user', 'group']: +            if user_group in snmp['v3']: +                default_values = defaults(base + ['v3', user_group]) +                for tmp in snmp['v3'][user_group]: +                    snmp['v3'][user_group][tmp] = dict_merge( +                        default_values, snmp['v3'][user_group][tmp]) + +            if 'trap_target' in snmp['v3']: +                default_values = defaults(base + ['v3', 'trap-target']) +                for trap in snmp['v3']['trap_target']: +                    snmp['v3']['trap_target'][trap] = dict_merge( +                        default_values, snmp['v3']['trap_target'][trap])      return snmp  def verify(snmp): -    if snmp is None: -        # we can not delete SNMP when LLDP is configured with SNMP -        conf = Config() -        if conf.exists('service lldp snmp enable'): -            raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!') - +    if not snmp:          return None +    if {'deleted', 'lldp_snmp'} <= set(snmp): +        raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!') +      ### check if the configured script actually exist -    if snmp['script_ext']: -        for ext in snmp['script_ext']: -            if not os.path.isfile(ext['script']): -                print ("WARNING: script: {} doesn't exist".format(ext['script'])) +    if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']: +        for extension, extension_opt in snmp['script_extensions']['extension_name'].items(): +            if 'script' not in extension_opt: +                raise ConfigError(f'Script extension "{extension}" requires an actual script to be configured!') + +            tmp = extension_opt['script'] +            if not os.path.isfile(tmp): +                Warning(f'script "{tmp}" does not exist!')              else: -                chmod_755(ext['script']) - -    for listen in snmp['listen_address']: -        addr = listen[0] -        port = listen[1] -        protocol = snmp['protocol'] - -        tmp = None -        if is_ipv4(addr): -            # example: udp:127.0.0.1:161 -            tmp = f'{protocol}:{addr}:{port}' -        elif snmp['ipv6_enabled']: -            # example: udp6:[::1]:161 -            tmp = f'{protocol}6:[{addr}]:{port}' - -        # We only wan't to configure addresses that exist on the system. -        # Hint the user if they don't exist -        if is_addr_assigned(addr): -            if tmp: snmp['listen_on'].append(tmp) -        else: -            print(f'WARNING: SNMP listen address {addr} not configured!') +                chmod_755(extension_opt['script']) + +    if 'listen_address' in snmp: +        for address in snmp['listen_address']: +            # We only wan't to configure addresses that exist on the system. +            # Hint the user if they don't exist +            if not is_addr_assigned(address): +                Warning(f'SNMP listen address "{address}" not configured!') + +    if 'trap_target' in snmp: +        for trap, trap_config in snmp['trap_target'].items(): +            if 'community' not in trap_config: +                raise ConfigError(f'Trap target "{trap}" requires a community to be set!')      verify_vrf(snmp)      # bail out early if SNMP v3 is not configured -    if not snmp['v3_enabled']: +    if 'v3' not in snmp:          return None -    if 'v3_groups' in snmp.keys(): -        for group in snmp['v3_groups']: -            # -            # A view must exist prior to mapping it into a group -            # -            if 'view' in group.keys(): -                error = True -                if 'v3_views' in snmp.keys(): -                    for view in snmp['v3_views']: -                        if view['name'] == group['view']: -                            error = False -                if error: -                    raise ConfigError('You must create view "{0}" first'.format(group['view'])) -            else: -                raise ConfigError('"view" must be specified') - -            if not 'mode' in group.keys(): -                raise ConfigError('"mode" must be specified') - -            if not 'seclevel' in group.keys(): -                raise ConfigError('"seclevel" must be specified') - -    if 'v3_traps' in snmp.keys(): -        for trap in snmp['v3_traps']: -            if trap['authPassword'] and trap['authMasterKey']: -                raise ConfigError('Must specify only one of encrypted-password/plaintext-key for trap auth') - -            if trap['authPassword'] == '' and trap['authMasterKey'] == '': -                raise ConfigError('Must specify encrypted-password or plaintext-key for trap auth') - -            if trap['privPassword'] and trap['privMasterKey']: -                raise ConfigError('Must specify only one of encrypted-password/plaintext-key for trap privacy') +    if 'user' in snmp['v3']: +        for user, user_config in snmp['v3']['user'].items(): +            if 'group' not in user_config: +                raise ConfigError(f'Group membership required for user "{user}"!') -            if trap['privPassword'] == '' and trap['privMasterKey'] == '': -                raise ConfigError('Must specify encrypted-password or plaintext-key for trap privacy') +            if 'plaintext_password' not in user_config['auth'] and 'encrypted_password' not in user_config['auth']: +                raise ConfigError(f'Must specify authentication encrypted-password or plaintext-password for user "{user}"!') -            if not 'type' in trap.keys(): -                raise ConfigError('v3 trap: "type" must be specified') +            if 'plaintext_password' not in user_config['privacy'] and 'encrypted_password' not in user_config['privacy']: +                raise ConfigError(f'Must specify privacy encrypted-password or plaintext-password for user "{user}"!') -            if not 'authPassword' and 'authMasterKey' in trap.keys(): -                raise ConfigError('v3 trap: "auth" must be specified') +    if 'group' in snmp['v3']: +        for group, group_config in snmp['v3']['group'].items(): +            if 'seclevel' not in group_config: +                raise ConfigError(f'Must configure "seclevel" for group "{group}"!') +            if 'view' not in group_config: +                raise ConfigError(f'Must configure "view" for group "{group}"!') -            if not 'authProtocol' in trap.keys(): -                raise ConfigError('v3 trap: "protocol" must be specified') +            # Check if 'view' exists +            view = group_config['view'] +            if 'view' not in snmp['v3'] or view not in snmp['v3']['view']: +                raise ConfigError(f'You must create view "{view}" first!') -            if not 'privPassword' and 'privMasterKey' in trap.keys(): -                raise ConfigError('v3 trap: "user" must be specified') +    if 'view' in snmp['v3']: +        for view, view_config in snmp['v3']['view'].items(): +            if 'oid' not in view_config: +                raise ConfigError(f'Must configure an "oid" for view "{view}"!') -    if 'v3_users' in snmp.keys(): -        for user in snmp['v3_users']: -            # -            # Group must exist prior to mapping it into a group -            # seclevel will be extracted from group -            # -            if user['group']: -                error = True -                if 'v3_groups' in snmp.keys(): -                    for group in snmp['v3_groups']: -                        if group['name'] == user['group']: -                            seclevel = group['seclevel'] -                            error = False +    if 'trap_target' in snmp['v3']: +        for trap, trap_config in snmp['v3']['trap_target'].items(): +            if 'plaintext_password' not in trap_config['auth'] and 'encrypted_password' not in trap_config['auth']: +                raise ConfigError(f'Must specify one of authentication encrypted-password or plaintext-password for trap "{trap}"!') -                if error: -                    raise ConfigError('You must create group "{0}" first'.format(user['group'])) +            if {'plaintext_password', 'encrypted_password'} <= set(trap_config['auth']): +                raise ConfigError(f'Can not specify both authentication encrypted-password and plaintext-password for trap "{trap}"!') -            # Depending on the configured security level the user has to provide additional info -            if (not user['authPassword'] and not user['authMasterKey']): -                raise ConfigError('Must specify encrypted-password or plaintext-key for user auth') +            if 'plaintext_password' not in trap_config['privacy'] and 'encrypted_password' not in trap_config['privacy']: +                raise ConfigError(f'Must specify one of privacy encrypted-password or plaintext-password for trap "{trap}"!') -            if user['privPassword'] == '' and user['privMasterKey'] == '': -                raise ConfigError('Must specify encrypted-password or plaintext-key for user privacy') +            if {'plaintext_password', 'encrypted_password'} <= set(trap_config['privacy']): +                raise ConfigError(f'Can not specify both privacy encrypted-password and plaintext-password for trap "{trap}"!') -            if user['mode'] == '': -                raise ConfigError('Must specify user mode ro/rw') - -    if 'v3_views' in snmp.keys(): -        for view in snmp['v3_views']: -            if not view['oids']: -                raise ConfigError('Must configure an oid') +            if 'type' not in trap_config: +                raise ConfigError('SNMP v3 trap "type" must be specified!')      return None  def generate(snmp): +      #      # As we are manipulating the snmpd user database we have to stop it first!      # This is even save if service is going to be removed -    call('systemctl stop snmpd.service') -    config_files = [config_file_client, config_file_daemon, config_file_access, -                    config_file_user, systemd_override] +    call(f'systemctl stop {systemd_service}') +    # Clean config files +    config_files = [config_file_client, config_file_daemon, +                    config_file_access, config_file_user, systemd_override]      for file in config_files: -        rmfile(file) +        if os.path.isfile(file): +            os.unlink(file)      if not snmp:          return None -    if 'v3_users' in snmp.keys(): +    if 'v3' in snmp:          # net-snmp is now regenerating the configuration file in the background          # thus we need to re-open and re-read the file as the content changed.          # After that we can no read the encrypted password from the config and          # replace the CLI plaintext password with its encrypted version. -        os.environ["vyos_libexec_dir"] = "/usr/libexec/vyos" +        os.environ['vyos_libexec_dir'] = '/usr/libexec/vyos' -        for user in snmp['v3_users']: -            if user['authProtocol'] == 'sha': -                hash = plaintext_to_sha1 -            else: -                hash = plaintext_to_md5 +        if 'user' in snmp['v3']: +            for user, user_config in snmp['v3']['user'].items(): +                if dict_search('auth.type', user_config)  == 'sha': +                    hash = plaintext_to_sha1 +                else: +                    hash = plaintext_to_md5 + +                if dict_search('auth.plaintext_password', user_config) is not None: +                    tmp = hash(dict_search('auth.plaintext_password', user_config), +                        dict_search('v3.engineid', snmp)) + +                    snmp['v3']['user'][user]['auth']['encrypted_password'] = tmp +                    del snmp['v3']['user'][user]['auth']['plaintext_password'] -            if user['authPassword']: -                user['authMasterKey'] = hash(user['authPassword'], snmp['v3_engineid']) -                user['authPassword'] = '' +                    call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" auth encrypted-password "{tmp}" > /dev/null') +                    call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" auth plaintext-password > /dev/null') -                call('/opt/vyatta/sbin/my_set service snmp v3 user "{name}" auth encrypted-password "{authMasterKey}" > /dev/null'.format(**user)) -                call('/opt/vyatta/sbin/my_delete service snmp v3 user "{name}" auth plaintext-password > /dev/null'.format(**user)) +                if dict_search('privacy.plaintext_password', user_config) is not None: +                    tmp = hash(dict_search('privacy.plaintext_password', user_config), +                        dict_search('v3.engineid', snmp)) -            if user['privPassword']: -                user['privMasterKey'] = hash(user['privPassword'], snmp['v3_engineid']) -                user['privPassword'] = '' +                    snmp['v3']['user'][user]['privacy']['encrypted_password'] = tmp +                    del snmp['v3']['user'][user]['privacy']['plaintext_password'] -                call('/opt/vyatta/sbin/my_set service snmp v3 user "{name}" privacy encrypted-password "{privMasterKey}" > /dev/null'.format(**user)) -                call('/opt/vyatta/sbin/my_delete service snmp v3 user "{name}" privacy plaintext-password > /dev/null'.format(**user)) +                    call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" privacy encrypted-password "{tmp}" > /dev/null') +                    call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" privacy plaintext-password > /dev/null')      # Write client config file -    render(config_file_client, 'snmp/etc.snmp.conf.tmpl', snmp) +    render(config_file_client, 'snmp/etc.snmp.conf.j2', snmp)      # Write server config file -    render(config_file_daemon, 'snmp/etc.snmpd.conf.tmpl', snmp) +    render(config_file_daemon, 'snmp/etc.snmpd.conf.j2', snmp)      # Write access rights config file -    render(config_file_access, 'snmp/usr.snmpd.conf.tmpl', snmp) +    render(config_file_access, 'snmp/usr.snmpd.conf.j2', snmp)      # Write access rights config file -    render(config_file_user, 'snmp/var.snmpd.conf.tmpl', snmp) +    render(config_file_user, 'snmp/var.snmpd.conf.j2', snmp)      # Write daemon configuration file -    render(systemd_override, 'snmp/override.conf.tmpl', snmp) +    render(systemd_override, 'snmp/override.conf.j2', snmp)      return None @@ -578,7 +290,7 @@ def apply(snmp):          return None      # start SNMP daemon -    call('systemctl restart snmpd.service') +    call(f'systemctl restart {systemd_service}')      # Enable AgentX in FRR      # This should be done for each daemon individually because common command diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 67724b043..28669694b 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.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 @@ -33,6 +33,9 @@ airbag.enable()  config_file = r'/run/sshd/sshd_config'  systemd_override = r'/etc/systemd/system/ssh.service.d/override.conf' +sshguard_config_file = '/etc/sshguard/sshguard.conf' +sshguard_whitelist = '/etc/sshguard/whitelist' +  key_rsa = '/etc/ssh/ssh_host_rsa_key'  key_dsa = '/etc/ssh/ssh_host_dsa_key'  key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' @@ -54,6 +57,11 @@ def get_config(config=None):      # pass config file path - used in override template      ssh['config_file'] = config_file +    # Ignore default XML values if config doesn't exists +    # Delete key from dict +    if not conf.exists(base + ['dynamic-protection']): +         del ssh['dynamic_protection'] +      return ssh  def verify(ssh): @@ -84,8 +92,12 @@ def generate(ssh):          syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!')          call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') -    render(config_file, 'ssh/sshd_config.tmpl', ssh) -    render(systemd_override, 'ssh/override.conf.tmpl', ssh) +    render(config_file, 'ssh/sshd_config.j2', ssh) +    render(systemd_override, 'ssh/override.conf.j2', ssh) + +    if 'dynamic_protection' in ssh: +        render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) +        render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh)      # Reload systemd manager configuration      call('systemctl daemon-reload') @@ -95,7 +107,12 @@ def apply(ssh):      if not ssh:          # SSH access is removed in the commit          call('systemctl stop ssh.service') +        call('systemctl stop sshguard.service')          return None +    if 'dynamic_protection' not in ssh: +        call('systemctl stop sshguard.service') +    else: +        call('systemctl restart sshguard.service')      call('systemctl restart ssh.service')      return None diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 32cb2f036..05fc3a97a 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system-ip.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 @@ -20,14 +20,13 @@ from vyos.config import Config  from vyos.configdict import dict_merge  from vyos.util import call  from vyos.util import dict_search +from vyos.util import sysctl_write +from vyos.util import write_file  from vyos.xml import defaults  from vyos import ConfigError  from vyos import airbag  airbag.enable() -def sysctl(name, value): -    call(f'sysctl -wq {name}={value}') -  def get_config(config=None):      if config:          conf = config @@ -50,29 +49,29 @@ def generate(opt):      pass  def apply(opt): +    # Apply ARP threshold values +    # table_size has a default value - thus the key always exists      size = int(dict_search('arp.table_size', opt)) -    if size: -        # apply ARP threshold values -        sysctl('net.ipv4.neigh.default.gc_thresh3', str(size)) -        sysctl('net.ipv4.neigh.default.gc_thresh2', str(size // 2)) -        sysctl('net.ipv4.neigh.default.gc_thresh1', str(size // 8)) +    # Amount upon reaching which the records begin to be cleared immediately +    sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) +    # Amount after which the records begin to be cleaned after 5 seconds +    sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) +    # Minimum number of stored records is indicated which is not cleared +    sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8)      # enable/disable IPv4 forwarding -    tmp = '1' -    if 'disable_forwarding' in opt: -        tmp = '0' -    sysctl('net.ipv4.conf.all.forwarding', tmp) +    tmp = dict_search('disable_forwarding', opt) +    value = '0' if (tmp != None) else '1' +    write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) -    tmp = '0' -    # configure multipath - dict_search() returns an empty dict if key was found -    if isinstance(dict_search('multipath.ignore_unreachable_nexthops', opt), dict): -        tmp = '1' -    sysctl('net.ipv4.fib_multipath_use_neigh', tmp) +    # configure multipath +    tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) +    value = '1' if (tmp != None) else '0' +    sysctl_write('net.ipv4.fib_multipath_use_neigh', value) -    tmp = '0' -    if isinstance(dict_search('multipath.layer4_hashing', opt), dict): -        tmp = '1' -    sysctl('net.ipv4.fib_multipath_hash_policy', tmp) +    tmp = dict_search('multipath.layer4_hashing', opt) +    value = '1' if (tmp != None) else '0' +    sysctl_write('net.ipv4.fib_multipath_hash_policy', value)  if __name__ == '__main__':      try: diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system-ipv6.py index f70ec2631..26aacf46b 100755 --- a/src/conf_mode/system-ipv6.py +++ b/src/conf_mode/system-ipv6.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019 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,95 +15,68 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os -import sys  from sys import exit -from copy import deepcopy  from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.util import dict_search +from vyos.util import sysctl_write +from vyos.util import write_file +from vyos.xml import defaults  from vyos import ConfigError -from vyos.util import call -  from vyos import airbag  airbag.enable() -ipv6_disable_file = '/etc/modprobe.d/vyos_disable_ipv6.conf' - -default_config_data = { -    'reboot_message': False, -    'ipv6_forward': '1', -    'disable_addr_assignment': False, -    'mp_layer4_hashing': '0', -    'neighbor_cache': 8192, -    'strict_dad': '1' - -} - -def sysctl(name, value): -    call('sysctl -wq {}={}'.format(name, value)) -  def get_config(config=None): -    ip_opt = deepcopy(default_config_data)      if config:          conf = config      else:          conf = Config() -    conf.set_level('system ipv6') -    if conf.exists(''): -        ip_opt['disable_addr_assignment'] = conf.exists('disable') -        if conf.exists_effective('disable') != conf.exists('disable'): -            ip_opt['reboot_message'] = True - -        if conf.exists('disable-forwarding'): -            ip_opt['ipv6_forward'] = '0' +    base = ['system', 'ipv6'] -        if conf.exists('multipath layer4-hashing'): -            ip_opt['mp_layer4_hashing'] = '1' +    opt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) -        if conf.exists('neighbor table-size'): -            ip_opt['neighbor_cache'] = int(conf.return_value('neighbor table-size')) +    # 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) +    opt = dict_merge(default_values, opt) -        if conf.exists('strict-dad'): -            ip_opt['strict_dad'] = 2 +    return opt -    return ip_opt - -def verify(ip_opt): +def verify(opt):      pass -def generate(ip_opt): +def generate(opt):      pass -def apply(ip_opt): -    # disable IPv6 address assignment -    if ip_opt['disable_addr_assignment']: -        with open(ipv6_disable_file, 'w') as f: -            f.write('options ipv6 disable_ipv6=1') -    else: -        if os.path.exists(ipv6_disable_file): -            os.unlink(ipv6_disable_file) - -    if ip_opt['reboot_message']: -        print('Changing IPv6 disable parameter will only take affect\n' \ -              'when the system is rebooted.') - +def apply(opt):      # configure multipath -    sysctl('net.ipv6.fib_multipath_hash_policy', ip_opt['mp_layer4_hashing']) - -    # apply neighbor table threshold values -    sysctl('net.ipv6.neigh.default.gc_thresh3', ip_opt['neighbor_cache']) -    sysctl('net.ipv6.neigh.default.gc_thresh2', ip_opt['neighbor_cache'] // 2) -    sysctl('net.ipv6.neigh.default.gc_thresh1', ip_opt['neighbor_cache'] // 8) +    tmp = dict_search('multipath.layer4_hashing', opt) +    value = '1' if (tmp != None) else '0' +    sysctl_write('net.ipv6.fib_multipath_hash_policy', value) + +    # Apply ND threshold values +    # table_size has a default value - thus the key always exists +    size = int(dict_search('neighbor.table_size', opt)) +    # Amount upon reaching which the records begin to be cleared immediately +    sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) +    # Amount after which the records begin to be cleaned after 5 seconds +    sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) +    # Minimum number of stored records is indicated which is not cleared +    sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8)      # enable/disable IPv6 forwarding -    with open('/proc/sys/net/ipv6/conf/all/forwarding', 'w') as f: -        f.write(ip_opt['ipv6_forward']) +    tmp = dict_search('disable_forwarding', opt) +    value = '0' if (tmp != None) else '1' +    write_file('/proc/sys/net/ipv6/conf/all/forwarding', value)      # configure IPv6 strict-dad +    tmp = dict_search('strict_dad', opt) +    value = '2' if (tmp != None) else '1'      for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'):          for name in files: -            if name == "accept_dad": -                with open(os.path.join(root, name), 'w') as f: -                    f.write(str(ip_opt['strict_dad'])) +            if name == 'accept_dad': +                write_file(os.path.join(root, name), value)  if __name__ == '__main__':      try: diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py index 2220d7b66..a521c9834 100755 --- a/src/conf_mode/system-login-banner.py +++ b/src/conf_mode/system-login-banner.py @@ -15,22 +15,20 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  from sys import exit +from copy import deepcopy +  from vyos.config import Config +from vyos.util import write_file  from vyos import ConfigError -  from vyos import airbag  airbag.enable() -motd=""" -Check out project news at https://blog.vyos.io -and feel free to report bugs at https://phabricator.vyos.net - -You can change this banner using "set system login banner post-login" command. - -VyOS is a free software distribution that includes multiple components, -you can check individual component licenses under /usr/share/doc/*/copyright - -""" +try: +    with open('/usr/share/vyos/default_motd') as f: +        motd = f.read() +except: +    # Use an empty banner if the default banner file cannot be read +    motd = "\n"  PRELOGIN_FILE = r'/etc/issue'  PRELOGIN_NET_FILE = r'/etc/issue.net' @@ -38,12 +36,12 @@ POSTLOGIN_FILE = r'/etc/motd'  default_config_data = {      'issue': 'Welcome to VyOS - \\n \\l\n\n', -    'issue_net': 'Welcome to VyOS\n', +    'issue_net': '',      'motd': motd  }  def get_config(config=None): -    banner = default_config_data +    banner = deepcopy(default_config_data)      if config:          conf = config      else: @@ -92,14 +90,9 @@ def generate(banner):      pass  def apply(banner): -    with open(PRELOGIN_FILE, 'w') as f: -        f.write(banner['issue']) - -    with open(PRELOGIN_NET_FILE, 'w') as f: -        f.write(banner['issue_net']) - -    with open(POSTLOGIN_FILE, 'w') as f: -        f.write(banner['motd']) +    write_file(PRELOGIN_FILE, banner['issue']) +    write_file(PRELOGIN_NET_FILE, banner['issue_net']) +    write_file(POSTLOGIN_FILE, banner['motd'])      return None diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index 4dd7f936d..c717286ae 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -23,6 +23,7 @@ from pwd import getpwall  from pwd import getpwnam  from spwd import getspnam  from sys import exit +from time import sleep  from vyos.config import Config  from vyos.configdict import dict_merge @@ -31,6 +32,7 @@ from vyos.template import render  from vyos.template import is_ipv4  from vyos.util import cmd  from vyos.util import call +from vyos.util import run  from vyos.util import DEVNULL  from vyos.util import dict_search  from vyos.xml import defaults @@ -195,7 +197,7 @@ def generate(login):                      pass      if 'radius' in login: -        render(radius_config_file, 'login/pam_radius_auth.conf.tmpl', login, +        render(radius_config_file, 'login/pam_radius_auth.conf.j2', login,                     permission=0o600, user='root', group='root')      else:          if os.path.isfile(radius_config_file): @@ -239,7 +241,7 @@ def apply(login):                  #                  # XXX: Should we deny using root at all?                  home_dir = getpwnam(user).pw_dir -                render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.tmpl', +                render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2',                         user_config, permission=0o600,                         formater=lambda _: _.replace(""", '"'),                         user=user, group='users') @@ -250,13 +252,22 @@ def apply(login):      if 'rm_users' in login:          for user in login['rm_users']:              try: +                # Disable user to prevent re-login +                call(f'usermod -s /sbin/nologin {user}') +                  # Logout user if he is still logged in                  if user in list(set([tmp[0] for tmp in users()])):                      print(f'{user} is logged in, forcing logout!') -                    call(f'pkill -HUP -u {user}') - -                # Remove user account but leave home directory to be safe -                call(f'userdel --remove {user}', stderr=DEVNULL) +                    # re-run command until user is logged out +                    while run(f'pkill -HUP -u {user}'): +                        sleep(0.250) + +                # Remove user account but leave home directory in place. Re-run +                # command until user is removed - userdel might return 8 as +                # SSH sessions are not all yet properly cleaned away, thus we +                # simply re-run the command until the account wen't away +                while run(f'userdel --remove {user}', stderr=DEVNULL): +                    sleep(0.250)              except Exception as e:                  raise ConfigError(f'Deleting user "{user}" raised exception: {e}') diff --git a/src/conf_mode/system-logs.py b/src/conf_mode/system-logs.py new file mode 100755 index 000000000..c71938a79 --- /dev/null +++ b/src/conf_mode/system-logs.py @@ -0,0 +1,83 @@ +#!/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/>. + +from sys import exit + +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.logger import syslog +from vyos.template import render +from vyos.util import dict_search +from vyos.xml import defaults +airbag.enable() + +# path to logrotate configs +logrotate_atop_file = '/etc/logrotate.d/vyos-atop' +logrotate_rsyslog_file = '/etc/logrotate.d/vyos-rsyslog' + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['system', 'logs'] +    default_values = defaults(base) +    logs_config = conf.get_config_dict(base, +                                       key_mangling=('-', '_'), +                                       get_first_key=True) +    logs_config = dict_merge(default_values, logs_config) + +    return logs_config + + +def verify(logs_config): +    # Nothing to verify here +    pass + + +def generate(logs_config): +    # get configuration for logrotate atop +    logrotate_atop = dict_search('logrotate.atop', logs_config) +    # generate new config file for atop +    syslog.debug('Adding logrotate config for atop') +    render(logrotate_atop_file, 'logs/logrotate/vyos-atop.j2', logrotate_atop) + +    # get configuration for logrotate rsyslog +    logrotate_rsyslog = dict_search('logrotate.messages', logs_config) +    # generate new config file for rsyslog +    syslog.debug('Adding logrotate config for rsyslog') +    render(logrotate_rsyslog_file, 'logs/logrotate/vyos-rsyslog.j2', +           logrotate_rsyslog) + + +def apply(logs_config): +    # No further actions needed +    pass + + +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/system-option.py b/src/conf_mode/system-option.py index b1c63e316..36dbf155b 100755 --- a/src/conf_mode/system-option.py +++ b/src/conf_mode/system-option.py @@ -74,8 +74,8 @@ def verify(options):      return None  def generate(options): -    render(curlrc_config, 'system/curlrc.tmpl', options) -    render(ssh_config, 'system/ssh_config.tmpl', options) +    render(curlrc_config, 'system/curlrc.j2', options) +    render(ssh_config, 'system/ssh_config.j2', options)      return None  def apply(options): diff --git a/src/conf_mode/system-proxy.py b/src/conf_mode/system-proxy.py index 02536c2ab..079c43e7e 100755 --- a/src/conf_mode/system-proxy.py +++ b/src/conf_mode/system-proxy.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018 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 @@ -13,83 +13,59 @@  #  # 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 os -import re -from vyos import ConfigError -from vyos.config import Config +from sys import exit +from vyos.config import Config +from vyos.template import render +from vyos import ConfigError  from vyos import airbag  airbag.enable()  proxy_def = r'/etc/profile.d/vyos-system-proxy.sh' - -def get_config(): -    c = Config() -    if not c.exists('system proxy'): +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() +    base = ['system', 'proxy'] +    if not conf.exists(base):          return None -    c.set_level('system proxy') +    proxy = conf.get_config_dict(base, get_first_key=True) +    return proxy -    cnf = { -        'url': None, -      'port': None, -      'usr': None, -      'passwd': None -    } +def verify(proxy): +    if not proxy: +        return -    if c.exists('url'): -        cnf['url'] = c.return_value('url') -    if c.exists('port'): -        cnf['port'] = c.return_value('port') -    if c.exists('username'): -        cnf['usr'] = c.return_value('username') -    if c.exists('password'): -        cnf['passwd'] = c.return_value('password') +    if 'url' not in proxy or 'port' not in proxy: +        raise ConfigError('Proxy URL and port require a value') -    return cnf +    if ('username' in proxy and 'password' not in proxy) or \ +       ('username' not in proxy and 'password' in proxy): +       raise ConfigError('Both username and password need to be defined!') +def generate(proxy): +    if not proxy: +        if os.path.isfile(proxy_def): +            os.unlink(proxy_def) +        return -def verify(c): -    if not c: -        return None -    if not c['url'] or not c['port']: -        raise ConfigError("proxy url and port requires a value") -    elif c['usr'] and not c['passwd']: -        raise ConfigError("proxy password requires a value") -    elif not c['usr'] and c['passwd']: -        raise ConfigError("proxy username requires a value") - +    render(proxy_def, 'system/proxy.j2', proxy, permission=0o755) -def generate(c): -    if not c: -        return None -    if not c['usr']: -        return str("export http_proxy={url}:{port}\nexport https_proxy=$http_proxy\nexport ftp_proxy=$http_proxy" -                   .format(url=c['url'], port=c['port'])) -    else: -        return str("export http_proxy=http://{usr}:{passwd}@{url}:{port}\nexport https_proxy=$http_proxy\nexport ftp_proxy=$http_proxy" -                   .format(url=re.sub('http://', '', c['url']), port=c['port'], usr=c['usr'], passwd=c['passwd'])) - - -def apply(ln): -    if not ln and os.path.exists(proxy_def): -        os.remove(proxy_def) -    else: -        open(proxy_def, 'w').write( -            "# generated by system-proxy.py\n{}\n".format(ln)) +def apply(proxy): +    pass  if __name__ == '__main__':      try:          c = get_config()          verify(c) -        ln = generate(c) -        apply(ln) +        generate(c) +        apply(c)      except ConfigError as e:          print(e) -        sys.exit(1) +        exit(1) diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py index 3d8a51cd8..a9d3bbe31 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system-syslog.py @@ -17,6 +17,7 @@  import os  import re +from pathlib import Path  from sys import exit  from vyos.config import Config @@ -89,7 +90,7 @@ def get_config(config=None):                      filename: {                          'log-file': '/var/log/user/' + filename,                          'max-files': '5', -                        'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/' + filename, +                        'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog-generated-' + filename,                          'selectors': '*.err',                          'max-size': 262144                      } @@ -203,12 +204,19 @@ def generate(c):          return None      conf = '/etc/rsyslog.d/vyos-rsyslog.conf' -    render(conf, 'syslog/rsyslog.conf.tmpl', c) +    render(conf, 'syslog/rsyslog.conf.j2', c) + +    # cleanup current logrotate config files +    logrotate_files = Path('/etc/logrotate.d/').glob('vyos-rsyslog-generated-*') +    for file in logrotate_files: +        file.unlink()      # eventually write for each file its own logrotate file, since size is      # defined it shouldn't matter -    conf = '/etc/logrotate.d/vyos-rsyslog' -    render(conf, 'syslog/logrotate.tmpl', c) +    for filename, fileconfig in c.get('files', {}).items(): +        if fileconfig['log-file'].startswith('/var/log/user/'): +            conf = '/etc/logrotate.d/vyos-rsyslog-generated-' + filename +            render(conf, 'syslog/logrotate.j2', { 'config_render': fileconfig })  def verify(c): diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 19b252513..86985d765 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -103,7 +103,7 @@ def generate(console):          config_file = base_dir + f'/serial-getty@{device}.service'          getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service' -        render(config_file, 'getty/serial-getty.service.tmpl', device_config) +        render(config_file, 'getty/serial-getty.service.j2', device_config)          os.symlink(config_file, getty_wants_symlink)      # GRUB diff --git a/src/conf_mode/system_lcd.py b/src/conf_mode/system_lcd.py index b5ce32beb..3341dd738 100755 --- a/src/conf_mode/system_lcd.py +++ b/src/conf_mode/system_lcd.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io>  #  # 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 @@ -61,9 +61,9 @@ def generate(lcd):          lcd['device'] = find_device_file(lcd['device'])      # Render config file for daemon LCDd -    render(lcdd_conf, 'lcd/LCDd.conf.tmpl', lcd) +    render(lcdd_conf, 'lcd/LCDd.conf.j2', lcd)      # Render config file for client lcdproc -    render(lcdproc_conf, 'lcd/lcdproc.conf.tmpl', lcd) +    render(lcdproc_conf, 'lcd/lcdproc.conf.j2', lcd)      return None diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py index 4f16d1ed6..2e0004ffa 100755 --- a/src/conf_mode/system_sysctl.py +++ b/src/conf_mode/system_sysctl.py @@ -50,7 +50,7 @@ def generate(sysctl):              os.unlink(config_file)          return None -    render(config_file, 'system/sysctl.conf.tmpl', sysctl) +    render(config_file, 'system/sysctl.conf.j2', sysctl)      return None  def apply(sysctl): diff --git a/src/conf_mode/tftp_server.py b/src/conf_mode/tftp_server.py index 2409eec1f..c5daccb7f 100755 --- a/src/conf_mode/tftp_server.py +++ b/src/conf_mode/tftp_server.py @@ -22,8 +22,10 @@ from copy import deepcopy  from glob import glob  from sys import exit +from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configverify import verify_vrf  from vyos.template import render  from vyos.template import is_ipv4  from vyos.util import call @@ -65,10 +67,11 @@ def verify(tftpd):      if 'listen_address' not in tftpd:          raise ConfigError('TFTP server listen address must be configured!') -    for address in tftpd['listen_address']: +    for address, address_config in tftpd['listen_address'].items():          if not is_addr_assigned(address): -            print(f'WARNING: TFTP server listen address "{address}" not ' \ -                  'assigned to any interface!') +            Warning(f'TFTP server listen address "{address}" not ' \ +                     'assigned to any interface!') +        verify_vrf(address_config)      return None @@ -83,7 +86,7 @@ def generate(tftpd):          return None      idx = 0 -    for address in tftpd['listen_address']: +    for address, address_config in tftpd['listen_address'].items():          config = deepcopy(tftpd)          port = tftpd['port']          if is_ipv4(address): @@ -91,8 +94,11 @@ def generate(tftpd):          else:              config['listen_address'] = f'[{address}]:{port} -6' +        if 'vrf' in address_config: +            config['vrf'] = address_config['vrf'] +          file = config_file + str(idx) -        render(file, 'tftp-server/default.tmpl', config) +        render(file, 'tftp-server/default.j2', config)          idx = idx + 1      return None diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 99b82ca2d..bad9cfbd8 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -503,7 +503,7 @@ def generate(ipsec):                              charon_radius_conf, interface_conf, swanctl_conf]:              if os.path.isfile(config_file):                  os.unlink(config_file) -        render(charon_conf, 'ipsec/charon.tmpl', {'install_routes': default_install_routes}) +        render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes})          return      if ipsec['dhcp_no_address']: @@ -553,25 +553,27 @@ def generate(ipsec):                      if not local_prefixes or not remote_prefixes:                          continue -                    passthrough = [] +                    passthrough = None                      for local_prefix in local_prefixes:                          for remote_prefix in remote_prefixes:                              local_net = ipaddress.ip_network(local_prefix)                              remote_net = ipaddress.ip_network(remote_prefix)                              if local_net.overlaps(remote_net): +                                if passthrough is None: +                                    passthrough = []                                  passthrough.append(local_prefix)                      ipsec['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough -    render(ipsec_conf, 'ipsec/ipsec.conf.tmpl', ipsec) -    render(ipsec_secrets, 'ipsec/ipsec.secrets.tmpl', ipsec) -    render(charon_conf, 'ipsec/charon.tmpl', ipsec) -    render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.tmpl', ipsec) -    render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.tmpl', ipsec) -    render(interface_conf, 'ipsec/interfaces_use.conf.tmpl', ipsec) -    render(swanctl_conf, 'ipsec/swanctl.conf.tmpl', ipsec) +    render(ipsec_conf, 'ipsec/ipsec.conf.j2', ipsec) +    render(ipsec_secrets, 'ipsec/ipsec.secrets.j2', ipsec) +    render(charon_conf, 'ipsec/charon.j2', ipsec) +    render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.j2', ipsec) +    render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.j2', ipsec) +    render(interface_conf, 'ipsec/interfaces_use.conf.j2', ipsec) +    render(swanctl_conf, 'ipsec/swanctl.conf.j2', ipsec)  def resync_nhrp(ipsec):      if ipsec and not ipsec['nhrp_exists']: diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 818e8fa0b..fd5a4acd8 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -358,10 +358,10 @@ def generate(l2tp):      if not l2tp:          return None -    render(l2tp_conf, 'accel-ppp/l2tp.config.tmpl', l2tp) +    render(l2tp_conf, 'accel-ppp/l2tp.config.j2', l2tp)      if l2tp['auth_mode'] == 'local': -        render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.tmpl', l2tp) +        render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.j2', l2tp)          os.chmod(l2tp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP)      else: diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index f6db196dc..8e0e30bbf 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -23,9 +23,12 @@ 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 is_systemd_service_running +from vyos.util import dict_search  from vyos.xml import defaults  from vyos import ConfigError  from crypt import crypt, mksalt, METHOD_SHA512 +from time import sleep  from vyos import airbag  airbag.enable() @@ -33,6 +36,7 @@ airbag.enable()  cfg_dir        = '/run/ocserv'  ocserv_conf    = cfg_dir + '/ocserv.conf'  ocserv_passwd  = cfg_dir + '/ocpasswd' +ocserv_otp_usr = cfg_dir + '/users.oath'  radius_cfg     = cfg_dir + '/radiusclient.conf'  radius_servers = cfg_dir + '/radius_servers' @@ -52,6 +56,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 ocserv:          ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'),                                  get_first_key=True, no_tag_node_value_mangle=True) @@ -61,17 +75,34 @@ def get_config():  def verify(ocserv):      if ocserv is None:          return None -      # Check authentication      if "authentication" in ocserv:          if "mode" in ocserv["authentication"]:              if "local" in ocserv["authentication"]["mode"]: -                if not ocserv["authentication"]["local_users"] or not ocserv["authentication"]["local_users"]["username"]: -                    raise ConfigError('openconnect mode local required at leat one user') +                if "radius" in ocserv["authentication"]["mode"]: +                    raise ConfigError('OpenConnect authentication modes are mutually-exclusive, remove either local or radius from your configuration') +                if not ocserv["authentication"]["local_users"]: +                    raise ConfigError('openconnect mode local required at least one user') +                if not ocserv["authentication"]["local_users"]["username"]: +                    raise ConfigError('openconnect mode local required at least one user')                  else: -                    for user in ocserv["authentication"]["local_users"]["username"]: -                        if not "password" in ocserv["authentication"]["local_users"]["username"][user]: -                            raise ConfigError(f'password required for user {user}') +                    # For OTP mode: verify that each local user has an OTP key +                    if "otp" in ocserv["authentication"]["mode"]["local"]: +                        users_wo_key = [] +                        for user, user_config in ocserv["authentication"]["local_users"]["username"].items(): +                            # User has no OTP key defined +                            if dict_search('otp.key', user_config) == None: +                                users_wo_key.append(user) +                        if users_wo_key: +                            raise ConfigError(f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}') +                    # For password (and default) mode: verify that each local user has password +                    if "password" in ocserv["authentication"]["mode"]["local"] or "otp" not in ocserv["authentication"]["mode"]["local"]: +                        users_wo_pswd = [] +                        for user in ocserv["authentication"]["local_users"]["username"]: +                            if not "password" in ocserv["authentication"]["local_users"]["username"][user]: +                                users_wo_pswd.append(user) +                        if users_wo_pswd: +                            raise ConfigError(f'password required for users:\n{users_wo_pswd}')          else:              raise ConfigError('openconnect authentication mode required')      else: @@ -120,22 +151,57 @@ def verify(ocserv):      else:          raise ConfigError('openconnect network settings required') -  def generate(ocserv):      if not ocserv:          return None      if "radius" in ocserv["authentication"]["mode"]:          # Render radius client configuration -        render(radius_cfg, 'ocserv/radius_conf.tmpl', ocserv["authentication"]["radius"]) +        render(radius_cfg, 'ocserv/radius_conf.j2', ocserv["authentication"]["radius"])          # Render radius servers -        render(radius_servers, 'ocserv/radius_servers.tmpl', ocserv["authentication"]["radius"]) +        render(radius_servers, 'ocserv/radius_servers.j2', ocserv["authentication"]["radius"]) +    elif "local" in ocserv["authentication"]["mode"]: +        # if mode "OTP", generate OTP users file parameters +        if "otp" in ocserv["authentication"]["mode"]["local"]: +            if "local_users" in ocserv["authentication"]: +                for user in ocserv["authentication"]["local_users"]["username"]: +                    # OTP token type from CLI parameters: +                    otp_interval = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("interval")) +                    token_type = ocserv["authentication"]["local_users"]["username"][user]["otp"].get("token_type") +                    otp_length = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("otp_length")) +                    if token_type == "hotp-time": +                        otp_type = "HOTP/T" + otp_interval +                    elif token_type == "hotp-event": +                        otp_type = "HOTP/E" +                    else: +                        otp_type = "HOTP/T" + otp_interval +                    ocserv["authentication"]["local_users"]["username"][user]["otp"]["token_tmpl"] = otp_type + "/" + otp_length +        # if there is a password, generate hash +        if "password" in ocserv["authentication"]["mode"]["local"] or not "otp" in ocserv["authentication"]["mode"]["local"]: +            if "local_users" in ocserv["authentication"]: +                for user in ocserv["authentication"]["local_users"]["username"]: +                    ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) + +        if "password-otp" in ocserv["authentication"]["mode"]["local"]: +            # Render local users ocpasswd +            render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) +            # Render local users OTP keys +            render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) +        elif "password" in ocserv["authentication"]["mode"]["local"]: +            # Render local users ocpasswd +            render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) +        elif "otp" in ocserv["authentication"]["mode"]["local"]: +            # Render local users OTP keys +            render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) +        else: +            # Render local users ocpasswd +            render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])      else:          if "local_users" in ocserv["authentication"]:              for user in ocserv["authentication"]["local_users"]["username"]:                  ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"])              # Render local users -            render(ocserv_passwd, 'ocserv/ocserv_passwd.tmpl', ocserv["authentication"]["local_users"]) +            render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])      if "ssl" in ocserv:          cert_file_path = os.path.join(cfg_dir, 'cert.pem') @@ -161,17 +227,27 @@ def generate(ocserv):                  f.write(wrap_certificate(pki_ca_cert['certificate']))      # Render config -    render(ocserv_conf, 'ocserv/ocserv_config.tmpl', ocserv) +    render(ocserv_conf, 'ocserv/ocserv_config.j2', ocserv)  def apply(ocserv):      if not ocserv:          call('systemctl stop ocserv.service') -        for file in [ocserv_conf, ocserv_passwd]: +        for file in [ocserv_conf, ocserv_passwd, ocserv_otp_usr]:              if os.path.exists(file):                  os.unlink(file)      else:          call('systemctl restart ocserv.service') +        counter = 0 +        while True: +            # exit early when service runs +            if is_systemd_service_running("ocserv.service"): +                break +            sleep(0.250) +            if counter > 5: +                raise ConfigError('openconnect failed to start, check the logs for details') +                break +            counter += 1  if __name__ == '__main__': diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index 30abe4782..7550c411e 100755 --- a/src/conf_mode/vpn_pptp.py +++ b/src/conf_mode/vpn_pptp.py @@ -264,10 +264,10 @@ def generate(pptp):      if not pptp:          return None -    render(pptp_conf, 'accel-ppp/pptp.config.tmpl', pptp) +    render(pptp_conf, 'accel-ppp/pptp.config.j2', pptp)      if pptp['local_users']: -        render(pptp_chap_secrets, 'accel-ppp/chap-secrets.tmpl', pptp) +        render(pptp_chap_secrets, 'accel-ppp/chap-secrets.j2', pptp)          os.chmod(pptp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP)      else:          if os.path.exists(pptp_chap_secrets): diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 68980e5ab..db53463cf 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -114,7 +114,7 @@ def generate(sstp):          return None      # accel-cmd reload doesn't work so any change results in a restart of the daemon -    render(sstp_conf, 'accel-ppp/sstp.config.tmpl', sstp) +    render(sstp_conf, 'accel-ppp/sstp.config.j2', sstp)      cert_name = sstp['ssl']['certificate']      pki_cert = sstp['pki']['certificate'][cert_name] @@ -127,7 +127,7 @@ def generate(sstp):      write_file(ca_cert_file_path, wrap_certificate(pki_ca['certificate']))      if dict_search('authentication.mode', sstp) == 'local': -        render(sstp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.tmpl', +        render(sstp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',                 sstp, permission=0o640)      else:          if os.path.exists(sstp_chap_secrets): diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 38c0c4463..972d0289b 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -29,6 +29,7 @@ from vyos.util import dict_search  from vyos.util import get_interface_config  from vyos.util import popen  from vyos.util import run +from vyos.util import sysctl_write  from vyos import ConfigError  from vyos import frr  from vyos import airbag @@ -37,10 +38,16 @@ airbag.enable()  config_file = '/etc/iproute2/rt_tables.d/vyos-vrf.conf'  nft_vrf_config = '/tmp/nftables-vrf-zones' -def list_rules(): -    command = 'ip -j -4 rule show' -    answer = loads(cmd(command)) -    return [_ for _ in answer if _] +def has_rule(af : str, priority : int, table : str): +    """ Check if a given ip rule exists """ +    if af not in ['-4', '-6']: +        raise ValueError() +    command = f'ip -j {af} rule show' +    for tmp in loads(cmd(command)): +        if {'priority', 'table'} <= set(tmp): +            if tmp['priority'] == priority and tmp['table'] == table: +                return True +    return False  def vrf_interfaces(c, match):      matched = [] @@ -69,7 +76,6 @@ def vrf_routing(c, match):      c.set_level(old_level)      return matched -  def get_config(config=None):      if config:          conf = config @@ -77,7 +83,8 @@ def get_config(config=None):          conf = Config()      base = ['vrf'] -    vrf = conf.get_config_dict(base, get_first_key=True) +    vrf = conf.get_config_dict(base, key_mangling=('-', '_'), +                               no_tag_node_value_mangle=True, get_first_key=True)      # determine which VRF has been removed      for name in node_changed(conf, base + ['name']): @@ -127,10 +134,10 @@ def verify(vrf):  def generate(vrf): -    render(config_file, 'vrf/vrf.conf.tmpl', vrf) +    render(config_file, 'vrf/vrf.conf.j2', vrf)      # Render nftables zones config -    render(nft_vrf_config, 'firewall/nftables-vrf-zones.tmpl', vrf) +    render(nft_vrf_config, 'firewall/nftables-vrf-zones.j2', vrf)      return None @@ -146,15 +153,13 @@ def apply(vrf):      # set the default VRF global behaviour      bind_all = '0' -    if 'bind-to-all' in vrf: +    if 'bind_to_all' in vrf:          bind_all = '1' -    call(f'sysctl -wq net.ipv4.tcp_l3mdev_accept={bind_all}') -    call(f'sysctl -wq net.ipv4.udp_l3mdev_accept={bind_all}') +    sysctl_write('net.ipv4.tcp_l3mdev_accept', bind_all) +    sysctl_write('net.ipv4.udp_l3mdev_accept', bind_all)      for tmp in (dict_search('vrf_remove', vrf) or []):          if os.path.isdir(f'/sys/class/net/{tmp}'): -            call(f'ip -4 route del vrf {tmp} unreachable default metric 4278198272') -            call(f'ip -6 route del vrf {tmp} unreachable default metric 4278198272')              call(f'ip link delete dev {tmp}')              # Remove nftables conntrack zone map item              nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' @@ -165,31 +170,68 @@ def apply(vrf):          # check if table already exists          _, err = popen('nft list table inet vrf_zones')          # If not, create a table -        if err: -            if os.path.exists(nft_vrf_config): -                cmd(f'nft -f {nft_vrf_config}') -                os.unlink(nft_vrf_config) +        if err and os.path.exists(nft_vrf_config): +            cmd(f'nft -f {nft_vrf_config}') +            os.unlink(nft_vrf_config) + +        # Linux routing uses rules to find tables - routing targets are then +        # looked up in those tables. If the lookup got a matching route, the +        # process ends. +        # +        # TL;DR; first table with a matching entry wins! +        # +        # You can see your routing table lookup rules using "ip rule", sadly the +        # local lookup is hit before any VRF lookup. Pinging an addresses from the +        # VRF will usually find a hit in the local table, and never reach the VRF +        # routing table - this is usually not what you want. Thus we will +        # re-arrange the tables and move the local lookup further down once VRFs +        # are enabled. +        # +        # Thanks to https://stbuehler.de/blog/article/2020/02/29/using_vrf__virtual_routing_and_forwarding__on_linux.html + +        for afi in ['-4', '-6']: +            # move lookup local to pref 32765 (from 0) +            if not has_rule(afi, 32765, 'local'): +                call(f'ip {afi} rule add pref 32765 table local') +            if has_rule(afi, 0, 'local'): +                call(f'ip {afi} rule del pref 0') +            # make sure that in VRFs after failed lookup in the VRF specific table +            # nothing else is reached +            if not has_rule(afi, 1000, 'l3mdev'): +                # this should be added by the kernel when a VRF is created +                # add it here for completeness +                call(f'ip {afi} rule add pref 1000 l3mdev protocol kernel') + +            # add another rule with an unreachable target which only triggers in VRF context +            # if a route could not be reached +            if not has_rule(afi, 2000, 'l3mdev'): +                call(f'ip {afi} rule add pref 2000 l3mdev unreachable')          for name, config in vrf['name'].items():              table = config['table'] -              if not os.path.isdir(f'/sys/class/net/{name}'):                  # For each VRF apart from your default context create a VRF                  # interface with a separate routing table                  call(f'ip link add {name} type vrf table {table}') -                # The kernel Documentation/networking/vrf.txt also recommends -                # adding unreachable routes to the VRF routing tables so that routes -                # afterwards are taken. -                call(f'ip -4 route add vrf {name} unreachable default metric 4278198272') -                call(f'ip -6 route add vrf {name} unreachable default metric 4278198272') -                # We also should add proper loopback IP addresses to the newly -                # created VRFs for services bound to the loopback address (SNMP, NTP) -                call(f'ip -4 addr add 127.0.0.1/8 dev {name}') -                call(f'ip -6 addr add ::1/128 dev {name}')              # set VRF description for e.g. SNMP monitoring              vrf_if = Interface(name) +            # We also should add proper loopback IP addresses to the newly added +            # VRF for services bound to the loopback address (SNMP, NTP) +            vrf_if.add_addr('127.0.0.1/8') +            vrf_if.add_addr('::1/128') +            # add VRF description if available              vrf_if.set_alias(config.get('description', '')) + +            # Enable/Disable IPv4 forwarding +            tmp = dict_search('ip.disable_forwarding', config) +            value = '0' if (tmp != None) else '1' +            vrf_if.set_ipv4_forwarding(value) +            # Enable/Disable IPv6 forwarding +            tmp = dict_search('ipv6.disable_forwarding', config) +            value = '0' if (tmp != None) else '1' +            vrf_if.set_ipv6_forwarding(value) +              # Enable/Disable of an interface must always be done at the end of the              # derived class to make use of the ref-counting set_admin_state()              # function. We will only enable the interface if 'up' was called as @@ -203,37 +245,9 @@ def apply(vrf):              nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}'              cmd(f'nft {nft_add_element}') -    # Linux routing uses rules to find tables - routing targets are then -    # looked up in those tables. If the lookup got a matching route, the -    # process ends. -    # -    # TL;DR; first table with a matching entry wins! -    # -    # You can see your routing table lookup rules using "ip rule", sadly the -    # local lookup is hit before any VRF lookup. Pinging an addresses from the -    # VRF will usually find a hit in the local table, and never reach the VRF -    # routing table - this is usually not what you want. Thus we will -    # re-arrange the tables and move the local lookup furhter down once VRFs -    # are enabled. - -    # get current preference on local table -    local_pref = [r.get('priority') for r in list_rules() if r.get('table') == 'local'][0] - -    # change preference when VRFs are enabled and local lookup table is default -    if not local_pref and 'name' in vrf: -        for af in ['-4', '-6']: -            call(f'ip {af} rule add pref 32765 table local') -            call(f'ip {af} rule del pref 0')      # return to default lookup preference when no VRF is configured      if 'name' not in vrf: -        for af in ['-4', '-6']: -            call(f'ip {af} rule add pref 0 table local') -            call(f'ip {af} rule del pref 32765') - -            # clean out l3mdev-table rule if present -            if 1000 in [r.get('priority') for r in list_rules() if r.get('priority') == 1000]: -                call(f'ip {af} rule del pref 1000')          # Remove VRF zones table from nftables          tmp = run('nft list table inet vrf_zones')          if tmp == 0: diff --git a/src/conf_mode/vrf_vni.py b/src/conf_mode/vrf_vni.py index 50d60f0dc..585fdbebf 100755 --- a/src/conf_mode/vrf_vni.py +++ b/src/conf_mode/vrf_vni.py @@ -40,20 +40,18 @@ def verify(vrf):      return None  def generate(vrf): -    vrf['new_frr_config'] = render_to_string('frr/vrf-vni.frr.tmpl', vrf) +    vrf['new_frr_config'] = render_to_string('frr/vrf-vni.frr.j2', vrf)      return None  def apply(vrf):      # add configuration to FRR      frr_cfg = frr.FRRConfig()      frr_cfg.load_configuration(frr_daemon) -    frr_cfg.modify_section(f'^vrf .+$', '') -    frr_cfg.add_before(r'(interface .*|line vty)', vrf['new_frr_config']) +    frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True) +    if 'new_frr_config' in vrf: +        frr_cfg.add_before(frr.default_add_before, vrf['new_frr_config'])      frr_cfg.commit_configuration(frr_daemon) -    # Save configuration to /run/frr/config/frr.conf -    frr.save_configuration() -      return None  if __name__ == '__main__': diff --git a/src/conf_mode/zone_policy.py b/src/conf_mode/zone_policy.py new file mode 100755 index 000000000..070a4deea --- /dev/null +++ b/src/conf_mode/zone_policy.py @@ -0,0 +1,213 @@ +#!/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/check-wwan b/src/etc/cron.d/check-wwan deleted file mode 100644 index 28190776f..000000000 --- a/src/etc/cron.d/check-wwan +++ /dev/null @@ -1 +0,0 @@ -*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper index 74a7e83bf..5d879471d 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper +++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper @@ -26,7 +26,7 @@ function iptovtysh () {      local VTYSH_GATEWAY=""      local VTYSH_DEV=""      local VTYSH_TAG="210" -    local VTYSH_DISTANCE="" +    local VTYSH_DISTANCE=$IF_METRIC      # convert default route to 0.0.0.0/0      if [ "$4" == "default" ] ; then          VTYSH_NETADDR="0.0.0.0/0" 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 24090e2a8..b1902b585 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf +++ b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf @@ -1,44 +1,48 @@ -# modified make_resolv_conf () for VyOS -make_resolv_conf() { -    hostsd_client="/usr/bin/vyos-hostsd-client" -    hostsd_changes= +# modified make_resolv_conf() for VyOS +# should be used only if vyos-hostsd is running -    if [ -n "$new_domain_name" ]; then -        logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client" -        $hostsd_client --delete-search-domains --tag "dhcp-$interface" -        logmsg info "Adding domain name \"$new_domain_name\" as search domain with tag \"dhcp-$interface\" via vyos-hostsd-client" -        $hostsd_client --add-search-domains "$new_domain_name" --tag "dhcp-$interface" -        hostsd_changes=y -    fi +if /usr/bin/systemctl -q is-active vyos-hostsd; then +    make_resolv_conf() { +        hostsd_client="/usr/bin/vyos-hostsd-client" +        hostsd_changes= -    if [ -n "$new_dhcp6_domain_search" ]; then -        logmsg info "Deleting search domains with tag \"dhcpv6-$interface\" via vyos-hostsd-client" -        $hostsd_client --delete-search-domains --tag "dhcpv6-$interface" -        logmsg info "Adding search domain \"$new_dhcp6_domain_search\" with tag \"dhcpv6-$interface\" via vyos-hostsd-client" -        $hostsd_client --add-search-domains "$new_dhcp6_domain_search" --tag "dhcpv6-$interface" -        hostsd_changes=y -    fi +        if [ -n "$new_domain_name" ]; then +            logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client" +            $hostsd_client --delete-search-domains --tag "dhcp-$interface" +            logmsg info "Adding domain name \"$new_domain_name\" as search domain with tag \"dhcp-$interface\" via vyos-hostsd-client" +            $hostsd_client --add-search-domains "$new_domain_name" --tag "dhcp-$interface" +            hostsd_changes=y +        fi -    if [ -n "$new_domain_name_servers" ]; then -        logmsg info "Deleting nameservers with tag \"dhcp-$interface\" via vyos-hostsd-client" -        $hostsd_client --delete-name-servers --tag "dhcp-$interface" -        logmsg info "Adding nameservers \"$new_domain_name_servers\" with tag \"dhcp-$interface\" via vyos-hostsd-client" -        $hostsd_client --add-name-servers $new_domain_name_servers --tag "dhcp-$interface" -        hostsd_changes=y -    fi +        if [ -n "$new_dhcp6_domain_search" ]; then +            logmsg info "Deleting search domains with tag \"dhcpv6-$interface\" via vyos-hostsd-client" +            $hostsd_client --delete-search-domains --tag "dhcpv6-$interface" +            logmsg info "Adding search domain \"$new_dhcp6_domain_search\" with tag \"dhcpv6-$interface\" via vyos-hostsd-client" +            $hostsd_client --add-search-domains "$new_dhcp6_domain_search" --tag "dhcpv6-$interface" +            hostsd_changes=y +        fi -    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" -        hostsd_changes=y -    fi +        if [ -n "$new_domain_name_servers" ]; then +            logmsg info "Deleting nameservers with tag \"dhcp-$interface\" via vyos-hostsd-client" +            $hostsd_client --delete-name-servers --tag "dhcp-$interface" +            logmsg info "Adding nameservers \"$new_domain_name_servers\" with tag \"dhcp-$interface\" via vyos-hostsd-client" +            $hostsd_client --add-name-servers $new_domain_name_servers --tag "dhcp-$interface" +            hostsd_changes=y +        fi -    if [ $hostsd_changes ]; then -        logmsg info "Applying changes via vyos-hostsd-client" -        $hostsd_client --apply -    else -        logmsg info "No changes to apply via vyos-hostsd-client" -    fi -} +        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" +            hostsd_changes=y +        fi + +        if [ $hostsd_changes ]; then +            logmsg info "Applying changes via vyos-hostsd-client" +            $hostsd_client --apply +        else +            logmsg info "No changes to apply via vyos-hostsd-client" +        fi +    } +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 fec792b64..ad6a1d5eb 100644 --- a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup +++ b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup @@ -4,14 +4,19 @@  # NOTE: here we use 'ip' wrapper, therefore a route will be actually deleted via /usr/sbin/ip or vtysh, according to the system state  hostsd_client="/usr/bin/vyos-hostsd-client"  hostsd_changes= +# check vyos-hostsd status +/usr/bin/systemctl -q is-active vyos-hostsd +hostsd_status=$?  if [[ $reason =~ (EXPIRE|FAIL|RELEASE|STOP) ]]; then -    # delete search domains and nameservers via vyos-hostsd -    logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client" -    $hostsd_client --delete-search-domains --tag "dhcp-$interface" -    logmsg info "Deleting nameservers with tag \"dhcp-${interface}\" via vyos-hostsd-client" -    $hostsd_client --delete-name-servers --tag "dhcp-${interface}" -    hostsd_changes=y +    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" +        $hostsd_client --delete-search-domains --tag "dhcp-$interface" +        logmsg info "Deleting nameservers with tag \"dhcp-${interface}\" via vyos-hostsd-client" +        $hostsd_client --delete-name-servers --tag "dhcp-${interface}" +        hostsd_changes=y +    fi      if_metric="$IF_METRIC" @@ -92,12 +97,14 @@ if [[ $reason =~ (EXPIRE|FAIL|RELEASE|STOP) ]]; then  fi  if [[ $reason =~ (EXPIRE6|RELEASE6|STOP6) ]]; then -    # delete search domains and nameservers via vyos-hostsd -    logmsg info "Deleting search domains with tag \"dhcpv6-$interface\" via vyos-hostsd-client" -    $hostsd_client --delete-search-domains --tag "dhcpv6-$interface" -    logmsg info "Deleting nameservers with tag \"dhcpv6-${interface}\" via vyos-hostsd-client" -    $hostsd_client --delete-name-servers --tag "dhcpv6-${interface}" -    hostsd_changes=y +    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" +        $hostsd_client --delete-search-domains --tag "dhcpv6-$interface" +        logmsg info "Deleting nameservers with tag \"dhcpv6-${interface}\" via vyos-hostsd-client" +        $hostsd_client --delete-name-servers --tag "dhcpv6-${interface}" +        hostsd_changes=y +    fi  fi  if [ $hostsd_changes ]; then diff --git a/src/etc/logrotate.d/conntrackd b/src/etc/logrotate.d/conntrackd new file mode 100644 index 000000000..b0b09dec1 --- /dev/null +++ b/src/etc/logrotate.d/conntrackd @@ -0,0 +1,9 @@ +/var/log/conntrackd-stats.log { +    weekly +    rotate 2 +    missingok + +    postrotate +        systemctl restart conntrackd.service > /dev/null +    endscript +} diff --git a/src/etc/logrotate.d/vyos-rsyslog b/src/etc/logrotate.d/vyos-rsyslog new file mode 100644 index 000000000..3c087b94e --- /dev/null +++ b/src/etc/logrotate.d/vyos-rsyslog @@ -0,0 +1,12 @@ +/var/log/messages { +    create +    missingok +    nomail +    notifempty +    rotate 10 +    size 1M +    postrotate +        # inform rsyslog service about rotation +        /usr/lib/rsyslog/rsyslog-rotate +    endscript +} diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback index bb918a468..fa1917ab1 100755 --- a/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback +++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback @@ -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 @@ -23,14 +23,9 @@  from sys import argv  from sys import exit -from syslog import syslog -from syslog import openlog -from syslog import LOG_PID -from syslog import LOG_INFO -  from vyos.configquery import ConfigTreeQuery +from vyos.configdict import get_interface_dict  from vyos.ifconfig import PPPoEIf -from vyos.util import read_file  # When the ppp link comes up, this script is called with the following  # parameters @@ -45,15 +40,10 @@ if (len(argv) < 7):      exit(1)  interface = argv[6] -dialer_pid = read_file(f'/var/run/{interface}.pid') - -openlog(ident=f'pppd[{dialer_pid}]', facility=LOG_INFO) -syslog('executing ' + argv[0])  conf = ConfigTreeQuery() -pppoe = conf.get_config_dict(['interfaces', 'pppoe', argv[6]], -                             get_first_key=True, key_mangling=('-', '_')) -pppoe['ifname'] = argv[6] +_, pppoe = get_interface_dict(conf.config, ['interfaces', 'pppoe'], interface) -p = PPPoEIf(pppoe['ifname']) +# Update the config +p = PPPoEIf(interface)  p.update(pppoe) diff --git a/src/etc/systemd/system/keepalived.service.d/override.conf b/src/etc/systemd/system/keepalived.service.d/override.conf deleted file mode 100644 index 1c68913f2..000000000 --- a/src/etc/systemd/system/keepalived.service.d/override.conf +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -ConditionPathExists= -ConditionPathExists=/run/keepalived/keepalived.conf -After= -After=vyos-router.service - -[Service] -KillMode=process -EnvironmentFile= -ExecStart= -ExecStart=/usr/sbin/keepalived --use-file /run/keepalived/keepalived.conf --pid /run/keepalived/keepalived.pid --dont-fork --snmp -PIDFile= -PIDFile=/run/keepalived/keepalived.pid diff --git a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py new file mode 100755 index 000000000..bf4bfd05d --- /dev/null +++ b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +import json +import re +import time + +from vyos.util import cmd + + +def get_nft_filter_chains(): +    """ +    Get list of nft chains for table filter +    """ +    nft = cmd('/usr/sbin/nft --json list table ip filter') +    nft = json.loads(nft) +    chain_list = [] + +    for output in nft['nftables']: +        if 'chain' in output: +            chain = output['chain']['name'] +            chain_list.append(chain) + +    return chain_list + + +def get_nftables_details(name): +    """ +    Get dict, counters packets and bytes for chain +    """ +    command = f'/usr/sbin/nft list chain ip filter {name}' +    try: +        results = cmd(command) +    except: +        return {} + +    # Trick to remove 'NAME_' from chain name in the comment +    # It was added to any chain T4218 +    # counter packets 0 bytes 0 return comment "FOO default-action accept" +    comment_name = name.replace("NAME_", "") +    out = {} +    for line in results.split('\n'): +        comment_search = re.search(rf'{comment_name}[\- ](\d+|default-action)', line) +        if not comment_search: +            continue + +        rule = {} +        rule_id = comment_search[1] +        counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line) +        if counter_search: +            rule['packets'] = counter_search[1] +            rule['bytes'] = counter_search[2] + +        rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip() +        out[rule_id] = rule +    return out + + +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},' +              f'ruleid={rule} ' +              f'pkts={rule_config["packets"]}i,' +              f'bytes={rule_config["bytes"]}i ' +              f'{str(int(time.time()))}000000000') + + +chains = get_nft_filter_chains() + +for chain in chains: +    get_nft_telegraf(chain) diff --git a/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py new file mode 100755 index 000000000..0c7474156 --- /dev/null +++ b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +from vyos.ifconfig import Section +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 +    By default don't get IPv6 link-local addresses +    If interface doesn't have address, return "-" +    """ +    addresses = [] +    addrs = Interface(iface).get_addr() + +    for addr in addrs: +        if link_local_v6 == False: +            if addr.startswith('fe80::'): +                continue +        addresses.append(addr) + +    if not addresses: +        return "-" + +    return (" ".join(addresses)) + +def get_interface_description(iface): +    """ +    Get interface description +    If none return "empty" +    """ +    description = Interface(iface).get_alias() + +    if not description: +        return "empty" + +    return description + +def get_interface_admin_state(iface): +    """ +    Interface administrative state +    up => 0, down => 2 +    """ +    state = Interface(iface).get_admin_state() +    if state == 'up': +        admin_state = 0 +    if state == 'down': +        admin_state = 2 + +    return admin_state + +def get_interface_oper_state(iface): +    """ +    Interface operational state +    up => 0, down => 1 +    """ +    state = Interface(iface).operational.get_state() +    if state == 'down': +        oper_state = 1 +    else: +        oper_state = 0 + +    return oper_state + +interfaces = get_interfaces() + +for iface in interfaces: +    print(f'show_interfaces,interface={iface} ' +          f'ip_addresses="{get_interface_addresses(iface)}",' +          f'state={get_interface_admin_state(iface)}i,' +          f'link={get_interface_oper_state(iface)}i,' +          f'description="{get_interface_description(iface)}" ' +          f'{str(int(time.time()))}000000000') diff --git a/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py new file mode 100755 index 000000000..df4eed131 --- /dev/null +++ b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py @@ -0,0 +1,61 @@ +#!/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 time +from vyos.configquery import ConfigTreeQuery +from vyos.util import is_systemd_service_running, process_named_running + +# Availible services and prouceses +# 1 - service +# 2 - process +services = { +    "protocols bgp"          : "bgpd", +    "protocols ospf"         : "ospfd", +    "protocols ospfv3"       : "ospf6d", +    "protocols rip"          : "ripd", +    "protocols ripng"        : "ripngd", +    "protocols isis"         : "isisd", +    "service pppoe"          : "accel-ppp@pppoe.service", +    "vpn l2tp remote-access" : "accel-ppp@l2tp.service", +    "vpn pptp remote-access" : "accel-ppp@pptp.service", +    "vpn sstp"               : "accel-ppp@sstp.service", +    "vpn ipsec"              : "charon" +} + +# Configured services +conf_services = { +    'zebra'   : 0, +    'staticd' : 0, +} +# Get configured service and create list to check if process running +config = ConfigTreeQuery() +for service in services: +    if config.exists(service): +        conf_services[services[service]] = 0 + +for conf_service in conf_services: +    status = 0 +    if ".service" in conf_service: +        # Check systemd service +        if is_systemd_service_running(conf_service): +            status = 1 +    else: +        # Check process +        if process_named_running(conf_service): +            status = 1 +    print(f'vyos_services,service="{conf_service}" ' +          f'status={str(status)}i {str(int(time.time()))}000000000') diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index e4e1fe11d..eb584edaf 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -1,6 +1,6 @@  #!/usr/bin/python3 -# 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 @@ -111,6 +111,10 @@ if __name__ == "__main__":          (True, re.compile(r'public-keys \S+'), 'public-keys xxxx@xxx.xxx'),          (True, re.compile(r'type \'ssh-(rsa|dss)\''), 'type ssh-xxx'),          (True, re.compile(r' key \S+'), ' key xxxxxx'), +        # Strip bucket +        (True, re.compile(r' bucket \S+'), ' bucket xxxxxx'), +        # Strip tokens +        (True, re.compile(r' token \S+'), ' token xxxxxx'),          # Strip OpenVPN secrets          (True, re.compile(r'(shared-secret-key-file|ca-cert-file|cert-file|dh-file|key-file|client) (\S+)'), r'\1 xxxxxx'),          # Strip IPSEC secrets @@ -123,8 +127,8 @@ if __name__ == "__main__":          # Strip MAC addresses          (args.mac, re.compile(r'([0-9a-fA-F]{2}\:){5}([0-9a-fA-F]{2}((\:{0,1})){3})'), r'xx:xx:xx:xx:xx:\2'), -        # Strip host-name, domain-name, and domain-search -        (args.hostname, re.compile(r'(host-name|domain-name|domain-search) \S+'), r'\1 xxxxxx'), +        # Strip host-name, domain-name, domain-search and url +        (args.hostname, re.compile(r'(host-name|domain-name|domain-search|url) \S+'), r'\1 xxxxxx'),          # Strip user-names          (args.username, re.compile(r'(user|username|user-id) \S+'), r'\1 xxxxxx'), diff --git a/src/helpers/system-versions-foot.py b/src/helpers/system-versions-foot.py index c33e41d79..2aa687221 100755 --- a/src/helpers/system-versions-foot.py +++ b/src/helpers/system-versions-foot.py @@ -21,7 +21,7 @@ import vyos.systemversions as systemversions  import vyos.defaults  import vyos.version -sys_versions = systemversions.get_system_versions() +sys_versions = systemversions.get_system_component_version()  component_string = formatversions.format_versions_string(sys_versions) diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py index c5bf22f10..b9cc87bfa 100755 --- a/src/helpers/vyos-boot-config-loader.py +++ b/src/helpers/vyos-boot-config-loader.py @@ -23,12 +23,12 @@ import grp  import traceback  from datetime import datetime -from vyos.defaults import directories +from vyos.defaults import directories, config_status  from vyos.configsession import ConfigSession, ConfigSessionError  from vyos.configtree import ConfigTree  from vyos.util import cmd -STATUS_FILE = '/tmp/vyos-config-status' +STATUS_FILE = config_status  TRACE_FILE = '/tmp/boot-config-trace'  CFG_GROUP = 'vyattacfg' diff --git a/src/helpers/vyos-check-wwan.py b/src/helpers/vyos-check-wwan.py index c6e6c54b7..2ff9a574f 100755 --- a/src/helpers/vyos-check-wwan.py +++ b/src/helpers/vyos-check-wwan.py @@ -18,7 +18,6 @@ from vyos.configquery import VbashOpRun  from vyos.configquery import ConfigTreeQuery  from vyos.util import is_wwan_connected -from vyos.util import call  conf = ConfigTreeQuery()  dict = conf.get_config_dict(['interfaces', 'wwan'], key_mangling=('-', '_'), @@ -30,8 +29,7 @@ for interface, interface_config in dict.items():              # do not restart this interface as it's disabled by the user              continue -        #op = VbashOpRun() -        #op.run(['connect', 'interface', interface]) -        call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces-wwan.py') +        op = VbashOpRun() +        op.run(['connect', 'interface', interface])  exit(0) diff --git a/src/helpers/vyos-vrrp-conntracksync.sh b/src/helpers/vyos-vrrp-conntracksync.sh index 4501aa63e..0cc718938 100755 --- a/src/helpers/vyos-vrrp-conntracksync.sh +++ b/src/helpers/vyos-vrrp-conntracksync.sh @@ -14,12 +14,10 @@  # Modified by : Mohit Mehta <mohit@vyatta.com>  # Slight modifications were made to this script for running with Vyatta  # The original script came from 0.9.14 debian conntrack-tools package -# -#  CONNTRACKD_BIN=/usr/sbin/conntrackd  CONNTRACKD_LOCK=/var/lock/conntrack.lock -CONNTRACKD_CONFIG=/etc/conntrackd/conntrackd.conf +CONNTRACKD_CONFIG=/run/conntrackd/conntrackd.conf  FACILITY=daemon  LEVEL=notice  TAG=conntrack-tools @@ -29,6 +27,10 @@ FAILOVER_STATE="/var/run/vyatta-conntrackd-failover-state"  $LOGCMD "vyatta-vrrp-conntracksync invoked at `date`" +if ! systemctl is-active --quiet conntrackd.service; then +    echo "conntrackd service not running" +    exit 1 +fi  if [ ! -e $FAILOVER_STATE ]; then  	mkdir -p /var/run diff --git a/src/helpers/vyos_net_name b/src/helpers/vyos_net_name index e21d8c9ff..1798e92db 100755 --- a/src/helpers/vyos_net_name +++ b/src/helpers/vyos_net_name @@ -20,19 +20,20 @@ import os  import re  import time  import logging +import tempfile  import threading  from sys import argv  from vyos.configtree import ConfigTree  from vyos.defaults import directories -from vyos.util import cmd +from vyos.util import cmd, boot_configuration_complete +from vyos.migrator import VirtualMigrator  vyos_udev_dir = directories['vyos_udev_dir']  vyos_log_dir = '/run/udev/log'  vyos_log_file = os.path.join(vyos_log_dir, 'vyos-net-name')  config_path = '/opt/vyatta/etc/config/config.boot' -config_status = '/tmp/vyos-config-status'  lock = threading.Lock() @@ -43,13 +44,6 @@ except FileExistsError:  logging.basicConfig(filename=vyos_log_file, level=logging.DEBUG) -def boot_configuration_complete() -> bool: -    """ Check if vyos-router has completed, hence hotplug event -    """ -    if os.path.isfile(config_status): -        return True -    return False -  def is_available(intfs: dict, intf_name: str) -> bool:      """ Check if interface name is already assigned      """ @@ -147,14 +141,20 @@ def get_configfile_interfaces() -> dict:      try:          config = ConfigTree(config_file)      except Exception: -        logging.debug(f"updating component version string syntax")          try: -            # this will update the component version string in place, for -            # updates 1.2 --> 1.3/1.4 -            os.system(f'/usr/libexec/vyos/run-config-migration.py {config_path} --virtual --set-vintage=vyos') -            with open(config_path) as f: -                config_file = f.read() +            logging.debug(f"updating component version string syntax") +            # this will update the component version string syntax, +            # required for updates 1.2 --> 1.3/1.4 +            with tempfile.NamedTemporaryFile() as fp: +                with open(fp.name, 'w') as fd: +                    fd.write(config_file) +                virtual_migration = VirtualMigrator(fp.name) +                virtual_migration.run() +                with open(fp.name) as fd: +                    config_file = fd.read() +              config = ConfigTree(config_file) +          except Exception as e:              logging.critical(f"ConfigTree error: {e}") @@ -254,4 +254,3 @@ if not boot_configuration_complete():  else:      logging.debug("boot configuration complete")  lock.release() - diff --git a/src/migration-scripts/bgp/0-to-1 b/src/migration-scripts/bgp/0-to-1 index b1d5a6514..5e9dffe1f 100755 --- a/src/migration-scripts/bgp/0-to-1 +++ b/src/migration-scripts/bgp/0-to-1 @@ -33,7 +33,7 @@ with open(file_name, 'r') as f:  base = ['protocols', 'bgp']  config = ConfigTree(config_file) -if not config.exists(base): +if not config.exists(base) or not config.is_tag(base):      # Nothing to do      exit(0) diff --git a/src/migration-scripts/bgp/1-to-2 b/src/migration-scripts/bgp/1-to-2 index 4c6d5ceb8..e2d3fcd33 100755 --- a/src/migration-scripts/bgp/1-to-2 +++ b/src/migration-scripts/bgp/1-to-2 @@ -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 @@ -20,7 +20,6 @@ from sys import argv  from sys import exit  from vyos.configtree import ConfigTree -from vyos.template import is_ipv4  if (len(argv) < 1):      print("Must specify file name!") @@ -51,23 +50,21 @@ if config.exists(base + ['parameters', 'default', 'no-ipv4-unicast']):      # Check if the "default" node is now empty, if so - remove it      if len(config.list_nodes(base + ['parameters'])) == 0:          config.delete(base + ['parameters']) +else: +    # As we now install a new default option into BGP we need to migrate all +    # existing BGP neighbors and restore the old behavior +    if config.exists(base + ['neighbor']): +        for neighbor in config.list_nodes(base + ['neighbor']): +            peer_group = base + ['neighbor', neighbor, 'peer-group'] +            if config.exists(peer_group): +                peer_group_name = config.return_value(peer_group) +                # peer group enables old behavior for neighbor - bail out +                if config.exists(base + ['peer-group', peer_group_name, 'address-family', 'ipv4-unicast']): +                    continue -    exit(0) - -# As we now install a new default option into BGP we need to migrate all -# existing BGP neighbors and restore the old behavior -if config.exists(base + ['neighbor']): -    for neighbor in config.list_nodes(base + ['neighbor']): -        peer_group = base + ['neighbor', neighbor, 'peer-group'] -        if config.exists(peer_group): -            peer_group_name = config.return_value(peer_group) -            # peer group enables old behavior for neighbor - bail out -            if config.exists(base + ['peer-group', peer_group_name, 'address-family', 'ipv4-unicast']): -                continue - -        afi_ipv4 = base + ['neighbor', neighbor, 'address-family', 'ipv4-unicast'] -        if not config.exists(afi_ipv4): -            config.set(afi_ipv4) +            afi_ipv4 = base + ['neighbor', neighbor, 'address-family', 'ipv4-unicast'] +            if not config.exists(afi_ipv4): +                config.set(afi_ipv4)  try:      with open(file_name, 'w') as f: diff --git a/src/migration-scripts/dns-forwarding/1-to-2 b/src/migration-scripts/dns-forwarding/1-to-2 index ba10c26f2..a8c930be7 100755 --- a/src/migration-scripts/dns-forwarding/1-to-2 +++ b/src/migration-scripts/dns-forwarding/1-to-2 @@ -16,7 +16,7 @@  #  # This migration script will remove the deprecated 'listen-on' statement -# from the dns forwarding service and will add the corresponding  +# from the dns forwarding service and will add the corresponding  # listen-address nodes instead. This is required as PowerDNS can only listen  # on interface addresses and not on interface names. @@ -37,53 +37,50 @@ with open(file_name, 'r') as f:  config = ConfigTree(config_file)  base = ['service', 'dns', 'forwarding'] -if not config.exists(base): +if not config.exists(base + ['listen-on']):      # Nothing to do      exit(0) -if config.exists(base + ['listen-on']): -    listen_intf = config.return_values(base + ['listen-on']) -    # Delete node with abandoned command -    config.delete(base + ['listen-on']) +listen_intf = config.return_values(base + ['listen-on']) +# Delete node with abandoned command +config.delete(base + ['listen-on']) -    # retrieve interface addresses for every configured listen-on interface -    listen_addr = [] -    for intf in listen_intf: -        # we need to evaluate the interface section before manipulating the 'intf' variable -        section = Interface.section(intf) -        if not section: -            raise ValueError(f'Invalid interface name {intf}') +# retrieve interface addresses for every configured listen-on interface +listen_addr = [] +for intf in listen_intf: +    # we need to evaluate the interface section before manipulating the 'intf' variable +    section = Interface.section(intf) +    if not section: +        raise ValueError(f'Invalid interface name {intf}') -        # we need to treat vif and vif-s interfaces differently, -        # both "real interfaces" use dots for vlan identifiers - those -        # need to be exchanged with vif and vif-s identifiers -        if intf.count('.') == 1: -            # this is a regular VLAN interface -            intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1] -        elif intf.count('.') == 2: -            # this is a QinQ VLAN interface -            intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' +  intf.split('.')[2] - -        # retrieve corresponding interface addresses in CIDR format -        # those need to be converted in pure IP addresses without network information -        path = ['interfaces', section, intf, 'address'] -        try: -            for addr in config.return_values(path): -                listen_addr.append( ip_interface(addr).ip ) -        except: -            # Some interface types do not use "address" option (e.g. OpenVPN) -            # and may not even have a fixed address -            print("Could not retrieve the address of the interface {} from the config".format(intf)) -            print("You will need to update your DNS forwarding configuration manually") - -    for addr in listen_addr: -        config.set(base + ['listen-address'], value=addr, replace=False) +    # we need to treat vif and vif-s interfaces differently, +    # both "real interfaces" use dots for vlan identifiers - those +    # need to be exchanged with vif and vif-s identifiers +    if intf.count('.') == 1: +        # this is a regular VLAN interface +        intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1] +    elif intf.count('.') == 2: +        # this is a QinQ VLAN interface +        intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' +  intf.split('.')[2] +    # retrieve corresponding interface addresses in CIDR format +    # those need to be converted in pure IP addresses without network information +    path = ['interfaces', section, intf, 'address']      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) +        for addr in config.return_values(path): +            listen_addr.append( ip_interface(addr).ip ) +    except: +        # Some interface types do not use "address" option (e.g. OpenVPN) +        # and may not even have a fixed address +        print("Could not retrieve the address of the interface {} from the config".format(intf)) +        print("You will need to update your DNS forwarding configuration manually") -exit(0) +for addr in listen_addr: +    config.set(base + ['listen-address'], value=addr, replace=False) + +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 new file mode 100755 index 000000000..5f4cff90d --- /dev/null +++ b/src/migration-scripts/firewall/6-to-7 @@ -0,0 +1,226 @@ +#!/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/>. + +# T2199: Remove unavailable nodes due to XML/Python implementation using nftables +#        monthdays: nftables does not have a monthdays equivalent +#        utc: nftables userspace uses localtime and calculates the UTC offset automatically +#        icmp/v6: migrate previously available `type-name` to valid type/code +# T4178: Update tcp flags to use multi value 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'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +icmp_remove = ['any'] +icmp_translations = { +    'ping': 'echo-request', +    'pong': 'echo-reply', +    'ttl-exceeded': 'time-exceeded', +    # Network Unreachable +    'network-unreachable': [3, 0], +    'host-unreachable': [3, 1], +    'protocol-unreachable': [3, 2], +    'port-unreachable': [3, 3], +    'fragmentation-needed': [3, 4], +    'source-route-failed': [3, 5], +    'network-unknown': [3, 6], +    'host-unknown': [3, 7], +    'network-prohibited': [3, 9], +    'host-prohibited': [3, 10], +    'TOS-network-unreachable': [3, 11], +    'TOS-host-unreachable': [3, 12], +    'communication-prohibited': [3, 13], +    'host-precedence-violation': [3, 14], +    'precedence-cutoff': [3, 15], +    # Redirect +    'network-redirect': [5, 0], +    'host-redirect': [5, 1], +    'TOS-network-redirect': [5, 2], +    'TOS host-redirect': [5, 3], +    #  Time Exceeded +    'ttl-zero-during-transit': [11, 0], +    'ttl-zero-during-reassembly': [11, 1], +    # Parameter Problem +    'ip-header-bad': [12, 0], +    'required-option-missing': [12, 1] +} + +icmpv6_remove = [] +icmpv6_translations = { +    'ping': 'echo-request', +    'pong': 'echo-reply', +    # Destination Unreachable +    'no-route': [1, 0], +    'communication-prohibited': [1, 1], +    'address-unreachble': [1, 3], +    'port-unreachable': [1, 4], +    # Redirect +    'redirect': 'nd-redirect', +    #  Time Exceeded +    'ttl-zero-during-transit': [3, 0], +    'ttl-zero-during-reassembly': [3, 1], +    # Parameter Problem +    'bad-header': [4, 0], +    'unknown-header-type': [4, 1], +    'unknown-option': [4, 2] +} + +if config.exists(base + ['name']): +    for name in config.list_nodes(base + ['name']): +        if not config.exists(base + ['name', name, 'rule']): +            continue + +        for rule in config.list_nodes(base + ['name', name, 'rule']): +            rule_recent = base + ['name', name, 'rule', rule, 'recent'] +            rule_time = base + ['name', name, 'rule', rule, 'time'] +            rule_tcp_flags = base + ['name', name, 'rule', rule, 'tcp', 'flags'] +            rule_icmp = base + ['name', name, 'rule', rule, 'icmp'] + +            if config.exists(rule_time + ['monthdays']): +                config.delete(rule_time + ['monthdays']) + +            if config.exists(rule_time + ['utc']): +                config.delete(rule_time + ['utc']) + +            if config.exists(rule_recent + ['time']): +                tmp = int(config.return_value(rule_recent + ['time'])) +                unit = 'minute' +                if tmp > 600: +                    unit = 'hour' +                elif tmp < 10: +                    unit = 'second' +                config.set(rule_recent + ['time'], value=unit) + +            if config.exists(rule_tcp_flags): +                tmp = config.return_value(rule_tcp_flags) +                config.delete(rule_tcp_flags) +                for flag in tmp.split(","): +                    if flag[0] == '!': +                        config.set(rule_tcp_flags + ['not', flag[1:].lower()]) +                    else: +                        config.set(rule_tcp_flags + [flag.lower()]) + +            if config.exists(rule_icmp + ['type-name']): +                tmp = config.return_value(rule_icmp + ['type-name']) +                if tmp in icmp_remove: +                    config.delete(rule_icmp + ['type-name']) +                elif tmp in icmp_translations: +                    translate = icmp_translations[tmp] +                    if isinstance(translate, str): +                        config.set(rule_icmp + ['type-name'], value=translate) +                    elif isinstance(translate, list): +                        config.delete(rule_icmp + ['type-name']) +                        config.set(rule_icmp + ['type'], value=translate[0]) +                        config.set(rule_icmp + ['code'], value=translate[1]) + +            for src_dst in ['destination', 'source']: +                pg_base = base + ['name', name, 'rule', rule, src_dst, 'group', 'port-group'] +                proto_base = base + ['name', name, 'rule', rule, 'protocol'] +                if config.exists(pg_base) and not config.exists(proto_base): +                    config.set(proto_base, value='tcp_udp') + +if config.exists(base + ['ipv6-name']): +    for name in config.list_nodes(base + ['ipv6-name']): +        if not config.exists(base + ['ipv6-name', name, 'rule']): +            continue + +        for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']): +            rule_recent = base + ['ipv6-name', name, 'rule', rule, 'recent'] +            rule_time = base + ['ipv6-name', name, 'rule', rule, 'time'] +            rule_tcp_flags = base + ['ipv6-name', name, 'rule', rule, 'tcp', 'flags'] +            rule_icmp = base + ['ipv6-name', name, 'rule', rule, 'icmpv6'] + +            if config.exists(rule_time + ['monthdays']): +                config.delete(rule_time + ['monthdays']) + +            if config.exists(rule_time + ['utc']): +                config.delete(rule_time + ['utc']) + +            if config.exists(rule_recent + ['time']): +                tmp = int(config.return_value(rule_recent + ['time'])) +                unit = 'minute' +                if tmp > 600: +                    unit = 'hour' +                elif tmp < 10: +                    unit = 'second' +                config.set(rule_recent + ['time'], value=unit) + +            if config.exists(rule_tcp_flags): +                tmp = config.return_value(rule_tcp_flags) +                config.delete(rule_tcp_flags) +                for flag in tmp.split(","): +                    if flag[0] == '!': +                        config.set(rule_tcp_flags + ['not', flag[1:].lower()]) +                    else: +                        config.set(rule_tcp_flags + [flag.lower()]) + +            if config.exists(base + ['ipv6-name', name, 'rule', rule, 'protocol']): +                tmp = config.return_value(base + ['ipv6-name', name, 'rule', rule, 'protocol']) +                if tmp == 'icmpv6': +                    config.set(base + ['ipv6-name', name, 'rule', rule, 'protocol'], value='ipv6-icmp') + +            if config.exists(rule_icmp + ['type']): +                tmp = config.return_value(rule_icmp + ['type']) +                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]) +                elif tmp in icmpv6_remove: +                    config.delete(rule_icmp + ['type']) +                elif tmp in icmpv6_translations: +                    translate = icmpv6_translations[tmp] +                    if isinstance(translate, str): +                        config.delete(rule_icmp + ['type']) +                        config.set(rule_icmp + ['type-name'], value=translate) +                    elif isinstance(translate, list): +                        config.set(rule_icmp + ['type'], value=translate[0]) +                        config.set(rule_icmp + ['code'], value=translate[1]) +                else: +                    config.rename(rule_icmp + ['type'], 'type-name') + +            for src_dst in ['destination', 'source']: +                pg_base = base + ['ipv6-name', name, 'rule', rule, src_dst, 'group', 'port-group'] +                proto_base = base + ['ipv6-name', name, 'rule', rule, 'protocol'] +                if config.exists(pg_base) and not config.exists(proto_base): +                    config.set(proto_base, value='tcp_udp') + +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/flow-accounting/0-to-1 b/src/migration-scripts/flow-accounting/0-to-1 new file mode 100755 index 000000000..72cce77b0 --- /dev/null +++ b/src/migration-scripts/flow-accounting/0-to-1 @@ -0,0 +1,69 @@ +#!/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/>. + +# T4099: flow-accounting: sync "source-ip" and "source-address" between netflow +#        and sflow ion CLI +# T4105: flow-accounting: drop "sflow agent-address auto" + +from sys import 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', 'flow-accounting'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +# T4099 +tmp = base + ['netflow', 'source-ip'] +if config.exists(tmp): +    config.rename(tmp, 'source-address') + +# T4105 +tmp = base + ['sflow', 'agent-address'] +if config.exists(tmp): +    value = config.return_value(tmp) +    if value == 'auto': +        # delete the "auto" +        config.delete(tmp) + +        # 1) check if BGP router-id is set +        # 2) check if OSPF router-id is set +        # 3) check if OSPFv3 router-id is set +        router_id = None +        for protocol in ['bgp', 'ospf', 'ospfv3']: +            if config.exists(['protocols', protocol, 'parameters', 'router-id']): +                router_id = config.return_value(['protocols', protocol, 'parameters', 'router-id']) +                break +        if router_id: +            config.set(tmp, value=router_id) + +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/interfaces/25-to-26 b/src/migration-scripts/interfaces/25-to-26 new file mode 100755 index 000000000..a8936235e --- /dev/null +++ b/src/migration-scripts/interfaces/25-to-26 @@ -0,0 +1,54 @@ +#!/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/>. + +# T4384: pppoe: replace default-route CLI option with common CLI nodes already +#        present for DHCP + +from sys import argv + +from vyos.ethtool import Ethtool +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 = ['interfaces', 'pppoe'] +config = ConfigTree(config_file) + +if not config.exists(base): +    exit(0) + +for ifname in config.list_nodes(base): +    tmp_config = base + [ifname, 'default-route'] +    if config.exists(tmp_config): +        # Retrieve current config value +        value = config.return_value(tmp_config) +        # Delete old Config node +        config.delete(tmp_config) +        if value == 'none': +            config.set(base + [ifname, 'no-default-route']) + +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/ipsec/8-to-9 b/src/migration-scripts/ipsec/8-to-9 new file mode 100755 index 000000000..eb44b6216 --- /dev/null +++ b/src/migration-scripts/ipsec/8-to-9 @@ -0,0 +1,48 @@ +#!/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 = ['vpn', 'ipsec', 'ike-group'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) +else: +    for ike_group in config.list_nodes(base): +        base_closeaction = base + [ike_group, 'close-action'] +        if config.exists(base_closeaction) and config.return_value(base_closeaction) == 'clear': +            config.set(base_closeaction, 'none', replace=True) + +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/openconnect/1-to-2 b/src/migration-scripts/openconnect/1-to-2 new file mode 100755 index 000000000..7031fb252 --- /dev/null +++ b/src/migration-scripts/openconnect/1-to-2 @@ -0,0 +1,54 @@ +#!/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/>. + +# Delete depricated outside-nexthop address + +import sys + +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 1): +    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) + +cfg_base = ['vpn', 'openconnect'] + +if not config.exists(cfg_base): +    # Nothing to do +    sys.exit(0) +else: +    if config.exists(cfg_base + ['authentication', 'mode']): +        if config.return_value(cfg_base + ['authentication', 'mode']) == 'radius': +            # if "mode value radius", change to "tag node mode + valueless node radius" +            config.delete(cfg_base + ['authentication','mode', 'radius']) +            config.set(cfg_base + ['authentication', 'mode', 'radius'], value=None, replace=True) +        elif not config.exists(cfg_base + ['authentication', 'mode', 'local']): +            # if "mode local", change to "tag node mode + node local value password" +            config.delete(cfg_base + ['authentication', 'mode', 'local']) +            config.set(cfg_base + ['authentication', 'mode', 'local'], value='password', replace=True) +    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/ospf/0-to-1 b/src/migration-scripts/ospf/0-to-1 new file mode 100755 index 000000000..678569d9e --- /dev/null +++ b/src/migration-scripts/ospf/0-to-1 @@ -0,0 +1,81 @@ +#!/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/>. + +# T3753: upgrade to FRR8 and move CLI options to better fit with the new FRR CLI + +from sys import argv +from vyos.configtree import ConfigTree + +def ospf_passive_migration(config, ospf_base): +    if config.exists(ospf_base): +        if config.exists(ospf_base + ['passive-interface']): +            default = False +            for interface in config.return_values(ospf_base + ['passive-interface']): +                if interface == 'default': +                    default = True +                    continue +                config.set(ospf_base + ['interface', interface, 'passive']) + +            config.delete(ospf_base + ['passive-interface']) +            config.set(ospf_base + ['passive-interface'], value='default') + +        if config.exists(ospf_base + ['passive-interface-exclude']): +            for interface in config.return_values(ospf_base + ['passive-interface-exclude']): +                config.set(ospf_base + ['interface', interface, 'passive', 'disable']) +            config.delete(ospf_base + ['passive-interface-exclude']) + +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) + +ospfv3_base = ['protocols', 'ospfv3'] +if config.exists(ospfv3_base): +    area_base = ospfv3_base + ['area'] +    if config.exists(area_base): +        for area in config.list_nodes(area_base): +            if not config.exists(area_base + [area, 'interface']): +                continue + +            for interface in config.return_values(area_base + [area, 'interface']): +                config.set(ospfv3_base + ['interface', interface, 'area'], value=area) +                config.set_tag(ospfv3_base + ['interface']) + +            config.delete(area_base + [area, 'interface']) + +# Migrate OSPF syntax in default VRF +ospf_base = ['protocols', 'ospf'] +ospf_passive_migration(config, ospf_base) + +vrf_base = ['vrf', 'name'] +if config.exists(vrf_base): +    for vrf in config.list_nodes(vrf_base): +        vrf_ospf_base = vrf_base + [vrf, 'protocols', 'ospf'] +        if config.exists(vrf_ospf_base): +            ospf_passive_migration(config, vrf_ospf_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/migration-scripts/policy/1-to-2 b/src/migration-scripts/policy/1-to-2 new file mode 100755 index 000000000..eebbf9d41 --- /dev/null +++ b/src/migration-scripts/policy/1-to-2 @@ -0,0 +1,86 @@ +#!/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/>. + +# T4170: rename "policy ipv6-route" to "policy route6" to match common +#        IPv4/IPv6 schema +# T4178: Update tcp flags to use multi value node + +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 = ['policy', 'ipv6-route'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +config.rename(base, 'route6') +config.set_tag(['policy', 'route6']) + +for route in ['route', 'route6']: +    route_path = ['policy', route] +    if config.exists(route_path): +        for name in config.list_nodes(route_path): +            if config.exists(route_path + [name, 'rule']): +                for rule in config.list_nodes(route_path + [name, 'rule']): +                    rule_tcp_flags = route_path + [name, 'rule', rule, 'tcp', 'flags'] + +                    if config.exists(rule_tcp_flags): +                        tmp = config.return_value(rule_tcp_flags) +                        config.delete(rule_tcp_flags) +                        for flag in tmp.split(","): +                            for flag in tmp.split(","): +                                if flag[0] == '!': +                                    config.set(rule_tcp_flags + ['not', flag[1:].lower()]) +                                else: +                                    config.set(rule_tcp_flags + [flag.lower()]) + +if config.exists(['interfaces']): +    def if_policy_rename(config, path): +        if config.exists(path + ['policy', 'ipv6-route']): +            config.rename(path + ['policy', 'ipv6-route'], 'route6') + +    for if_type in config.list_nodes(['interfaces']): +        for ifname in config.list_nodes(['interfaces', if_type]): +            if_path = ['interfaces', if_type, ifname] +            if_policy_rename(config, if_path) + +        for vif_type in ['vif', 'vif-s']: +            if config.exists(if_path + [vif_type]): +                for vifname in config.list_nodes(if_path + [vif_type]): +                    if_policy_rename(config, if_path + [vif_type, vifname]) + +                    if config.exists(if_path + [vif_type, vifname, 'vif-c']): +                        for vifcname in config.list_nodes(if_path + [vif_type, vifname, 'vif-c']): +                            if_policy_rename(config, if_path + [vif_type, vifname, 'vif-c', vifcname]) +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/quagga/9-to-10 b/src/migration-scripts/quagga/9-to-10 new file mode 100755 index 000000000..249738822 --- /dev/null +++ b/src/migration-scripts/quagga/9-to-10 @@ -0,0 +1,62 @@ +#!/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/>. + +# re-organize route-map as-path + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 2): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['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): +    # Bail out Early +    if not config.exists(base + [route_map, 'rule']): +        continue + +    for rule in config.list_nodes(base + [route_map, 'rule']): +        rule_base = base + [route_map, 'rule', rule] +        if config.exists(rule_base + ['set', 'as-path-exclude']): +            tmp = config.return_value(rule_base + ['set', 'as-path-exclude']) +            config.delete(rule_base + ['set', 'as-path-exclude']) +            config.set(rule_base + ['set', 'as-path', 'exclude'], value=tmp) + +        if config.exists(rule_base + ['set', 'as-path-prepend']): +            tmp = config.return_value(rule_base + ['set', 'as-path-prepend']) +            config.delete(rule_base + ['set', 'as-path-prepend']) +            config.set(rule_base + ['set', 'as-path', 'prepend'], value=tmp) + +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/ssh/1-to-2 b/src/migration-scripts/ssh/1-to-2 index bc8815753..31c40df16 100755 --- a/src/migration-scripts/ssh/1-to-2 +++ b/src/migration-scripts/ssh/1-to-2 @@ -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 @@ -30,26 +30,52 @@ file_name = argv[1]  with open(file_name, 'r') as f:      config_file = f.read() -base = ['service', 'ssh', 'loglevel'] +base = ['service', 'ssh']  config = ConfigTree(config_file)  if not config.exists(base):      # Nothing to do      exit(0) -else: -    # red in configured loglevel and convert it to lower case -    tmp = config.return_value(base).lower() +path_loglevel = base + ['loglevel'] +if config.exists(path_loglevel): +    # red in configured loglevel and convert it to lower case +    tmp = config.return_value(path_loglevel).lower()      # VyOS 1.2 had no proper value validation on the CLI thus the      # user could use any arbitrary values - sanitize them      if tmp not in ['quiet', 'fatal', 'error', 'info', 'verbose']:          tmp = 'info' +    config.set(path_loglevel, value=tmp) + +# T4273: migrate ssh cipher list to multi node +path_ciphers = base + ['ciphers'] +if config.exists(path_ciphers): +    tmp = [] +    # get curtrent cipher list - comma delimited +    for cipher in config.return_values(path_ciphers): +        tmp.extend(cipher.split(',')) +    # delete old cipher suite representation +    config.delete(path_ciphers) -    config.set(base, value=tmp) +    for cipher in tmp: +        config.set(path_ciphers, value=cipher, replace=False) -    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) +# T4273: migrate ssh key-exchange list to multi node +path_kex = base + ['key-exchange'] +if config.exists(path_kex): +    tmp = [] +    # get curtrent cipher list - comma delimited +    for kex in config.return_values(path_kex): +        tmp.extend(kex.split(',')) +    # delete old cipher suite representation +    config.delete(path_kex) + +    for kex in tmp: +        config.set(path_kex, value=kex, replace=False) + +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/system/22-to-23 b/src/migration-scripts/system/22-to-23 new file mode 100755 index 000000000..7f832e48a --- /dev/null +++ b/src/migration-scripts/system/22-to-23 @@ -0,0 +1,50 @@ +#!/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, 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', 'ipv6'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +# T4346: drop support to disbale IPv6 address family within the OS Kernel +if config.exists(base + ['disable']): +    config.delete(base + ['disable']) +    # IPv6 address family disable was the only CLI option set - we can cleanup +    # the entire tree +    if len(config.list_nodes(base)) == 0: +        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/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24 new file mode 100755 index 000000000..5ea71d51a --- /dev/null +++ b/src/migration-scripts/system/23-to-24 @@ -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 os + +from ipaddress import ip_interface +from ipaddress import ip_address +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 = ['protocols', 'static', 'arp'] +tmp_base = ['protocols', 'static', 'arp-tmp'] +config = ConfigTree(config_file) + +def fixup_cli(config, path, interface): +    if config.exists(path + ['address']): +        for address in config.return_values(path + ['address']): +            tmp = ip_interface(address) +            if ip_address(host) in tmp.network.hosts(): +                mac = config.return_value(tmp_base + [host, 'hwaddr']) +                iface_path = ['protocols', 'static', 'arp', 'interface'] +                config.set(iface_path + [interface, 'address', host, 'mac'], value=mac) +                config.set_tag(iface_path) +                config.set_tag(iface_path + [interface, 'address']) +                continue + +if not config.exists(base): +    # Nothing to do +    exit(0) + +# We need a temporary copy of the config tree as the original one needs to be +# deleted first due to a change iun thge tagNode structure. +config.copy(base, tmp_base) +config.delete(base) + +for host in config.list_nodes(tmp_base): +    for type in config.list_nodes(['interfaces']): +        for interface in config.list_nodes(['interfaces', type]): +            if_base = ['interfaces', type, interface] +            fixup_cli(config, if_base, interface) + +            if config.exists(if_base + ['vif']): +                for vif in config.list_nodes(if_base + ['vif']): +                    vif_base = ['interfaces', type, interface, 'vif', vif] +                    fixup_cli(config, vif_base, f'{interface}.{vif}') + +            if config.exists(if_base + ['vif-s']): +                for vif_s in config.list_nodes(if_base + ['vif-s']): +                    vif_s_base = ['interfaces', type, interface, 'vif-s', vif_s] +                    fixup_cli(config, vif_s_base, f'{interface}.{vif_s}') + +                    if config.exists(if_base + ['vif-s', vif_s, 'vif-c']): +                        for vif_c in config.list_nodes(if_base + ['vif-s', vif_s, 'vif-c']): +                            vif_c_base = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c', vif_c] +                            fixup_cli(config, vif_c_base, f'{interface}.{vif_s}.{vif_c}') + +config.delete(tmp_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/conntrack_sync.py b/src/op_mode/conntrack_sync.py index 66ecf8439..e45c38f07 100755 --- a/src/op_mode/conntrack_sync.py +++ b/src/op_mode/conntrack_sync.py @@ -20,12 +20,15 @@ import xmltodict  from argparse import ArgumentParser  from vyos.configquery import CliShellApiConfigQuery +from vyos.configquery import ConfigTreeQuery +from vyos.util import call  from vyos.util import cmd  from vyos.util import run  from vyos.template import render_to_string  conntrackd_bin = '/usr/sbin/conntrackd'  conntrackd_config = '/run/conntrackd/conntrackd.conf' +failover_state_file = '/var/run/vyatta-conntrackd-failover-state'  parser = ArgumentParser(description='Conntrack Sync')  group = parser.add_mutually_exclusive_group() @@ -36,6 +39,8 @@ group.add_argument('--show-internal', help='Show internal (main) tracking cache'  group.add_argument('--show-external', help='Show external (main) tracking cache', action='store_true')  group.add_argument('--show-internal-expect', help='Show internal (expect) tracking cache', action='store_true')  group.add_argument('--show-external-expect', help='Show external (expect) tracking cache', action='store_true') +group.add_argument('--show-statistics', help='Show connection syncing statistics', action='store_true') +group.add_argument('--show-status', help='Show conntrack-sync status', action='store_true')  def is_configured():      """ Check if conntrack-sync service is configured """ @@ -72,7 +77,7 @@ def xml_to_stdout(xml):          parsed = xmltodict.parse(line)          out.append(parsed) -    print(render_to_string('conntrackd/conntrackd.op-mode.tmpl', {'data' : out})) +    print(render_to_string('conntrackd/conntrackd.op-mode.j2', {'data' : out}))  if __name__ == '__main__':      args = parser.parse_args() @@ -131,6 +136,46 @@ if __name__ == '__main__':          out = cmd(f'sudo {conntrackd_bin} -C {conntrackd_config} {opt} -x')          xml_to_stdout(out) +    elif args.show_statistics: +        is_configured() +        config = ConfigTreeQuery() +        print('\nMain Table Statistics:\n') +        call(f'sudo {conntrackd_bin} -C {conntrackd_config} -s') +        print() +        if config.exists(['service', 'conntrack-sync', 'expect-sync']): +            print('\nExpect Table Statistics:\n') +            call(f'sudo {conntrackd_bin} -C {conntrackd_config} -s exp') +            print() + +    elif args.show_status: +        is_configured() +        config = ConfigTreeQuery() +        ct_sync_intf = config.list_nodes(['service', 'conntrack-sync', 'interface']) +        ct_sync_intf = ', '.join(ct_sync_intf) +        failover_state = "no transition yet!" +        expect_sync_protocols = "disabled" + +        if config.exists(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp']): +            failover_mechanism = "vrrp" +            vrrp_sync_grp = config.value(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group']) + +        if os.path.isfile(failover_state_file): +            with open(failover_state_file, "r") as f: +                failover_state = f.readline() + +        if config.exists(['service', 'conntrack-sync', 'expect-sync']): +            expect_sync_protocols = config.values(['service', 'conntrack-sync', 'expect-sync']) +            if 'all' in expect_sync_protocols: +                expect_sync_protocols = ["ftp", "sip", "h323", "nfs", "sqlnet"] +            expect_sync_protocols = ', '.join(expect_sync_protocols) + +        show_status = (f'\nsync-interface        : {ct_sync_intf}\n' +                       f'failover-mechanism    : {failover_mechanism} [sync-group {vrrp_sync_grp}]\n' +                       f'last state transition : {failover_state}' +                       f'ExpectationSync       : {expect_sync_protocols}') + +        print(show_status) +      else:          parser.print_help()          exit(1) diff --git a/src/op_mode/containers_op.py b/src/op_mode/containers_op.py deleted file mode 100755 index bc317029c..000000000 --- a/src/op_mode/containers_op.py +++ /dev/null @@ -1,78 +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 argparse - -from getpass import getuser -from vyos.configquery import ConfigTreeQuery -from vyos.util import cmd - -parser = argparse.ArgumentParser() -parser.add_argument("-a", "--all", action="store_true", help="Show all containers") -parser.add_argument("-i", "--image", action="store_true", help="Show container images") -parser.add_argument("-n", "--networks", action="store_true", help="Show container images") -parser.add_argument("-p", "--pull", action="store", help="Pull image for container") -parser.add_argument("-d", "--remove", action="store", help="Delete container image") -parser.add_argument("-u", "--update", action="store", help="Update given container image") - -config = ConfigTreeQuery() -base = ['container'] -if not config.exists(base): -    print('Containers not configured') -    exit(0) - -if getuser() != 'root': -    raise OSError('This functions needs to be run as root to return correct results!') - -if __name__ == '__main__': -    args = parser.parse_args() - -    if args.all: -        print(cmd('podman ps --all')) - -    elif args.image: -        print(cmd('podman image ls')) - -    elif args.networks: -        print(cmd('podman network ls')) - -    elif args.pull: -        image = args.pull -        try: -            print(cmd(f'podman image pull {image}')) -        except: -            print(f'Can\'t find or download image "{image}"') - -    elif args.remove: -        image = args.remove -        try: -            print(cmd(f'podman image rm {image}')) -        except: -            print(f'Can\'t delete image "{image}"') - -    elif args.update: -        tmp = config.get_config_dict(base + ['name', args.update], -                                     key_mangling=('-', '_'), get_first_key=True) -        try: -            image = tmp['image'] -            print(cmd(f'podman image pull {image}')) -        except: -            print(f'Can\'t find or download image "{image}"') -    else: -        parser.print_help() -        exit(1) - -    exit(0) diff --git a/src/op_mode/cpu_summary.py b/src/op_mode/cpu_summary.py index cfd321522..3bdf5a718 100755 --- a/src/op_mode/cpu_summary.py +++ b/src/op_mode/cpu_summary.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018 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,18 +19,30 @@ from vyos.util import colon_separated_to_dict  FILE_NAME = '/proc/cpuinfo' -with open(FILE_NAME, 'r') as f: -    data_raw = f.read() +def get_raw_data(): +    with open(FILE_NAME, 'r') as f: +        data_raw = f.read() -data = colon_separated_to_dict(data_raw) +    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'])) +    # 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'] = map(lambda s: re.sub(r'\s+', ' ', s), cpu_data['models']) +    # 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()) -print("CPU(s): {0}".format(cpu_data['cpu_number'])) -print("CPU model(s): {0}".format(",".join(cpu_data['models']))) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py new file mode 100755 index 000000000..3146fc357 --- /dev/null +++ b/src/op_mode/firewall.py @@ -0,0 +1,361 @@ +#!/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 ipaddress +import json +import re +import tabulate + +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) + +    directions = ['in', 'out', 'local'] + +    def parse_if(ifname, if_conf): +        if 'firewall' in if_conf: +            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 not name: +                            firewall['name'][fw_name]['interface'].append(name_str) +                        elif not ipv6 and name == fw_name: +                            firewall['interface'].append(name_str) + +                    if 'ipv6_name' in fw_conf: +                        fw_name = fw_conf['ipv6_name'] + +                        if not name: +                            firewall['ipv6_name'][fw_name]['interface'].append(name_str) +                        elif 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) + +    for iftype, iftype_conf in interfaces.items(): +        for ifname, if_conf in iftype_conf.items(): +            parse_if(ifname, if_conf) + +    return firewall + +def get_config_firewall(conf, name=None, ipv6=False, interfaces=True): +    config_path = ['firewall'] +    if name: +        config_path += ['ipv6-name' if ipv6 else 'name', name] + +    firewall = conf.get_config_dict(config_path, key_mangling=('-', '_'), +                                get_first_key=True, no_tag_node_value_mangle=True) +    if firewall and interfaces: +        if name: +            firewall['interface'] = [] +        else: +            if 'name' in firewall: +                for fw_name, name_conf in firewall['name'].items(): +                    name_conf['interface'] = [] + +            if 'ipv6_name' in firewall: +                for fw_name, name_conf in firewall['ipv6_name'].items(): +                    name_conf['interface'] = [] + +        get_firewall_interfaces(conf, 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}' +    try: +        results = cmd(command) +    except: +        return {} + +    out = {} +    for line in results.split('\n'): +        comment_search = re.search(rf'{name}[\- ](\d+|default-action)', line) +        if not comment_search: +            continue + +        rule = {} +        rule_id = comment_search[1] +        counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line) +        if counter_search: +            rule['packets'] = counter_search[1] +            rule['bytes'] = counter_search[2] + +        rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip() +        out[rule_id] = rule +    return out + +def output_firewall_name(name, name_conf, ipv6=False, single_rule_id=None): +    ip_str = 'IPv6' if ipv6 else 'IPv4' +    print(f'\n---------------------------------\n{ip_str} Firewall "{name}"\n') + +    if name_conf['interface']: +        print('Active on: {0}\n'.format(" ".join(name_conf['interface']))) + +    details = get_nftables_details(name, ipv6) +    rows = [] + +    if 'rule' in name_conf: +        for rule_id, rule_conf in name_conf['rule'].items(): +            if single_rule_id and rule_id != single_rule_id: +                continue + +            if 'disable' in rule_conf: +                continue + +            row = [rule_id, rule_conf['action'], rule_conf['protocol'] if 'protocol' in rule_conf else 'all'] +            if rule_id in details: +                rule_details = details[rule_id] +                row.append(rule_details.get('packets', 0)) +                row.append(rule_details.get('bytes', 0)) +                row.append(rule_details['conditions']) +            rows.append(row) + +    if 'default_action' in name_conf and not single_rule_id: +        row = ['default', name_conf['default_action'], 'all'] +        if 'default-action' in details: +            rule_details = details['default-action'] +            row.append(rule_details.get('packets', 0)) +            row.append(rule_details.get('bytes', 0)) +        rows.append(row) + +    if rows: +        header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] +        print(tabulate.tabulate(rows, header) + '\n') + +def output_firewall_name_statistics(name, name_conf, ipv6=False, single_rule_id=None): +    ip_str = 'IPv6' if ipv6 else 'IPv4' +    print(f'\n---------------------------------\n{ip_str} Firewall "{name}"\n') + +    if name_conf['interface']: +        print('Active on: {0}\n'.format(" ".join(name_conf['interface']))) + +    details = get_nftables_details(name, ipv6) +    rows = [] + +    if 'rule' in name_conf: +        for rule_id, rule_conf in name_conf['rule'].items(): +            if single_rule_id and rule_id != single_rule_id: +                continue + +            if 'disable' in rule_conf: +                continue + +            source_addr = dict_search_args(rule_conf, 'source', 'address') or '0.0.0.0/0' +            dest_addr = dict_search_args(rule_conf, 'destination', 'address') or '0.0.0.0/0' + +            row = [rule_id] +            if rule_id in details: +                rule_details = details[rule_id] +                row.append(rule_details.get('packets', 0)) +                row.append(rule_details.get('bytes', 0)) +            else: +                row.append('0') +                row.append('0') +            row.append(rule_conf['action']) +            row.append(source_addr) +            row.append(dest_addr) +            rows.append(row) + +    if 'default_action' in name_conf and not single_rule_id: +        row = ['default'] +        if 'default-action' in details: +            rule_details = details['default-action'] +            row.append(rule_details.get('packets', 0)) +            row.append(rule_details.get('bytes', 0)) +        else: +            row.append('0') +            row.append('0') +        row.append(name_conf['default_action']) +        row.append('0.0.0.0/0') # Source +        row.append('0.0.0.0/0') # Dest +        rows.append(row) + +    if rows: +        header = ['Rule', 'Packets', 'Bytes', 'Action', 'Source', 'Destination'] +        print(tabulate.tabulate(rows, header) + '\n') + +def show_firewall(): +    print('Rulesets Information') + +    conf = Config() +    firewall = get_config_firewall(conf) + +    if not firewall: +        return + +    if 'name' in firewall: +        for name, name_conf in firewall['name'].items(): +            output_firewall_name(name, name_conf, ipv6=False) + +    if 'ipv6_name' in firewall: +        for name, name_conf in firewall['ipv6_name'].items(): +            output_firewall_name(name, name_conf, ipv6=True) + +def show_firewall_name(name, ipv6=False): +    print('Ruleset Information') + +    conf = Config() +    firewall = get_config_firewall(conf, name, ipv6) +    if firewall: +        output_firewall_name(name, firewall, ipv6) + +def show_firewall_rule(name, rule_id, ipv6=False): +    print('Rule Information') + +    conf = Config() +    firewall = get_config_firewall(conf, name, ipv6) +    if firewall: +        output_firewall_name(name, firewall, ipv6, rule_id) + +def show_firewall_group(name=None): +    conf = Config() +    firewall = get_config_firewall(conf, interfaces=False) + +    if 'group' not in firewall: +        return + +    def find_references(group_type, group_name): +        out = [] +        for name_type in ['name', 'ipv6_name']: +            if name_type not in firewall: +                continue +            for name, name_conf in firewall[name_type].items(): +                if 'rule' not in name_conf: +                    continue +                for rule_id, rule_conf in name_conf['rule'].items(): +                    source_group = dict_search_args(rule_conf, 'source', 'group', group_type) +                    dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) +                    if source_group and group_name == source_group: +                        out.append(f'{name}-{rule_id}') +                    elif dest_group and group_name == dest_group: +                        out.append(f'{name}-{rule_id}') +        return out + +    header = ['Name', 'Type', 'References', 'Members'] +    rows = [] + +    for group_type, group_type_conf in firewall['group'].items(): +        for group_name, group_conf in group_type_conf.items(): +            if name and name != group_name: +                continue + +            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))) +            elif 'network' in group_conf: +                row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) +            elif 'mac_address' in group_conf: +                row.append("\n".join(sorted(group_conf['mac_address']))) +            elif 'port' in group_conf: +                row.append("\n".join(sorted(group_conf['port']))) +            else: +                row.append('N/A') +            rows.append(row) + +    if rows: +        print('Firewall Groups\n') +        print(tabulate.tabulate(rows, header)) + +def show_summary(): +    print('Ruleset Summary') + +    conf = Config() +    firewall = get_config_firewall(conf) + +    if not firewall: +        return + +    header = ['Ruleset Name', 'Description', 'References'] +    v4_out = [] +    v6_out = [] + +    if 'name' in firewall: +        for name, name_conf in firewall['name'].items(): +            description = name_conf.get('description', '') +            interfaces = ", ".join(name_conf['interface']) +            v4_out.append([name, description, interfaces]) + +    if 'ipv6_name' in firewall: +        for name, name_conf in firewall['ipv6_name'].items(): +            description = name_conf.get('description', '') +            interfaces = ", ".join(name_conf['interface']) +            v6_out.append([name, description, interfaces or 'N/A']) + +    if v6_out: +        print('\nIPv6 name:\n') +        print(tabulate.tabulate(v6_out, header) + '\n') + +    if v4_out: +        print('\nIPv4 name:\n') +        print(tabulate.tabulate(v4_out, header) + '\n') + +    show_firewall_group() + +def show_statistics(): +    print('Rulesets Statistics') + +    conf = Config() +    firewall = get_config_firewall(conf) + +    if not firewall: +        return + +    if 'name' in firewall: +        for name, name_conf in firewall['name'].items(): +            output_firewall_name_statistics(name, name_conf, ipv6=False) + +    if 'ipv6_name' in firewall: +        for name, name_conf in firewall['ipv6_name'].items(): +            output_firewall_name_statistics(name, name_conf, ipv6=True) + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument('--action', help='Action', required=False) +    parser.add_argument('--name', help='Firewall name', required=False, action='store', nargs='?', default='') +    parser.add_argument('--rule', help='Firewall Rule ID', required=False) +    parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true') + +    args = parser.parse_args() + +    if args.action == 'show': +        if not args.rule: +            show_firewall_name(args.name, args.ipv6) +        else: +            show_firewall_rule(args.name, args.rule, args.ipv6) +    elif args.action == 'show_all': +        show_firewall() +    elif args.action == 'show_group': +        show_firewall_group(args.name) +    elif args.action == 'show_statistics': +        show_statistics() +    elif args.action == 'show_summary': +        show_summary() diff --git a/src/op_mode/format_disk.py b/src/op_mode/format_disk.py index df4486bce..b3ba44e87 100755 --- a/src/op_mode/format_disk.py +++ b/src/op_mode/format_disk.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -17,11 +17,10 @@  import argparse  import os  import re -import sys +  from datetime import datetime -from time import sleep -from vyos.util import is_admin, ask_yes_no +from vyos.util import ask_yes_no  from vyos.util import call  from vyos.util import cmd  from vyos.util import DEVNULL @@ -38,16 +37,17 @@ def list_disks():  def is_busy(disk: str):      """Check if given disk device is busy by re-reading it's partition table""" -    return call(f'sudo blockdev --rereadpt /dev/{disk}', stderr=DEVNULL) != 0 +    return call(f'blockdev --rereadpt /dev/{disk}', stderr=DEVNULL) != 0  def backup_partitions(disk: str):      """Save sfdisk partitions output to a backup file""" -    device_path = '/dev/' + disk -    backup_ts = datetime.now().strftime('%Y-%m-%d-%H:%M') -    backup_file = '/var/tmp/backup_{}.{}'.format(disk, backup_ts) -    cmd(f'sudo /sbin/sfdisk -d {device_path} > {backup_file}') +    device_path = f'/dev/{disk}' +    backup_ts = datetime.now().strftime('%Y%m%d-%H%M') +    backup_file = f'/var/tmp/backup_{disk}.{backup_ts}' +    call(f'sfdisk -d {device_path} > {backup_file}') +    print(f'Partition table backup saved to {backup_file}')  def list_partitions(disk: str): @@ -65,11 +65,11 @@ def list_partitions(disk: str):  def delete_partition(disk: str, partition_idx: int): -    cmd(f'sudo /sbin/parted /dev/{disk} rm {partition_idx}') +    cmd(f'parted /dev/{disk} rm {partition_idx}')  def format_disk_like(target: str, proto: str): -    cmd(f'sudo /sbin/sfdisk -d /dev/{proto} | sudo /sbin/sfdisk --force /dev/{target}') +    cmd(f'sfdisk -d /dev/{proto} | sfdisk --force /dev/{target}')  if __name__ == '__main__': @@ -79,10 +79,6 @@ if __name__ == '__main__':      group.add_argument('-p', '--proto', type=str, required=True, help='Prototype device to use as reference')      args = parser.parse_args() -    if not is_admin(): -        print('Must be admin or root to format disk') -        sys.exit(1) -      target_disk = args.target      eligible_target_disks = list_disks() @@ -90,54 +86,48 @@ if __name__ == '__main__':      eligible_proto_disks = eligible_target_disks.copy()      eligible_proto_disks.remove(target_disk) -    fmt = { -        'target_disk': target_disk, -        'proto_disk': proto_disk, -    } -      if proto_disk == target_disk:          print('The two disk drives must be different.') -        sys.exit(1) +        exit(1) -    if not os.path.exists('/dev/' + proto_disk): -        print('Device /dev/{proto_disk} does not exist'.format_map(fmt)) -        sys.exit(1) +    if not os.path.exists(f'/dev/{proto_disk}'): +        print(f'Device /dev/{proto_disk} does not exist') +        exit(1)      if not os.path.exists('/dev/' + target_disk): -        print('Device /dev/{target_disk} does not exist'.format_map(fmt)) -        sys.exit(1) +        print(f'Device /dev/{target_disk} does not exist') +        exit(1)      if target_disk not in eligible_target_disks: -        print('Device {target_disk} can not be formatted'.format_map(fmt)) -        sys.exit(1) +        print(f'Device {target_disk} can not be formatted') +        exit(1)      if proto_disk not in eligible_proto_disks: -        print('Device {proto_disk} can not be used as a prototype for {target_disk}'.format_map(fmt)) -        sys.exit(1) +        print(f'Device {proto_disk} can not be used as a prototype for {target_disk}') +        exit(1)      if is_busy(target_disk): -        print("Disk device {target_disk} is busy. Can't format it now".format_map(fmt)) -        sys.exit(1) +        print(f'Disk device {target_disk} is busy, unable to format') +        exit(1) -    print('This will re-format disk {target_disk} so that it has the same disk\n' -          'partion sizes and offsets as {proto_disk}. This will not copy\n' -          'data from {proto_disk} to {target_disk}. But this will erase all\n' -          'data on {target_disk}.\n'.format_map(fmt)) +    print(f'\nThis will re-format disk {target_disk} so that it has the same disk' +          f'\npartion sizes and offsets as {proto_disk}. This will not copy' +          f'\ndata from {proto_disk} to {target_disk}. But this will erase all' +          f'\ndata on {target_disk}.\n') -    if not ask_yes_no("Do you wish to proceed?"): -        print('OK. Disk drive {target_disk} will not be re-formated'.format_map(fmt)) -        sys.exit(0) +    if not ask_yes_no('Do you wish to proceed?'): +        print(f'Disk drive {target_disk} will not be re-formated') +        exit(0) -    print('OK. Re-formating disk drive {target_disk}...'.format_map(fmt)) +    print(f'Re-formating disk drive {target_disk}...')      print('Making backup copy of partitions...')      backup_partitions(target_disk) -    sleep(1)      print('Deleting old partitions...')      for p in list_partitions(target_disk):          delete_partition(disk=target_disk, partition_idx=p) -    print('Creating new partitions on {target_disk} based on {proto_disk}...'.format_map(fmt)) +    print(f'Creating new partitions on {target_disk} based on {proto_disk}...')      format_disk_like(target=target_disk, proto=proto_disk) -    print('Done.') +    print('Done!') diff --git a/src/op_mode/generate_openconnect_otp_key.py b/src/op_mode/generate_openconnect_otp_key.py new file mode 100755 index 000000000..363bcf3ea --- /dev/null +++ b/src/op_mode/generate_openconnect_otp_key.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/>. + +import argparse +import os + +from vyos.util import popen +from secrets import token_hex +from base64 import b32encode + +if os.geteuid() != 0: +    exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True) +    parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval',  default="30", required=False) +    parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False) +    args = parser.parse_args() + +    hostname = os.uname()[1] +    username = args.username +    digits = args.digits +    period = args.interval + +    # check variables: +    if int(digits) < 6 or int(digits) > 8: +        print("") +        quit("The number of digits in the one-time password must be between '6' and '8'") + +    if int(period) < 5 or int(period) > 86400: +        print("") +        quit("Time token interval must be between '5' and '86400' seconds") + +    # generate OTP key, URL & QR: +    key_hex = token_hex(20) +    key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + +    otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period]) +    qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + +    print("# You can share it with the user, he just needs to scan the QR in his OTP app") +    print("# username: ", username) +    print("# OTP KEY: ", key_base32) +    print("# OTP URL: ", otp_url) +    print(qrcode) +    print('# To add this OTP key to configuration, run the following commands:') +    print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'") +    if period != "30": +        print(f"set vpn openconnect authentication local-users username {username} otp interval '{period}'") +    if digits != "6": +        print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{digits}'") diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py new file mode 100755 index 000000000..0628e6135 --- /dev/null +++ b/src/op_mode/generate_ovpn_client_file.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os + +from jinja2 import Template +from textwrap import fill + +from vyos.configquery import ConfigTreeQuery +from vyos.ifconfig import Section +from vyos.util import cmd + + +client_config = """ + +client +nobind +remote {{ remote_host }} {{ port }} +remote-cert-tls server +proto {{ 'tcp-client' if protocol == 'tcp-active' else 'udp' }} +dev {{ device }} +dev-type {{ device }} +persist-key +persist-tun +verb 3 + +# Encryption options +{% if encryption is defined and encryption is not none %} +{%   if encryption.cipher is defined and encryption.cipher is not none %} +cipher {{ encryption.cipher }} +{%     if encryption.cipher == 'bf128' %} +keysize 128 +{%     elif encryption.cipher == 'bf256' %} +keysize 256 +{%     endif %} +{%   endif %} +{%   if encryption.ncp_ciphers is defined and encryption.ncp_ciphers is not none %} +data-ciphers {{ encryption.ncp_ciphers }} +{%   endif %} +{% endif %} + +{% if hash is defined and hash is not none %} +auth {{ hash }} +{% endif %} +keysize 256 +comp-lzo {{ '' if use_lzo_compression is defined else 'no' }} + +<ca> +-----BEGIN CERTIFICATE----- +{{ ca }} +-----END CERTIFICATE----- + +</ca> + +<cert> +-----BEGIN CERTIFICATE----- +{{ cert }} +-----END CERTIFICATE----- + +</cert> + +<key> +-----BEGIN PRIVATE KEY----- +{{ key }} +-----END PRIVATE KEY----- + +</key> + +""" + +config = ConfigTreeQuery() +base = ['interfaces', 'openvpn'] + +if not config.exists(base): +    print('OpenVPN not configured') +    exit(0) + + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument("-i", "--interface", type=str, help='OpenVPN interface the client is connecting to', required=True) +    parser.add_argument("-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True) +    parser.add_argument("-c", "--cert", type=str, help='OpenVPN client cerificate', required=True) +    parser.add_argument("-k", "--key", type=str, help='OpenVPN client cerificate key', action="store") +    args = parser.parse_args() + +    interface = args.interface +    ca = args.ca +    cert = args.cert +    key = args.key +    if not key: +        key = args.cert + +    if interface not in Section.interfaces('openvpn'): +        exit(f'OpenVPN interface "{interface}" does not exist!') + +    if not config.exists(['pki', 'ca', ca, 'certificate']): +        exit(f'OpenVPN CA certificate "{ca}" does not exist!') + +    if not config.exists(['pki', 'certificate', cert, 'certificate']): +        exit(f'OpenVPN certificate "{cert}" does not exist!') + +    if not config.exists(['pki', 'certificate', cert, 'private', 'key']): +        exit(f'OpenVPN certificate key "{key}" does not exist!') + +    ca = config.value(['pki', 'ca', ca, 'certificate']) +    ca = fill(ca, width=64) +    cert = config.value(['pki', 'certificate', cert, 'certificate']) +    cert = fill(cert, width=64) +    key = config.value(['pki', 'certificate', key, 'private', 'key']) +    key = fill(key, width=64) +    remote_host = config.value(base + [interface, 'local-host']) + +    ovpn_conf = config.get_config_dict(base + [interface], key_mangling=('-', '_'), get_first_key=True) + +    port = '1194' if 'local_port' not in ovpn_conf else ovpn_conf['local_port'] +    proto = 'udp' if 'protocol' not in ovpn_conf else ovpn_conf['protocol'] +    device = 'tun' if 'device_type' not in ovpn_conf else ovpn_conf['device_type'] + +    config = { +        'interface'   : interface, +        'ca'          : ca, +        'cert'        : cert, +        'key'         : key, +        'device'      : device, +        'port'        : port, +        'proto'       : proto, +        'remote_host' : remote_host, +        'address'     : [], +    } + +# Clear out terminal first +print('\x1b[2J\x1b[H') +client = Template(client_config, trim_blocks=True).render(config) +print(client) diff --git a/src/op_mode/generate_public_key_command.py b/src/op_mode/generate_public_key_command.py index 7a7b6c923..f071ae350 100755 --- a/src/op_mode/generate_public_key_command.py +++ b/src/op_mode/generate_public_key_command.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 @@ -29,8 +29,12 @@ def get_key(path):          key_string = vyos.remote.get_remote_config(path)      return key_string.split() -username = sys.argv[1] -algorithm, key, identifier = get_key(sys.argv[2]) +try: +    username = sys.argv[1] +    algorithm, key, identifier = get_key(sys.argv[2]) +except Exception as e: +    print("Failed to retrieve the public key: {}".format(e)) +    sys.exit(1)  print('# To add this key as an embedded key, run the following commands:')  print('configure') @@ -39,3 +43,4 @@ print(f'set system login user {username} authentication public-keys {identifier}  print('commit')  print('save')  print('exit') + diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index 990b06c12..21561d16f 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -222,9 +222,9 @@ except KeyboardInterrupt:  print('\n\n==== <snip> ====')  if args.os == 'ios': -    print(render_to_string('ipsec/ios_profile.tmpl', data)) +    print(render_to_string('ipsec/ios_profile.j2', data))      print('==== </snip> ====\n')      print('Save the XML from above to a new file named "vyos.mobileconfig" and E-Mail it to your phone.')  elif args.os == 'windows': -    print(render_to_string('ipsec/windows_profile.tmpl', data)) +    print(render_to_string('ipsec/windows_profile.j2', data))      print('==== </snip> ====\n') diff --git a/src/op_mode/lldp_op.py b/src/op_mode/lldp_op.py index 731e71891..17f6bf552 100755 --- a/src/op_mode/lldp_op.py +++ b/src/op_mode/lldp_op.py @@ -54,12 +54,15 @@ def parse_data(data, interface):          for local_if, values in neighbor.items():              if interface is not None and local_if != interface:                  continue +            cap = ''              for chassis, c_value in values.get('chassis', {}).items(): +                # bail out early if no capabilities found +                if 'capability' not in c_value: +                    continue                  capabilities = c_value['capability']                  if isinstance(capabilities, dict):                      capabilities = [capabilities] -                cap = ''                  for capability in capabilities:                      if capability['enabled']:                          if capability['type'] == 'Router': diff --git a/src/op_mode/monitor_bandwidth_test.sh b/src/op_mode/monitor_bandwidth_test.sh index 900223bca..a6ad0b42c 100755 --- a/src/op_mode/monitor_bandwidth_test.sh +++ b/src/op_mode/monitor_bandwidth_test.sh @@ -24,6 +24,9 @@ elif [[ $(dig $1 AAAA +short | grep -v '\.$' | wc -l) -gt 0 ]]; then      # Set address family to IPv6 when FQDN has at least one AAAA record      OPT="-V" +else +    # It's not IPv6, no option needed +    OPT=""  fi  /usr/bin/iperf $OPT -c $1 $2 diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py new file mode 100755 index 000000000..5be40082f --- /dev/null +++ b/src/op_mode/policy_route.py @@ -0,0 +1,189 @@ +#!/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 re +import tabulate + +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): +    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 + +def get_nftables_details(name, ipv6=False): +    suffix = '6' if ipv6 else '' +    command = f'sudo nft list chain ip{suffix} mangle VYOS_PBR{suffix}_{name}' +    try: +        results = cmd(command) +    except: +        return {} + +    out = {} +    for line in results.split('\n'): +        comment_search = re.search(rf'{name}[\- ](\d+|default-action)', line) +        if not comment_search: +            continue + +        rule = {} +        rule_id = comment_search[1] +        counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line) +        if counter_search: +            rule['packets'] = counter_search[1] +            rule['bytes'] = counter_search[2] + +        rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip() +        out[rule_id] = rule +    return out + +def output_policy_route(name, route_conf, ipv6=False, single_rule_id=None): +    ip_str = 'IPv6' if ipv6 else 'IPv4' +    print(f'\n---------------------------------\n{ip_str} Policy Route "{name}"\n') + +    if route_conf['interface']: +        print('Active on: {0}\n'.format(" ".join(route_conf['interface']))) + +    details = get_nftables_details(name, ipv6) +    rows = [] + +    if 'rule' in route_conf: +        for rule_id, rule_conf in route_conf['rule'].items(): +            if single_rule_id and rule_id != single_rule_id: +                continue + +            if 'disable' in rule_conf: +                continue + +            action = rule_conf['action'] if 'action' in rule_conf else 'set' +            protocol = rule_conf['protocol'] if 'protocol' in rule_conf else 'all' + +            row = [rule_id, action, protocol] +            if rule_id in details: +                rule_details = details[rule_id] +                row.append(rule_details.get('packets', 0)) +                row.append(rule_details.get('bytes', 0)) +                row.append(rule_details['conditions']) +            rows.append(row) + +    if 'default_action' in route_conf and not single_rule_id: +        row = ['default', route_conf['default_action'], 'all'] +        if 'default-action' in details: +            rule_details = details['default-action'] +            row.append(rule_details.get('packets', 0)) +            row.append(rule_details.get('bytes', 0)) +        rows.append(row) + +    if rows: +        header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] +        print(tabulate.tabulate(rows, header) + '\n') + +def show_policy(ipv6=False): +    print('Ruleset Information') + +    conf = Config() +    policy = get_config_policy(conf) + +    if not policy: +        return + +    if not ipv6 and 'route' in policy: +        for route, route_conf in policy['route'].items(): +            output_policy_route(route, route_conf, ipv6=False) + +    if ipv6 and 'route6' in policy: +        for route, route_conf in policy['route6'].items(): +            output_policy_route(route, route_conf, ipv6=True) + +def show_policy_name(name, ipv6=False): +    print('Ruleset Information') + +    conf = Config() +    policy = get_config_policy(conf, name, ipv6) +    if policy: +        output_policy_route(name, policy, ipv6) + +def show_policy_rule(name, rule_id, ipv6=False): +    print('Rule Information') + +    conf = Config() +    policy = get_config_policy(conf, name, ipv6) +    if policy: +        output_policy_route(name, policy, ipv6, rule_id) + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument('--action', help='Action', required=False) +    parser.add_argument('--name', help='Policy name', required=False, action='store', nargs='?', default='') +    parser.add_argument('--rule', help='Policy Rule ID', required=False) +    parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true') + +    args = parser.parse_args() + +    if args.action == 'show': +        if not args.rule: +            show_policy_name(args.name, args.ipv6) +        else: +            show_policy_rule(args.name, args.rule, args.ipv6) +    elif args.action == 'show_all': +        show_policy(args.ipv6) diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index 679b03c0b..fd4f86d88 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -33,10 +33,12 @@ def utc2local(datetime):  def parse_time(s):      try: -        if re.match(r'^\d{1,2}$', s): -            if (int(s) > 59): +        if re.match(r'^\d{1,9999}$', s): +            if (int(s) > 59) and (int(s) < 1440):                  s = str(int(s)//60) + ":" + str(int(s)%60)                  return datetime.strptime(s, "%H:%M").time() +            if (int(s) >= 1440): +                return s.split()              else:                  return datetime.strptime(s, "%M").time()          else: @@ -141,7 +143,7 @@ def execute_shutdown(time, reboot=True, ask=True):              cmd(f'/usr/bin/wall "{wall_msg}"')          else:              if not ts: -                exit(f'Invalid time "{time[0]}". The valid format is HH:MM') +                exit(f'Invalid time "{time[0]}". Uses 24 Hour Clock format')              else:                  exit(f'Invalid date "{time[1]}". A valid format is YYYY-MM-DD [HH:MM]')      else: @@ -172,7 +174,12 @@ def main():      action.add_argument("--reboot", "-r",                          help="Reboot the system",                          nargs="*", -                        metavar="Minutes|HH:MM") +                        metavar="HH:MM") + +    action.add_argument("--reboot_in", "-i", +                        help="Reboot the system", +                        nargs="*", +                        metavar="Minutes")      action.add_argument("--poweroff", "-p",                          help="Poweroff the system", @@ -190,7 +197,17 @@ def main():      try:          if args.reboot is not None: +            for r in args.reboot: +                if ':' not in r and '/' not in r and '.' not in r: +                    print("Incorrect  format! Use HH:MM") +                    exit(1)              execute_shutdown(args.reboot, reboot=True, ask=args.yes) +        if args.reboot_in is not None: +            for i in args.reboot_in: +                if ':' in i: +                    print("Incorrect format! Use Minutes") +                    exit(1) +            execute_shutdown(args.reboot_in, reboot=True, ask=args.yes)          if args.poweroff is not None:              execute_shutdown(args.poweroff, reboot=False, ask=args.yes)          if args.cancel: diff --git a/src/op_mode/ppp-server-ctrl.py b/src/op_mode/ppp-server-ctrl.py index 670cdf879..e93963fdd 100755 --- a/src/op_mode/ppp-server-ctrl.py +++ b/src/op_mode/ppp-server-ctrl.py @@ -60,7 +60,7 @@ def main():          output, err = popen(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][args.proto]) + args.action + ses_pattern, stderr=DEVNULL, decode='utf-8')          if not err:              try: -                print(output) +                print(f' {output}')              except:                  sys.exit(0)          else: diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py index 109c8dd7b..91b25567a 100755 --- a/src/op_mode/restart_frr.py +++ b/src/op_mode/restart_frr.py @@ -22,6 +22,7 @@ import psutil  from logging.handlers import SysLogHandler  from shutil import rmtree +from vyos.base import Warning  from vyos.util import call  from vyos.util import ask_yes_no  from vyos.util import process_named_running @@ -138,7 +139,7 @@ def _reload_config(daemon):  # define program arguments  cmd_args_parser = argparse.ArgumentParser(description='restart frr daemons')  cmd_args_parser.add_argument('--action', choices=['restart'], required=True, help='action to frr daemons') -cmd_args_parser.add_argument('--daemon', choices=['bfdd', 'bgpd', 'ospfd', 'ospf6d', 'isisd', 'ripd', 'ripngd', 'staticd', 'zebra'], required=False,  nargs='*', help='select single or multiple daemons') +cmd_args_parser.add_argument('--daemon', choices=['bfdd', 'bgpd', 'ldpd', 'ospfd', 'ospf6d', 'isisd', 'ripd', 'ripngd', 'staticd', 'zebra'], required=False,  nargs='*', help='select single or multiple daemons')  # parse arguments  cmd_args = cmd_args_parser.parse_args() @@ -163,7 +164,7 @@ if cmd_args.action == 'restart':      if cmd_args.daemon != ['']:          for daemon in cmd_args.daemon:              if not process_named_running(daemon): -                print('WARNING: some of listed daemons are not running!') +                Warning('some of listed daemons are not running!')      # run command to restart daemon      for daemon in cmd_args.daemon: diff --git a/src/op_mode/show_configuration_json.py b/src/op_mode/show_configuration_json.py new file mode 100755 index 000000000..fdece533b --- /dev/null +++ b/src/op_mode/show_configuration_json.py @@ -0,0 +1,36 @@ +#!/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 json + +from vyos.configquery import ConfigTreeQuery + + +config = ConfigTreeQuery() +c = config.get_config_dict() + +parser = argparse.ArgumentParser() +parser.add_argument("-p", "--pretty", action="store_true", help="Show pretty configuration in JSON format") + + +if __name__ == '__main__': +    args = parser.parse_args() + +    if args.pretty: +        print(json.dumps(c, indent=4)) +    else: +        print(json.dumps(c)) diff --git a/src/op_mode/show_cpu.py b/src/op_mode/show_cpu.py index 0040e950d..9973d9789 100755 --- a/src/op_mode/show_cpu.py +++ b/src/op_mode/show_cpu.py @@ -21,7 +21,7 @@ from sys import exit  from vyos.util import popen, DEVNULL  OUT_TMPL_SRC = """ -{% if cpu %} +{%- 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 %} @@ -31,31 +31,42 @@ OUT_TMPL_SRC = """  {% 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 %} +{%- endif -%}  """ -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'] - -if len(cpu) > 0: -    tmp = { 'cpu':cpu } +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) -    print(tmpl.render(tmp)) -    exit(0) -else: -    print('CPU information could not be determined\n') -    exit(1) +    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_dhcp.py b/src/op_mode/show_dhcp.py index cd6e8ed43..4b1758eea 100755 --- a/src/op_mode/show_dhcp.py +++ b/src/op_mode/show_dhcp.py @@ -26,6 +26,7 @@ from datetime import datetime  from isc_dhcp_leases import Lease, IscDhcpLeases +from vyos.base import Warning  from vyos.config import Config  from vyos.util import is_systemd_service_running @@ -213,7 +214,7 @@ if __name__ == '__main__':      # 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'): -        print("WARNING: DHCP server is configured but not started. Data may be stale.") +        Warning('DHCP server is configured but not started. Data may be stale.')      if args.leases:          leases = get_leases(conf, lease_file, args.state, args.pool, args.sort) diff --git a/src/op_mode/show_dhcpv6.py b/src/op_mode/show_dhcpv6.py index 1f987ff7b..b34b730e6 100755 --- a/src/op_mode/show_dhcpv6.py +++ b/src/op_mode/show_dhcpv6.py @@ -26,6 +26,7 @@ from datetime import datetime  from isc_dhcp_leases import Lease, IscDhcpLeases +from vyos.base import Warning  from vyos.config import Config  from vyos.util import is_systemd_service_running @@ -203,7 +204,7 @@ if __name__ == '__main__':      # 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-server6.service'): -        print("WARNING: DHCPv6 server is configured but not started. Data may be stale.") +        Warning('DHCPv6 server is configured but not started. Data may be stale.')      if args.leases:          leases = get_leases(conf, lease_file, args.state, args.pool, args.sort) diff --git a/src/op_mode/show_ipsec_sa.py b/src/op_mode/show_ipsec_sa.py index e72f0f965..5b8f00dba 100755 --- a/src/op_mode/show_ipsec_sa.py +++ b/src/op_mode/show_ipsec_sa.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019 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,119 +14,117 @@  # 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 +from re import split as re_split +from sys import exit -import vici -import tabulate -import hurry.filesize +from hurry import filesize +from tabulate import tabulate +from vici import Session as vici_session + +from vyos.util import seconds_to_human -import vyos.util  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))] +    return [convert(c) for c in re_split('([0-9]+)', str(key))] -def format_output(conns, sas): + +def format_output(sas):      sa_data = [] -    for peer, parent_conn in conns.items(): -        if peer not in sas: -            continue - -        parent_sa = sas[peer] -        child_sas = parent_sa['child-sas'] -        installed_sas = {v['name'].decode(): v for k, v in child_sas.items() if v["state"] == b"INSTALLED"} - -        # parent_sa["state"] = IKE state, child_sas["state"] = ESP state -        state = 'down' -        uptime = 'N/A' - -        if parent_sa["state"] == b"ESTABLISHED" and installed_sas: -            state = "up" - -        remote_host = parent_sa["remote-host"].decode() -        remote_id = parent_sa["remote-id"].decode() - -        if remote_host == remote_id: -            remote_id = "N/A" - -        # The counters can only be obtained from the child SAs -        for child_conn in parent_conn['children']: -            if child_conn not in installed_sas: -                data = [child_conn, "down", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"] -                sa_data.append(data) -                continue - -            isa = installed_sas[child_conn] -            csa_name = isa['name'] -            csa_name = csa_name.decode() - -            bytes_in = hurry.filesize.size(int(isa["bytes-in"].decode())) -            bytes_out = hurry.filesize.size(int(isa["bytes-out"].decode())) -            bytes_str = "{0}/{1}".format(bytes_in, bytes_out) - -            pkts_in = hurry.filesize.size(int(isa["packets-in"].decode()), system=hurry.filesize.si) -            pkts_out = hurry.filesize.size(int(isa["packets-out"].decode()), system=hurry.filesize.si) -            pkts_str = "{0}/{1}".format(pkts_in, pkts_out) -            # Remove B from <1K values -            pkts_str = re.sub(r'B', r'', pkts_str) - -            uptime = vyos.util.seconds_to_human(isa['install-time'].decode()) - -            enc = isa["encr-alg"].decode() -            if "encr-keysize" in isa: -                key_size = isa["encr-keysize"].decode() -            else: -                key_size = "" -            if "integ-alg" in isa: -                hash = isa["integ-alg"].decode() -            else: -                hash = "" -            if "dh-group" in isa: -                dh_group = isa["dh-group"].decode() -            else: -                dh_group = "" - -            proposal = enc -            if key_size: -                proposal = "{0}_{1}".format(proposal, key_size) -            if hash: -                proposal = "{0}/{1}".format(proposal, hash) -            if dh_group: -                proposal = "{0}/{1}".format(proposal, dh_group) - -            data = [csa_name, state, uptime, bytes_str, pkts_str, remote_host, remote_id, proposal] -            sa_data.append(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.decode() +                if sa_state: +                    if sa_state == b'INSTALLED': +                        sa_out_state = 'up' +                    else: +                        sa_out_state = 'down' +                if sa_uptime: +                    sa_out_uptime = seconds_to_human(sa_uptime.decode()) +                if sa_bytes_in and sa_bytes_out: +                    bytes_in = filesize.size(int(sa_bytes_in.decode())) +                    bytes_out = filesize.size(int(sa_bytes_out.decode())) +                    sa_out_bytes = f'{bytes_in}/{bytes_out}' +                if sa_packets_in and sa_packets_out: +                    packets_in = filesize.size(int(sa_packets_in.decode()), +                                               system=filesize.si) +                    packets_out = filesize.size(int(sa_packets_out.decode()), +                                                system=filesize.si) +                    sa_out_packets = f'{packets_in}/{packets_out}' +                if sa_remote_addr: +                    sa_out_remote_addr = sa_remote_addr.decode() +                if sa_remote_id: +                    sa_out_remote_id = sa_remote_id.decode() +                # format proposal +                if sa_proposal_encr_alg: +                    sa_out_proposal = sa_proposal_encr_alg.decode() +                if sa_proposal_encr_keysize: +                    sa_proposal_encr_keysize_str = sa_proposal_encr_keysize.decode() +                    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.decode() +                    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.decode() +                    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 +                ]) + +    # return output data      return sa_data +  if __name__ == '__main__':      try: -        session = vici.Session() -        conns = {} -        sas = {} +        session = vici_session() +        sas = list(session.list_sas()) -        for conn in session.list_conns(): -            for key in conn: -                conns[key] = conn[key] - -        for sa in session.list_sas(): -            for key in sa: -                sas[key] = sa[key] - -        headers = ["Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", "Remote address", "Remote ID", "Proposal"] -        sa_data = format_output(conns, sas) +        sa_data = format_output(sas)          sa_data = sorted(sa_data, key=alphanum_key) -        output = tabulate.tabulate(sa_data, headers) + +        headers = [ +            "Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", +            "Remote address", "Remote ID", "Proposal" +        ] +        output = tabulate(sa_data, headers)          print(output)      except PermissionError:          print("You do not have a permission to connect to the IPsec daemon") -        sys.exit(1) +        exit(1)      except ConnectionRefusedError:          print("IPsec is not runing") -        sys.exit(1) +        exit(1)      except Exception as e:          print("An error occured: {0}".format(e)) -        sys.exit(1) +        exit(1) diff --git a/src/op_mode/show_nat_rules.py b/src/op_mode/show_nat_rules.py index d68def26a..98adb31dd 100755 --- a/src/op_mode/show_nat_rules.py +++ b/src/op_mode/show_nat_rules.py @@ -32,7 +32,7 @@ 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 "-----------------")) @@ -40,7 +40,7 @@ if args.source or args.destination:      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 @@ -50,9 +50,9 @@ if args.source or args.destination:                  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-']: @@ -60,7 +60,7 @@ if args.source or args.destination:                  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')): @@ -88,7 +88,7 @@ if args.source or args.destination:                      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): @@ -98,10 +98,10 @@ if args.source or args.destination:                      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 ' + tran_addr_json['port'] -             +                    tran_addr += 'port ' + str(tran_addr_json['port']) +              else:                  if 'masquerade' in data['expr'][i]:                      tran_addr = 'masquerade' @@ -112,10 +112,10 @@ if args.source or args.destination:              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() diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py index f7b99cc0d..9a5adcffb 100755 --- a/src/op_mode/show_openvpn.py +++ b/src/op_mode/show_openvpn.py @@ -26,10 +26,10 @@ outp_tmpl = """  {% if clients %}  OpenVPN status on {{ intf }} -Client CN       Remote Host           Local Host            TX bytes    RX bytes   Connected Since ----------       -----------           ----------            --------    --------   --------------- +Client CN       Remote Host            Tunnel IP        Local Host            TX bytes    RX bytes   Connected Since +---------       -----------            ---------        ----------            --------    --------   ---------------  {% for c in clients %} -{{ "%-15s"|format(c.name) }} {{ "%-21s"|format(c.remote) }} {{ "%-21s"|format(local) }} {{ "%-9s"|format(c.tx_bytes) }}   {{ "%-9s"|format(c.rx_bytes) }}  {{ c.online_since }} +{{ "%-15s"|format(c.name) }}  {{ "%-21s"|format(c.remote) }}  {{ "%-15s"|format(c.tunnel) }}  {{ "%-21s"|format(local) }} {{ "%-9s"|format(c.tx_bytes) }}   {{ "%-9s"|format(c.rx_bytes) }}  {{ c.online_since }}  {% endfor %}  {% endif %}  """ @@ -50,6 +50,19 @@ def bytes2HR(size):      output="{0:.1f} {1}".format(size, suff[suffIdx])      return output +def get_vpn_tunnel_address(peer, interface): +    lst = [] +    status_file = '/var/run/openvpn/{}.status'.format(interface) + +    with open(status_file, 'r') as f: +        lines = f.readlines() +        for line in lines: +            if peer in line: +                lst.append(line) +        tunnel_ip = lst[1].split(',')[0] + +        return tunnel_ip +  def get_status(mode, interface):      status_file = '/var/run/openvpn/{}.status'.format(interface)      # this is an empirical value - I assume we have no more then 999999 @@ -110,7 +123,7 @@ def get_status(mode, interface):                          'tx_bytes': bytes2HR(line.split(',')[3]),                          'online_since': line.split(',')[4]                      } - +                    client["tunnel"] = get_vpn_tunnel_address(client['remote'], interface)                      data['clients'].append(client)                      continue              else: @@ -173,5 +186,7 @@ if __name__ == '__main__':                  if len(remote_host) >= 1:                      client['remote'] = str(remote_host[0]) + ':' + remote_port +                client['tunnel'] = 'N/A' +          tmpl = jinja2.Template(outp_tmpl)          print(tmpl.render(data)) diff --git a/src/op_mode/show_ram.py b/src/op_mode/show_ram.py index 5818ec132..2b0be3965 100755 --- a/src/op_mode/show_ram.py +++ b/src/op_mode/show_ram.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 @@ -55,10 +55,17 @@ def get_system_memory_human():      return mem -if __name__ == '__main__': -    mem = get_system_memory_human() +def get_raw_data(): +    return get_system_memory_human() + +def get_formatted_output(): +    mem = get_raw_data() -    print("Total: {}".format(mem["total"])) -    print("Free:  {}".format(mem["free"])) -    print("Used:  {}".format(mem["used"])) +    out = "Total: {}\n".format(mem["total"]) +    out += "Free:  {}\n".format(mem["free"]) +    out += "Used:  {}".format(mem["used"]) +    return out + +if __name__ == '__main__': +    print(get_formatted_output()) diff --git a/src/op_mode/show_uptime.py b/src/op_mode/show_uptime.py index c3dea52e6..b70c60cf8 100755 --- a/src/op_mode/show_uptime.py +++ b/src/op_mode/show_uptime.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 as @@ -26,25 +26,41 @@ def get_uptime_seconds():  def get_load_averages():      from re import search      from vyos.util import cmd +    from vyos.cpu import get_core_count      data = cmd("uptime")      matches = search(r"load average:\s*(?P<one>[0-9\.]+)\s*,\s*(?P<five>[0-9\.]+)\s*,\s*(?P<fifteen>[0-9\.]+)\s*", data) +    core_count = get_core_count() +      res = {} -    res[1]  = float(matches["one"]) -    res[5]  = float(matches["five"]) -    res[15] = float(matches["fifteen"]) +    res[1]  = float(matches["one"]) / core_count +    res[5]  = float(matches["five"]) / core_count +    res[15] = float(matches["fifteen"]) / core_count      return res -if __name__ == '__main__': +def get_raw_data():      from vyos.util import seconds_to_human -    print("Uptime: {}\n".format(seconds_to_human(get_uptime_seconds()))) +    res = {} +    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() -    avgs = get_load_averages() +    out = "Uptime: {}\n\n".format(data["uptime"]) +    avgs = data["load_average"] +    out += "Load averages:\n" +    out += "1  minute:   {:.01f}%\n".format(avgs[1]*100) +    out += "5  minutes:  {:.01f}%\n".format(avgs[5]*100) +    out += "15 minutes:  {:.01f}%\n".format(avgs[15]*100) -    print("Load averages:") -    print("1  minute:   {:.02f}%".format(avgs[1]*100)) -    print("5  minutes:  {:.02f}%".format(avgs[5]*100)) -    print("15 minutes:  {:.02f}%".format(avgs[15]*100)) +    return out + +if __name__ == '__main__': +    print(get_formatted_output()) diff --git a/src/op_mode/show_version.py b/src/op_mode/show_version.py index 7962e1e7b..b82ab6eca 100755 --- a/src/op_mode/show_version.py +++ b/src/op_mode/show_version.py @@ -26,10 +26,6 @@ from jinja2 import Template  from sys import exit  from vyos.util import call -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") -  version_output_tmpl = """  Version:          VyOS {{version}}  Release train:    {{release_train}} @@ -51,7 +47,20 @@ Hardware UUID:    {{hardware_uuid}}  Copyright:        VyOS maintainers and contributors  """ +def get_raw_data(): +    version_data = vyos.version.get_full_version_data() +    return version_data + +def get_formatted_output(): +    version_data = get_raw_data() +    tmpl = Template(version_output_tmpl) +    return tmpl.render(version_data) +  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") +      args = parser.parse_args()      version_data = vyos.version.get_full_version_data() @@ -60,9 +69,8 @@ if __name__ == '__main__':          import json          print(json.dumps(version_data))          exit(0) - -    tmpl = Template(version_output_tmpl) -    print(tmpl.render(version_data)) +    else: +        print(get_formatted_output())      if args.funny:          print(vyos.limericks.get_random()) diff --git a/src/op_mode/show_virtual_server.py b/src/op_mode/show_virtual_server.py new file mode 100755 index 000000000..377180dec --- /dev/null +++ b/src/op_mode/show_virtual_server.py @@ -0,0 +1,33 @@ +#!/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 vyos.configquery import ConfigTreeQuery +from vyos.util import call + +def is_configured(): +    """ Check if high-availability virtual-server is configured """ +    config = ConfigTreeQuery() +    if not config.exists(['high-availability', 'virtual-server']): +        return False +    return True + +if __name__ == '__main__': + +    if is_configured() == False: +        print('Virtual server not configured!') +        exit(0) + +    call('sudo ipvsadm --list --numeric') diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py new file mode 100755 index 000000000..4299d6e5f --- /dev/null +++ b/src/op_mode/traceroute.py @@ -0,0 +1,207 @@ +#! /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 +import socket +import ipaddress + +options = { +    'backward-hops': { +        'traceroute': '{command} --back', +        'type': 'noarg', +        'help': 'Display number of backward hops when they different from the forwarded path' +    }, +    'bypass': { +        'traceroute': '{command} -r', +        'type': 'noarg', +        'help': 'Bypass the normal routing tables and send directly to a host on an attached network' +    }, +    'do-not-fragment': { +        'traceroute': '{command} -F', +        'type': 'noarg', +        'help': 'Do not fragment probe packets.' +    }, +    'first-ttl': { +        'traceroute': '{command} -f {value}', +        'type': '<ttl>', +        'help': 'Specifies with what TTL to start. Defaults to 1.' +    }, +    'icmp': { +        'traceroute': '{command} -I', +        'type': 'noarg', +        'help': 'Use ICMP ECHO for tracerouting' +    }, +    'interface': { +        'traceroute': '{command} -i {value}', +        'type': '<interface>', +        'help': 'Source interface' +    }, +    'lookup-as': { +        'traceroute': '{command} -A', +        'type': 'noarg', +        'help': 'Perform AS path lookups' +    }, +    'mark': { +        'traceroute': '{command} --fwmark={value}', +        'type': '<fwmark>', +        'help': 'Set the firewall mark for outgoing packets' +    }, +    'no-resolve': { +        'traceroute': '{command} -n', +        'type': 'noarg', +        'help': 'Do not resolve hostnames' +    }, +    'port': { +        'traceroute': '{command} -p {value}', +        'type': '<port>', +        'help': 'Destination port' +    }, +    'source-address': { +        'traceroute': '{command} -s {value}', +        'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', +        'help': 'Specify source IP v4/v6 address' +    }, +    'tcp': { +        'traceroute': '{command} -T', +        'type': 'noarg', +        'help': 'Use TCP SYN for tracerouting (default port is 80)' +    }, +    'tos': { +        'traceroute': '{commad} -t {value}', +        'type': '<tos>', +        'help': 'Mark packets with specified TOS' +    }, +    'ttl': { +        'traceroute': '{command} -m {value}', +        'type': '<ttl>', +        'help': 'Maximum number of hops' +    }, +    'udp': { +        'traceroute': '{command} -U', +        'type': 'noarg', +        'help': 'Use UDP to particular port for tracerouting (default port is 53)' +    }, +    'vrf': { +        'traceroute': 'sudo ip vrf exec {value} {command}', +        'type': '<vrf>', +        'help': 'Use specified VRF table', +        'dflt': 'default'} +} + +traceroute = { +    4: '/bin/traceroute -4', +    6: '/bin/traceroute -6', +} + + +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 expension_failure(option, completions): +    reason = 'Ambiguous' if completions else 'Invalid' +    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)) +        sys.stderr.write('\n') +    sys.stdout.write('<nocomps>') +    sys.exit(1) + + +def complete(prefix): +    return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): +    while args: +        shortname = args.first() +        longnames = complete(shortname) +        if len(longnames) != 1: +            expension_failure(shortname, longnames) +        longname = longnames[0] +        if options[longname]['type'] == 'noarg': +            command = options[longname]['traceroute'].format( +                command=command, value='') +        elif not args: +            sys.exit(f'traceroute: missing argument for {longname} option') +        else: +            command = options[longname]['traceroute'].format( +                command=command, value=args.first()) +    return command + + +if __name__ == '__main__': +    args = List(sys.argv[1:]) +    host = args.first() + +    if not host: +        sys.exit("traceroute: Missing host") + +    if host == '--get-options': +        args.first()  # pop traceroute +        args.first()  # pop IP +        while args: +            option = args.first() + +            matched = complete(option) +            if not args: +                sys.stdout.write(' '.join(matched)) +                sys.exit(0) + +            if len(matched) > 1 : +                sys.stdout.write(' '.join(matched)) +                sys.exit(0) + +            if options[matched[0]]['type'] == 'noarg': +                continue + +            value = args.first() +            if not args: +                matched = complete(option) +                sys.stdout.write(options[matched[0]]['type']) +                sys.exit(0) + +    for name,option in options.items(): +        if 'dflt' in option and name not in args: +            args.append(name) +            args.append(option['dflt']) + +    try: +        ip = socket.gethostbyname(host) +    except UnicodeError: +        sys.exit(f'tracroute: Unknown host: {host}') +    except socket.gaierror: +        ip = host + +    try: +        version = ipaddress.ip_address(ip).version +    except ValueError: +        sys.exit(f'traceroute: Unknown host: {host}') + +    command = convert(traceroute[version],args) + +    # print(f'{command} {host}') +    os.system(f'{command} {host}') + diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index 40854fa8f..8955e5a59 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -88,7 +88,22 @@ def reset_profile(profile, tunnel):  def debug_peer(peer, tunnel):      if not peer or peer == "all": -        call('sudo /usr/sbin/ipsec statusall') +        debug_commands = [ +            "sudo ipsec statusall", +            "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 | head -100", +            "sudo ip route show table 220" +        ] +        for debug_cmd in debug_commands: +            print(f'\n### {debug_cmd} ###') +            call(debug_cmd)          return      if not tunnel or tunnel == 'all': diff --git a/src/op_mode/vrrp.py b/src/op_mode/vrrp.py index 2c1db20bf..dab146d28 100755 --- a/src/op_mode/vrrp.py +++ b/src/op_mode/vrrp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018 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,6 +23,7 @@ import tabulate  import vyos.util +from vyos.configquery import ConfigTreeQuery  from vyos.ifconfig.vrrp import VRRP  from vyos.ifconfig.vrrp import VRRPError, VRRPNoData @@ -35,7 +36,17 @@ group.add_argument("-d", "--data", action="store_true", help="Print detailed VRR  args = parser.parse_args() +def is_configured(): +    """ Check if VRRP is configured """ +    config = ConfigTreeQuery() +    if not config.exists(['high-availability', 'vrrp', 'group']): +        return False +    return True +  # Exit early if VRRP is dead or not configured +if  is_configured() == False: +    print('VRRP not configured!') +    exit(0)  if not VRRP.is_running():      print('VRRP is not running')      sys.exit(0) diff --git a/src/op_mode/zone_policy.py b/src/op_mode/zone_policy.py new file mode 100755 index 000000000..7b43018c2 --- /dev/null +++ b/src/op_mode/zone_policy.py @@ -0,0 +1,81 @@ +#!/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 tabulate + +from vyos.config import Config +from vyos.util import dict_search_args + +def get_config_zone(conf, name=None): +    config_path = ['zone-policy'] +    if name: +        config_path += ['zone', name] + +    zone_policy = conf.get_config_dict(config_path, key_mangling=('-', '_'), +                                get_first_key=True, no_tag_node_value_mangle=True) +    return zone_policy + +def output_zone_name(zone, zone_conf): +    print(f'\n---------------------------------\nZone: "{zone}"\n') +     +    interfaces = ', '.join(zone_conf['interface']) if 'interface' in zone_conf else '' +    if 'local_zone' in zone_conf: +        interfaces = 'LOCAL' + +    print(f'Interfaces: {interfaces}\n') + +    header = ['From Zone', 'Firewall'] +    rows = [] + +    if 'from' in zone_conf: +        for from_name, from_conf in zone_conf['from'].items(): +            row = [from_name] +            v4_name = dict_search_args(from_conf, 'firewall', 'name') +            v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') + +            if v4_name: +                rows.append(row + [v4_name]) + +            if v6_name: +                rows.append(row + [f'{v6_name} [IPv6]']) + +    if rows: +        print('From Zones:\n') +        print(tabulate.tabulate(rows, header)) + +def show_zone_policy(zone): +    conf = Config() +    zone_policy = get_config_zone(conf, zone) + +    if not zone_policy: +        return + +    if 'zone' in zone_policy: +        for zone, zone_conf in zone_policy['zone'].items(): +            output_zone_name(zone, zone_conf) +    elif zone: +        output_zone_name(zone, zone_policy) + +if __name__ == '__main__': +    parser = argparse.ArgumentParser() +    parser.add_argument('--action', help='Action', required=False) +    parser.add_argument('--name', help='Zone name', required=False, action='store', nargs='?', default='') + +    args = parser.parse_args() + +    if args.action == 'show': +        show_zone_policy(args.name) diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql index 29f58f709..1133d79ed 100644 --- a/src/services/api/graphql/README.graphql +++ b/src/services/api/graphql/README.graphql @@ -1,7 +1,12 @@ +The following examples are in the form as entered in the GraphQL +'playground', which is found at: + +https://{{ host_address }}/graphql +  Example using GraphQL mutations to configure a DHCP server: -This assumes that the http-api is running: +All examples assume that the http-api is running:  'set service https api' @@ -58,8 +63,8 @@ N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to  save to /config/config.boot; to save to an alternative path, specify  fileName. -Similarly, using the same 'endpoint' (meaning the form of the request and -resolver; the actual enpoint for all GraphQL requests is +Similarly, using an analogous 'endpoint' (meaning the form of the request +and resolver; the actual enpoint for all GraphQL requests is  https://hostname/graphql), one can load an arbitrary config file from a  path. @@ -73,17 +78,73 @@ mutation {    }  } +Op-mode 'show' commands may be requested by path, e.g.: + +query { +  Show (data: {path: ["interfaces", "ethernet", "detail"]}) { +    success +    errors +    data { +      result +    } +  } +} -The GraphQL playground will be found at: +N.B. to see the output the 'data' field 'result' must be present in the +request. -https://{{ host_address }}/graphql +Mutations to manipulate firewall address groups: -An equivalent curl command to the first example above would be: +mutation { +  CreateFirewallAddressGroup (data: {name: "ADDR-GRP", address: "10.0.0.1"}) { +    success +    errors +  } +} + +mutation { +  UpdateFirewallAddressGroupMembers (data: {name: "ADDR-GRP", +                                            address: ["10.0.0.1-10.0.0.8", "192.168.0.1"]}) { +    success +    errors +  } +} + +mutation { +  RemoveFirewallAddressGroupMembers (data: {name: "ADDR-GRP", +                                            address: "192.168.0.1"}) { +    success +    errors +  } +} + +N.B. The schema for the above specify that 'address' be of the form 'list of +strings' (SDL type [String!]! for UpdateFirewallAddressGroupMembers, where +the ! indicates that the input is required; SDL type [String] in +CreateFirewallAddressGroup, since a group may be created without any +addresses). However, notice that a single string may be passed without being +a member of a list, in which case the specification allows for 'input +coercion': + +http://spec.graphql.org/October2021/#sec-Scalars.Input-Coercion + +Similarly, IPv6 versions of the above: + +CreateFirewallAddressIpv6Group +UpdateFirewallAddressIpv6GroupMembers +RemoveFirewallAddressIpv6GroupMembers + + +Instead of using the GraphQL playground, an equivalent curl command to the +first example above would be:  curl -k 'https://192.168.100.168/graphql' -H 'Content-Type: application/json' --data-binary '{"query": "mutation {createInterfaceEthernet (data: {interface: \"eth1\", address: \"192.168.0.1/24\", description: \"BOB\"}) {success errors data {address}}}"}'  Note that the 'mutation' term is prefaced by 'query' in the curl command. +Curl equivalents may be read from within the GraphQL playground at the 'copy +curl' button. +  What's here:  services @@ -97,15 +158,22 @@ services  │       │   └── schema  │       │       ├── config_file.graphql  │       │       ├── dhcp_server.graphql +│       │       ├── firewall_group.graphql  │       │       ├── interface_ethernet.graphql -│       │       └── schema.graphql +│       │       ├── schema.graphql +│       │       ├── show_config.graphql +│       │       └── show.graphql  │       ├── README.graphql  │       ├── recipes  │       │   ├── __init__.py +│       │   ├── remove_firewall_address_group_members.py  │       │   ├── session.py  │       │   └── templates  │       │       ├── create_dhcp_server.tmpl -│       │       └── create_interface_ethernet.tmpl +│       │       ├── create_firewall_address_group.tmpl +│       │       ├── create_interface_ethernet.tmpl +│       │       ├── remove_firewall_address_group_members.tmpl +│       │       └── update_firewall_address_group_members.tmpl  │       └── state.py  ├── vyos-configd  ├── vyos-hostsd diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 1fbe13d0c..84d719fda 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -1,4 +1,20 @@ +# Copyright 2021 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 vyos.defaults +from . graphql.queries import query  from . graphql.mutations import mutation  from . graphql.directives import directives_dict  from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers @@ -8,6 +24,6 @@ def generate_schema():      type_defs = load_schema_from_path(api_schema_dir) -    schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives=directives_dict) +    schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict)      return schema diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index f5cd88acd..0a9298f55 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -1,5 +1,21 @@ +# Copyright 2021 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 ariadne import SchemaDirectiveVisitor, ObjectType -from . mutations import make_configure_resolver, make_config_file_resolver +from . queries import * +from . mutations import *  def non(arg):      pass @@ -19,19 +35,45 @@ class VyosDirective(SchemaDirectiveVisitor):  class ConfigureDirective(VyosDirective):      """      Class providing implementation of 'configure' directive in schema. -      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type,                                         make_resolver=make_configure_resolver) +class ShowConfigDirective(VyosDirective): +    """ +    Class providing implementation of 'show' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_show_config_resolver) +  class ConfigFileDirective(VyosDirective):      """      Class providing implementation of 'configfile' directive in schema. -      """      def visit_field_definition(self, field, object_type):          super().visit_field_definition(field, object_type,                                         make_resolver=make_config_file_resolver) -directives_dict = {"configure": ConfigureDirective, "configfile": ConfigFileDirective} +class ShowDirective(VyosDirective): +    """ +    Class providing implementation of 'show' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_show_resolver) + +class ImageDirective(VyosDirective): +    """ +    Class providing implementation of 'image' directive in schema. +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_image_resolver) + +directives_dict = {"configure": ConfigureDirective, +                   "showconfig": ShowConfigDirective, +                   "configfile": ConfigFileDirective, +                   "show": ShowDirective, +                   "image": ImageDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 8a28b13d7..0c3eb702a 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,3 +1,17 @@ +# Copyright 2021 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 importlib import import_module  from typing import Any, Dict @@ -10,7 +24,7 @@ from api.graphql.recipes.session import Session  mutation = ObjectType("Mutation") -def make_resolver(mutation_name, class_name, session_func): +def make_mutation_resolver(mutation_name, class_name, session_func):      """Dynamically generate a resolver for the mutation named in the      schema by 'mutation_name'. @@ -51,7 +65,8 @@ def make_resolver(mutation_name, class_name, session_func):                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) -            method() +            result = method() +            data['result'] = result              return {                  "success": True, @@ -65,16 +80,20 @@ def make_resolver(mutation_name, class_name, session_func):      return func_impl +def make_prefix_resolver(mutation_name, prefix=[]): +    for pre in prefix: +        Pre = pre.capitalize() +        if Pre in mutation_name: +            class_name = mutation_name.replace(Pre, '', 1) +            return make_mutation_resolver(mutation_name, class_name, pre) +    raise Exception +  def make_configure_resolver(mutation_name):      class_name = mutation_name -    return make_resolver(mutation_name, class_name, 'configure') +    return make_mutation_resolver(mutation_name, class_name, 'configure')  def make_config_file_resolver(mutation_name): -    if 'Save' in mutation_name: -        class_name = mutation_name.replace('Save', '', 1) -        return make_resolver(mutation_name, class_name, 'save') -    elif 'Load' in mutation_name: -        class_name = mutation_name.replace('Load', '', 1) -        return make_resolver(mutation_name, class_name, 'load') -    else: -        raise Exception +    return make_prefix_resolver(mutation_name, prefix=['save', 'load']) + +def make_image_resolver(mutation_name): +    return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py new file mode 100644 index 000000000..e1868091e --- /dev/null +++ b/src/services/api/graphql/graphql/queries.py @@ -0,0 +1,89 @@ +# Copyright 2021 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 importlib import import_module +from typing import Any, Dict +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 + +query = ObjectType("Query") + +def make_query_resolver(query_name, class_name, session_func): +    """Dynamically generate a resolver for the query named in the +    schema by 'query_name'. + +    Dynamic generation is provided using the package 'makefun' (via the +    decorator 'with_signature'), which provides signature-preserving +    function wrappers; it provides several improvements over, say, +    functools.wraps. + +    :raise Exception: +        raising ConfigErrors, or internal errors +    """ + +    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)' + +    @query.field(query_name) +    @convert_kwargs_to_snake_case +    @with_signature(func_sig, func_name=resolver_name) +    async def func_impl(*args, **kwargs): +        try: +            if 'data' not in kwargs: +                return { +                    "success": False, +                    "errors": ['missing data'] +                } + +            data = kwargs['data'] +            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}') +                klass = getattr(mod, class_name) +            except ImportError: +                # otherwise, dynamically generate subclass to invoke subclass +                # name based templates +                klass = type(class_name, (Session,), {}) +            k = klass(session, data) +            method = getattr(k, session_func) +            result = method() +            data['result'] = result + +            return { +                "success": True, +                "data": data +            } +        except Exception as error: +            return { +                "success": False, +                "errors": [str(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_show_resolver(query_name): +    class_name = query_name +    return make_query_resolver(query_name, class_name, 'show') diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql index efe7de632..d89904b9e 100644 --- a/src/services/api/graphql/graphql/schema/firewall_group.graphql +++ b/src/services/api/graphql/graphql/schema/firewall_group.graphql @@ -45,3 +45,51 @@ type RemoveFirewallAddressGroupMembersResult {      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 new file mode 100644 index 000000000..7d1b4f9d0 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/image.graphql @@ -0,0 +1,29 @@ +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/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 9e97a0d60..952e46f34 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -3,12 +3,16 @@ schema {      mutation: Mutation  } -type Query { -    _dummy: String -} -  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 + +type Query { +    Show(data: ShowInput) : ShowResult @show +    ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig +}  type Mutation {      CreateDhcpServer(data: DhcpServerConfigInput) : CreateDhcpServerResult @configure @@ -16,6 +20,11 @@ type Mutation {      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  } diff --git a/src/services/api/graphql/graphql/schema/show.graphql b/src/services/api/graphql/graphql/schema/show.graphql new file mode 100644 index 000000000..c7709e48b --- /dev/null +++ b/src/services/api/graphql/graphql/schema/show.graphql @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..34afd2aa9 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/show_config.graphql @@ -0,0 +1,21 @@ +""" +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/recipes/remove_firewall_address_group_members.py b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py index cde30c27a..b91932e14 100644 --- a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py +++ b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py @@ -1,3 +1,17 @@ +# Copyright 2021 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 . session import Session diff --git a/src/services/api/graphql/recipes/session.py b/src/services/api/graphql/recipes/session.py index b96cc1753..1f844ff70 100644 --- a/src/services/api/graphql/recipes/session.py +++ b/src/services/api/graphql/recipes/session.py @@ -1,9 +1,33 @@ +# Copyright 2021 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 json +  from ariadne import convert_camel_case_to_snake +  import vyos.defaults  from vyos.config import Config +from vyos.configtree import ConfigTree  from vyos.template import render -class Session(object): +class Session: +    """ +    Wrapper for calling configsession functions based on GraphQL requests. +    Non-nullable fields in the respective schema allow avoiding a key check +    in 'data'. +    """      def __init__(self, session, data):          self._session = session          self._data = data @@ -43,6 +67,21 @@ class Session(object):              session.delete(path)              session.commit() +    def show_config(self): +        session = self._session +        data = self._data +        out = '' + +        try: +            out = session.show_config(data['path']) +            if data.get('config_format', '') == 'json': +                config_tree = vyos.configtree.ConfigTree(out) +                out = json.loads(config_tree.to_json()) +        except Exception as error: +            raise error + +        return out +      def save(self):          session = self._session          data = self._data @@ -63,3 +102,37 @@ class Session(object):              session.commit()          except Exception as error:              raise error + +    def show(self): +        session = self._session +        data = self._data +        out = '' + +        try: +            out = session.show(data['path']) +        except Exception as error: +            raise error + +        return out + +    def add(self): +        session = self._session +        data = self._data + +        try: +            res = session.install_image(data['location']) +        except Exception as error: +            raise error + +        return res + +    def delete(self): +        session = self._session +        data = self._data + +        try: +            res = session.remove_image(data['name']) +        except Exception as error: +            raise error + +        return res diff --git a/src/services/api/graphql/recipes/templates/create_firewall_address_ipv_6_group.tmpl b/src/services/api/graphql/recipes/templates/create_firewall_address_ipv_6_group.tmpl new file mode 100644 index 000000000..e9b660722 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/create_firewall_address_ipv_6_group.tmpl @@ -0,0 +1,4 @@ +set firewall group ipv6-address-group {{ name }} +{% for add in address %} +set firewall group ipv6-address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/api/graphql/recipes/templates/remove_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/recipes/templates/remove_firewall_address_ipv_6_group_members.tmpl new file mode 100644 index 000000000..0efa0b226 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/remove_firewall_address_ipv_6_group_members.tmpl @@ -0,0 +1,3 @@ +{% for add in address %} +delete firewall group ipv6-address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/api/graphql/recipes/templates/update_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/recipes/templates/update_firewall_address_ipv_6_group_members.tmpl new file mode 100644 index 000000000..f98a5517c --- /dev/null +++ b/src/services/api/graphql/recipes/templates/update_firewall_address_ipv_6_group_members.tmpl @@ -0,0 +1,3 @@ +{% for add in address %} +set firewall group ipv6-address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/vyos-configd b/src/services/vyos-configd index 670b6e66a..48c9135e2 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -28,6 +28,7 @@ import zmq  from contextlib import contextmanager  from vyos.defaults import directories +from vyos.util import boot_configuration_complete  from vyos.configsource import ConfigSourceString, ConfigSourceError  from vyos.config import Config  from vyos import ConfigError @@ -186,7 +187,7 @@ def initialization(socket):          session_out = None      # if not a 'live' session, for example on boot, write to file -    if not session_out or not os.path.isfile('/tmp/vyos-config-status'): +    if not session_out or not boot_configuration_complete():          session_out = script_stdout_log          session_mode = 'a' diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd index f4b1d0fc2..9ae7b1ea9 100755 --- a/src/services/vyos-hostsd +++ b/src/services/vyos-hostsd @@ -139,6 +139,27 @@  # }  #  # +### authoritative_zones +## Additional zones hosted authoritatively by pdns-recursor. +## We add NTAs for these zones but do not do much else here. +# +# { 'type': 'authoritative_zones', +#   'op': 'add', +#   'data': ['<str zone>', ...] +# } +# +# { 'type': 'authoritative_zones', +#   'op': 'delete', +#   'data': ['<str zone>', ...] +# } +# +# { 'type': 'authoritative_zones', +#   'op': 'get', +# } +# response: +# { 'data': ['<str zone>', ...] } +# +#  ### search_domains  #  # { 'type': 'search_domains', @@ -255,6 +276,7 @@ STATE = {      "name_server_tags_recursor": [],      "name_server_tags_system": [],      "forward_zones": {}, +    "authoritative_zones": [],      "hosts": {},      "host_name": "vyos",      "domain_name": "", @@ -267,7 +289,8 @@ base_schema = Schema({      Required('op'): Any('add', 'delete', 'set', 'get', 'apply'),      'type': Any('name_servers',          'name_server_tags_recursor', 'name_server_tags_system', -        'forward_zones', 'search_domains', 'hosts', 'host_name'), +        'forward_zones', 'authoritative_zones', 'search_domains', +        'hosts', 'host_name'),      'data': Any(list, dict),      'tag': str,      'tag_regex': str @@ -347,6 +370,11 @@ msg_schema_map = {          'delete': data_list_schema,          'get': op_type_schema          }, +    'authoritative_zones': { +        'add': data_list_schema, +        'delete': data_list_schema, +        'get': op_type_schema +        },      'search_domains': {          'add': data_dict_list_schema,          'delete': data_list_schema, @@ -393,12 +421,12 @@ def pdns_rec_control(command):  def make_resolv_conf(state):      logger.info(f"Writing {RESOLV_CONF_FILE}") -    render(RESOLV_CONF_FILE, 'vyos-hostsd/resolv.conf.tmpl', state, +    render(RESOLV_CONF_FILE, 'vyos-hostsd/resolv.conf.j2', state,              user='root', group='root')  def make_hosts(state):      logger.info(f"Writing {HOSTS_FILE}") -    render(HOSTS_FILE, 'vyos-hostsd/hosts.tmpl', state, +    render(HOSTS_FILE, 'vyos-hostsd/hosts.j2', state,              user='root', group='root')  def make_pdns_rec_conf(state): @@ -409,12 +437,12 @@ def make_pdns_rec_conf(state):      chmod_755(PDNS_REC_RUN_DIR)      render(PDNS_REC_LUA_CONF_FILE, -            'dns-forwarding/recursor.vyos-hostsd.conf.lua.tmpl', +            'dns-forwarding/recursor.vyos-hostsd.conf.lua.j2',              state, user=PDNS_REC_USER, group=PDNS_REC_GROUP)      logger.info(f"Writing {PDNS_REC_ZONES_FILE}")      render(PDNS_REC_ZONES_FILE, -            'dns-forwarding/recursor.forward-zones.conf.tmpl', +            'dns-forwarding/recursor.forward-zones.conf.j2',              state, user=PDNS_REC_USER, group=PDNS_REC_GROUP)  def set_host_name(state, data): @@ -522,7 +550,7 @@ def handle_message(msg):          data = get_option(msg, 'data')          if _type in ['name_servers', 'forward_zones', 'search_domains', 'hosts']:              delete_items_from_dict(STATE[_type], data) -        elif _type in ['name_server_tags_recursor', 'name_server_tags_system']: +        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:              delete_items_from_list(STATE[_type], data)          else:              raise ValueError(f'Operation "{op}" unknown data type "{_type}"') @@ -534,7 +562,7 @@ def handle_message(msg):          elif _type in ['forward_zones', 'hosts']:              add_items_to_dict(STATE[_type], data)              # maybe we need to rec_control clear-nta each domain that was removed here? -        elif _type in ['name_server_tags_recursor', 'name_server_tags_system']: +        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:              add_items_to_list(STATE[_type], data)          else:              raise ValueError(f'Operation "{op}" unknown data type "{_type}"') @@ -550,7 +578,7 @@ def handle_message(msg):          if _type in ['name_servers', 'search_domains', 'hosts']:              tag_regex = get_option(msg, 'tag_regex')              result = get_items_from_dict_regex(STATE[_type], tag_regex) -        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'forward_zones']: +        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'forward_zones', 'authoritative_zones']:              result = STATE[_type]          else:              raise ValueError(f'Operation "{op}" unknown data type "{_type}"') diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index aa7ac6708..c1b595412 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -32,6 +32,7 @@ from fastapi.responses import HTMLResponse  from fastapi.exceptions import RequestValidationError  from fastapi.routing import APIRoute  from pydantic import BaseModel, StrictStr, validator +from starlette.middleware.cors import CORSMiddleware  from starlette.datastructures import FormData  from starlette.formparsers import FormParser, MultiPartParser  from multipart.multipart import parse_options_header @@ -351,7 +352,7 @@ class MultipartRoute(APIRoute):                  return error(e.status_code, e.detail)              except Exception as e:                  if request.ERR_MISSING_KEY: -                    return error(422, "Valid API key is required") +                    return error(401, "Valid API key is required")                  if request.ERR_MISSING_DATA:                      return error(422, "Non-empty data field is required")                  if request.ERR_NOT_JSON: @@ -610,13 +611,19 @@ def show_op(data: ShowModel):  # GraphQL integration  ### -from api.graphql.bindings import generate_schema +def graphql_init(fast_api_app): +    from api.graphql.bindings import generate_schema -api.graphql.state.init() +    api.graphql.state.init() +    api.graphql.state.settings['app'] = app -schema = generate_schema() +    schema = generate_schema() -app.add_route('/graphql', GraphQL(schema, debug=True)) +    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"))) +    else: +        app.add_route('/graphql', GraphQL(schema, debug=True))  ### @@ -640,15 +647,22 @@ if __name__ == '__main__':      app.state.vyos_session = config_session      app.state.vyos_keys = server_config['api_keys'] -    app.state.vyos_debug = bool(server_config['debug'] == 'true') -    app.state.vyos_strict = bool(server_config['strict'] == 'true') +    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', []) -    api.graphql.state.settings['app'] = app +    if app.state.vyos_gql: +        graphql_init(app)      try: -        uvicorn.run(app, host=server_config["listen_address"], -                         port=int(server_config["port"]), -                         proxy_headers=True) +        if not server_config['socket']: +            uvicorn.run(app, host=server_config["listen_address"], +                             port=int(server_config["port"]), +                             proxy_headers=True) +        else: +            uvicorn.run(app, uds="/run/api.sock", +                             proxy_headers=True)      except OSError as err:          logger.critical(f"OSError {err}")          sys.exit(1) diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index 1fba0d75b..a8df232ae 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -29,6 +29,7 @@ from logging.handlers import SysLogHandler  from vyos.ifconfig.vrrp import VRRP  from vyos.configquery import ConfigTreeQuery  from vyos.util import cmd +from vyos.util import dict_search  # configure logging  logger = logging.getLogger(__name__) @@ -69,22 +70,11 @@ class KeepalivedFifo:                  raise ValueError()              # Read VRRP configuration directly from CLI -            vrrp_config_dict = conf.get_config_dict(base, key_mangling=('-', '_'), -                                                    get_first_key=True) -            self.vrrp_config = {'vrrp_groups': {}, 'sync_groups': {}} -            for key in ['group', 'sync_group']: -                if key not in vrrp_config_dict: -                    continue -                for group, group_config in vrrp_config_dict[key].items(): -                    if 'transition_script' not in group_config: -                        continue -                    self.vrrp_config['vrrp_groups'][group] = { -                        'STOP': group_config['transition_script'].get('stop'), -                        'FAULT': group_config['transition_script'].get('fault'), -                        'BACKUP': group_config['transition_script'].get('backup'), -                        'MASTER': group_config['transition_script'].get('master'), -                    } -            logger.info(f'Loaded configuration: {self.vrrp_config}') +            self.vrrp_config_dict = conf.get_config_dict(base, +                                     key_mangling=('-', '_'), get_first_key=True, +                                     no_tag_node_value_mangle=True) + +            logger.debug(f'Loaded configuration: {self.vrrp_config_dict}')          except Exception as err:              logger.error(f'Unable to load configuration: {err}') @@ -129,20 +119,17 @@ class KeepalivedFifo:                              if os.path.exists(mdns_running_file):                                  cmd(mdns_update_command) -                            if n_name in self.vrrp_config['vrrp_groups'] and n_state in self.vrrp_config['vrrp_groups'][n_name]: -                                n_script = self.vrrp_config['vrrp_groups'][n_name].get(n_state) -                                if n_script: -                                    self._run_command(n_script) +                            tmp = dict_search(f'group.{n_name}.transition_script.{n_state.lower()}', self.vrrp_config_dict) +                            if tmp != None: +                                self._run_command(tmp)                          # check and run commands for VRRP sync groups -                        # currently, this is not available in VyOS CLI -                        if n_type == 'GROUP': +                        elif n_type == 'GROUP':                              if os.path.exists(mdns_running_file):                                  cmd(mdns_update_command) -                            if n_name in self.vrrp_config['sync_groups'] and n_state in self.vrrp_config['sync_groups'][n_name]: -                                n_script = self.vrrp_config['sync_groups'][n_name].get(n_state) -                                if n_script: -                                    self._run_command(n_script) +                            tmp = dict_search(f'sync_group.{n_name}.transition_script.{n_state.lower()}', self.vrrp_config_dict) +                            if tmp != None: +                                self._run_command(tmp)                      # mark task in queue as done                      self.message_queue.task_done()              except Exception as err: diff --git a/src/systemd/keepalived.service b/src/systemd/keepalived.service new file mode 100644 index 000000000..a462d8614 --- /dev/null +++ b/src/systemd/keepalived.service @@ -0,0 +1,13 @@ +[Unit] +Description=Keepalive Daemon (LVS and VRRP) +After=vyos-router.service +# Only start if there is a configuration file +ConditionFileNotEmpty=/run/keepalived/keepalived.conf + +[Service] +KillMode=process +Type=simple +# Read configuration variable file if it is present +ExecStart=/usr/sbin/keepalived --use-file /run/keepalived/keepalived.conf --pid /run/keepalived/keepalived.pid --dont-fork --snmp +ExecReload=/bin/kill -HUP $MAINPID +PIDFile=/run/keepalived/keepalived.pid diff --git a/src/systemd/miniupnpd.service b/src/systemd/miniupnpd.service new file mode 100644 index 000000000..51cb2eed8 --- /dev/null +++ b/src/systemd/miniupnpd.service @@ -0,0 +1,13 @@ +[Unit] +Description=UPnP service +ConditionPathExists=/run/upnp/miniupnp.conf +After=vyos-router.service +StartLimitIntervalSec=0 + +[Service] +WorkingDirectory=/run/upnp +Type=simple +ExecStart=/usr/sbin/miniupnpd -d -f /run/upnp/miniupnp.conf +PrivateTmp=yes +PIDFile=/run/miniupnpd.pid +Restart=on-failure diff --git a/src/systemd/tftpd@.service b/src/systemd/tftpd@.service index 266bc0962..a674bf598 100644 --- a/src/systemd/tftpd@.service +++ b/src/systemd/tftpd@.service @@ -7,7 +7,7 @@ RequiresMountsFor=/run  Type=forking  #NotifyAccess=main  EnvironmentFile=-/etc/default/tftpd%I -ExecStart=/usr/sbin/in.tftpd "$DAEMON_ARGS" +ExecStart=/bin/sh -c "${VRF_ARGS} /usr/sbin/in.tftpd ${DAEMON_ARGS}"  Restart=on-failure  [Install] diff --git a/src/systemd/vyos-hostsd.service b/src/systemd/vyos-hostsd.service index b77335778..4da55f518 100644 --- a/src/systemd/vyos-hostsd.service +++ b/src/systemd/vyos-hostsd.service @@ -7,7 +7,7 @@ DefaultDependencies=no  # Seemingly sensible way to say "as early as the system is ready"  # All vyos-hostsd needs is read/write mounted root -After=systemd-remount-fs.service +After=systemd-remount-fs.service cloud-init.service  [Service]  WorkingDirectory=/run/vyos-hostsd diff --git a/src/systemd/vyos-http-api.service b/src/systemd/vyos-http-api.service deleted file mode 100644 index ba5df5984..000000000 --- a/src/systemd/vyos-http-api.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=VyOS HTTP API service -After=auditd.service systemd-user-sessions.service time-sync.target vyos-router.service -Requires=vyos-router.service - -[Service] -ExecStartPre=/usr/libexec/vyos/init/vyos-config -ExecStart=/usr/libexec/vyos/services/vyos-http-api-server -Type=idle - -SyslogIdentifier=vyos-http-api -SyslogFacility=daemon - -Restart=on-failure - -# Does't work but leave it here -User=root -Group=vyattacfg - -[Install] -# Installing in a earlier target leaves ExecStartPre waiting -WantedBy=getty.target - diff --git a/src/tests/test_util.py b/src/tests/test_util.py index 9bd27adc0..8ac9a500a 100644 --- a/src/tests/test_util.py +++ b/src/tests/test_util.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020-2021 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 @@ -23,3 +23,6 @@ class TestVyOSUtil(TestCase):          expected_data = {"foo_bar": {"baz_quux": None}}          new_data = mangle_dict_keys(data, '-', '_')          self.assertEqual(new_data, expected_data) + +    def test_sysctl_read(self): +        self.assertEqual(sysctl_read('net.ipv4.conf.lo.forwarding'), '1') diff --git a/src/tests/test_validate.py b/src/tests/test_validate.py index b43dbd97e..68a257d25 100644 --- a/src/tests/test_validate.py +++ b/src/tests/test_validate.py @@ -30,8 +30,12 @@ class TestVyOSValidate(TestCase):          self.assertFalse(vyos.validate.is_ipv6_link_local('169.254.0.1'))          self.assertTrue(vyos.validate.is_ipv6_link_local('fe80::'))          self.assertTrue(vyos.validate.is_ipv6_link_local('fe80::affe:1')) +        self.assertTrue(vyos.validate.is_ipv6_link_local('fe80::affe:1%eth0'))          self.assertFalse(vyos.validate.is_ipv6_link_local('2001:db8::')) +        self.assertFalse(vyos.validate.is_ipv6_link_local('2001:db8::%eth0'))          self.assertFalse(vyos.validate.is_ipv6_link_local('VyOS')) +        self.assertFalse(vyos.validate.is_ipv6_link_local('::1')) +        self.assertFalse(vyos.validate.is_ipv6_link_local('::1%lo'))      def test_is_ipv6_link_local(self):          self.assertTrue(vyos.validate.is_loopback_addr('127.0.0.1')) diff --git a/src/utils/vyos-hostsd-client b/src/utils/vyos-hostsd-client index d4d38315a..a0515951a 100755 --- a/src/utils/vyos-hostsd-client +++ b/src/utils/vyos-hostsd-client @@ -129,7 +129,8 @@ try:              params = h.split(",")              if len(params) < 2:                  raise ValueError("Malformed host entry") -            entry['address'] = params[1] +            # Address needs to be a list because of changes made in T2683 +            entry['address'] = [params[1]]              entry['aliases'] = params[2:]              data[params[0]] = entry          client.add_hosts({args.tag: data}) diff --git a/src/validators/as-number-list b/src/validators/as-number-list new file mode 100755 index 000000000..432d44180 --- /dev/null +++ b/src/validators/as-number-list @@ -0,0 +1,29 @@ +#!/bin/sh +# +# 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/>. + +if [ $# -lt 1 ]; then +      echo "Illegal number of parameters" +      exit 1 +fi + +for var in "$@"; do +    ${vyos_validators_dir}/numeric --range 1-4294967294 $var +    if [ $? -ne 0 ]; then +        exit 1 +    fi +done + +exit 0 diff --git a/src/validators/bgp-route-target b/src/validators/bgp-rd-rt index e7e4d403f..b2b69c9be 100755 --- a/src/validators/bgp-route-target +++ b/src/validators/bgp-rd-rt @@ -19,29 +19,37 @@ from vyos.template import is_ipv4  parser = ArgumentParser()  group = parser.add_mutually_exclusive_group() -group.add_argument('--single', action='store', help='Validate and allow only one route-target') -group.add_argument('--multi', action='store', help='Validate multiple, whitespace separated route-targets') +group.add_argument('--route-distinguisher', action='store', help='Validate BGP route distinguisher') +group.add_argument('--route-target', action='store', help='Validate one BGP route-target') +group.add_argument('--route-target-multi', action='store', help='Validate multiple, whitespace separated BGP route-targets')  args = parser.parse_args() -def is_valid_rt(rt): -    # every route target needs to have a colon and must consists of two parts +def is_valid(rt): +    """ Verify BGP RD/RT - both can be verified using the same logic """ +    # every RD/RT (route distinguisher/route target) needs to have a colon and +    # must consists of two parts      value = rt.split(':')      if len(value) != 2:          return False -    # A route target must either be only numbers, or the first part must be an -    # IPv4 address + +    # An RD/RT must either be only numbers, or the first part must be an IPv4 +    # address      if (is_ipv4(value[0]) or value[0].isdigit()) and value[1].isdigit():          return True      return False  if __name__ == '__main__': -    if args.single: -        if not is_valid_rt(args.single): +    if args.route_distinguisher: +        if not is_valid(args.route_distinguisher): +            exit(1) + +    elif args.route_target: +        if not is_valid(args.route_target):              exit(1) -    elif args.multi: -        for rt in args.multi.split(' '): -            if not is_valid_rt(rt): +    elif args.route_target_multi: +        for rt in args.route_target_multi.split(' '): +            if not is_valid(rt):                  exit(1)      else: diff --git a/src/validators/ip-address b/src/validators/ip-address index 51fb72c85..11d6df09e 100755 --- a/src/validators/ip-address +++ b/src/validators/ip-address @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-any-single $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IP address" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ip-cidr b/src/validators/ip-cidr index 987bf84ca..60d2ac295 100755 --- a/src/validators/ip-cidr +++ b/src/validators/ip-cidr @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-any-cidr $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IP CIDR" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ip-host b/src/validators/ip-host index f2906e8cf..77c578fa2 100755 --- a/src/validators/ip-host +++ b/src/validators/ip-host @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-any-host $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IP host" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ip-prefix b/src/validators/ip-prefix index e58aad395..e5a64fea8 100755 --- a/src/validators/ip-prefix +++ b/src/validators/ip-prefix @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-any-net $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IP prefix" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ip-protocol b/src/validators/ip-protocol index 078f8e319..c4c882502 100755 --- a/src/validators/ip-protocol +++ b/src/validators/ip-protocol @@ -31,11 +31,12 @@ if __name__ == '__main__':      pattern = "!?\\b(all|ip|hopopt|icmp|igmp|ggp|ipencap|st|tcp|egp|igp|pup|udp|" \                "tcp_udp|hmp|xns-idp|rdp|iso-tp4|dccp|xtp|ddp|idpr-cmtp|ipv6|" \ -              "ipv6-route|ipv6-frag|idrp|rsvp|gre|esp|ah|skip|ipv6-icmp|" \ +              "ipv6-route|ipv6-frag|idrp|rsvp|gre|esp|ah|skip|ipv6-icmp|icmpv6|" \                "ipv6-nonxt|ipv6-opts|rspf|vmtp|eigrp|ospf|ax.25|ipip|etherip|" \                "encap|99|pim|ipcomp|vrrp|l2tp|isis|sctp|fc|mobility-header|" \                "udplite|mpls-in-ip|manet|hip|shim6|wesp|rohc)\\b"      if re.match(pattern, input):          exit(0) +    print(f'Error: {input} is not a valid IP protocol')      exit(1) diff --git a/src/validators/ipv4 b/src/validators/ipv4 index 53face090..8676d5800 100755 --- a/src/validators/ipv4 +++ b/src/validators/ipv4 @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv4 $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not IPv4" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv4-address b/src/validators/ipv4-address index 872a7645a..058db088b 100755 --- a/src/validators/ipv4-address +++ b/src/validators/ipv4-address @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv4-single $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv4 address" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv4-host b/src/validators/ipv4-host index f42feffa4..74b8c36a7 100755 --- a/src/validators/ipv4-host +++ b/src/validators/ipv4-host @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv4-host $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv4 host" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv4-multicast b/src/validators/ipv4-multicast index e5cbc9532..3f28c51db 100755 --- a/src/validators/ipv4-multicast +++ b/src/validators/ipv4-multicast @@ -1,3 +1,10 @@  #!/bin/sh -ipaddrcheck --is-ipv4-multicast $1 +ipaddrcheck --is-ipv4-multicast $1 && ipaddrcheck --is-ipv4-single $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv4 multicast address" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv4-prefix b/src/validators/ipv4-prefix index 8ec8a2c45..7e1e0e8dd 100755 --- a/src/validators/ipv4-prefix +++ b/src/validators/ipv4-prefix @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv4-net $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv4 prefix" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv4-range b/src/validators/ipv4-range index cc59039f1..6492bfc52 100755 --- a/src/validators/ipv4-range +++ b/src/validators/ipv4-range @@ -7,6 +7,11 @@ ip2dec () {      printf '%d\n' "$((a * 256 ** 3 + b * 256 ** 2 + c * 256 + d))"  } +error_exit() { +  echo "Error: $1 is not a valid IPv4 address range" +  exit 1 +} +  # Only run this if there is a hypen present in $1  if [[ "$1" =~ "-" ]]; then    # This only works with real bash (<<<) - split IP addresses into array with @@ -15,21 +20,21 @@ if [[ "$1" =~ "-" ]]; then    ipaddrcheck --is-ipv4-single ${strarr[0]}    if [ $? -gt 0 ]; then -    exit 1 +    error_exit $1    fi    ipaddrcheck --is-ipv4-single ${strarr[1]}    if [ $? -gt 0 ]; then -    exit 1 +    error_exit $1    fi    start=$(ip2dec ${strarr[0]})    stop=$(ip2dec ${strarr[1]})    if [ $start -ge $stop ]; then -    exit 1 +    error_exit $1    fi    exit 0  fi -exit 1 +error_exit $1 diff --git a/src/validators/ipv6 b/src/validators/ipv6 index f18d4a63e..4ae130eb5 100755 --- a/src/validators/ipv6 +++ b/src/validators/ipv6 @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv6 $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not IPv6" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv6-address b/src/validators/ipv6-address index e5d68d756..1fca77668 100755 --- a/src/validators/ipv6-address +++ b/src/validators/ipv6-address @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv6-single $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv6 address" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv6-host b/src/validators/ipv6-host index f7a745077..7085809a9 100755 --- a/src/validators/ipv6-host +++ b/src/validators/ipv6-host @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv6-host $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv6 host" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv6-link-local b/src/validators/ipv6-link-local new file mode 100755 index 000000000..05e693b77 --- /dev/null +++ b/src/validators/ipv6-link-local @@ -0,0 +1,12 @@ +#!/usr/bin/python3 + +import sys +from vyos.validate import is_ipv6_link_local + +if __name__ == '__main__': +    if len(sys.argv)>1: +        addr = sys.argv[1] +        if not is_ipv6_link_local(addr): +            sys.exit(1) + +    sys.exit(0) diff --git a/src/validators/ipv6-multicast b/src/validators/ipv6-multicast index 66cd90c9c..5aa7d734a 100755 --- a/src/validators/ipv6-multicast +++ b/src/validators/ipv6-multicast @@ -1,3 +1,10 @@  #!/bin/sh -ipaddrcheck --is-ipv6-multicast $1 +ipaddrcheck --is-ipv6-multicast $1 && ipaddrcheck --is-ipv6-single $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv6 multicast address" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv6-prefix b/src/validators/ipv6-prefix index e43616350..890dda723 100755 --- a/src/validators/ipv6-prefix +++ b/src/validators/ipv6-prefix @@ -1,3 +1,10 @@  #!/bin/sh  ipaddrcheck --is-ipv6-net $1 + +if [ $? -gt 0 ]; then +    echo "Error: $1 is not a valid IPv6 prefix" +    exit 1 +fi + +exit 0
\ No newline at end of file diff --git a/src/validators/ipv6-range b/src/validators/ipv6-range index 033b6461b..7080860c4 100755 --- a/src/validators/ipv6-range +++ b/src/validators/ipv6-range @@ -1,16 +1,20 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 -import sys -import re -from vyos.template import is_ipv6 +from ipaddress import IPv6Address +from sys import argv, exit  if __name__ == '__main__': -    if len(sys.argv)>1: -        ipv6_range = sys.argv[1] -        # Regex for ipv6-ipv6 https://regexr.com/ -        if re.search('([a-f0-9:]+:+)+[a-f0-9]+-([a-f0-9:]+:+)+[a-f0-9]+', ipv6_range): -            for tmp in ipv6_range.split('-'): -                if not is_ipv6(tmp): -                    sys.exit(1) - -    sys.exit(0) +    if len(argv) > 1: +        # try to pass validation and raise an error if failed +        try: +            ipv6_range = argv[1] +            range_left = ipv6_range.split('-')[0] +            range_right = ipv6_range.split('-')[1] +            if not IPv6Address(range_left) < IPv6Address(range_right): +                raise ValueError(f'left element {range_left} must be less than right element {range_right}') +        except Exception as err: +            print(f'Error: {ipv6_range} is not a valid IPv6 range: {err}') +            exit(1) +    else: +        print('Error: an IPv6 range argument must be provided') +        exit(1) diff --git a/src/validators/mac-address-firewall b/src/validators/mac-address-firewall new file mode 100755 index 000000000..70551f86d --- /dev/null +++ b/src/validators/mac-address-firewall @@ -0,0 +1,27 @@ +#!/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/port-multi b/src/validators/port-multi new file mode 100755 index 000000000..bd6f0ef60 --- /dev/null +++ b/src/validators/port-multi @@ -0,0 +1,52 @@ +#!/usr/bin/python3 + +from sys import argv +from sys import exit +import re + +from vyos.util import read_file + +services_file = '/etc/services' + +def get_services(): +    names = [] +    service_data = read_file(services_file, "") +    for line in service_data.split("\n"): +        if not line or line[0] == '#': +            continue +        tmp = line.split() +        names.append(tmp[0]) +        if len(tmp) > 2: +            # Add port aliases to service list, too +            names.extend(tmp[2:]) +    # remove duplicate entries (e.g. echo) from list +    names = list(dict.fromkeys(names)) +    return names + +if __name__ == '__main__': +    if len(argv)>1: +        ports = argv[1].split(",") +        services = get_services() + +        for port in ports: +            if port and port[0] == '!': +                port = port[1:] +            if re.match('^[0-9]{1,5}-[0-9]{1,5}$', port): +                port_1, port_2 = port.split('-') +                if int(port_1) not in range(1, 65536) or int(port_2) not in range(1, 65536): +                    print(f'Error: {port} is not a valid port range') +                    exit(1) +                if int(port_1) > int(port_2): +                    print(f'Error: {port} is not a valid port range') +                    exit(1) +            elif port.isnumeric(): +                if int(port) not in range(1, 65536): +                    print(f'Error: {port} is not a valid port') +                    exit(1) +            elif port not in services: +                print(f'Error: {port} is not a valid service name') +                exit(1) +    else: +        exit(2) + +    exit(0) diff --git a/src/validators/port-range b/src/validators/port-range index abf0b09d5..5468000a7 100755 --- a/src/validators/port-range +++ b/src/validators/port-range @@ -3,16 +3,37 @@  import sys  import re +from vyos.util import read_file + +services_file = '/etc/services' + +def get_services(): +    names = [] +    service_data = read_file(services_file, "") +    for line in service_data.split("\n"): +        if not line or line[0] == '#': +            continue +        names.append(line.split(None, 1)[0]) +    return names + +def error(port_range): +    print(f'Error: {port_range} is not a valid port or port range') +    sys.exit(1) +  if __name__ == '__main__':      if len(sys.argv)>1:          port_range = sys.argv[1] -        if re.search('[0-9]{1,5}-[0-9]{1,5}', port_range): -            for tmp in port_range.split('-'): -                if int(tmp) not in range(1, 65535): -                    sys.exit(1) -        else: -            if int(port_range) not in range(1, 65535): -                sys.exit(1) +        if re.match('^[0-9]{1,5}-[0-9]{1,5}$', port_range): +            port_1, port_2 = port_range.split('-') +            if int(port_1) not in range(1, 65536) or int(port_2) not in range(1, 65536): +                error(port_range) +            if int(port_1) > int(port_2): +                error(port_range) +        elif port_range.isnumeric() and int(port_range) not in range(1, 65536): +            error(port_range) +        elif not port_range.isnumeric() and port_range not in get_services(): +            print(f'Error: {port_range} is not a valid service name') +            sys.exit(1)      else:          sys.exit(2) diff --git a/src/validators/range b/src/validators/range new file mode 100755 index 000000000..d4c25f3c4 --- /dev/null +++ b/src/validators/range @@ -0,0 +1,56 @@ +#!/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/script b/src/validators/script index 1d8a27e5c..4ffdeb2a0 100755 --- a/src/validators/script +++ b/src/validators/script @@ -36,7 +36,7 @@ if __name__ == '__main__':      # File outside the config dir is just a warning      if not vyos.util.file_is_persistent(script): -        sys.exit( -            f'Warning: file {path} is outside the / config directory\n' +        sys.exit(0)( +            f'Warning: file {script} is outside the "/config" directory\n'              'It will not be automatically migrated to a new image on system update'          ) diff --git a/src/validators/tcp-flag b/src/validators/tcp-flag new file mode 100755 index 000000000..1496b904a --- /dev/null +++ b/src/validators/tcp-flag @@ -0,0 +1,17 @@ +#!/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)  | 
