diff options
Diffstat (limited to 'src/conf_mode')
31 files changed, 744 insertions, 704 deletions
diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py index 4e6e39c0f..68877f794 100755 --- a/src/conf_mode/conntrack.py +++ b/src/conf_mode/conntrack.py @@ -97,7 +97,7 @@ 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. for module, module_config in module_map.items(): - if dict_search(f'modules.{module}.disable', conntrack) != None: + if dict_search(f'modules.{module}', conntrack) is None: if 'ko' in module_config: for mod in module_config['ko']: # Only remove the module if it's loaded @@ -105,8 +105,9 @@ def apply(conntrack): cmd(f'rmmod {mod}') if 'iptables' in module_config: for rule in module_config['iptables']: - print(f'iptables --delete {rule}') - cmd(f'iptables --delete {rule}') + # Only install iptables rule if it does not exist + tmp = run(f'iptables --check {rule}') + if tmp == 0: cmd(f'iptables --delete {rule}') else: if 'ko' in module_config: for mod in module_config['ko']: @@ -115,9 +116,7 @@ def apply(conntrack): 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 tmp > 0: cmd(f'iptables --insert {rule}') if process_named_running('conntrackd'): # Reload conntrack-sync daemon to fetch new sysctl values diff --git a/src/conf_mode/containers.py b/src/conf_mode/containers.py index 21b47f42a..1e0197a13 100755 --- a/src/conf_mode/containers.py +++ b/src/conf_mode/containers.py @@ -19,15 +19,23 @@ 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.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 popen -from vyos.template import render +from vyos.util import run +from vyos.util import read_file +from vyos.util import write_file +from vyos.util import is_systemd_service_active +from vyos.util import is_systemd_service_running +from vyos.template import inc_ip from vyos.template import is_ipv4 from vyos.template import is_ipv6 +from vyos.template import render from vyos.xml import defaults from vyos import ConfigError from vyos import airbag @@ -41,27 +49,7 @@ def _cmd(command): print(command) return cmd(command) -# Container management functions -def container_exists(name): - ''' - https://docs.podman.io/en/latest/_static/api.html#operation/ContainerExistsLibpod - Check if container exists. Response codes. - 204 - container exists - 404 - no such container - ''' - tmp = _cmd(f"curl --unix-socket /run/podman/podman.sock 'http://d/v3.0.0/libpod/containers/{name}/exists'") - # If container exists it return status code "0" - code can not be displayed - return (tmp == "") - -def container_status(name): - ''' - https://docs.podman.io/en/latest/_static/api.html#operation/ContainerInspectLibpod - ''' - tmp = _cmd(f"curl --unix-socket /run/podman/podman.sock 'http://d/v3.0.0/libpod/containers/{name}/json'") - data = json.loads(tmp) - return data['State']['Status'] - -def ctnr_network_exists(name): +def network_exists(name): # Check explicit name for network, returns True if network exists c = _cmd(f'podman network ls --quiet --filter name=^{name}$') return bool(c) @@ -79,11 +67,20 @@ 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) + # container base default values can not be merged here - remove and add them later + if 'name' in default_values: + del default_values['name'] container = dict_merge(default_values, container) + # Merge per-container default values + if 'name' in container: + default_values = defaults(base + ['name']) + for name in container['name']: + container['name'][name] = dict_merge(default_values, container['name'][name]) + # Delete container network, delete containers tmp = node_changed(conf, ['container', 'network']) - if tmp: container.update({'net_remove' : tmp}) + if tmp: container.update({'network_remove' : tmp}) tmp = node_changed(conf, ['container', 'name']) if tmp: container.update({'container_remove' : tmp}) @@ -102,7 +99,6 @@ def verify(container): if len(container_config['network']) > 1: raise ConfigError(f'Only one network can be specified for container "{name}"!') - # Check if the specified container network exists network_name = list(container_config['network'])[0] if network_name not in container['network']: @@ -125,8 +121,25 @@ def verify(container): # We can not use the first IP address of a network prefix as this is used by podman if ip_address(address) == ip_network(network)[1]: - raise ConfigError(f'Address "{address}" reserved for the container engine!') + raise ConfigError(f'IP address "{address}" can not be used for a container, '\ + 'reserved for the container engine!') + if 'environment' in container_config: + for var, cfg in container_config['environment'].items(): + if 'value' not in cfg: + raise ConfigError(f'Environment variable {var} has no value assigned!') + + if 'volume' in container_config: + for volume, volume_config in container_config['volume'].items(): + if 'source' not in volume_config: + raise ConfigError(f'Volume "{volume}" has no source path configured!') + + if 'destination' not in volume_config: + raise ConfigError(f'Volume "{volume}" has no destination path configured!') + + source = volume_config['source'] + 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: @@ -142,9 +155,9 @@ def verify(container): # Add new network if 'network' in container: - v4_prefix = 0 - v6_prefix = 0 for network, network_config in container['network'].items(): + v4_prefix = 0 + v6_prefix = 0 # If ipv4-prefix not defined for user-defined network if 'prefix' not in network_config: raise ConfigError(f'prefix for network "{net}" must be defined!') @@ -160,8 +173,8 @@ def verify(container): # A network attached to a container can not be deleted - if {'net_remove', 'name'} <= set(container): - for network in container['net_remove']: + if {'network_remove', 'name'} <= set(container): + for network in container['network_remove']: for container, container_config in container['name'].items(): if 'network' in container_config and network in container_config['network']: raise ConfigError(f'Can not remove network "{network}", used by container "{container}"!') @@ -173,6 +186,37 @@ def generate(container): if not container: return None + if 'network' in container: + for network, network_config in container['network'].items(): + tmp = { + 'cniVersion' : '0.4.0', + 'name' : network, + 'plugins' : [{ + 'type': 'bridge', + 'bridge': f'cni-{network}', + 'isGateway': True, + 'ipMasq': False, + 'hairpinMode': False, + 'ipam' : { + 'type': 'host-local', + 'routes': [], + 'ranges' : [], + }, + }] + } + + for prefix in network_config['prefix']: + net = [{'gateway' : inc_ip(prefix, 1), 'subnet' : prefix}] + tmp['plugins'][0]['ipam']['ranges'].append(net) + + # install per address-family default orutes + default_route = '0.0.0.0/0' + if is_ipv6(prefix): + default_route = '::/0' + tmp['plugins'][0]['ipam']['routes'].append({'dst': default_route}) + + 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) @@ -183,79 +227,90 @@ def apply(container): # Option "--force" allows to delete containers with any status if 'container_remove' in container: for name in container['container_remove']: - if container_status(name) == 'running': - _cmd(f'podman stop {name}') - _cmd(f'podman rm --force {name}') + call(f'podman stop {name}') + call(f'podman rm --force {name}') # Delete old networks if needed - if 'net_remove' in container: - for network in container['net_remove']: - _cmd(f'podman network rm {network}') - - # Add network - if 'network' in container: - for network, network_config in container['network'].items(): - # Check if the network has already been created - if not ctnr_network_exists(network) and 'prefix' in network_config: - tmp = f'podman network create {network}' - # we can not use list comprehension here as the --ipv6 option - # must immediately follow the specified subnet!!! - for prefix in sorted(network_config['prefix']): - tmp += f' --subnet={prefix}' - if is_ipv6(prefix): - tmp += ' --ipv6' - _cmd(tmp) + if 'network_remove' in container: + for network in container['network_remove']: + tmp = f'/etc/cni/net.d/{network}.conflist' + if os.path.exists(tmp): + os.unlink(tmp) + + service_name = 'podman.service' + if 'network' in container or 'name' in container: + # Start podman if it's required and not yet running + if not is_systemd_service_active(service_name): + _cmd(f'systemctl start {service_name}') + # Wait for podman to be running + while not is_systemd_service_running(service_name): + sleep(0.250) + else: + _cmd(f'systemctl stop {service_name}') # Add container if 'name' in container: for name, container_config in container['name'].items(): - # Check if the container has already been created - if not container_exists(name): - image = container_config['image'] - # Currently the best way to run a command and immediately print stdout - print(os.system(f'podman pull {image}')) - - # Check/set environment options "-e foo=bar" - env_opt = '' - if 'environment' in container_config: - env_opt = '-e ' - env_opt += " -e ".join(f"{k}={v['value']}" for k, v in container_config['environment'].items()) - - # Publish ports - port = '' - if 'port' in container_config: - protocol = '' - for portmap in container_config['port']: - if 'protocol' in container_config['port'][portmap]: - protocol = container_config['port'][portmap]['protocol'] - protocol = f'/{protocol}' - else: - protocol = '/tcp' - sport = container_config['port'][portmap]['source'] - dport = container_config['port'][portmap]['destination'] - port += f' -p {sport}:{dport}{protocol}' - - # Bind volume - volume = '' - if 'volume' in container_config: - for vol in container_config['volume']: - svol = container_config['volume'][vol]['source'] - dvol = container_config['volume'][vol]['destination'] - volume += f' -v {svol}:{dvol}' - - if 'allow_host_networks' in container_config: - _cmd(f'podman run -dit --name {name} --net host {port} {volume} {env_opt} {image}') - else: - for network in container_config['network']: - ipparam = '' - if 'address' in container_config['network'][network]: - ipparam = '--ip ' + container_config['network'][network]['address'] - _cmd(f'podman run --name {name} -dit --net {network} {ipparam} {port} {volume} {env_opt} {image}') - - # Else container is already created. Just start it. - # It's needed after reboot. - elif container_status(name) != 'running': - _cmd(f'podman start {name}') + image = container_config['image'] + + if 'disable' in container_config: + # check if there is a container by that name running + tmp = _cmd('podman ps -a --format "{{.Names}}"') + if name in tmp: + _cmd(f'podman stop {name}') + _cmd(f'podman rm --force {name}') + continue + + 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}')) + + # Check/set environment options "-e foo=bar" + env_opt = '' + if 'environment' in container_config: + for k, v in container_config['environment'].items(): + env_opt += f" -e \"{k}={v['value']}\"" + + # Publish ports + port = '' + if 'port' in container_config: + protocol = '' + for portmap in container_config['port']: + if 'protocol' in container_config['port'][portmap]: + protocol = container_config['port'][portmap]['protocol'] + protocol = f'/{protocol}' + else: + protocol = '/tcp' + sport = container_config['port'][portmap]['source'] + dport = container_config['port'][portmap]['destination'] + port += f' -p {sport}:{dport}{protocol}' + + # Bind volume + volume = '' + if 'volume' in container_config: + for vol, vol_config in container_config['volume'].items(): + svol = vol_config['source'] + dvol = vol_config['destination'] + volume += f' -v {svol}:{dvol}' + + container_base_cmd = f'podman run --detach --interactive --tty --replace ' \ + f'--memory {memory}m --memory-swap 0 --restart {restart} ' \ + f'--name {name} {port} {volume} {env_opt}' + if 'allow_host_networks' in container_config: + _cmd(f'{container_base_cmd} --net host {image}') + else: + for network in container_config['network']: + ipparam = '' + if 'address' in container_config['network'][network]: + address = container_config['network'][network]['address'] + ipparam = f'--ip {address}' + _cmd(f'{container_base_cmd} --net {network} {ipparam} {image}') return None diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py index cdee72e09..28f2a4ca5 100755 --- a/src/conf_mode/dhcp_server.py +++ b/src/conf_mode/dhcp_server.py @@ -148,9 +148,9 @@ def verify(dhcp): 'At least one DHCP shared network must be configured.') # Inspect shared-network/subnet - failover_names = [] listen_ok = False subnets = [] + failover_ok = False # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): @@ -159,9 +159,18 @@ def verify(dhcp): 'lease subnet must be configured.') for subnet, subnet_config in network_config['subnet'].items(): - if 'static_route' in subnet_config and len(subnet_config['static_route']) != 2: - raise ConfigError('Missing DHCP static-route parameter(s):\n' \ - 'destination-subnet | router must be defined!') + # All delivered static routes require a next-hop to be set + if 'static_route' in subnet_config: + for route, route_option in subnet_config['static_route'].items(): + if 'next_hop' not in route_option: + raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!') + + # DHCP failover needs at least one subnet that uses it + if 'enable_failover' in subnet_config: + if 'failover' not in dhcp: + raise ConfigError(f'Can not enable failover for "{subnet}" in "{network}".\n' \ + 'Failover is not configured globally!') + failover_ok = True # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: @@ -191,23 +200,6 @@ def verify(dhcp): tmp = IPRange(range_config['start'], range_config['stop']) networks.append(tmp) - if 'failover' in subnet_config: - for key in ['local_address', 'peer_address', 'name', 'status']: - if key not in subnet_config['failover']: - raise ConfigError(f'Missing DHCP failover parameter "{key}"!') - - # Failover names must be uniquie - if subnet_config['failover']['name'] in failover_names: - name = subnet_config['failover']['name'] - raise ConfigError(f'DHCP failover names must be unique:\n' \ - f'{name} has already been configured!') - failover_names.append(subnet_config['failover']['name']) - - # Failover requires start/stop ranges for pool - if 'range' not in subnet_config: - raise ConfigError(f'DHCP failover requires at least one start-stop range to be configured\n'\ - f'within shared-network "{network}, {subnet}" for using failover!') - # Exclude addresses must be in bound if 'exclude' in subnet_config: for exclude in subnet_config['exclude']: @@ -251,6 +243,15 @@ def verify(dhcp): if net.overlaps(net2): raise ConfigError('Conflicting subnet ranges: "{net}" overlaps "{net2}"!') + if 'failover' in dhcp: + if not failover_ok: + raise ConfigError('DHCP failover must be enabled for at least one subnet!') + + for key in ['name', 'remote', 'source_address', 'status']: + if key not in dhcp['failover']: + tmp = key.replace('_', '-') + raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') + for address in (dict_search('listen_address', dhcp) or []): if is_addr_assigned(address): listen_ok = True diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index c44e6c974..06366362a 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -66,21 +66,6 @@ def get_config(config=None): if conf.exists(base_nameservers_dhcp): dns.update({'system_name_server_dhcp': conf.return_values(base_nameservers_dhcp)}) - # Split the source_address property into separate IPv4 and IPv6 lists - # NOTE: In future versions of pdns-recursor (> 4.4.0), this logic can be removed - # as both IPv4 and IPv6 addresses can be specified in a single setting. - source_address_v4 = [] - source_address_v6 = [] - - for source_address in dns['source_address']: - if is_ipv6(source_address): - source_address_v6.append(source_address) - else: - source_address_v4.append(source_address) - - dns.update({'source_address_v4': source_address_v4}) - dns.update({'source_address_v6': source_address_v6}) - return dns def verify(dns): diff --git a/src/conf_mode/firewall_options.py b/src/conf_mode/firewall_options.py deleted file mode 100755 index 67bf5d0e2..000000000 --- a/src/conf_mode/firewall_options.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -# - -import sys -import os -import copy - -from vyos.config import Config -from vyos import ConfigError -from vyos.util import call - -from vyos import airbag -airbag.enable() - -default_config_data = { - 'intf_opts': [], - 'new_chain4': False, - 'new_chain6': False -} - -def get_config(config=None): - opts = copy.deepcopy(default_config_data) - if config: - conf = config - else: - conf = Config() - if not conf.exists('firewall options'): - # bail out early - return opts - else: - conf.set_level('firewall options') - - # Parse configuration of each individual instance - if conf.exists('interface'): - for intf in conf.list_nodes('interface'): - conf.set_level('firewall options interface {0}'.format(intf)) - config = { - 'intf': intf, - 'disabled': False, - 'mss4': '', - 'mss6': '' - } - - # Check if individual option is disabled - if conf.exists('disable'): - config['disabled'] = True - - # - # Get MSS value IPv4 - # - if conf.exists('adjust-mss'): - config['mss4'] = conf.return_value('adjust-mss') - - # We need a marker that a new iptables chain needs to be generated - if not opts['new_chain4']: - opts['new_chain4'] = True - - # - # Get MSS value IPv6 - # - if conf.exists('adjust-mss6'): - config['mss6'] = conf.return_value('adjust-mss6') - - # We need a marker that a new ip6tables chain needs to be generated - if not opts['new_chain6']: - opts['new_chain6'] = True - - # Append interface options to global list - opts['intf_opts'].append(config) - - return opts - -def verify(tcp): - # syntax verification is done via cli - return None - -def apply(tcp): - target = 'VYOS_FW_OPTIONS' - - # always cleanup iptables - call('iptables --table mangle --delete FORWARD --jump {} >&/dev/null'.format(target)) - call('iptables --table mangle --flush {} >&/dev/null'.format(target)) - call('iptables --table mangle --delete-chain {} >&/dev/null'.format(target)) - - # always cleanup ip6tables - call('ip6tables --table mangle --delete FORWARD --jump {} >&/dev/null'.format(target)) - call('ip6tables --table mangle --flush {} >&/dev/null'.format(target)) - call('ip6tables --table mangle --delete-chain {} >&/dev/null'.format(target)) - - # Setup new iptables rules - if tcp['new_chain4']: - call('iptables --table mangle --new-chain {} >&/dev/null'.format(target)) - call('iptables --table mangle --append FORWARD --jump {} >&/dev/null'.format(target)) - - for opts in tcp['intf_opts']: - intf = opts['intf'] - mss = opts['mss4'] - - # Check if this rule iis disabled - if opts['disabled']: - continue - - # adjust TCP MSS per interface - if mss: - call('iptables --table mangle --append {} --out-interface {} --protocol tcp ' - '--tcp-flags SYN,RST SYN --jump TCPMSS --set-mss {} >&/dev/null'.format(target, intf, mss)) - - # Setup new ip6tables rules - if tcp['new_chain6']: - call('ip6tables --table mangle --new-chain {} >&/dev/null'.format(target)) - call('ip6tables --table mangle --append FORWARD --jump {} >&/dev/null'.format(target)) - - for opts in tcp['intf_opts']: - intf = opts['intf'] - mss = opts['mss6'] - - # Check if this rule iis disabled - if opts['disabled']: - continue - - # adjust TCP MSS per interface - if mss: - call('ip6tables --table mangle --append {} --out-interface {} --protocol tcp ' - '--tcp-flags SYN,RST SYN --jump TCPMSS --set-mss {} >&/dev/null'.format(target, intf, mss)) - - return None - -if __name__ == '__main__': - - try: - c = get_config() - verify(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index f4c75c257..a7135911d 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/host_name.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,10 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -""" -conf-mode script for 'system host-name' and 'system domain-name'. -""" - import re import sys import copy @@ -25,10 +21,13 @@ import copy import vyos.util import vyos.hostsd_client -from vyos.config import Config from vyos import ConfigError -from vyos.util import cmd, call, process_named_running - +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 airbag airbag.enable() @@ -37,7 +36,7 @@ default_config_data = { 'domain_name': '', 'domain_search': [], 'nameserver': [], - 'nameservers_dhcp_interfaces': [], + 'nameservers_dhcp_interfaces': {}, 'static_host_mapping': {} } @@ -51,29 +50,37 @@ def get_config(config=None): hosts = copy.deepcopy(default_config_data) - hosts['hostname'] = conf.return_value("system host-name") + hosts['hostname'] = conf.return_value(['system', 'host-name']) # This may happen if the config is not loaded yet, # e.g. if run by cloud-init if not hosts['hostname']: hosts['hostname'] = default_config_data['hostname'] - if conf.exists("system domain-name"): - hosts['domain_name'] = conf.return_value("system domain-name") + if conf.exists(['system', 'domain-name']): + hosts['domain_name'] = conf.return_value(['system', 'domain-name']) hosts['domain_search'].append(hosts['domain_name']) - for search in conf.return_values("system domain-search domain"): + for search in conf.return_values(['system', 'domain-search', 'domain']): hosts['domain_search'].append(search) - hosts['nameserver'] = conf.return_values("system name-server") + if conf.exists(['system', 'name-server']): + for ns in conf.return_values(['system', 'name-server']): + if is_ip(ns): + hosts['nameserver'].append(ns) + else: + tmp = '' + if_type = Section.section(ns) + if conf.exists(['interfaces', if_type, ns, 'address']): + tmp = conf.return_values(['interfaces', if_type, ns, 'address']) - hosts['nameservers_dhcp_interfaces'] = conf.return_values("system name-servers-dhcp") + hosts['nameservers_dhcp_interfaces'].update({ ns : tmp }) # system static-host-mapping - for hn in conf.list_nodes('system static-host-mapping host-name'): + for hn in conf.list_nodes(['system', 'static-host-mapping', 'host-name']): hosts['static_host_mapping'][hn] = {} - hosts['static_host_mapping'][hn]['address'] = conf.return_value(f'system static-host-mapping host-name {hn} inet') - hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(f'system static-host-mapping host-name {hn} alias') + hosts['static_host_mapping'][hn]['address'] = conf.return_value(['system', 'static-host-mapping', 'host-name', hn, 'inet']) + hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'alias']) return hosts @@ -103,8 +110,10 @@ def verify(hosts): if not hostname_regex.match(a) and len(a) != 0: raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') - # TODO: add warnings for nameservers_dhcp_interfaces if interface doesn't - # exist or doesn't have address dhcp(v6) + 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!') return None diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index 78c24952b..e7250fb49 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -37,6 +37,7 @@ from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call from vyos.util import dict_search +from vyos.util import write_file from vyos import ConfigError from vyos import airbag airbag.enable() @@ -54,15 +55,17 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['interfaces', 'ethernet'] - tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + # This must be called prior to get_interface_dict(), as this function will + # alter the config level (config.set_level()) + pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + base = ['interfaces', 'ethernet'] ethernet = get_interface_dict(conf, base) if 'deleted' not in ethernet: - ethernet['pki'] = tmp_pki + if pki: ethernet['pki'] = pki return ethernet @@ -72,12 +75,6 @@ def verify(ethernet): ifname = ethernet['ifname'] verify_interface_exists(ifname) - - # No need to check speed and duplex keys as both have default values. - if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or - (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): - raise ConfigError('Speed/Duplex missmatch. Must be both auto or manually configured') - verify_mtu(ethernet) verify_mtu_ipv6(ethernet) verify_dhcpv6(ethernet) @@ -86,25 +83,31 @@ def verify(ethernet): verify_eapol(ethernet) verify_mirror(ethernet) - # verify offloading capabilities - if dict_search('offload.rps', ethernet) != None: - if not os.path.exists(f'/sys/class/net/{ifname}/queues/rx-0/rps_cpus'): - raise ConfigError('Interface does not suport RPS!') + ethtool = Ethtool(ifname) + # No need to check speed and duplex keys as both have default values. + if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or + (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): + raise ConfigError('Speed/Duplex missmatch. Must be both auto or manually configured') - driver = EthernetIf(ifname).get_driver_name() - # T3342 - Xen driver requires special treatment - if driver == 'vif': - if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: - raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ - 'for MTU size larger then 1500 bytes') + if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': + # We need to verify if the requested speed and duplex setting is + # supported by the underlaying NIC. + speed = ethernet['speed'] + duplex = ethernet['duplex'] + if not ethtool.check_speed_duplex(speed, duplex): + raise ConfigError(f'Adapter does not support changing speed and duplex '\ + f'settings to: {speed}/{duplex}!') + + if 'disable_flow_control' in ethernet: + if not ethtool.check_flow_control(): + raise ConfigError('Adapter does not support changing flow-control settings!') - ethtool = Ethtool(ifname) if 'ring_buffer' in ethernet: - max_rx = ethtool.get_rx_buffer() + max_rx = ethtool.get_ring_buffer_max('rx') if not max_rx: raise ConfigError('Driver does not support RX ring-buffer configuration!') - max_tx = ethtool.get_tx_buffer() + max_tx = ethtool.get_ring_buffer_max('tx') if not max_tx: raise ConfigError('Driver does not support TX ring-buffer configuration!') @@ -118,6 +121,18 @@ def verify(ethernet): raise ConfigError(f'Driver only supports a maximum TX ring-buffer '\ f'size of "{max_tx}" bytes!') + # verify offloading capabilities + if dict_search('offload.rps', ethernet) != None: + if not os.path.exists(f'/sys/class/net/{ifname}/queues/rx-0/rps_cpus'): + raise ConfigError('Interface does not suport RPS!') + + driver = ethtool.get_driver_name() + # T3342 - Xen driver requires special treatment + if driver == 'vif': + if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: + raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ + 'for MTU size larger then 1500 bytes') + # XDP requires multiple TX queues if 'xdp' in ethernet: queues = glob(f'/sys/class/net/{ifname}/queues/tx-*') @@ -136,7 +151,7 @@ def generate(ethernet): if 'eapol' in ethernet: render(wpa_suppl_conf.format(**ethernet), 'ethernet/wpa_supplicant.conf.tmpl', ethernet) - + ifname = ethernet['ifname'] cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') @@ -144,19 +159,16 @@ def generate(ethernet): cert_name = ethernet['eapol']['certificate'] pki_cert = ethernet['pki']['certificate'][cert_name] - with open(cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_cert['certificate'])) - - with open(cert_key_path, 'w') as f: - f.write(wrap_private_key(pki_cert['private']['key'])) + write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) + 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] - with open(ca_cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_ca_cert['certificate'])) + write_file(ca_cert_file_path, + wrap_certificate(pki_ca_cert['certificate'])) else: # delete configuration on interface removal if os.path.isfile(wpa_suppl_conf.format(**ethernet)): diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 4bd0b22a9..2533a5b02 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-2020 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 @@ -100,7 +100,7 @@ def get_config(config=None): # need to check this first and drop those keys if 'totp' not in tmp_openvpn['server']: del openvpn['server']['mfa']['totp'] - + return openvpn def is_ec_private_key(pki, cert_name): @@ -295,6 +295,9 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-active': raise ConfigError('Protocol "tcp-active" is not valid in server mode') + if dict_search('authentication.username', openvpn) or dict_search('authentication.password', openvpn): + raise ConfigError('Cannot specify "authentication" in server mode') + if 'remote_port' in openvpn: raise ConfigError('Cannot specify "remote-port" in server mode') diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 6c4c6c95b..584adc75e 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 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 @@ -22,12 +22,16 @@ 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.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.ifconfig import PPPoEIf from vyos.template import render from vyos.util import call +from vyos.util import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -44,6 +48,32 @@ def get_config(config=None): base = ['interfaces', 'pppoe'] 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': {}}) + return pppoe def verify(pppoe): @@ -66,57 +96,42 @@ def generate(pppoe): # rendered into ifname = pppoe['ifname'] config_pppoe = f'/etc/ppp/peers/{ifname}' - script_pppoe_pre_up = f'/etc/ppp/ip-pre-up.d/1000-vyos-pppoe-{ifname}' - script_pppoe_ip_up = f'/etc/ppp/ip-up.d/1000-vyos-pppoe-{ifname}' - script_pppoe_ip_down = f'/etc/ppp/ip-down.d/1000-vyos-pppoe-{ifname}' - script_pppoe_ipv6_up = f'/etc/ppp/ipv6-up.d/1000-vyos-pppoe-{ifname}' - config_wide_dhcp6c = f'/run/dhcp6c/dhcp6c.{ifname}.conf' - - config_files = [config_pppoe, script_pppoe_pre_up, script_pppoe_ip_up, - script_pppoe_ip_down, script_pppoe_ipv6_up, config_wide_dhcp6c] if 'deleted' in pppoe or 'disable' in pppoe: - # stop DHCPv6-PD client - call(f'systemctl stop dhcp6c@{ifname}.service') - # Hang-up PPPoE connection - call(f'systemctl stop ppp@{ifname}.service') - - # Delete PPP configuration files - for file in config_files: - if os.path.exists(file): - os.unlink(file) + if os.path.exists(config_pppoe): + os.unlink(config_pppoe) return None # Create PPP configuration files - render(config_pppoe, 'pppoe/peer.tmpl', pppoe, permission=0o755) - - # Create script for ip-pre-up.d - render(script_pppoe_pre_up, 'pppoe/ip-pre-up.script.tmpl', pppoe, - permission=0o755) - # Create script for ip-up.d - render(script_pppoe_ip_up, 'pppoe/ip-up.script.tmpl', pppoe, - permission=0o755) - # Create script for ip-down.d - render(script_pppoe_ip_down, 'pppoe/ip-down.script.tmpl', pppoe, - permission=0o755) - # Create script for ipv6-up.d - render(script_pppoe_ipv6_up, 'pppoe/ipv6-up.script.tmpl', pppoe, - permission=0o755) - - if 'dhcpv6_options' in pppoe and 'pd' in pppoe['dhcpv6_options']: - # ipv6.tmpl relies on ifname - this should be made consitent in the - # future better then double key-ing the same value - render(config_wide_dhcp6c, 'dhcp-client/ipv6.tmpl', pppoe) + render(config_pppoe, 'pppoe/peer.tmpl', pppoe, permission=0o640) return None def apply(pppoe): + ifname = pppoe['ifname'] if 'deleted' in pppoe or 'disable' in pppoe: - call('systemctl stop ppp@{ifname}.service'.format(**pppoe)) + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.remove() + call(f'systemctl stop ppp@{ifname}.service') return None - call('systemctl restart ppp@{ifname}.service'.format(**pppoe)) + # reconnect should only be necessary when certain config options change, + # like ACS name, authentication, no-peer-dns, source-interface + if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or + 'shutdown_required' in pppoe): + + # cleanup system (e.g. FRR routes first) + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.remove() + + call(f'systemctl restart ppp@{ifname}.service') + else: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.update(pppoe) return None diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index 294da8ef9..ef385d2e7 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -18,6 +18,7 @@ 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 @@ -31,6 +32,7 @@ from vyos.configverify import verify_mtu_ipv6 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 @@ -94,6 +96,38 @@ 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['encapsulation'] in ['gre'] and \ + tunnel['source_address'] == '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'): + tunnel_cfg = get_interface_config(tunnel_if) + exist_encap = tunnel_cfg['linkinfo']['info_kind'] + exist_source_address = tunnel_cfg['address'] + exist_key = tunnel_cfg['linkinfo']['info_data']['ikey'] + new_source_address = tunnel['source_address'] + # Convert tunnel key to ip key, format "ip -j link show" + # 1 => 0.0.0.1, 999 => 0.0.3.231 + orig_new_key = int(tunnel['parameters']['ip']['key']) + new_key = IPv4Address(orig_new_key) + new_key = str(new_key) + if tunnel['encapsulation'] == exist_encap and \ + new_source_address == exist_source_address and \ + new_key == exist_key: + raise ConfigError(f'Key "{orig_new_key}" for source-address "{new_source_address}" ' \ + f'is already used for tunnel "{tunnel_if}"!') + + # Keys are not allowed with ipip and sit tunnels + if tunnel['encapsulation'] in ['ipip', 'sit']: + if dict_search('parameters.ip.key', tunnel) != None: + raise ConfigError('Keys are not allowed with ipip and sit tunnels!') + verify_mtu_ipv6(tunnel) verify_address(tunnel) verify_vrf(tunnel) diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 4c566a5ad..da64dd076 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WireGuardIf from vyos.util import check_kmod +from vyos.util import check_port_availability from vyos import ConfigError from vyos import airbag airbag.enable() @@ -46,6 +47,9 @@ def get_config(config=None): base = ['interfaces', 'wireguard'] wireguard = get_interface_dict(conf, base) + # Check if a port was changed + wireguard['port_changed'] = leaf_node_changed(conf, ['port']) + # Determine which Wireguard peer has been removed. # Peers can only be removed with their public key! dict = {} @@ -73,6 +77,13 @@ def verify(wireguard): if 'peer' not in wireguard: raise ConfigError('At least one Wireguard peer is required!') + if 'port' in wireguard and wireguard['port_changed']: + listen_port = int(wireguard['port']) + if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: + raise ConfigError( + f'The UDP port {listen_port} is busy or unavailable and cannot be used for the interface' + ) + # run checks on individual configured WireGuard peer for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index 31c599145..faa5eb628 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -26,7 +26,6 @@ from vyos.configverify import verify_vrf from vyos.ifconfig import WWANIf from vyos.util import cmd from vyos.util import dict_search -from vyos.template import render from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index dae958774..59939d0fb 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -139,12 +139,10 @@ def verify(nat): for rule, config in dict_search('source.rule', nat).items(): err_msg = f'Source NAT configuration error in rule {rule}:' if 'outbound_interface' not in config: - raise ConfigError(f'{err_msg}\n' \ - 'outbound-interface not specified') - else: - 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') + 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') addr = dict_search('translation.address', config) if addr != None: diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index e2bd6417d..fb376a434 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -55,7 +55,7 @@ def get_config(config=None): conf = config else: conf = Config() - + base = ['nat66'] nat = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) @@ -90,7 +90,7 @@ def get_config(config=None): # be done only once if not get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK'): 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') @@ -109,21 +109,22 @@ def verify(nat): if 'helper_functions' in nat and nat['helper_functions'] != 'has': if not (nat['pre_ct_conntrack'] or nat['out_ct_conntrack']): raise Exception('could not determine nftable ruleset handlers') - + if dict_search('source.rule', nat): for rule, config in dict_search('source.rule', nat).items(): err_msg = f'Source NAT66 configuration error in rule {rule}:' if 'outbound_interface' not in config: - raise ConfigError(f'{err_msg}\n' \ - 'outbound-interface not specified') - else: - if config['outbound_interface'] not in interfaces(): - print(f'WARNING: rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') + 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') 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') + else: + raise ConfigError(f'{err_msg} translation address not specified') prefix = dict_search('source.prefix', config) if prefix != None: @@ -145,7 +146,7 @@ def verify(nat): def generate(nat): render(iptables_nat_config, 'firewall/nftables-nat66.tmpl', nat, permission=0o755) - render(ndppd_config, 'proxy-ndp/ndppd.conf.tmpl', nat, permission=0o755) + render(ndppd_config, 'ndppd/ndppd.conf.tmpl', nat, permission=0o755) return None def apply(nat): diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index ef1b57650..efa3578b4 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -16,8 +16,11 @@ from sys import exit +import jmespath + from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import node_changed from vyos.pki import is_ca_certificate from vyos.pki import load_certificate from vyos.pki import load_certificate_request @@ -26,6 +29,7 @@ from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters from vyos.util import ask_input +from vyos.util import dict_search_recursive from vyos.xml import defaults from vyos import ConfigError from vyos import airbag @@ -37,14 +41,29 @@ def get_config(config=None): else: conf = Config() base = ['pki'] - if not conf.exists(base): - return None pki = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + get_first_key=True, + no_tag_node_value_mangle=True) + + pki['changed'] = {} + tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_')) + if tmp: pki['changed'].update({'ca' : tmp}) + + tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_')) + if tmp: pki['changed'].update({'certificate' : tmp}) + + # We only merge on the defaults of there is a configuration at all + if conf.exists(base): + default_values = defaults(base) + pki = dict_merge(default_values, pki) + + # We need to get the entire system configuration to verify that we are not + # deleting a certificate that is still referenced somewhere! + pki['system'] = conf.get_config_dict([], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) - default_values = defaults(base) - pki = dict_merge(default_values, pki) return pki def is_valid_certificate(raw_data): @@ -142,6 +161,21 @@ def verify(pki): if len(country) != 2 or not country.isalpha(): raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + if 'changed' in pki: + # if the list is getting longer, we can move to a dict() and also embed the + # search key as value from line 173 or 176 + for cert_type in ['ca', 'certificate']: + if not cert_type in pki['changed']: + continue + for certificate in pki['changed'][cert_type]: + if cert_type not in pki or certificate not in pki['changed'][cert_type]: + if cert_type == 'ca': + if certificate in dict_search_recursive(pki['system'], 'ca_certificate'): + raise ConfigError(f'CA certificate "{certificate}" is still in use!') + elif cert_type == 'certificate': + if certificate in dict_search_recursive(pki['system'], 'certificate'): + raise ConfigError(f'Certificate "{certificate}" is still in use!') + return None def generate(pki): @@ -154,6 +188,8 @@ def apply(pki): if not pki: return None + # XXX: restart services if the content of a certificate changes + return None if __name__ == '__main__': diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy-local-route.py index 013f22665..539189442 100755 --- a/src/conf_mode/policy-local-route.py +++ b/src/conf_mode/policy-local-route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -44,17 +44,26 @@ def get_config(config=None): 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) + 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) return pbr @@ -65,8 +74,8 @@ def verify(pbr): if 'rule' in pbr: for rule in pbr['rule']: - if 'source' not in pbr['rule'][rule]: - raise ConfigError('Source address required!') + 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!') @@ -86,16 +95,34 @@ def apply(pbr): # Delete old rule if needed if 'rule_remove' in pbr: for rule in pbr['rule_remove']: - for src in pbr['rule_remove'][rule]['source']: - call(f'ip rule del prio {rule} from {src}') + 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}') # Generate new config if 'rule' in pbr: for rule in pbr['rule']: table = pbr['rule'][rule]['set']['table'] - if pbr['rule'][rule]['source']: + # 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}') return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index d56bae9e9..1a03d520b 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -190,6 +190,7 @@ 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.commit_configuration(bgp_daemon) diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index 348bae59f..539fd7b8e 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -92,7 +92,7 @@ def generate(bfd): bfd['new_frr_config'] = '' return None - bfd['new_frr_config'] = render_to_string('frr/bfd.frr.tmpl', bfd) + bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.tmpl', bfd) def apply(bfd): # Save original configuration prior to starting any commit actions diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 9ecfd07fe..68284e0f9 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -23,6 +23,7 @@ from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_prefix_list from vyos.configverify import verify_route_map +from vyos.configverify import verify_vrf from vyos.template import is_ip from vyos.template import is_interface from vyos.template import render_to_string @@ -129,7 +130,7 @@ def verify(bgp): if 'local_as' in peer_config: if len(peer_config['local_as']) > 1: - raise ConfigError('Only one local-as number may be specified!') + raise ConfigError(f'Only one local-as number can be specified for peer "{peer}"!') # Neighbor local-as override can not be the same as the local-as # we use for this BGP instane! @@ -139,7 +140,7 @@ def verify(bgp): # 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\'t set both ebgp-multihop and ttl-security hops') + 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. if 'override_capability' in peer_config and 'strict_capability_match' in peer_config: @@ -147,7 +148,7 @@ def verify(bgp): # Check spaces in the password if 'password' in peer_config and ' ' in peer_config['password']: - raise ConfigError('You can\'t use spaces in the password') + raise ConfigError('Whitespace is not allowed in passwords!') # Some checks can/must only be done on a neighbor and not a peer-group if neighbor == 'neighbor': @@ -221,27 +222,47 @@ def verify(bgp): raise ConfigError(f'Peer-group "{peer_group}" requires remote-as to be set!') # Throw an error if the global administrative distance parameters aren't all filled out. - if dict_search('parameters.distance', bgp) == None: - pass - else: - if dict_search('parameters.distance.global', bgp): - for key in ['external', 'internal', 'local']: - if dict_search(f'parameters.distance.global.{key}', bgp) == None: - raise ConfigError('Missing mandatory configuration option for '\ - f'global administrative distance {key}!') - - # Throw an error if the address family specific administrative distance parameters aren't all filled out. - if dict_search('address_family', bgp) == None: - pass - else: - for address_family_name in dict_search('address_family', bgp): - if dict_search(f'address_family.{address_family_name}.distance', bgp) == None: - pass - else: + if dict_search('parameters.distance.global', bgp) != None: + for key in ['external', 'internal', 'local']: + if dict_search(f'parameters.distance.global.{key}', bgp) == None: + raise ConfigError('Missing mandatory configuration option for '\ + f'global administrative distance {key}!') + + # Address Family specific validation + if 'address_family' in bgp: + for afi, afi_config in bgp['address_family'].items(): + if 'distance' in afi_config: + # Throw an error if the address family specific administrative + # distance parameters aren't all filled out. for key in ['external', 'internal', 'local']: - if dict_search(f'address_family.{address_family_name}.distance.{key}', bgp) == None: + if key not in afi_config['distance']: raise ConfigError('Missing mandatory configuration option for '\ - f'{address_family_name} administrative distance {key}!') + f'{afi} administrative distance {key}!') + + if afi in ['ipv4_unicast', 'ipv6_unicast']: + if 'import' in afi_config and 'vrf' in afi_config['import']: + # Check if VRF exists + verify_vrf(afi_config['import']['vrf']) + + # FRR error: please unconfigure vpn to vrf commands before + # using import vrf commands + if 'vpn' in afi_config['import'] or dict_search('export.vpn', afi_config) != None: + raise ConfigError('Please unconfigure VPN to VRF commands before '\ + 'using "import vrf" commands!') + + # Verify that the export/import route-maps do exist + for export_import in ['export', 'import']: + 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 diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index d4c82249b..4505e2496 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -113,9 +113,13 @@ def verify(isis): # Interface MTU must be >= configured lsp-mtu mtu = Interface(interface).get_mtu() area_mtu = isis['lsp_mtu'] - if mtu < int(area_mtu): - raise ConfigError(f'Interface {interface} has MTU {mtu}, minimum ' \ - f'area MTU is {area_mtu}!') + # Recommended maximum PDU size = interface MTU - 3 bytes + recom_area_mtu = mtu - 3 + if mtu < int(area_mtu) or int(area_mtu) > recom_area_mtu: + raise ConfigError(f'Interface {interface} has MTU {mtu}, ' \ + f'current area MTU is {area_mtu}! \n' \ + f'Recommended area lsp-mtu {recom_area_mtu} or less ' \ + '(calculated on MTU size).') if 'vrf' in isis: # If interface specific options are set, we must ensure that the @@ -144,7 +148,7 @@ def verify(isis): exist_timers = set(required_timers).difference(set(exist_timers)) if len(exist_timers) > 0: - raise ConfigError('All types of delay must be specified: ' + ', '.join(exist_timers).replace('_', '-')) + raise ConfigError('All types of spf-delay must be configured. Missing: ' + ', '.join(exist_timers).replace('_', '-')) # If Redistribute set, but level don't set if 'redistribute' in isis: diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 78c1c82bd..6ccda2e5a 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -87,7 +87,13 @@ def get_config(config=None): del default_values['area']['area_type']['nssa'] if 'mpls_te' not in ospf: del default_values['mpls_te'] - for protocol in ['bgp', 'connected', 'isis', 'kernel', 'rip', 'static']: + + for protocol in ['bgp', 'connected', 'isis', 'kernel', 'rip', 'static', 'table']: + # table is a tagNode thus we need to clean out all occurances for the + # default values and load them in later individually + if protocol == 'table': + del default_values['redistribute']['table'] + continue if dict_search(f'redistribute.{protocol}', ospf) is None: del default_values['redistribute'][protocol] @@ -109,7 +115,6 @@ def get_config(config=None): default_values = defaults(base + ['area', 'virtual-link']) for area, area_config in ospf['area'].items(): if 'virtual_link' in area_config: - print(default_values) for virtual_link in area_config['virtual_link']: ospf['area'][area]['virtual_link'][virtual_link] = dict_merge( default_values, ospf['area'][area]['virtual_link'][virtual_link]) @@ -127,6 +132,12 @@ def get_config(config=None): ospf['interface'][interface] = dict_merge(default_values, ospf['interface'][interface]) + if 'redistribute' in ospf and 'table' in ospf['redistribute']: + default_values = defaults(base + ['redistribute', 'table']) + for table in ospf['redistribute']['table']: + ospf['redistribute']['table'][table] = dict_merge(default_values, + ospf['redistribute']['table'][table]) + # We also need some additional information from the config, prefix-lists # and route-maps for instance. They will be used in verify(). # @@ -149,14 +160,23 @@ def verify(ospf): if route_map_name: verify_route_map(route_map_name, ospf) if 'interface' in ospf: - for interface in ospf['interface']: + for interface, interface_config in ospf['interface'].items(): verify_interface_exists(interface) # One can not use dead-interval and hello-multiplier at the same # time. FRR will only activate the last option set via CLI. - if {'hello_multiplier', 'dead_interval'} <= set(ospf['interface'][interface]): + if {'hello_multiplier', 'dead_interval'} <= set(interface_config): raise ConfigError(f'Can not use hello-multiplier and dead-interval ' \ f'concurrently for {interface}!') + # One can not use the "network <prefix> area <id>" command and an + # per interface area assignment at the same time. FRR will error + # out using: "Please remove all network commands first." + if 'area' in ospf and 'area' in interface_config: + for area, area_config in ospf['area'].items(): + if 'network' in area_config: + 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 @@ -177,7 +197,7 @@ def generate(ospf): 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/ospf.frr.tmpl', ospf) + ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.tmpl', ospf) return None def apply(ospf): diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index fef0f509b..536ffa690 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -65,7 +65,7 @@ def verify(ospfv3): if 'ifmtu' in if_config: mtu = Interface(ifname).get_mtu() if int(if_config['ifmtu']) > int(mtu): - raise ConfigError(f'OSPFv3 ifmtu cannot go beyond physical MTU of "{mtu}"') + raise ConfigError(f'OSPFv3 ifmtu can not exceed physical MTU of "{mtu}"') return None @@ -74,7 +74,7 @@ def generate(ospfv3): ospfv3['new_frr_config'] = '' return None - ospfv3['new_frr_config'] = render_to_string('frr/ospfv3.frr.tmpl', ospfv3) + ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.tmpl', ospfv3) return None def apply(ospfv3): diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index e56eb1f56..6b78f6f2d 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -93,7 +93,7 @@ def generate(rip): rip['new_frr_config'] = '' return None - rip['new_frr_config'] = render_to_string('frr/rip.frr.tmpl', rip) + rip['new_frr_config'] = render_to_string('frr/ripd.frr.tmpl', rip) return None diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index aaec5dacb..bc4954f63 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -95,7 +95,7 @@ def generate(ripng): ripng['new_frr_config'] = '' return None - ripng['new_frr_config'] = render_to_string('frr/ripng.frr.tmpl', ripng) + ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.tmpl', ripng) return None def apply(ripng): diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index 338247e30..597fcc443 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -80,7 +80,7 @@ def verify(static): return None def generate(static): - static['new_frr_config'] = render_to_string('frr/static.frr.tmpl', static) + static['new_frr_config'] = render_to_string('frr/staticd.frr.tmpl', static) return None def apply(static): diff --git a/src/conf_mode/service_webproxy.py b/src/conf_mode/service_webproxy.py index cbbd2e0bc..a16cc4aeb 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -23,6 +23,7 @@ from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render from vyos.util import call +from vyos.util import chmod_755 from vyos.util import dict_search from vyos.util import write_file from vyos.validate import is_addr_assigned @@ -192,6 +193,8 @@ def apply(proxy): return None + if os.path.exists(squidguard_db_dir): + chmod_755(squidguard_db_dir) call('systemctl restart squid.service') return None diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index f0b92aea8..4dd7f936d 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -59,7 +59,7 @@ def get_config(config=None): conf = Config() base = ['system', 'login'] login = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) + no_tag_node_value_mangle=True, get_first_key=True) # users no longer existing in the running configuration need to be deleted local_users = get_local_users() @@ -80,12 +80,6 @@ def get_config(config=None): login['radius']['server'][server] = dict_merge(default_values, login['radius']['server'][server]) - # XXX: for a yet unknown reason when we only have one source-address - # get_config_dict() will show a string over a string - if 'radius' in login and 'source_address' in login['radius']: - if isinstance(login['radius']['source_address'], str): - login['radius']['source_address'] = [login['radius']['source_address']] - # create a list of all users, cli and users all_users = list(set(local_users + cli_users)) # We will remove any normal users that dos not exist in the current @@ -246,7 +240,9 @@ 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', - user_config, permission=0o600, user=user, group='users') + user_config, permission=0o600, + formater=lambda _: _.replace(""", '"'), + user=user, group='users') except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index d3065fc47..99b82ca2d 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -286,20 +286,34 @@ def verify(ipsec): if 'pre_shared_secret' not in ra_conf['authentication']: raise ConfigError(f"Missing pre-shared-key on {name} remote-access config") + if 'client_mode' not in ra_conf['authentication']: + raise ConfigError('Client authentication method is required!') - if 'client_mode' in ra_conf['authentication']: - if ra_conf['authentication']['client_mode'] == 'eap-radius': - if 'radius' not in ipsec['remote_access'] or 'server' not in ipsec['remote_access']['radius'] or len(ipsec['remote_access']['radius']['server']) == 0: - raise ConfigError('RADIUS authentication requires at least one server') + if dict_search('authentication.client_mode', ra_conf) == 'eap-radius': + if dict_search('remote_access.radius.server', ipsec) == None: + raise ConfigError('RADIUS authentication requires at least one server') if 'pool' in ra_conf: + if {'dhcp', 'radius'} <= set(ra_conf['pool']): + raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\ + f'at the same time for "{name}"!') + if 'dhcp' in ra_conf['pool'] and len(ra_conf['pool']) > 1: - raise ConfigError(f'Can not use both DHCP and a predefined address pool for "{name}"!') + raise ConfigError(f'Can not use DHCP and a predefined address pool for "{name}"!') + + if 'radius' in ra_conf['pool'] and len(ra_conf['pool']) > 1: + raise ConfigError(f'Can not use RADIUS and a predefined address pool for "{name}"!') for pool in ra_conf['pool']: if pool == 'dhcp': if dict_search('remote_access.dhcp.server', ipsec) == None: raise ConfigError('IPSec DHCP server is not configured!') + elif pool == 'radius': + if dict_search('remote_access.radius.server', ipsec) == None: + raise ConfigError('IPSec RADIUS server is not configured!') + + if dict_search('authentication.client_mode', ra_conf) != 'eap-radius': + raise ConfigError('RADIUS IP pool requires eap-radius client authentication!') elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']: raise ConfigError(f'Requested pool "{pool}" does not exist!') @@ -348,6 +362,9 @@ def verify(ipsec): if 'authentication' not in peer_conf or 'mode' not in peer_conf['authentication']: raise ConfigError(f"Missing authentication on site-to-site peer {peer}") + if {'id', 'use_x509_id'} <= set(peer_conf['authentication']): + raise ConfigError(f"Manually set peer id and use-x509-id are mutually exclusive!") + if peer_conf['authentication']['mode'] == 'x509': if 'x509' not in peer_conf['authentication']: raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index c1cfc1dcb..919083ac4 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -24,7 +24,6 @@ from vyos.config import Config from vyos.configdict import node_changed from vyos.ifconfig import Interface from vyos.template import render -from vyos.template import render_to_string from vyos.util import call from vyos.util import cmd from vyos.util import dict_search @@ -32,12 +31,9 @@ from vyos.util import get_interface_config from vyos.util import popen from vyos.util import run from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() -frr_daemon = 'zebra' - config_file = r'/etc/iproute2/rt_tables.d/vyos-vrf.conf' def list_rules(): @@ -131,7 +127,6 @@ def verify(vrf): def generate(vrf): render(config_file, 'vrf/vrf.conf.tmpl', vrf) - vrf['new_frr_config'] = render_to_string('frr/vrf.frr.tmpl', vrf) # Render nftables zones config vrf['nft_vrf_zones'] = NamedTemporaryFile().name render(vrf['nft_vrf_zones'], 'firewall/nftables-vrf-zones.tmpl', vrf) @@ -242,21 +237,6 @@ def apply(vrf): if tmp == 0: cmd('nft delete table inet vrf_zones') - # T3694: Somehow we hit a priority inversion here as we need to remove the - # VRF assigned VNI before we can remove a BGP bound VRF instance. Maybe - # move this to an individual helper script that set's up the VNI for the - # given VRF after any routing protocol. - # - # # add configuration to FRR - # frr_cfg = frr.FRRConfig() - # frr_cfg.load_configuration(frr_daemon) - # frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') - # frr_cfg.add_before(r'(interface .*|line vty)', 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/vrf_vni.py b/src/conf_mode/vrf_vni.py new file mode 100755 index 000000000..87ee8f2d1 --- /dev/null +++ b/src/conf_mode/vrf_vni.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import argv +from sys import exit + +from vyos.config import Config +from vyos.template import render_to_string +from vyos import ConfigError +from vyos import frr +from vyos import airbag +airbag.enable() + +frr_daemon = 'zebra' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + # This script only works with a passed VRF name + if len(argv) < 1: + raise NotImplementedError + vrf = argv[1] + + # "assemble" dict - easier here then use a full blown get_config_dict() + # on a single leafNode + vni = { 'vrf' : vrf } + tmp = conf.return_value(['vrf', 'name', vrf, 'vni']) + if tmp: vni.update({ 'vni' : tmp }) + + return vni + +def verify(vni): + return None + +def generate(vni): + vni['new_frr_config'] = render_to_string('frr/vrf-vni.frr.tmpl', vni) + return None + +def apply(vni): + # add configuration to FRR + frr_cfg = frr.FRRConfig() + frr_cfg.load_configuration(frr_daemon) + frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') + frr_cfg.add_before(r'(interface .*|line vty)', vni['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__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vrrp.py b/src/conf_mode/vrrp.py index 680a80859..e8f1c1f99 100755 --- a/src/conf_mode/vrrp.py +++ b/src/conf_mode/vrrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-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,244 +17,131 @@ import os from sys import exit -from ipaddress import ip_address, ip_interface, IPv4Interface, IPv6Interface, IPv4Address, IPv6Address -from json import dumps -from pathlib import Path - -import vyos.config - -from vyos import ConfigError -from vyos.util import call -from vyos.template import render +from ipaddress import ip_interface +from ipaddress import IPv4Interface +from ipaddress import IPv6Interface +from vyos.config import Config +from vyos.configdict import dict_merge from vyos.ifconfig.vrrp import VRRP - +from vyos.template import render +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError from vyos import airbag airbag.enable() def get_config(config=None): - vrrp_groups = [] - sync_groups = [] - if config: - config = config + conf = config else: - config = vyos.config.Config() - - # Get the VRRP groups - for group_name in config.list_nodes("high-availability vrrp group"): - config.set_level("high-availability vrrp group {0}".format(group_name)) - - # Retrieve the values - group = {"preempt": True, "use_vmac": False, "disable": False} - - if config.exists("disable"): - group["disable"] = True - - group["name"] = group_name - group["vrid"] = config.return_value("vrid") - group["interface"] = config.return_value("interface") - group["description"] = config.return_value("description") - group["advertise_interval"] = config.return_value("advertise-interval") - group["priority"] = config.return_value("priority") - group["hello_source"] = config.return_value("hello-source-address") - group["peer_address"] = config.return_value("peer-address") - group["sync_group"] = config.return_value("sync-group") - group["preempt_delay"] = config.return_value("preempt-delay") - group["virtual_addresses"] = config.return_values("virtual-address") - group["virtual_addresses_excluded"] = config.return_values("virtual-address-excluded") - - group["auth_password"] = config.return_value("authentication password") - group["auth_type"] = config.return_value("authentication type") - - group["health_check_script"] = config.return_value("health-check script") - group["health_check_interval"] = config.return_value("health-check interval") - group["health_check_count"] = config.return_value("health-check failure-count") - - group["master_script"] = config.return_value("transition-script master") - group["backup_script"] = config.return_value("transition-script backup") - group["fault_script"] = config.return_value("transition-script fault") - group["stop_script"] = config.return_value("transition-script stop") - group["script_mode_force"] = config.exists("transition-script mode-force") - - if config.exists("no-preempt"): - group["preempt"] = False - if config.exists("rfc3768-compatibility"): - group["use_vmac"] = True - - # Substitute defaults where applicable - if not group["advertise_interval"]: - group["advertise_interval"] = 1 - if not group["priority"]: - group["priority"] = 100 - if not group["preempt_delay"]: - group["preempt_delay"] = 0 - if not group["health_check_interval"]: - group["health_check_interval"] = 60 - if not group["health_check_count"]: - group["health_check_count"] = 3 - - # FIXUP: translate our option for auth type to keepalived's syntax - # for simplicity - if group["auth_type"]: - if group["auth_type"] == "plaintext-password": - group["auth_type"] = "PASS" - else: - group["auth_type"] = "AH" - - vrrp_groups.append(group) - - config.set_level("") - - # Get the sync group used for conntrack-sync - conntrack_sync_group = None - if config.exists("service conntrack-sync failover-mechanism vrrp"): - conntrack_sync_group = config.return_value("service conntrack-sync failover-mechanism vrrp sync-group") - - # Get the sync groups - for sync_group_name in config.list_nodes("high-availability vrrp sync-group"): - config.set_level("high-availability vrrp sync-group {0}".format(sync_group_name)) - - sync_group = {"conntrack_sync": False} - sync_group["name"] = sync_group_name - sync_group["members"] = config.return_values("member") - if conntrack_sync_group: - if conntrack_sync_group == sync_group_name: - sync_group["conntrack_sync"] = True - - # add transition script configuration - sync_group["master_script"] = config.return_value("transition-script master") - sync_group["backup_script"] = config.return_value("transition-script backup") - sync_group["fault_script"] = config.return_value("transition-script fault") - sync_group["stop_script"] = config.return_value("transition-script stop") - - sync_groups.append(sync_group) - - # create a file with dict with proposed configuration - with open("{}.temp".format(VRRP.location['vyos']), 'w') as dict_file: - dict_file.write(dumps({'vrrp_groups': vrrp_groups, 'sync_groups': sync_groups})) - - return (vrrp_groups, sync_groups) - - -def verify(data): - vrrp_groups, sync_groups = data - - for group in vrrp_groups: - # Check required fields - if not group["vrid"]: - raise ConfigError("vrid is required but not set in VRRP group {0}".format(group["name"])) - if not group["interface"]: - raise ConfigError("interface is required but not set in VRRP group {0}".format(group["name"])) - if not group["virtual_addresses"]: - raise ConfigError("virtual-address is required but not set in VRRP group {0}".format(group["name"])) - - if group["auth_password"] and (not group["auth_type"]): - raise ConfigError("authentication type is required but not set in VRRP group {0}".format(group["name"])) - - # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction - - # XXX: filter on map object is destructive, so we force it to list. - # Additionally, filter objects always evaluate to True, empty or not, - # so we force them to lists as well. - vaddrs = list(map(lambda i: ip_interface(i), group["virtual_addresses"])) - vaddrs4 = list(filter(lambda x: isinstance(x, IPv4Interface), vaddrs)) - vaddrs6 = list(filter(lambda x: isinstance(x, IPv6Interface), vaddrs)) - - if vaddrs4 and vaddrs6: - raise ConfigError("VRRP group {0} mixes IPv4 and IPv6 virtual addresses, this is not allowed. Create separate groups for IPv4 and IPv6".format(group["name"])) - - if vaddrs4: - if group["hello_source"]: - hsa = ip_address(group["hello_source"]) - if isinstance(hsa, IPv6Address): - raise ConfigError("VRRP group {0} uses IPv4 but its hello-source-address is IPv6".format(group["name"])) - if group["peer_address"]: - pa = ip_address(group["peer_address"]) - if isinstance(pa, IPv6Address): - raise ConfigError("VRRP group {0} uses IPv4 but its peer-address is IPv6".format(group["name"])) - - if vaddrs6: - if group["hello_source"]: - hsa = ip_address(group["hello_source"]) - if isinstance(hsa, IPv4Address): - raise ConfigError("VRRP group {0} uses IPv6 but its hello-source-address is IPv4".format(group["name"])) - if group["peer_address"]: - pa = ip_address(group["peer_address"]) - if isinstance(pa, IPv4Address): - raise ConfigError("VRRP group {0} uses IPv6 but its peer-address is IPv4".format(group["name"])) - - # Warn the user about the deprecated mode-force option - if group['script_mode_force']: - print("""Warning: "transition-script mode-force" VRRP option is deprecated and will be removed in VyOS 1.4.""") - print("""It's no longer necessary, so you can safely remove it from your config now.""") - - # Disallow same VRID on multiple interfaces - _groups = sorted(vrrp_groups, key=(lambda x: x["interface"])) - count = len(_groups) - 1 - index = 0 - while (index < count): - if (_groups[index]["vrid"] == _groups[index + 1]["vrid"]) and (_groups[index]["interface"] == _groups[index + 1]["interface"]): - raise ConfigError("VRID {0} is used in groups {1} and {2} that both use interface {3}. Groups on the same interface must use different VRIDs".format( - _groups[index]["vrid"], _groups[index]["name"], _groups[index + 1]["name"], _groups[index]["interface"])) - else: - index += 1 - + conf = Config() + + base = ['high-availability', 'vrrp'] + if not conf.exists(base): + return None + + vrrp = 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]) + + ## 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) + + return vrrp + +def verify(vrrp): + if not vrrp: + return None + + used_vrid_if = [] + if 'group' in vrrp: + for group, group_config in 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}"') + + if 'interface' not in group_config: + raise ConfigError(f'Interface is required but not set in VRRP group "{group}"') + + if 'address' not in group_config: + raise ConfigError(f'Virtual IP address is required but not set in VRRP group "{group}"') + + if 'authentication' in group_config: + if not {'password', 'type'} <= set(group_config['authentication']): + raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') + + # We can not use a VRID once per interface + interface = group_config['interface'] + vrid = group_config['vrid'] + tmp = {'interface': interface, 'vrid': vrid} + if tmp in used_vrid_if: + raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface}"!') + used_vrid_if.append(tmp) + + # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction + + # XXX: filter on map object is destructive, so we force it to list. + # Additionally, filter objects always evaluate to True, empty or not, + # so we force them to lists as well. + vaddrs = list(map(lambda i: ip_interface(i), group_config['address'])) + vaddrs4 = list(filter(lambda x: isinstance(x, IPv4Interface), vaddrs)) + vaddrs6 = list(filter(lambda x: isinstance(x, IPv6Interface), vaddrs)) + + if vaddrs4 and vaddrs6: + raise ConfigError(f'VRRP group "{group}" mixes IPv4 and IPv6 virtual addresses, this is not allowed.\n' \ + 'Create individual groups for IPv4 and IPv6!') + if vaddrs4: + if 'hello_source_address' in group_config: + if is_ipv6(group_config['hello_source_address']): + raise ConfigError(f'VRRP group "{group}" uses IPv4 but hello-source-address is IPv6!') + + if 'peer_address' in group_config: + if is_ipv6(group_config['peer_address']): + raise ConfigError(f'VRRP group "{group}" uses IPv4 but peer-address is IPv6!') + + if vaddrs6: + if 'hello_source_address' in group_config: + if is_ipv4(group_config['hello_source_address']): + raise ConfigError(f'VRRP group "{group}" uses IPv6 but hello-source-address is IPv4!') + + if 'peer_address' in group_config: + if is_ipv4(group_config['peer_address']): + raise ConfigError(f'VRRP group "{group}" uses IPv6 but peer-address is IPv4!') # Check sync groups - vrrp_group_names = list(map(lambda x: x["name"], vrrp_groups)) - - for sync_group in sync_groups: - for m in sync_group["members"]: - if not (m in vrrp_group_names): - raise ConfigError("VRRP sync-group {0} refers to VRRP group {1}, but group {1} does not exist".format(sync_group["name"], m)) - - -def generate(data): - vrrp_groups, sync_groups = data - - # Remove disabled groups from the sync group member lists - for sync_group in sync_groups: - for member in sync_group["members"]: - g = list(filter(lambda x: x["name"] == member, vrrp_groups))[0] - if g["disable"]: - print("Warning: ignoring disabled VRRP group {0} in sync-group {1}".format(g["name"], sync_group["name"])) - # Filter out disabled groups - vrrp_groups = list(filter(lambda x: x["disable"] is not True, vrrp_groups)) - - render(VRRP.location['config'], 'vrrp/keepalived.conf.tmpl', - {"groups": vrrp_groups, "sync_groups": sync_groups}) - render(VRRP.location['daemon'], 'vrrp/daemon.tmpl', {}) + if 'sync_group' in vrrp: + for sync_group, sync_config in vrrp['sync_group'].items(): + if 'member' in sync_config: + for member in sync_config['member']: + if member not in 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: + return None + + render(VRRP.location['config'], 'vrrp/keepalived.conf.tmpl', vrrp) return None +def apply(vrrp): + service_name = 'keepalived.service' + if not vrrp: + call(f'systemctl stop {service_name}') + return None -def apply(data): - vrrp_groups, sync_groups = data - if vrrp_groups: - # safely rename a temporary file with configuration dict - try: - dict_file = Path("{}.temp".format(VRRP.location['vyos'])) - dict_file.rename(Path(VRRP.location['vyos'])) - except Exception as err: - print("Unable to rename the file with keepalived config for FIFO pipe: {}".format(err)) - - if not VRRP.is_running(): - print("Starting the VRRP process") - ret = call("systemctl restart keepalived.service") - else: - print("Reloading the VRRP process") - ret = call("systemctl reload keepalived.service") - - if ret != 0: - raise ConfigError("keepalived failed to start") - else: - # VRRP is removed in the commit - print("Stopping the VRRP process") - call("systemctl stop keepalived.service") - os.unlink(VRRP.location['daemon']) - + call(f'systemctl restart {service_name}') return None - if __name__ == '__main__': try: c = get_config() @@ -262,5 +149,5 @@ if __name__ == '__main__': generate(c) apply(c) except ConfigError as e: - print("VRRP error: {0}".format(str(e))) + print(e) exit(1) |