diff options
Diffstat (limited to 'src/conf_mode')
108 files changed, 3498 insertions, 2018 deletions
diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py index 7dc5206e0..b141f1141 100755 --- a/src/conf_mode/arp.py +++ b/src/conf_mode/arp.py @@ -18,7 +18,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import node_changed -from vyos.util import call +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py index 39a2971ce..31c552f5a 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-2022 VyOS maintainers and contributors +# Copyright (C) 2017-2023 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,12 +17,14 @@ import os from glob import glob -from netifaces import interfaces +from netifaces import AF_INET from sys import exit from vyos.config import Config -from vyos.util import call +from vyos.configverify import verify_interface_exists from vyos.template import render +from vyos.utils.process import call +from vyos.utils.network import is_afi_configured from vyos import ConfigError from vyos import airbag airbag.enable() @@ -50,18 +52,16 @@ def verify(relay): # we certainly require a UDP port to listen to if 'port' not in config: - raise ConfigError(f'Port number mandatory for udp broadcast relay "{instance}"') + raise ConfigError(f'Port number is mandatory for UDP broadcast relay "{instance}"') - # if only oone interface is given it's a string -> move to list - if isinstance(config.get('interface', []), str): - config['interface'] = [ config['interface'] ] # Relaying data without two interface is kinda senseless ... if len(config.get('interface', [])) < 2: - raise ConfigError('At least two interfaces are required for udp broadcast relay "{instance}"') + raise ConfigError('At least two interfaces are required for UDP broadcast relay "{instance}"') for interface in config.get('interface', []): - if interface not in interfaces(): - raise ConfigError('Interface "{interface}" does not exist!') + verify_interface_exists(interface) + if not is_afi_configured(interface, AF_INET): + raise ConfigError(f'Interface "{interface}" has no IPv4 address configured!') return None diff --git a/src/conf_mode/config_mgmt.py b/src/conf_mode/config_mgmt.py new file mode 100755 index 000000000..c681a8405 --- /dev/null +++ b/src/conf_mode/config_mgmt.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys + +from vyos import ConfigError +from vyos.config import Config +from vyos.config_mgmt import ConfigMgmt +from vyos.config_mgmt import commit_post_hook_dir, commit_hooks + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['system', 'config-management'] + if not conf.exists(base): + return None + + mgmt = ConfigMgmt(config=conf) + + return mgmt + +def verify(_mgmt): + return + +def generate(mgmt): + if mgmt is None: + return + + mgmt.initialize_revision() + +def apply(mgmt): + if mgmt is None: + return + + locations = mgmt.locations + archive_target = os.path.join(commit_post_hook_dir, + commit_hooks['commit_archive']) + if locations: + try: + os.symlink('/usr/bin/config-mgmt', archive_target) + except FileExistsError: + pass + except OSError as exc: + raise ConfigError from exc + else: + try: + os.unlink(archive_target) + except FileNotFoundError: + pass + except OSError as exc: + raise ConfigError from exc + + revisions = mgmt.max_revisions + revision_target = os.path.join(commit_post_hook_dir, + commit_hooks['commit_revision']) + if revisions > 0: + try: + os.symlink('/usr/bin/config-mgmt', revision_target) + except FileExistsError: + pass + except OSError as exc: + raise ConfigError from exc + else: + try: + os.unlink(revision_target) + except FileNotFoundError: + pass + except OSError as exc: + raise ConfigError from exc + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py index 82289526f..a0de914bc 100755 --- a/src/conf_mode/conntrack.py +++ b/src/conf_mode/conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,15 +20,15 @@ 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 -from vyos.util import dict_search +from vyos.utils.process import process_named_running +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.utils.process import run from vyos.template import render -from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() @@ -64,6 +64,13 @@ module_map = { }, } +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group' +] + def resync_conntrackd(): tmp = run('/usr/libexec/vyos/conf_mode/conntrack_sync.py') if tmp > 0: @@ -77,26 +84,56 @@ def get_config(config=None): base = ['system', 'conntrack'] conntrack = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) + get_first_key=True, + with_recursive_defaults=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) - # 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) + conntrack['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) 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}') + for inet in ['ipv4', 'ipv6']: + if dict_search_args(conntrack, 'ignore', inet, 'rule') != None: + for rule, rule_config in conntrack['ignore'][inet]['rule'].items(): + if dict_search('destination.port', rule_config) or \ + dict_search('destination.group.port_group', rule_config) or \ + dict_search('source.port', rule_config) or \ + dict_search('source.group.port_group', 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}') + + for side in ['destination', 'source']: + if side in rule_config: + side_conf = rule_config[side] + + if 'group' in side_conf: + if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group or domain-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + error_group = group.replace("_", "-") + + if group in ['address_group', 'network_group', 'domain_group']: + if 'address' in side_conf: + raise ConfigError(f'{error_group} and address cannot both be defined') + + if group_name and group_name[0] == '!': + group_name = group_name[1:] + + if inet == 'ipv6': + group = f'ipv6_{group}' + + group_obj = dict_search_args(conntrack['firewall_group'], group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on ignore rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members!') return None @@ -104,26 +141,18 @@ def generate(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): +def find_nftables_ct_rule(table, chain, 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]) + return find_nftables_rule(table, chain, [rule]) -def find_remove_rule(rule): - handle = find_nftables_ct_rule(rule) +def find_remove_rule(table, chain, rule): + handle = find_nftables_ct_rule(table, chain, rule) if handle: - remove_nftables_rule('raw', 'VYOS_CT_HELPER', handle) + remove_nftables_rule(table, chain, handle) def apply(conntrack): # Depending on the enable/disable state of the ALG (Application Layer Gateway) @@ -137,18 +166,24 @@ def apply(conntrack): cmd(f'rmmod {mod}') if 'nftables' in module_config: for rule in module_config['nftables']: - find_remove_rule(rule) + find_remove_rule('raw', 'VYOS_CT_HELPER', rule) + find_remove_rule('ip6 raw', 'VYOS_CT_HELPER', rule) else: if 'ko' in module_config: for mod in module_config['ko']: cmd(f'modprobe {mod}') 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}') + if not find_nftables_ct_rule('raw', 'VYOS_CT_HELPER', rule): + cmd(f'nft insert rule raw VYOS_CT_HELPER {rule}') + + if not find_nftables_ct_rule('ip6 raw', 'VYOS_CT_HELPER', rule): + cmd(f'nft insert rule ip6 raw VYOS_CT_HELPER {rule}') # Load new nftables ruleset - cmd(f'nft -f {nftables_ct_file}') + install_result, output = rc_cmd(f'nft -f {nftables_ct_file}') + if install_result == 1: + raise ConfigError(f'Failed to apply configuration: {output}') 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 c4b2bb488..4fb2ce27f 100755 --- a/src/conf_mode/conntrack_sync.py +++ b/src/conf_mode/conntrack_sync.py @@ -18,17 +18,15 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configverify import verify_interface_exists -from vyos.util import call -from vyos.util import dict_search -from vyos.util import process_named_running -from vyos.util import read_file -from vyos.util import run +from vyos.utils.dict import dict_search +from vyos.utils.process import process_named_running +from vyos.utils.file import read_file +from vyos.utils.process import call +from vyos.utils.process import run from vyos.template import render from vyos.template import get_ipv4 -from vyos.validate import is_addr_assigned -from vyos.xml import defaults +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() @@ -50,11 +48,7 @@ def get_config(config=None): return None conntrack = 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) - conntrack = dict_merge(default_values, conntrack) + get_first_key=True, with_defaults=True) conntrack['hash_size'] = read_file('/sys/module/nf_conntrack/parameters/hashsize') conntrack['table_size'] = read_file('/proc/sys/net/netfilter/nf_conntrack_max') diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 8efeaed54..46eb10714 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,30 +16,36 @@ import os +from hashlib import sha256 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 write_file +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.ifconfig import Interface +from vyos.utils.file import write_file +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.utils.process import rc_cmd +from vyos.template import bracketize_ipv6 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.xml_ref import default_value from vyos import ConfigError from vyos import airbag airbag.enable() -config_containers_registry = '/etc/containers/registries.conf' -config_containers_storage = '/etc/containers/storage.conf' +config_containers = '/etc/containers/containers.conf' +config_registry = '/etc/containers/registries.conf' +config_storage = '/etc/containers/storage.conf' systemd_unit_path = '/run/systemd/system' def _cmd(command): @@ -61,20 +67,28 @@ def get_config(config=None): base = ['container'] container = 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) - # 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]) + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + for name in container.get('name', []): + # T5047: Any container related configuration changed? We only + # wan't to restart the required containers and not all of them ... + tmp = is_node_changed(conf, base + ['name', name]) + if tmp: + if 'container_restart' not in container: + container['container_restart'] = [name] + else: + container['container_restart'].append(name) + + # registry is a tagNode with default values - merge the list from + # default_values['registry'] into the tagNode variables + if 'registry' not in container: + container.update({'registry' : {}}) + default_values = default_value(base + ['registry']) + for registry in default_values: + tmp = {registry : {}} + container['registry'] = dict_merge(tmp, container['registry']) # Delete container network, delete containers tmp = node_changed(conf, base + ['network']) @@ -123,21 +137,29 @@ def verify(container): raise ConfigError(f'Container network "{network_name}" does not exist!') if 'address' in container_config['network'][network_name]: - address = container_config['network'][network_name]['address'] - network = None - if is_ipv4(address): - network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] - elif is_ipv6(address): - network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] - - # Specified container IP address must belong to network prefix - if ip_address(address) not in ip_network(network): - raise ConfigError(f'Used container address "{address}" not in network "{network}"!') - - # 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'IP address "{address}" can not be used for a container, '\ - 'reserved for the container engine!') + cnt_ipv4 = 0 + cnt_ipv6 = 0 + for address in container_config['network'][network_name]['address']: + network = None + if is_ipv4(address): + network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] + cnt_ipv4 += 1 + elif is_ipv6(address): + network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] + cnt_ipv6 += 1 + + # Specified container IP address must belong to network prefix + if ip_address(address) not in ip_network(network): + raise ConfigError(f'Used container address "{address}" not in network "{network}"!') + + # 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'IP address "{address}" can not be used for a container, '\ + 'reserved for the container engine!') + + if cnt_ipv4 > 1 or cnt_ipv6 > 1: + raise ConfigError(f'Only one IP address per address family can be used for '\ + f'container "{name}". {cnt_ipv4} IPv4 and {cnt_ipv6} IPv6 address(es)!') if 'device' in container_config: for dev, dev_config in container_config['device'].items(): @@ -156,6 +178,11 @@ def verify(container): if 'value' not in cfg: raise ConfigError(f'Environment variable {var} has no value assigned!') + if 'label' in container_config: + for var, cfg in container_config['label'].items(): + if 'value' not in cfg: + raise ConfigError(f'Label 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: @@ -168,6 +195,11 @@ def verify(container): if not os.path.exists(source): raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') + if 'port' in container_config: + for tmp in container_config['port']: + if not {'source', 'destination'} <= set(container_config['port'][tmp]): + raise ConfigError(f'Both "source" and "destination" must be specified for a port mapping!') + # 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}"!') @@ -194,6 +226,8 @@ def verify(container): if v6_prefix > 1: raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!') + # Verify VRF exists + verify_vrf(network_config) # A network attached to a container can not be deleted if {'network_remove', 'name'} <= set(container): @@ -202,6 +236,13 @@ def verify(container): if 'network' in container_config and network in container_config['network']: raise ConfigError(f'Can not remove network "{network}", used by container "{container}"!') + if 'registry' in container: + for registry, registry_config in container['registry'].items(): + if 'authentication' not in registry_config: + continue + if not {'username', 'password'} <= set(registry_config['authentication']): + raise ConfigError('If registry username or or password is defined, so must be the other!') + return None def generate_run_arguments(name, container_config): @@ -230,21 +271,36 @@ def generate_run_arguments(name, container_config): env_opt = '' if 'environment' in container_config: for k, v in container_config['environment'].items(): - env_opt += f" -e \"{k}={v['value']}\"" + env_opt += f" --env \"{k}={v['value']}\"" + + # Check/set label options "--label foo=bar" + env_opt = '' + if 'label' in container_config: + for k, v in container_config['label'].items(): + env_opt += f" --label \"{k}={v['value']}\"" + + hostname = '' + if 'host_name' in container_config: + hostname = container_config['host_name'] + hostname = f'--hostname {hostname}' # 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' + protocol = container_config['port'][portmap]['protocol'] sport = container_config['port'][portmap]['source'] dport = container_config['port'][portmap]['destination'] - port += f' -p {sport}:{dport}{protocol}' + listen_addresses = container_config['port'][portmap].get('listen_address', []) + + # If listen_addresses is not empty, include them in the publish command + if listen_addresses: + for listen_address in listen_addresses: + port += f' --publish {bracketize_ipv6(listen_address)}:{sport}:{dport}/{protocol}' + else: + # If listen_addresses is empty, just include the standard publish command + port += f' --publish {sport}:{dport}/{protocol}' # Bind volume volume = '' @@ -252,66 +308,102 @@ def generate_run_arguments(name, container_config): for vol, vol_config in container_config['volume'].items(): svol = vol_config['source'] dvol = vol_config['destination'] - volume += f' -v {svol}:{dvol}' + mode = vol_config['mode'] + prop = vol_config['propagation'] + volume += f' --volume {svol}:{dvol}:{mode},{prop}' container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \ f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ - f'--name {name} {device} {port} {volume} {env_opt}' + f'--name {name} {hostname} {device} {port} {volume} {env_opt}' + + entrypoint = '' + if 'entrypoint' in container_config: + # it needs to be json-formatted with single quote on the outside + entrypoint = json_write(container_config['entrypoint'].split()).replace('"', """) + entrypoint = f'--entrypoint '{entrypoint}'' + + hostname = '' + if 'host_name' in container_config: + hostname = container_config['host_name'] + hostname = f'--hostname {hostname}' + + command = '' + if 'command' in container_config: + command = container_config['command'].strip() + + command_arguments = '' + if 'arguments' in container_config: + command_arguments = container_config['arguments'].strip() if 'allow_host_networks' in container_config: - return f'{container_base_cmd} --net host {image}' + return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip() ip_param = '' networks = ",".join(container_config['network']) for network in container_config['network']: - if 'address' in container_config['network'][network]: - address = container_config['network'][network]['address'] - ip_param = f'--ip {address}' + if 'address' not in container_config['network'][network]: + continue + for address in container_config['network'][network]['address']: + if is_ipv6(address): + ip_param += f' --ip6 {address}' + else: + ip_param += f' --ip {address}' - return f'{container_base_cmd} --net {networks} {ip_param} {image}' + return f'{container_base_cmd} --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() 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) + for file in [config_containers, config_registry, config_storage]: + if os.path.exists(file): + os.unlink(file) 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' : [], - }, - }] + 'name': network, + 'id' : sha256(f'{network}'.encode()).hexdigest(), + 'driver': 'bridge', + 'network_interface': f'pod-{network}', + 'subnets': [], + 'ipv6_enabled': False, + 'internal': False, + 'dns_enabled': True, + 'ipam_options': { + 'driver': 'host-local' + } } - for prefix in network_config['prefix']: - net = [{'gateway' : inc_ip(prefix, 1), 'subnet' : prefix}] - tmp['plugins'][0]['ipam']['ranges'].append(net) + net = {'subnet' : prefix, 'gateway' : inc_ip(prefix, 1)} + tmp['subnets'].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}) + tmp['ipv6_enabled'] = True + + write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2)) - write_file(f'/etc/cni/net.d/{network}.conflist', json_write(tmp, indent=2)) + if 'registry' in container: + cmd = f'podman logout --all' + rc, out = rc_cmd(cmd) + if rc != 0: + raise ConfigError(out) - render(config_containers_registry, 'container/registries.conf.j2', container) - render(config_containers_storage, 'container/storage.conf.j2', container) + for registry, registry_config in container['registry'].items(): + if 'disable' in registry_config: + continue + if 'authentication' in registry_config: + if {'username', 'password'} <= set(registry_config['authentication']): + username = registry_config['authentication']['username'] + password = registry_config['authentication']['password'] + cmd = f'podman login --username {username} --password {password} {registry}' + rc, out = rc_cmd(cmd) + if rc != 0: + raise ConfigError(out) + + render(config_containers, 'container/containers.conf.j2', container) + render(config_registry, 'container/registries.conf.j2', container) + render(config_storage, 'container/storage.conf.j2', container) if 'name' in container: for name, container_config in container['name'].items(): @@ -320,7 +412,8 @@ def generate(container): file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') run_args = generate_run_arguments(name, container_config) - render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args}) + render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args,}, + formater=lambda _: _.replace(""", '"').replace("'", "'")) return None @@ -339,10 +432,7 @@ def apply(container): # Delete old networks if needed if 'network_remove' in container: for network in container['network_remove']: - call(f'podman network rm {network}') - tmp = f'/etc/cni/net.d/{network}.conflist' - if os.path.exists(tmp): - os.unlink(tmp) + call(f'podman network rm {network} >/dev/null 2>&1') # Add container disabled_new = False @@ -366,11 +456,27 @@ def apply(container): os.unlink(file_path) continue - cmd(f'systemctl restart vyos-container-{name}.service') + if 'container_restart' in container and name in container['container_restart']: + cmd(f'systemctl restart vyos-container-{name}.service') if disabled_new: call('systemctl daemon-reload') + # Start network and assign it to given VRF if requested. this can only be done + # after the containers got started as the podman network interface will + # only be enabled by the first container and yet I do not know how to enable + # the network interface in advance + if 'network' in container: + for network, network_config in container['network'].items(): + network_name = f'pod-{network}' + # T5147: Networks are started only as soon as there is a consumer. + # If only a network is created in the first place, no need to assign + # it to a VRF as there's no consumer, yet. + if os.path.exists(f'/sys/class/net/{network_name}'): + tmp = Interface(network_name) + tmp.add_ipv6_eui64_address('fe80::/64') + tmp.set_vrf(network_config.get('vrf', '')) + return None if __name__ == '__main__': diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/dhcp_relay.py index 4de2ca2f3..37d708847 100755 --- a/src/conf_mode/dhcp_relay.py +++ b/src/conf_mode/dhcp_relay.py @@ -18,12 +18,12 @@ import os from sys import exit +from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.base import Warning +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -39,17 +39,15 @@ def get_config(config=None): if not conf.exists(base): return None - relay = 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) - relay = dict_merge(default_values, relay) + relay = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return relay def verify(relay): # bail out early - looks like removal from running config - if not relay: + if not relay or 'disable' in relay: return None if 'lo' in (dict_search('interface', relay) or []): @@ -59,11 +57,24 @@ def verify(relay): raise ConfigError('No DHCP relay server(s) configured.\n' \ 'At least one DHCP relay server required.') + if 'interface' in relay: + Warning('DHCP relay interface is DEPRECATED - please use upstream-interface and listen-interface instead!') + if 'upstream_interface' in relay or 'listen_interface' in relay: + raise ConfigError('<interface> configuration is not compatible with upstream/listen interface') + else: + Warning('<interface> is going to be deprecated.\n' \ + 'Please use <listen-interface> and <upstream-interface>') + + if 'upstream_interface' in relay and 'listen_interface' not in relay: + raise ConfigError('No listen-interface configured') + if 'listen_interface' in relay and 'upstream_interface' not in relay: + raise ConfigError('No upstream-interface configured') + return None def generate(relay): # bail out early - looks like removal from running config - if not relay: + if not relay or 'disable' in relay: return None render(config_file, 'dhcp-relay/dhcrelay.conf.j2', relay) @@ -72,7 +83,7 @@ def generate(relay): def apply(relay): # bail out early - looks like removal from running config service_name = 'isc-dhcp-relay.service' - if not relay: + if not relay or 'disable' in relay: call(f'systemctl stop {service_name}') if os.path.exists(config_file): os.unlink(config_file) diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py index 52b682d6d..ac7d95632 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-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,19 +23,18 @@ from netaddr import IPRange from sys import exit 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 dict_search -from vyos.util import run -from vyos.validate import is_subnet_connected -from vyos.validate import is_addr_assigned -from vyos.xml import defaults +from vyos.utils.dict import dict_search +from vyos.utils.process import call +from vyos.utils.process import run +from vyos.utils.network import is_subnet_connected +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() config_file = '/run/dhcp-server/dhcpd.conf' +systemd_override = r'/run/systemd/system/isc-dhcp-server.service.d/10-override.conf' def dhcp_slice_range(exclude_list, range_dict): """ @@ -109,19 +108,15 @@ def get_config(config=None): if not conf.exists(base): return None - 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']) + dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): if 'subnet' in network_config: for subnet, subnet_config in network_config['subnet'].items(): - if 'lease' not in subnet_config: - dhcp['shared_network_name'][network]['subnet'][subnet] = dict_merge( - default_values, dhcp['shared_network_name'][network]['subnet'][subnet]) - # If exclude IP addresses are defined we need to slice them out of # the defined ranges if {'exclude', 'range'} <= set(subnet_config): @@ -247,7 +242,7 @@ def verify(dhcp): net2 = ip_network(n) if (net != net2): if net.overlaps(net2): - raise ConfigError('Conflicting subnet ranges: "{net}" overlaps "{net2}"!') + raise ConfigError(f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!') # Prevent 'disable' for shared-network if only one network is configured if (shared_networks - disabled_shared_networks) < 1: @@ -283,7 +278,7 @@ def generate(dhcp): if not dhcp or 'disable' in dhcp: return None - # Please see: https://phabricator.vyos.net/T1129 for quoting of the raw + # Please see: https://vyos.dev/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.j2', dhcp, @@ -301,10 +296,16 @@ def generate(dhcp): # render the "real" configuration render(config_file, 'dhcp-server/dhcpd.conf.j2', dhcp, formater=lambda _: _.replace(""", '"')) + render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) + + # Clean up configuration test file + if os.path.exists(tmp_file): + os.unlink(tmp_file) return None def apply(dhcp): + call('systemctl daemon-reload') # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: call('systemctl stop isc-dhcp-server.service') diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py index c1bd51f62..6537ca3c2 100755 --- a/src/conf_mode/dhcpv6_relay.py +++ b/src/conf_mode/dhcpv6_relay.py @@ -19,13 +19,11 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge from vyos.ifconfig import Interface from vyos.template import render -from vyos.util import call -from vyos.util import dict_search -from vyos.validate import is_ipv6_link_local -from vyos.xml import defaults +from vyos.template import is_ipv6 +from vyos.utils.process import call +from vyos.utils.network import is_ipv6_link_local from vyos import ConfigError from vyos import airbag airbag.enable() @@ -41,17 +39,15 @@ def get_config(config=None): if not conf.exists(base): return None - relay = 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) - relay = dict_merge(default_values, relay) + relay = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return relay def verify(relay): # bail out early - looks like removal from running config - if not relay: + if not relay or 'disable' in relay: return None if 'upstream_interface' not in relay: @@ -69,7 +65,7 @@ def verify(relay): for interface in relay['listen_interface']: has_global = False for addr in Interface(interface).get_addr(): - if not is_ipv6_link_local(addr): + if is_ipv6(addr) and not is_ipv6_link_local(addr): has_global = True if not has_global: raise ConfigError(f'Interface {interface} does not have global '\ @@ -79,7 +75,7 @@ def verify(relay): def generate(relay): # bail out early - looks like removal from running config - if not relay: + if not relay or 'disable' in relay: return None render(config_file, 'dhcp-relay/dhcrelay6.conf.j2', relay) @@ -88,7 +84,7 @@ def generate(relay): def apply(relay): # bail out early - looks like removal from running config service_name = 'isc-dhcp-relay6.service' - if not relay: + if not relay or 'disable' in relay: # DHCPv6 relay support is removed in the commit call(f'systemctl stop {service_name}') if os.path.exists(config_file): diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py index 078ff327c..427001609 100755 --- a/src/conf_mode/dhcpv6_server.py +++ b/src/conf_mode/dhcpv6_server.py @@ -23,9 +23,9 @@ from sys import exit from vyos.config import Config from vyos.template import render from vyos.template import is_ipv6 -from vyos.util import call -from vyos.util import dict_search -from vyos.validate import is_subnet_connected +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.network import is_subnet_connected from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py new file mode 100755 index 000000000..ab80defe8 --- /dev/null +++ b/src/conf_mode/dns_dynamic.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/ddclient/ddclient.conf' +systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' + +# Protocols that require zone +zone_allowed = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn'] + +# Protocols that do not require username +username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla'] + +# Protocols that support both IPv4 and IPv6 +dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla'] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base_level = ['service', 'dns', 'dynamic'] + if not conf.exists(base_level): + return None + + dyndns = conf.get_config_dict(base_level, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + dyndns['config_file'] = config_file + return dyndns + +def verify(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'address' not in dyndns: + return None + + for address in dyndns['address']: + # RFC2136 - configuration validation + if 'rfc2136' in dyndns['address'][address]: + for config in dyndns['address'][address]['rfc2136'].values(): + for field in ['host_name', 'zone', 'server', 'key']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" is required for RFC2136 ' + f'based Dynamic DNS service on "{address}"') + + # Dynamic DNS service provider - configuration validation + if 'service' in dyndns['address'][address]: + for service, config in dyndns['address'][address]['service'].items(): + error_msg = f'is required for Dynamic DNS service "{service}" on "{address}"' + + for field in ['host_name', 'password', 'protocol']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}') + + if config['protocol'] in zone_allowed and 'zone' not in config: + raise ConfigError(f'"zone" {error_msg}') + + if config['protocol'] not in zone_allowed and 'zone' in config: + raise ConfigError(f'"{config["protocol"]}" does not support "zone"') + + if config['protocol'] not in username_unnecessary: + if 'username' not in config: + raise ConfigError(f'"username" {error_msg}') + + if config['ip_version'] == 'both': + if config['protocol'] not in dualstack_supported: + raise ConfigError(f'"{config["protocol"]}" does not support ' + f'both IPv4 and IPv6 at the same time') + # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) + if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] != 'members.dyndns.org': + raise ConfigError(f'"{config["protocol"]}" does not support ' + f'both IPv4 and IPv6 at the same time for "{config["server"]}"') + + return None + +def generate(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'address' not in dyndns: + return None + + render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns) + render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) + return None + +def apply(dyndns): + systemd_service = 'ddclient.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + + # bail out early - looks like removal from running config + if not dyndns or 'address' not in dyndns: + call(f'systemctl stop {systemd_service}') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call(f'systemctl reload-or-restart {systemd_service}') + + 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/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index d0d87d73e..c186f47af 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -21,14 +21,12 @@ from sys import exit from glob import glob from vyos.config import Config -from vyos.configdict import dict_merge from vyos.hostsd_client import Client as hostsd_client from vyos.template import render -from vyos.template import is_ipv6 -from vyos.util import call -from vyos.util import chown -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.template import bracketize_ipv6 +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag @@ -52,13 +50,10 @@ def get_config(config=None): if not conf.exists(base): return None - 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 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) + dns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) # some additions to the default dictionary if 'system' in dns: @@ -81,7 +76,7 @@ def get_config(config=None): recorddata = zonedata['records'] - for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: + for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ns', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: if rtype not in recorddata: continue for subnode in recorddata[rtype]: @@ -91,11 +86,8 @@ def get_config(config=None): 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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one address is required') continue if subnode == 'any': @@ -108,12 +100,9 @@ def get_config(config=None): 'ttl': rdata['ttl'], 'value': address }) - elif rtype in ['cname', 'ptr']: - rdefaults = defaults(base + ['authoritative-domain', 'records', rtype]) # T2665 - rdata = dict_merge(rdefaults, rdata) - + elif rtype in ['cname', 'ptr', 'ns']: if not 'target' in rdata: - dns['authoritative_zone_errors'].append('{}.{}: target is required'.format(subnode, node)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: target is required') continue zone['records'].append({ @@ -123,18 +112,12 @@ def get_config(config=None): '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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one server is required') 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(), @@ -142,11 +125,8 @@ def get_config(config=None): '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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one value is required') continue for value in rdata['value']: @@ -157,11 +137,8 @@ def get_config(config=None): '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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: value is required') continue zone['records'].append({ @@ -171,25 +148,18 @@ def get_config(config=None): '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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one entry is required') 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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: hostname is required for entry {entryno}') continue if not 'port' in entrydata: - dns['authoritative_zone_errors'].append('{}.{}: port is required for entry {}'.format(subnode, node, entryno)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: port is required for entry {entryno}') continue zone['records'].append({ @@ -199,19 +169,12 @@ def get_config(config=None): '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)) + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one rule is required') 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" @@ -263,7 +226,7 @@ def verify(dns): # as a domain will contains dot's which is out dictionary delimiter. if 'domain' in dns: for domain in dns['domain']: - if 'server' not in dns['domain'][domain]: + if 'name_server' not in dns['domain'][domain]: raise ConfigError(f'No server configured for domain {domain}!') if 'dns64_prefix' in dns: @@ -329,7 +292,12 @@ def apply(dns): # sources hc.delete_name_servers([hostsd_tag]) if 'name_server' in dns: - hc.add_name_servers({hostsd_tag: dns['name_server']}) + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + nslist = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in dns['name_server'].items()] + hc.add_name_servers({hostsd_tag: nslist}) # delete all nameserver tags hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor()) @@ -358,7 +326,14 @@ def apply(dns): # the list and keys() are required as get returns a dict, not list hc.delete_forward_zones(list(hc.get_forward_zones().keys())) if 'domain' in dns: - hc.add_forward_zones(dns['domain']) + zones = dns['domain'] + for domain in zones.keys(): + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + zones[domain]['name_server'] = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in zones[domain]['name_server'].items()] + hc.add_forward_zones(zones) # hostsd generates NTAs for the authoritative zones # the list and keys() are required as get returns a dict, not list diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py deleted file mode 100755 index 06a2f7e15..000000000 --- a/src/conf_mode/dynamic_dns.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.template import render -from vyos.util import call -from vyos.xml import defaults -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/ddclient/ddclient.conf' - -# Mapping of service name to service protocol -default_service_protocol = { - 'afraid': 'freedns', - 'changeip': 'changeip', - 'cloudflare': 'cloudflare', - 'dnspark': 'dnspark', - 'dslreports': 'dslreports1', - 'dyndns': 'dyndns2', - 'easydns': 'easydns', - 'namecheap': 'namecheap', - 'noip': 'noip', - 'sitelutions': 'sitelutions', - 'zoneedit': 'zoneedit1' -} - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base_level = ['service', 'dns', 'dynamic'] - if not conf.exists(base_level): - return None - - dyndns = conf.get_config_dict(base_level, 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. - for interface in dyndns['interface']: - if 'service' in dyndns['interface'][interface]: - # 'Autodetect' protocol used by DynDNS service - for service in dyndns['interface'][interface]['service']: - if service in default_service_protocol: - dyndns['interface'][interface]['service'][service].update( - {'protocol' : default_service_protocol.get(service)}) - else: - dyndns['interface'][interface]['service'][service].update( - {'custom': ''}) - - if 'rfc2136' in dyndns['interface'][interface]: - default_values = defaults(base_level + ['interface', 'rfc2136']) - for rfc2136 in dyndns['interface'][interface]['rfc2136']: - dyndns['interface'][interface]['rfc2136'][rfc2136] = dict_merge( - default_values, dyndns['interface'][interface]['rfc2136'][rfc2136]) - - return dyndns - -def verify(dyndns): - # bail out early - looks like removal from running config - if not dyndns: - return None - - # A 'node' corresponds to an interface - if 'interface' not in dyndns: - return None - - for interface in dyndns['interface']: - # RFC2136 - configuration validation - if 'rfc2136' in dyndns['interface'][interface]: - for rfc2136, config in dyndns['interface'][interface]['rfc2136'].items(): - - for tmp in ['record', 'zone', 'server', 'key']: - if tmp not in config: - raise ConfigError(f'"{tmp}" required for rfc2136 based ' - f'DynDNS service on "{interface}"') - - if not os.path.isfile(config['key']): - raise ConfigError(f'"key"-file not found for rfc2136 based ' - f'DynDNS service on "{interface}"') - - # DynDNS service provider - configuration validation - if 'service' in dyndns['interface'][interface]: - for service, config in dyndns['interface'][interface]['service'].items(): - error_msg = f'required for DynDNS service "{service}" on "{interface}"' - if 'host_name' not in config: - raise ConfigError(f'"host-name" {error_msg}') - - if 'login' not in config: - raise ConfigError(f'"login" (username) {error_msg}') - - if 'password' not in config: - raise ConfigError(f'"password" {error_msg}') - - if 'zone' in config: - if service != 'cloudflare' and ('protocol' not in config or config['protocol'] != 'cloudflare'): - raise ConfigError(f'"zone" option only supported with CloudFlare') - - if 'custom' in config: - if 'protocol' not in config: - raise ConfigError(f'"protocol" {error_msg}') - - if 'server' not in config: - raise ConfigError(f'"server" {error_msg}') - - return None - -def generate(dyndns): - # bail out early - looks like removal from running config - if not dyndns: - return None - - render(config_file, 'dynamic-dns/ddclient.conf.j2', dyndns) - return None - -def apply(dyndns): - if not dyndns: - call('systemctl stop ddclient.service') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call('systemctl restart ddclient.service') - - 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 f68acfe02..c3b1ee015 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -23,7 +23,6 @@ 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.configdiff import get_config_diff, Diff from vyos.configdep import set_dependents, call_dependents @@ -31,13 +30,12 @@ from vyos.configdep import set_dependents, call_dependents from vyos.firewall import fqdn_config_parse from vyos.firewall import geoip_update from vyos.template import render -from vyos.util import call -from vyos.util import cmd -from vyos.util import dict_search_args -from vyos.util import dict_search_recursive -from vyos.util import process_named_running -from vyos.util import rc_cmd -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive +from vyos.utils.process import process_named_running +from vyos.utils.process import rc_cmd from vyos import ConfigError from vyos import airbag airbag.enable() @@ -56,7 +54,6 @@ sysfs_config = { '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'} } @@ -65,7 +62,8 @@ valid_groups = [ 'address_group', 'domain_group', 'network_group', - 'port_group' + 'port_group', + 'interface_group' ] nested_group_types = [ @@ -96,19 +94,22 @@ def geoip_updated(conf, firewall): updated = False for key, path in dict_search_recursive(firewall, 'geoip'): - set_name = f'GEOIP_CC_{path[1]}_{path[3]}' - if path[0] == 'name': + set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + if (path[0] == 'ipv4'): out['name'].append(set_name) - elif path[0] == 'ipv6_name': + elif (path[0] == 'ipv6'): + set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' out['ipv6_name'].append(set_name) + updated = True if 'delete' in node_diff: for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): - set_name = f'GEOIP_CC_{path[1]}_{path[3]}' - if path[0] == 'name': + set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + if (path[0] == 'ipv4'): out['deleted_name'].append(set_name) - elif path[0] == 'ipv6-name': + elif (path[0] == 'ipv6'): + set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' out['deleted_ipv6_name'].append(set_name) updated = True @@ -124,54 +125,17 @@ def get_config(config=None): conf = Config() base = ['firewall'] - firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - # We have gathered the dict representation of the CLI, but there are - # default options which we need to update into the dictionary retrived. - # XXX: T2665: we currently have no nice way for defaults under tag - # nodes, thus we load the defaults "by hand" - default_values = defaults(base) - for tmp in ['name', 'ipv6_name']: - if tmp in default_values: - del default_values[tmp] - - if 'zone' in default_values: - del default_values['zone'] - - firewall = dict_merge(default_values, firewall) - - # Merge in defaults for IPv4 ruleset - if 'name' in firewall: - default_values = defaults(base + ['name']) - for name in firewall['name']: - firewall['name'][name] = dict_merge(default_values, - firewall['name'][name]) - - # Merge in defaults for IPv6 ruleset - if 'ipv6_name' in firewall: - default_values = defaults(base + ['ipv6-name']) - for ipv6_name in firewall['ipv6_name']: - firewall['ipv6_name'][ipv6_name] = dict_merge(default_values, - firewall['ipv6_name'][ipv6_name]) - - if 'zone' in firewall: - default_values = defaults(base + ['zone']) - for zone in firewall['zone']: - firewall['zone'][zone] = dict_merge(default_values, firewall['zone'][zone]) + firewall = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + firewall['group_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) if firewall['group_resync']: # Update nat and policy-route as firewall groups were updated set_dependents('group_resync', conf) - if 'config_trap' in firewall and firewall['config_trap'] == 'enable': - diff = get_config_diff(conf) - 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) - firewall['geoip_updated'] = geoip_updated(conf, firewall) fqdn_config_parse(firewall) @@ -190,11 +154,20 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError('jump-target defined, but action jump needed and it is not defined') target = rule_conf['jump_target'] if not ipv6: - if target not in dict_search_args(firewall, 'name'): + if target not in dict_search_args(firewall, 'ipv4', 'name'): raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') else: - if target not in dict_search_args(firewall, 'ipv6_name'): - raise ConfigError(f'Invalid jump-target. Firewall ipv6-name {target} does not exist on the system') + if target not in dict_search_args(firewall, 'ipv6', 'name'): + raise ConfigError(f'Invalid jump-target. Firewall ipv6 name {target} does not exist on the system') + + if 'queue_options' in rule_conf: + if 'queue' not in rule_conf['action']: + raise ConfigError('queue-options defined, but action queue needed and it is not defined') + if 'fanout' in rule_conf['queue_options'] and ('queue' not in rule_conf or '-' not in rule_conf['queue']): + raise ConfigError('queue-options fanout defined, then queue needs to be defined as a range') + + if 'queue' in rule_conf and 'queue' not in rule_conf['action']: + raise ConfigError('queue defined, but action queue needed and it is not defined') if 'fragment' in rule_conf: if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): @@ -272,6 +245,24 @@ def verify_rule(firewall, rule_conf, ipv6): if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') + if 'port' in side_conf and dict_search_args(side_conf, 'group', 'port_group'): + raise ConfigError(f'{side} port-group and port cannot both be defined') + + if 'log_options' in rule_conf: + if 'log' not in rule_conf or 'enable' not in rule_conf['log']: + raise ConfigError('log-options defined, but log is not enable') + + if 'snapshot_length' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']: + raise ConfigError('log-options snapshot-length defined, but log group is not define') + + if 'queue_threshold' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']: + raise ConfigError('log-options queue-threshold defined, but log group is not define') + + for direction in ['inbound_interface','outbound_interface']: + if direction in rule_conf: + if 'interface_name' in rule_conf[direction] and 'interface_group' in rule_conf[direction]: + raise ConfigError(f'Cannot specify both interface-group and interface-name for {direction}') + def verify_nested_group(group_name, group, groups, seen): if 'include' not in group: return @@ -289,10 +280,6 @@ def verify_nested_group(group_name, group, groups, seen): verify_nested_group(g, groups[g], groups, seen) def verify(firewall): - if 'config_trap' in firewall and firewall['config_trap'] == 'enable': - if not firewall['trap_targets']: - raise ConfigError(f'Firewall config-trap enabled but "service snmp trap-target" is not defined') - if 'group' in firewall: for group_type in nested_group_types: if group_type in firewall['group']: @@ -300,95 +287,45 @@ def verify(firewall): for group_name, group in groups.items(): verify_nested_group(group_name, group, groups, []) - for name in ['name', 'ipv6_name']: - if name in firewall: - for name_id, name_conf in firewall[name].items(): - if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: - raise ConfigError('default-action set to jump, but no default-jump-target specified') - if 'default_jump_target' in name_conf: - target = name_conf['default_jump_target'] - if 'jump' not in name_conf['default_action']: - raise ConfigError('default-jump-target defined,but default-action jump needed and it is not defined') - if name_conf['default_jump_target'] == name_id: - raise ConfigError(f'Loop detected on default-jump-target.') - ## Now need to check that default-jump-target exists (other firewall chain/name) - if target not in dict_search_args(firewall, name): - raise ConfigError(f'Invalid jump-target. Firewall {name} {target} does not exist on the system') - - if 'rule' in name_conf: - for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, rule_conf, name == 'ipv6_name') - - if 'interface' in firewall: - for ifname, if_firewall in firewall['interface'].items(): - # verify ifname needs to be disabled, dynamic devices come up later - # verify_interface_exists(ifname) - - 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'Invalid firewall name "{name}" referenced on interface {ifname}') - - if ipv6_name and dict_search_args(firewall, 'ipv6_name', ipv6_name) == None: - raise ConfigError(f'Invalid firewall ipv6-name "{ipv6_name}" referenced on interface {ifname}') - - local_zone = False - zone_interfaces = [] - - if 'zone' in firewall: - for zone, zone_conf in firewall['zone'].items(): - if 'local_zone' not in zone_conf and 'interface' not in zone_conf: - raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') - - if 'local_zone' in zone_conf: - if local_zone: - raise ConfigError('There cannot be multiple local zones') - if 'interface' in zone_conf: - raise ConfigError('Local zone cannot have interfaces assigned') - if 'intra_zone_filtering' in zone_conf: - raise ConfigError('Local zone cannot use intra-zone-filtering') - local_zone = True - - if 'interface' in zone_conf: - found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] - - if found_duplicates: - raise ConfigError(f'Interfaces cannot be assigned to multiple zones') - - zone_interfaces += zone_conf['interface'] - - if 'intra_zone_filtering' in zone_conf: - intra_zone = zone_conf['intra_zone_filtering'] - - if len(intra_zone) > 1: - raise ConfigError('Only one intra-zone-filtering action must be specified') - - if 'firewall' in intra_zone: - v4_name = dict_search_args(intra_zone, 'firewall', 'name') - if v4_name and not dict_search_args(firewall, 'name', v4_name): - raise ConfigError(f'Firewall name "{v4_name}" does not exist') - - v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name') - if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): - raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - - if not v4_name and not v6_name: - raise ConfigError('No firewall names specified for intra-zone-filtering') - - if 'from' in zone_conf: - for from_zone, from_conf in zone_conf['from'].items(): - if from_zone not in firewall['zone']: - raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') - - v4_name = dict_search_args(from_conf, 'firewall', 'name') - if v4_name and not dict_search_args(firewall, 'name', v4_name): - raise ConfigError(f'Firewall name "{v4_name}" does not exist') - - v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') - if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): - raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + if 'ipv4' in firewall: + for name in ['name','forward','input','output']: + if name in firewall['ipv4']: + for name_id, name_conf in firewall['ipv4'][name].items(): + if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in name_conf: + target = name_conf['default_jump_target'] + if 'jump' not in name_conf['default_action']: + raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') + if name_conf['default_jump_target'] == name_id: + raise ConfigError(f'Loop detected on default-jump-target.') + ## Now need to check that default-jump-target exists (other firewall chain/name) + if target not in dict_search_args(firewall['ipv4'], 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + + if 'rule' in name_conf: + for rule_id, rule_conf in name_conf['rule'].items(): + verify_rule(firewall, rule_conf, False) + + if 'ipv6' in firewall: + for name in ['name','forward','input','output']: + if name in firewall['ipv6']: + for name_id, name_conf in firewall['ipv6'][name].items(): + if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in name_conf: + target = name_conf['default_jump_target'] + if 'jump' not in name_conf['default_action']: + raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') + if name_conf['default_jump_target'] == name_id: + raise ConfigError(f'Loop detected on default-jump-target.') + ## Now need to check that default-jump-target exists (other firewall chain/name) + if target not in dict_search_args(firewall['ipv6'], 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + + if 'rule' in name_conf: + for rule_id, rule_conf in name_conf['rule'].items(): + verify_rule(firewall, rule_conf, True) return None @@ -396,18 +333,16 @@ def generate(firewall): if not os.path.exists(nftables_conf): firewall['first_install'] = True - if 'zone' in firewall: - for local_zone, local_zone_conf in firewall['zone'].items(): - if 'local_zone' not in local_zone_conf: - continue - - local_zone_conf['from_local'] = {} + # Determine if conntrack is needed + firewall['ipv4_conntrack_action'] = 'return' + firewall['ipv6_conntrack_action'] = 'return' - for zone, zone_conf in firewall['zone'].items(): - if zone == local_zone or 'from' not in zone_conf: - continue - if local_zone in zone_conf['from']: - local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] + for rules, path in dict_search_recursive(firewall, 'rule'): + if any(('state' in rule_conf or 'connection_status' in rule_conf) for rule_conf in rules.values()): + if path[0] == 'ipv4': + firewall['ipv4_conntrack_action'] = 'accept' + elif path[0] == 'ipv6': + firewall['ipv6_conntrack_action'] = 'accept' render(nftables_conf, 'firewall/nftables.j2', firewall) return None @@ -417,9 +352,8 @@ def apply_sysfs(firewall): paths = glob(conf['sysfs']) value = None - if name in firewall: - conf_value = firewall[name] - + if name in firewall['global_options']: + conf_value = firewall['global_options'][name] if conf_value in conf: value = conf[conf_value] elif conf_value == 'enable': @@ -432,42 +366,6 @@ def apply_sysfs(firewall): 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 apply(firewall): install_result, output = rc_cmd(f'nft -f {nftables_conf}') if install_result == 1: @@ -492,8 +390,6 @@ def apply(firewall): print('Updating GeoIP. Please wait...') geoip_update(firewall) - 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 7e16235c1..71acd69fa 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-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,27 +18,24 @@ import os import re from sys import exit -import ipaddress - from ipaddress import ip_address from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.config import config_dict_merge +from vyos.configverify import verify_vrf from vyos.ifconfig import Section -from vyos.ifconfig import Interface 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.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() uacctd_conf_path = '/run/pmacct/uacctd.conf' systemd_service = 'uacctd.service' -systemd_override = f'/etc/systemd/system/{systemd_service}.d/override.conf' +systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' nftables_nflog_table = 'raw' nftables_nflog_chain = 'VYOS_CT_PREROUTING_HOOK' egress_nftables_nflog_table = 'inet mangle' @@ -130,30 +127,19 @@ def get_config(config=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) + # We have gathered the dict representation of the CLI, but there are + # default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**flow_accounting.kwargs, + recursive=True) - # delete individual flow type default - should only be added if user uses - # this feature + # delete individual flow type defaults - should only be added if user + # sets this feature for flow_type in ['sflow', 'netflow']: - if flow_type in default_values: + if flow_type not in flow_accounting and 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]) + flow_accounting = config_dict_merge(default_values, flow_accounting) return flow_accounting @@ -192,8 +178,9 @@ def verify(flow_config): raise ConfigError("All sFlow servers must use the same IP protocol") else: sflow_collector_ipver = ip_address(server).version - + # check if vrf is defined for Sflow + verify_vrf(flow_config) sflow_vrf = None if 'vrf' in flow_config: sflow_vrf = flow_config['vrf'] @@ -211,7 +198,7 @@ def verify(flow_config): if not is_addr_assigned(tmp, sflow_vrf): raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') - # Check if configured netflow source-address exist in the system + # Check if configured sflow source-address exist in the system if 'source_address' in flow_config['sflow']: if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf): tmp = flow_config['sflow']['source_address'] @@ -219,13 +206,18 @@ def verify(flow_config): # check NetFlow configuration if 'netflow' in flow_config: + # check if vrf is defined for netflow + netflow_vrf = None + if 'vrf' in flow_config: + netflow_vrf = flow_config['vrf'] + # check if at least one NetFlow collector is configured if NetFlow configuration is presented 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']): + if not is_addr_assigned(flow_config['netflow']['source_address'], netflow_vrf): tmp = flow_config['netflow']['source_address'] raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index 8a959dc79..70f43ab52 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,25 +14,32 @@ # 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 time from sys import exit from ipaddress import ip_interface from ipaddress import IPv4Interface from ipaddress import IPv6Interface +from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.configdict import leaf_node_changed 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.utils.network import is_ipv6_tentative +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() + +systemd_override = r'/run/systemd/system/keepalived.service.d/10-override.conf' + + def get_config(config=None): if config: conf = config @@ -40,35 +47,25 @@ def get_config(config=None): conf = Config() base = ['high-availability'] - base_vrrp = ['high-availability', 'vrrp'] if not conf.exists(base): return None 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 '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]) + no_tag_node_value_mangle=True, + get_first_key=True, with_defaults=True) ## Get the sync group used for conntrack-sync conntrack_path = ['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group'] if conf.exists(conntrack_path): ha['conntrack_sync_group'] = conf.return_value(conntrack_path) + if leaf_node_changed(conf, base + ['vrrp', 'disable-snmp']): + ha.update({'restart_required': {}}) + return ha def verify(ha): - if not ha: + if not ha or 'disable' in ha: return None used_vrid_if = [] @@ -88,6 +85,18 @@ def verify(ha): if not {'password', 'type'} <= set(group_config['authentication']): raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') + if 'health_check' in group_config: + health_check_types = ["script", "ping"] + from vyos.utils.dict import check_mutually_exclusive_options + try: + check_mutually_exclusive_options(group_config["health_check"], health_check_types, required=True) + except ValueError: + Warning(f'Health check configuration for VRRP group "{group}" will remain unused ' \ + f'until it has one of the following options: {health_check_types}') + # XXX: health check has default options so we need to remove it + # to avoid generating useless config statements in keepalived.conf + del group_config["health_check"] + # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction # We also need to make sure VRID is not used twice on the same interface with the # same address family. @@ -144,8 +153,15 @@ def verify(ha): # 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 'address' not in vs_config and 'fwmark' not in vs_config: + raise ConfigError('Either address or fwmark is required ' + f'but not set for virtual-server "{vs}"') + + if 'port' not in vs_config and 'fwmark' not in vs_config: + raise ConfigError(f'Port or fwmark is required but not set for virtual-server "{vs}"') + if 'port' in vs_config and 'fwmark' in vs_config: + raise ConfigError(f'Cannot set both port and fwmark 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 @@ -155,19 +171,39 @@ def verify(ha): def generate(ha): - if not ha: + if not ha or 'disable' in ha: + if os.path.isfile(systemd_override): + os.unlink(systemd_override) return None render(VRRP.location['config'], 'high-availability/keepalived.conf.j2', ha) + render(systemd_override, 'high-availability/10-override.conf.j2', ha) return None def apply(ha): service_name = 'keepalived.service' - if not ha: + call('systemctl daemon-reload') + if not ha or 'disable' in ha: call(f'systemctl stop {service_name}') return None - call(f'systemctl reload-or-restart {service_name}') + # Check if IPv6 address is tentative T5533 + for group, group_config in ha.get('vrrp', {}).get('group', {}).items(): + if 'hello_source_address' in group_config: + if is_ipv6(group_config['hello_source_address']): + ipv6_address = group_config['hello_source_address'] + interface = group_config['interface'] + checks = 20 + interval = 0.1 + for _ in range(checks): + if is_ipv6_tentative(interface, ipv6_address): + time.sleep(interval) + + systemd_action = 'reload-or-restart' + if 'restart_required' in ha: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {service_name}') return None if __name__ == '__main__': diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py index 93f244f42..36d1f6493 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-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,16 +18,15 @@ import re import sys import copy -import vyos.util import vyos.hostsd_client 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.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import process_named_running from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 6328294c1..793a90d88 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -24,12 +24,9 @@ from copy import deepcopy import vyos.defaults from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configdep import set_dependents, call_dependents from vyos.template import render -from vyos.util import cmd -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() @@ -72,16 +69,18 @@ def get_config(config=None): return None api_dict = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True) + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) # One needs to 'flatten' the keys dict from the config into the # http-api.conf format for api_keys: if 'keys' in api_dict: api_dict['api_keys'] = [] - for el in list(api_dict['keys']['id']): - key = api_dict['keys']['id'][el]['key'] - api_dict['api_keys'].append({'id': el, 'key': key}) + for el in list(api_dict['keys'].get('id', {})): + key = api_dict['keys']['id'][el].get('key', '') + if key: + api_dict['api_keys'].append({'id': el, 'key': key}) del api_dict['keys'] # Do we run inside a VRF context? @@ -92,8 +91,8 @@ def get_config(config=None): if 'api_keys' in api_dict: keys_added = True - if 'graphql' in api_dict: - api_dict = dict_merge(defaults(base), api_dict) + if api_dict.from_defaults(['graphql']): + del api_dict['graphql'] http_api.update(api_dict) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index 7cd7ea42e..010490c7e 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -28,16 +28,16 @@ from vyos import ConfigError from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render -from vyos.util import call -from vyos.util import check_port_availability -from vyos.util import is_listen_port_bind_service -from vyos.util import write_file +from vyos.utils.process import call +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.file import write_file from vyos import airbag airbag.enable() config_file = '/etc/nginx/sites-available/default' -systemd_override = r'/etc/systemd/system/nginx.service.d/override.conf' +systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' cert_dir = '/etc/ssl/certs' key_dir = '/etc/ssl/private' certbot_dir = vyos.defaults.directories['certbot'] @@ -159,6 +159,8 @@ def generate(https): server_block['port'] = data.get('listen-port', '443') name = data.get('server-name', ['_']) server_block['name'] = name + allow_client = data.get('allow-client', {}) + server_block['allow_client'] = allow_client.get('address', []) server_block_list.append(server_block) # get certificate data diff --git a/src/conf_mode/igmp_proxy.py b/src/conf_mode/igmp_proxy.py index de6a51c64..40db417dd 100755 --- a/src/conf_mode/igmp_proxy.py +++ b/src/conf_mode/igmp_proxy.py @@ -21,11 +21,9 @@ 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.util import call -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -39,16 +37,9 @@ def get_config(config=None): conf = Config() base = ['protocols', 'igmp-proxy'] - igmp_proxy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - if 'interface' in igmp_proxy: - # T2665: we must add the tagNode defaults individually until this is - # moved to the base class - default_values = defaults(base + ['interface']) - for interface in igmp_proxy['interface']: - igmp_proxy['interface'][interface] = dict_merge(default_values, - igmp_proxy['interface'][interface]) - + igmp_proxy = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_defaults=True) if conf.exists(['protocols', 'igmp']): igmp_proxy.update({'igmp_configured': ''}) diff --git a/src/conf_mode/intel_qat.py b/src/conf_mode/intel_qat.py index dd04a002d..e4b248675 100755 --- a/src/conf_mode/intel_qat.py +++ b/src/conf_mode/intel_qat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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,8 @@ import re from sys import exit from vyos.config import Config -from vyos.util import popen, run +from vyos.utils.process import popen +from vyos.utils.process import run from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index b883ebef2..0bd306ed0 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -21,6 +21,7 @@ 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 is_member from vyos.configdict import is_source_interface @@ -34,9 +35,9 @@ from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ifconfig import BondIf from vyos.ifconfig import Section -from vyos.util import dict_search -from vyos.validate import has_address_configured -from vyos.validate import has_vrf_configured +from vyos.utils.dict import dict_search +from vyos.configdict import has_address_configured +from vyos.configdict import has_vrf_configured from vyos import ConfigError from vyos import airbag airbag.enable() @@ -81,10 +82,10 @@ def get_config(config=None): if 'mode' in bond: bond['mode'] = get_bond_mode(bond['mode']) - tmp = leaf_node_changed(conf, base + [ifname, 'mode']) + tmp = is_node_changed(conf, base + [ifname, 'mode']) if tmp: bond['shutdown_required'] = {} - tmp = leaf_node_changed(conf, base + [ifname, 'lacp-rate']) + tmp = is_node_changed(conf, base + [ifname, 'lacp-rate']) if tmp: bond['shutdown_required'] = {} # determine which members have been removed @@ -194,11 +195,11 @@ def verify(bond): raise ConfigError(error_msg + 'it does not exist!') if 'is_bridge_member' in interface_config: - tmp = interface_config['is_bridge_member'] + tmp = next(iter(interface_config['is_bridge_member'])) raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') if 'is_bond_member' in interface_config: - tmp = interface_config['is_bond_member'] + tmp = next(iter(interface_config['is_bond_member'])) raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') if 'is_source_interface' in interface_config: diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index b961408db..c82f01e53 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -14,10 +14,7 @@ # 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 netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict @@ -25,17 +22,14 @@ from vyos.configdict import 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 -from vyos.validate import has_vrf_configured -from vyos.xml import defaults +from vyos.configdict import has_address_configured +from vyos.configdict import has_vrf_configured -from vyos.util import cmd -from vyos.util import dict_search +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag @@ -61,22 +55,8 @@ def get_config(config=None): else: bridge.update({'member' : {'interface_remove' : tmp }}) - if dict_search('member.interface', bridge) != None: - # XXX: T2665: we need a copy of the dict keys for iteration, else we will get: - # RuntimeError: dictionary changed size during iteration + if dict_search('member.interface', bridge) is not None: for interface in list(bridge['member']['interface']): - for key in ['cost', 'priority']: - if interface == key: - del bridge['member']['interface'][key] - continue - - # the default dictionary is not properly paged into the dict (see T2665) - # thus we will ammend it ourself - default_member_values = defaults(base + ['member', 'interface']) - for interface,interface_config in bridge['member']['interface'].items(): - bridge['member']['interface'][interface] = dict_merge( - default_member_values, bridge['member']['interface'][interface]) - # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') if tmp and bridge['ifname'] not in tmp: @@ -131,11 +111,11 @@ def verify(bridge): raise ConfigError('Loopback interface "lo" can not be added to a bridge') if 'is_bridge_member' in interface_config: - tmp = interface_config['is_bridge_member'] + tmp = next(iter(interface_config['is_bridge_member'])) raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') if 'is_bond_member' in interface_config: - tmp = interface_config['is_bond_member'] + tmp = next(iter(interface_config['is_bond_member'])) raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') if 'is_source_interface' in interface_config: diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py index e771581e1..db768b94d 100755 --- a/src/conf_mode/interfaces-dummy.py +++ b/src/conf_mode/interfaces-dummy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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 generate(dummy): return None def apply(dummy): - d = DummyIf(dummy['ifname']) + d = DummyIf(**dummy) # Remove dummy interface if 'deleted' in dummy: diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index b49c945cd..f3e65ad5e 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -22,6 +22,7 @@ from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed from vyos.configverify import verify_address from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_eapol @@ -39,9 +40,9 @@ 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 -from vyos.util import dict_search -from vyos.util import write_file +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file from vyos import ConfigError from vyos import airbag airbag.enable() @@ -66,11 +67,17 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) base = ['interfaces', 'ethernet'] - _, ethernet = get_interface_dict(conf, base) + ifname, ethernet = get_interface_dict(conf, base) if 'deleted' not in ethernet: if pki: ethernet['pki'] = pki + tmp = is_node_changed(conf, base + [ifname, 'speed']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'duplex']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + return ethernet def verify(ethernet): @@ -138,12 +145,6 @@ def verify(ethernet): 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-*') - if len(queues) < 2: - raise ConfigError('XDP requires additional TX queues, too few available!') - if {'is_bond_member', 'mac'} <= set(ethernet): Warning(f'changing mac address "{mac}" will be ignored as "{ifname}" ' \ f'is a member of bond "{is_bond_member}"'.format(**ethernet)) @@ -185,14 +186,15 @@ def generate(ethernet): 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'][ca_cert_name] + ca_chains = [] - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + for ca_cert_name in ethernet['eapol']['ca_certificate']: + 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) + ca_chains.append('\n'.join(encode_certificate(c) for c in ca_full_chain)) - write_file(ca_cert_file_path, - '\n'.join(encode_certificate(c) for c in ca_full_chain)) + write_file(ca_cert_file_path, '\n'.join(ca_chains)) return None diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index 08cc3a48d..f6694ddde 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.py @@ -14,14 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os - from sys import exit 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 @@ -49,13 +46,10 @@ def get_config(config=None): # 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]): + for cli_option in ['remote', 'vni', 'parameters']: + if is_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): diff --git a/src/conf_mode/interfaces-input.py b/src/conf_mode/interfaces-input.py new file mode 100755 index 000000000..ad248843d --- /dev/null +++ b/src/conf_mode/interfaces-input.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 get_interface_dict +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import InputIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'input'] + _, ifb = get_interface_dict(conf, base) + + return ifb + +def verify(ifb): + if 'deleted' in ifb: + return None + + verify_mirror_redirect(ifb) + return None + +def generate(ifb): + return None + +def apply(ifb): + d = InputIf(ifb['ifname']) + + # Remove input interface + if 'deleted' in ifb: + d.remove() + else: + d.update(ifb) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index ca321e01d..e1db3206e 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -28,8 +28,8 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import L2TPv3If -from vyos.util import check_kmod -from vyos.validate import is_addr_assigned +from vyos.utils.kernel import check_kmod +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index 649ea8d50..0a927ac88 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,9 +33,9 @@ from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import MACsecIf from vyos.ifconfig import Interface from vyos.template import render -from vyos.util import call -from vyos.util import dict_search -from vyos.util import is_systemd_service_running +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -43,6 +43,14 @@ airbag.enable() # XXX: wpa_supplicant works on the source interface wpa_suppl_conf = '/run/wpa_supplicant/{source_interface}.conf' +# Constants +## gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit +GCM_AES_128_LEN: int = 32 +GCM_128_KEY_ERROR = 'gcm-aes-128 requires a 128bit long key!' +## gcm-aes-256 requires a 256bit long key - 64 characters (string) = 32byte = 256bit +GCM_AES_256_LEN: int = 64 +GCM_256_KEY_ERROR = 'gcm-aes-256 requires a 256bit long key!' + def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -89,18 +97,54 @@ def verify(macsec): raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) if dict_search('security.encrypt', macsec) != None: - if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: - raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') + # Check that only static or MKA config is present + if dict_search('security.static', macsec) != None and (dict_search('security.mka.cak', macsec) != None or dict_search('security.mka.ckn', macsec) != None): + raise ConfigError('Only static or MKA can be used!') + + # Logic to check static configuration + if dict_search('security.static', macsec) != None: + # tx-key must be defined + if dict_search('security.static.key', macsec) == None: + raise ConfigError('Static MACsec tx-key must be defined.') + + tx_len = len(dict_search('security.static.key', macsec)) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and tx_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) + + if dict_search('security.cipher', macsec) == 'gcm-aes-256' and tx_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) + + # Make sure at least one peer is defined + if 'peer' not in macsec['security']['static']: + raise ConfigError('Must have at least one peer defined for static MACsec') + + # For every enabled peer, make sure a MAC and rx-key is defined + for peer, peer_config in macsec['security']['static']['peer'].items(): + if 'disable' not in peer_config and ('mac' not in peer_config or 'key' not in peer_config): + raise ConfigError('Every enabled MACsec static peer must have a MAC address and rx-key defined.') + + # check rx-key length against cipher suite + rx_len = len(peer_config['key']) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and rx_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) + + if dict_search('security.cipher', macsec) == 'gcm-aes-256' and rx_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) + + # Logic to check MKA configuration + else: + if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: + raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') - cak_len = len(dict_search('security.mka.cak', macsec)) + cak_len = len(dict_search('security.mka.cak', macsec)) - if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != 32: - # gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit - raise ConfigError('gcm-aes-128 requires a 128bit long key!') + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) - elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != 64: - # gcm-aes-128 requires a 128bit long key - 64 characters (string) = 32byte = 256bit - raise ConfigError('gcm-aes-128 requires a 256bit long key!') + elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad @@ -115,7 +159,9 @@ def verify(macsec): def generate(macsec): - render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec) + # Only generate wpa_supplicant config if using MKA + if dict_search('security.mka.cak', macsec): + render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec) return None @@ -142,8 +188,10 @@ def apply(macsec): i = MACsecIf(**macsec) i.update(macsec) - if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: - call(f'systemctl reload-or-restart {systemd_service}') + # Only reload/restart if using MKA + if dict_search('security.mka.cak', macsec): + if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: + call(f'systemctl reload-or-restart {systemd_service}') return None diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 8155f36c2..1d0feb56f 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-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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 @@ -50,16 +50,18 @@ from vyos.pki import wrap_private_key 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 chown -from vyos.util import cmd -from vyos.util import dict_search -from vyos.util import dict_search_args -from vyos.util import is_list_equal -from vyos.util import makedir -from vyos.util import read_file -from vyos.util import write_file -from vyos.validate import is_addr_assigned +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.list import is_list_equal +from vyos.utils.file import makedir +from vyos.utils.file import read_file +from vyos.utils.file import write_file +from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import unload_kmod +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.process import cmd +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag @@ -86,30 +88,45 @@ def get_config(config=None): conf = Config() base = ['interfaces', 'openvpn'] - tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - 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. - tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) - - # We have to cleanup the config dict, as default values could enable features - # which are not explicitly enabled on the CLI. Example: server mfa totp - # originate comes with defaults, which will enable the - # totp plugin, even when not set via CLI so we - # need to check this first and drop those keys - if dict_search('server.mfa.totp', tmp) == None: - del openvpn['server']['mfa'] - openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) + if 'deleted' in openvpn: + return openvpn + + openvpn['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + if is_node_changed(conf, base + [ifname, 'openvpn-option']): + openvpn.update({'restart_required': {}}) + if is_node_changed(conf, base + [ifname, 'enable-dco']): + 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. + tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) + + # We have to cleanup the config dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: server mfa totp + # originate comes with defaults, which will enable the + # totp plugin, even when not set via CLI so we + # need to check this first and drop those keys + if dict_search('server.mfa.totp', tmp) == None: + del openvpn['server']['mfa'] + + # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all + # OpenVPN interfaces. Check if DCO is used by any other interface instance. + tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + for interface, interface_config in tmp.items(): + # If one interface has DCO configured, enable it. No need to further check + # all other OpenVPN interfaces. We must use a dedicated key to indicate + # the Kernel module must be loaded or not. The per interface "offload.dco" + # key is required per OpenVPN interface instance. + if dict_search('offload.dco', interface_config) != None: + openvpn['module_load_dco'] = {} + break + return openvpn def is_ec_private_key(pki, cert_name): @@ -149,17 +166,23 @@ def verify_pki(openvpn): raise ConfigError(f'Invalid shared-secret on openvpn interface {interface}') if tls: - if 'ca_certificate' not in tls: - raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface}') + if (mode in ['server', 'client']) and ('ca_certificate' not in tls): + raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface},\ + it is required in server and client modes') + else: + if ('ca_certificate' not in tls) and ('peer_fingerprint' not in tls): + raise ConfigError('Either "tls ca-certificate" or "tls peer-fingerprint" is required\ + on openvpn interface {interface} in site-to-site mode') - for ca_name in tls['ca_certificate']: - if ca_name not in pki['ca']: - raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + if 'ca_certificate' in tls: + for ca_name in tls['ca_certificate']: + if ca_name not in pki['ca']: + raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') - if len(tls['ca_certificate']) > 1: - sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) - if not verify_ca_chain(sorted_chain, pki['ca']): - raise ConfigError(f'CA certificates are not a valid chain') + if len(tls['ca_certificate']) > 1: + sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) + if not verify_ca_chain(sorted_chain, pki['ca']): + raise ConfigError(f'CA certificates are not a valid chain') if mode != 'client' and 'auth_key' not in tls: if 'certificate' not in tls: @@ -172,16 +195,7 @@ def verify_pki(openvpn): if dict_search_args(pki, 'certificate', tls['certificate'], 'private', 'password_protected') is not None: raise ConfigError(f'Cannot use encrypted private key on openvpn interface {interface}') - if mode == 'server' and 'dh_params' not in tls and not is_ec_private_key(pki, tls['certificate']): - raise ConfigError('Must specify "tls dh-params" when not using EC keys in server mode') - if 'dh_params' in tls: - if 'dh' not in pki: - raise ConfigError('There are no DH parameters in PKI configuration') - - if tls['dh_params'] not in pki['dh']: - raise ConfigError(f'Invalid dh-params on openvpn interface {interface}') - pki_dh = pki['dh'][tls['dh_params']] dh_params = load_dh_parameters(pki_dh['parameters']) dh_numbers = dh_params.parameter_numbers() @@ -190,6 +204,7 @@ def verify_pki(openvpn): if dh_bits < 2048: raise ConfigError(f'Minimum DH key-size is 2048 bits') + if 'auth_key' in tls or 'crypt_key' in tls: if not dict_search_args(pki, 'openvpn', 'shared_secret'): raise ConfigError('There are no openvpn shared-secrets in PKI configuration') @@ -478,9 +493,6 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-active': raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') - if not dict_search('tls.dh_params', openvpn): - raise ConfigError('Must specify "tls dh-params" when "tls role" is "passive"') - if 'certificate' in openvpn['tls'] and is_ec_private_key(openvpn['pki'], openvpn['tls']['certificate']): if 'dh_params' in openvpn['tls']: print('Warning: using dh-params and EC keys simultaneously will ' \ @@ -597,7 +609,7 @@ def generate_pki_files(openvpn): def generate(openvpn): interface = openvpn['ifname'] directory = os.path.dirname(cfg_file.format(**openvpn)) - plugin_dir = '/usr/lib/openvpn' + openvpn['plugin_dir'] = '/usr/lib/openvpn' # create base config directory on demand makedir(directory, user, group) # enforce proper permissions on /run/openvpn @@ -645,7 +657,7 @@ def generate(openvpn): user=user, group=group) # we need to support quoting of raw parameters from OpenVPN CLI - # see https://phabricator.vyos.net/T1632 + # see https://vyos.dev/T1632 render(cfg_file.format(**openvpn), 'openvpn/server.conf.j2', openvpn, formater=lambda _: _.replace(""", '"'), user=user, group=group) @@ -670,6 +682,15 @@ def apply(openvpn): if interface in interfaces(): VTunIf(interface).remove() + # dynamically load/unload DCO Kernel extension if requested + dco_module = 'ovpn_dco_v2' + if 'module_load_dco' in openvpn: + check_kmod(dco_module) + else: + unload_kmod(dco_module) + + # Now bail out early if interface is disabled or got deleted + if 'deleted' in openvpn or 'disable' in openvpn: return None # verify specified IP address is present on any interface on this system diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index ee4defa0d..fca91253c 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -32,8 +32,8 @@ 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 -from vyos.util import is_systemd_service_running +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -54,7 +54,8 @@ def get_config(config=None): # All parameters that can be changed on-the-fly (like interface description) # should not lead to a reconnect! for options in ['access-concentrator', 'connect-on-demand', 'service-name', - 'source-interface', 'vrf', 'no-default-route', 'authentication']: + 'source-interface', 'vrf', 'no-default-route', + 'authentication', 'host_uniq']: if is_node_changed(conf, base + [ifname, options]): pppoe.update({'shutdown_required': {}}) # bail out early - no need to further process other nodes diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 4c65bc0b6..dce5c2358 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -21,7 +21,7 @@ from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configdict import is_source_interface -from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete @@ -51,7 +51,7 @@ def get_config(config=None): mode = is_node_changed(conf, ['mode']) if mode: peth.update({'shutdown_required' : {}}) - if leaf_node_changed(conf, base + [ifname, 'mode']): + if is_node_changed(conf, base + [ifname, 'mode']): peth.update({'rebuild_required': {}}) if 'source_interface' in peth: diff --git a/src/conf_mode/interfaces-sstpc.py b/src/conf_mode/interfaces-sstpc.py index 6b8094c51..b588910dc 100755 --- a/src/conf_mode/interfaces-sstpc.py +++ b/src/conf_mode/interfaces-sstpc.py @@ -27,10 +27,10 @@ from vyos.pki import encode_certificate from vyos.pki import find_chain from vyos.pki import load_certificate from vyos.template import render -from vyos.util import call -from vyos.util import dict_search -from vyos.util import is_systemd_service_running -from vyos.util import write_file +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.process import is_systemd_service_running +from vyos.utils.file import write_file from vyos import ConfigError from vyos import airbag airbag.enable() @@ -70,7 +70,10 @@ def verify(sstpc): verify_authentication(sstpc) verify_vrf(sstpc) - if dict_search('ssl.ca_certificate', sstpc) == None: + if not dict_search('server', sstpc): + raise ConfigError('Remote SSTP server must be specified!') + + if not dict_search('ssl.ca_certificate', sstpc): raise ConfigError('Missing mandatory CA certificate!') return None diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index acef1fda7..91aed9cc3 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -21,7 +21,7 @@ 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_bridge_delete from vyos.configverify import verify_interface_exists @@ -33,8 +33,8 @@ from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import Interface from vyos.ifconfig import Section from vyos.ifconfig import TunnelIf -from vyos.util import get_interface_config -from vyos.util import dict_search +from vyos.utils.network import get_interface_config +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -52,9 +52,12 @@ def get_config(config=None): ifname, tunnel = get_interface_dict(conf, base) if 'deleted' not in tunnel: - tmp = leaf_node_changed(conf, base + [ifname, 'encapsulation']) + tmp = is_node_changed(conf, base + [ifname, 'encapsulation']) if tmp: tunnel.update({'encapsulation_changed': {}}) + tmp = is_node_changed(conf, base + [ifname, 'parameters', 'ip', 'key']) + if tmp: tunnel.update({'key_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 @@ -136,7 +139,7 @@ def verify(tunnel): 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}"!') + f'is already used for tunnel "{o_tunnel}"!') else: our_source_if = dict_search('source_interface', tunnel) their_source_if = dict_search('source_interface', o_tunnel_conf) @@ -197,7 +200,8 @@ def apply(tunnel): remote = dict_search('linkinfo.info_data.remote', tmp) if ('deleted' in tunnel or 'encapsulation_changed' in tunnel or encap in - ['gretap', 'ip6gretap', 'erspan', 'ip6erspan'] or remote in ['any']): + ['gretap', 'ip6gretap', 'erspan', 'ip6erspan'] or remote in ['any'] or + 'key_changed' in tunnel): if interface in interfaces(): tmp = Interface(interface) tmp.remove() diff --git a/src/conf_mode/interfaces-vti.py b/src/conf_mode/interfaces-vti.py index f4b0436af..9871810ae 100755 --- a/src/conf_mode/interfaces-vti.py +++ b/src/conf_mode/interfaces-vti.py @@ -21,7 +21,7 @@ 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.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index af2d0588d..a3b0867e0 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -52,13 +52,11 @@ def get_config(config=None): # 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', + for cli_option in ['parameters', 'external', 'gpe', 'group', 'port', 'remote', 'source-address', 'source-interface', 'vni']: - if leaf_node_changed(conf, base + [ifname, cli_option]): + if is_node_changed(conf, base + [ifname, cli_option]): vxlan.update({'rebuild_required': {}}) - - if is_node_changed(conf, base + [ifname, 'parameters']): - vxlan.update({'rebuild_required': {}}) + break # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation @@ -89,8 +87,8 @@ def verify(vxlan): 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 not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan): + raise ConfigError('Group, remote, source-address or source-interface must be configured') if 'vni' not in vxlan and 'external' not in vxlan: raise ConfigError( diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 762bad94f..122d9589a 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-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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 @@ -27,12 +27,14 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import WireGuardIf -from vyos.util import check_kmod -from vyos.util import check_port_availability +from vyos.utils.kernel import check_kmod +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_wireguard_key_pair from vyos import ConfigError from vyos import airbag airbag.enable() + def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -88,7 +90,6 @@ def verify(wireguard): # run checks on individual configured WireGuard peer public_keys = [] - for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] @@ -105,6 +106,10 @@ def verify(wireguard): if peer['public_key'] in public_keys: raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + if 'disable' not in peer: + if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): + raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"') + public_keys.append(peer['public_key']) def apply(wireguard): diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index dd798b5a2..02b4a2500 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -25,16 +25,14 @@ from vyos.configdict import get_interface_dict from vyos.configdict import dict_merge 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.configverify import verify_bond_bridge_member from vyos.ifconfig import WiFiIf from vyos.template import render -from vyos.util import call -from vyos.util import dict_search +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -42,6 +40,8 @@ airbag.enable() # XXX: wpa_supplicant works on the source interface wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' hostapd_conf = '/run/hostapd/{ifname}.conf' +hostapd_accept_station_conf = '/run/hostapd/{ifname}_station_accept.conf' +hostapd_deny_station_conf = '/run/hostapd/{ifname}_station_deny.conf' def find_other_stations(conf, base, ifname): """ @@ -79,30 +79,14 @@ def get_config(config=None): 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(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: + if 'deleted' not in wifi: + # then get_interface_dict provides default keys + if wifi.from_defaults(['security', 'wep']): # if not set by user + del wifi['security']['wep'] + if wifi.from_defaults(['security', 'wpa']): # if not set by user 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 - if dict_search('security.wpa.radius.server.port', wifi) != None: - del wifi['security']['wpa']['radius']['server']['port'] - if not len(wifi['security']['wpa']['radius']['server']): - del wifi['security']['wpa']['radius'] - if not len(wifi['security']['wpa']): - del wifi['security']['wpa'] - if not len(wifi['security']): - del wifi['security'] - if 'security' in wifi and 'wpa' in wifi['security']: + if dict_search('security.wpa', wifi) != None: wpa_cipher = wifi['security']['wpa'].get('cipher') wpa_mode = wifi['security']['wpa'].get('mode') if not wpa_cipher: @@ -120,13 +104,9 @@ def get_config(config=None): tmp = find_other_stations(conf, base, wifi['ifname']) if tmp: wifi['station_interfaces'] = tmp - # Add individual RADIUS server default values - if dict_search('security.wpa.radius.server', wifi): - default_values = defaults(base + ['security', 'wpa', 'radius', 'server']) - - for server in dict_search('security.wpa.radius.server', wifi): - wifi['security']['wpa']['radius']['server'][server] = dict_merge( - default_values, wifi['security']['wpa']['radius']['server'][server]) + # used in hostapt.conf.j2 + wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi) + wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi) return wifi @@ -142,7 +122,7 @@ def verify(wifi): raise ConfigError('You must specify a WiFi mode') if 'ssid' not in wifi and wifi['type'] != 'monitor': - raise ConfigError('SSID must be configured') + raise ConfigError('SSID must be configured unless type is set to "monitor"!') if wifi['type'] == 'access-point': if 'country_code' not in wifi: @@ -215,7 +195,10 @@ def generate(wifi): if 'deleted' in wifi: if os.path.isfile(hostapd_conf.format(**wifi)): os.unlink(hostapd_conf.format(**wifi)) - + if os.path.isfile(hostapd_accept_station_conf.format(**wifi)): + os.unlink(hostapd_accept_station_conf.format(**wifi)) + if os.path.isfile(hostapd_deny_station_conf.format(**wifi)): + os.unlink(hostapd_deny_station_conf.format(**wifi)) if os.path.isfile(wpa_suppl_conf.format(**wifi)): os.unlink(wpa_suppl_conf.format(**wifi)) @@ -250,12 +233,12 @@ 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.j2', - wifi) + render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.j2', wifi) + render(hostapd_accept_station_conf.format(**wifi), 'wifi/hostapd_accept_station.conf.j2', wifi) + render(hostapd_deny_station_conf.format(**wifi), 'wifi/hostapd_deny_station.conf.j2', wifi) elif wifi['type'] == 'station': - render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.j2', - wifi) + 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 a14a992ae..2515dc838 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -27,12 +27,12 @@ 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.utils.dict import dict_search +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import DEVNULL +from vyos.utils.process import is_systemd_service_active +from vyos.utils.file import write_file from vyos import ConfigError from vyos import airbag airbag.enable() @@ -75,7 +75,6 @@ def get_config(config=None): # We need to know the amount of other WWAN interfaces as ModemManager needs # to be started or stopped. - conf.set_level(base) wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) @@ -171,7 +170,7 @@ def apply(wwan): options = f'ip-type={ip_type},apn=' + wwan['apn'] if 'authentication' in wwan: - options += ',user={user},password={password}'.format(**wwan['authentication']) + options += ',user={username},password={password}'.format(**wwan['authentication']) command = f'{base_cmd} --simple-connect="{options}"' call(command, stdout=DEVNULL) diff --git a/src/conf_mode/le_cert.py b/src/conf_mode/le_cert.py index 6e169a3d5..06c7e7b72 100755 --- a/src/conf_mode/le_cert.py +++ b/src/conf_mode/le_cert.py @@ -20,9 +20,9 @@ import os import vyos.defaults from vyos.config import Config from vyos import ConfigError -from vyos.util import cmd -from vyos.util import call -from vyos.util import is_systemd_service_running +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import airbag airbag.enable() diff --git a/src/conf_mode/lldp.py b/src/conf_mode/lldp.py index c703c1fe0..c2e87d171 100755 --- a/src/conf_mode/lldp.py +++ b/src/conf_mode/lldp.py @@ -20,13 +20,11 @@ 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.utils.network import is_addr_assigned +from vyos.utils.network import is_loopback_addr from vyos.version import get_version_data -from vyos.util import call -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos.template import render from vyos import ConfigError from vyos import airbag @@ -46,7 +44,9 @@ def get_config(config=None): return {} lldp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) if conf.exists(['service', 'snmp']): lldp['system_snmp_enabled'] = '' @@ -54,27 +54,12 @@ def get_config(config=None): 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]) + # prune location information if not set by user + for interface in lldp.get('interface', []): + if lldp.from_defaults(['interface', interface, 'location']): + del lldp['interface'][interface]['location'] + elif lldp.from_defaults(['interface', interface, 'location','coordinate_based']): + del lldp['interface'][interface]['location']['coordinate_based'] return lldp diff --git a/src/conf_mode/load-balancing-haproxy.py b/src/conf_mode/load-balancing-haproxy.py new file mode 100755 index 000000000..8fe429653 --- /dev/null +++ b/src/conf_mode/load-balancing-haproxy.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 shutil import rmtree + +from vyos.config import Config +from vyos.utils.process import call +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +load_balancing_dir = '/run/haproxy' +load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' +systemd_service = 'haproxy.service' +systemd_override = r'/run/systemd/system/haproxy.service.d/10-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'reverse-proxy'] + lb = conf.get_config_dict(base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True) + + if lb: + lb['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + if lb: + lb = conf.merge_defaults(lb, recursive=True) + + return lb + + +def verify(lb): + if not lb: + return None + + if 'backend' not in lb or 'service' not in lb: + raise ConfigError(f'"service" and "backend" must be configured!') + + for front, front_config in lb['service'].items(): + if 'port' not in front_config: + raise ConfigError(f'"{front} service port" must be configured!') + + # Check if bind address:port are used by another service + tmp_address = front_config.get('address', '0.0.0.0') + tmp_port = front_config['port'] + if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ + not is_listen_port_bind_service(int(tmp_port), 'haproxy'): + raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') + + for back, back_config in lb['backend'].items(): + if 'server' not in back_config: + raise ConfigError(f'"{back} server" must be configured!') + for bk_server, bk_server_conf in back_config['server'].items(): + if 'address' not in bk_server_conf or 'port' not in bk_server_conf: + raise ConfigError(f'"backend {back} server {bk_server} address and port" must be configured!') + + if {'send_proxy', 'send_proxy_v2'} <= set(bk_server_conf): + raise ConfigError(f'Cannot use both "send-proxy" and "send-proxy-v2" for server "{bk_server}"') + +def generate(lb): + if not lb: + # Delete /run/haproxy/haproxy.cfg + config_files = [load_balancing_conf_file, systemd_override] + for file in config_files: + if os.path.isfile(file): + os.unlink(file) + # Delete old directories + #if os.path.isdir(load_balancing_dir): + # rmtree(load_balancing_dir, ignore_errors=True) + + return None + + # Create load-balance dir + if not os.path.isdir(load_balancing_dir): + os.mkdir(load_balancing_dir) + + # SSL Certificates for frontend + for front, front_config in lb['service'].items(): + if 'ssl' in front_config: + cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') + cert_key_path = os.path.join(load_balancing_dir, 'cert.pem.key') + ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') + + if 'certificate' in front_config['ssl']: + #cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') + #cert_key_path = os.path.join(load_balancing_dir, 'cert.key') + cert_name = front_config['ssl']['certificate'] + pki_cert = lb['pki']['certificate'][cert_name] + + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + with open(cert_key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) + + if 'ca_certificate' in front_config['ssl']: + ca_name = front_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + # SSL Certificates for backend + for back, back_config in lb['backend'].items(): + if 'ssl' in back_config: + ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') + + if 'ca_certificate' in back_config['ssl']: + ca_name = back_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + render(load_balancing_conf_file, 'load-balancing/haproxy.cfg.j2', lb) + render(systemd_override, 'load-balancing/override_haproxy.conf.j2', lb) + + return None + + +def apply(lb): + call('systemctl daemon-reload') + if not lb: + call(f'systemctl stop {systemd_service}') + else: + call(f'systemctl reload-or-restart {systemd_service}') + + 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/load-balancing-wan.py b/src/conf_mode/load-balancing-wan.py index 11840249f..ad9c80d72 100755 --- a/src/conf_mode/load-balancing-wan.py +++ b/src/conf_mode/load-balancing-wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright (C) 2023 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,17 +14,23 @@ # 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 shutil import rmtree +from vyos.base import Warning from vyos.config import Config -from vyos.configdict import node_changed -from vyos.util import call +from vyos.utils.process import cmd +from vyos.template import render from vyos import ConfigError -from pprint import pprint from vyos import airbag airbag.enable() +load_balancing_dir = '/run/load-balance' +load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf' +systemd_service = 'vyos-wan-load-balance.service' + def get_config(config=None): if config: @@ -33,27 +39,102 @@ def get_config(config=None): conf = Config() base = ['load-balancing', 'wan'] - lb = conf.get_config_dict(base, get_first_key=True, - no_tag_node_value_mangle=True) + lb = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + # prune limit key if not set by user + for rule in lb.get('rule', []): + if lb.from_defaults(['rule', rule, 'limit']): + del lb['rule'][rule]['limit'] - pprint(lb) return lb + def verify(lb): - return None + if not lb: + return None + + if 'interface_health' not in lb: + raise ConfigError( + 'A valid WAN load-balance configuration requires an interface with a nexthop!' + ) + + for interface, interface_config in lb['interface_health'].items(): + if 'nexthop' not in interface_config: + raise ConfigError( + f'interface-health {interface} nexthop must be specified!') + + if 'test' in interface_config: + for test_rule, test_config in interface_config['test'].items(): + if 'type' in test_config: + if test_config['type'] == 'user-defined' and 'test_script' not in test_config: + raise ConfigError( + f'test {test_rule} script must be defined for test-script!' + ) + + if 'rule' not in lb: + Warning( + 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!' + ) + else: + for rule, rule_config in lb['rule'].items(): + if 'inbound_interface' not in rule_config: + raise ConfigError(f'rule {rule} inbound-interface must be specified!') + if {'failover', 'exclude'} <= set(rule_config): + raise ConfigError(f'rule {rule} failover cannot be configured with exclude!') + if {'limit', 'exclude'} <= set(rule_config): + raise ConfigError(f'rule {rule} limit cannot be used with exclude!') + if 'interface' not in rule_config: + if 'exclude' not in rule_config: + Warning( + f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule' + ) + for direction in {'source', 'destination'}: + if direction in rule_config: + if 'protocol' in rule_config and 'port' in rule_config[ + direction]: + if rule_config['protocol'] not in {'tcp', 'udp'}: + raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"') def generate(lb): if not lb: + # Delete /run/load-balance/wlb.conf + if os.path.isfile(load_balancing_conf_file): + os.unlink(load_balancing_conf_file) + # Delete old directories + if os.path.isdir(load_balancing_dir): + rmtree(load_balancing_dir, ignore_errors=True) + if os.path.exists('/var/run/load-balance/wlb.out'): + os.unlink('/var/run/load-balance/wlb.out') + return None + # Create load-balance dir + if not os.path.isdir(load_balancing_dir): + os.mkdir(load_balancing_dir) + + render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb) + return None def apply(lb): + if not lb: + try: + cmd(f'systemctl stop {systemd_service}') + except Exception as e: + print(f"Error message: {e}") + + else: + cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') + cmd(f'systemctl restart {systemd_service}') return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 9f8221514..08e96f10b 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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 @@ -25,16 +25,14 @@ 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.util import dict_search_args -from vyos.validate import is_addr_assigned -from vyos.xml import defaults +from vyos.utils.kernel import check_kmod +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag @@ -72,6 +70,7 @@ def verify_rule(config, err_msg, groups_dict): """ Common verify steps used for both source and destination NAT """ if (dict_search('translation.port', config) != None or + dict_search('translation.redirect.port', config) != None or dict_search('destination.port', config) != None or dict_search('source.port', config)): @@ -113,7 +112,7 @@ def verify_rule(config, err_msg, groups_dict): group_obj = dict_search_args(groups_dict, group, group_name) if group_obj is None: - raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule') + raise ConfigError(f'Invalid {error_group} "{group_name}" on nat rule') if not group_obj: Warning(f'{error_group} "{group_name}" has no members!') @@ -125,6 +124,18 @@ def verify_rule(config, err_msg, groups_dict): if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') + if 'load_balance' in config: + for item in ['source-port', 'destination-port']: + if item in config['load_balance']['hash'] and config['protocol'] not in ['tcp', 'udp']: + raise ConfigError('Protocol must be tcp or udp when specifying hash ports') + count = 0 + if 'backend' in config['load_balance']: + for member in config['load_balance']['backend']: + weight = config['load_balance']['backend'][member]['weight'] + count = count + int(weight) + if count != 100: + Warning(f'Sum of weight for nat load balance rule is not 100. You may get unexpected behaviour') + def get_config(config=None): if config: conf = config @@ -132,16 +143,9 @@ def get_config(config=None): conf = Config() base = ['nat'] - nat = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - # T2665: we must add the tagNode defaults individually until this is - # moved to the base class - for direction in ['source', 'destination', 'static']: - if direction in nat: - default_values = defaults(base + [direction, 'rule']) - for rule in dict_search(f'{direction}.rule', nat) or []: - nat[direction]['rule'][rule] = dict_merge(default_values, - nat[direction]['rule'][rule]) + nat = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) # read in current nftable (once) for further processing tmp = cmd('nft -j list table raw') @@ -198,7 +202,7 @@ def verify(nat): Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') if not dict_search('translation.address', config) and not dict_search('translation.port', config): - if 'exclude' not in config: + if 'exclude' not in config and 'backend' not in config['load_balance']: raise ConfigError(f'{err_msg} translation requires address and/or port') addr = dict_search('translation.address', config) @@ -210,7 +214,6 @@ def verify(nat): # common rule verification verify_rule(config, err_msg, nat['firewall_group']) - if dict_search('destination.rule', nat): for rule, config in dict_search('destination.rule', nat).items(): err_msg = f'Destination NAT configuration error in rule {rule}:' @@ -221,8 +224,8 @@ def verify(nat): elif config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') - if not dict_search('translation.address', config) and not dict_search('translation.port', config): - if 'exclude' not in config: + if not dict_search('translation.address', config) and not dict_search('translation.port', config) and 'redirect' not in config['translation']: + if 'exclude' not in config and 'backend' not in config['load_balance']: raise ConfigError(f'{err_msg} translation requires address and/or port') # common rule verification diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index d8f913b0c..4c12618bc 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -23,13 +23,11 @@ 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.util import cmd -from vyos.util import check_kmod -from vyos.util import dict_search +from vyos.utils.process import cmd +from vyos.utils.kernel import check_kmod +from vyos.utils.dict import dict_search from vyos.template import is_ipv6 -from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() @@ -60,16 +58,6 @@ def get_config(config=None): base = ['nat66'] nat = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # T2665: we must add the tagNode defaults individually until this is - # moved to the base class - for direction in ['source', 'destination']: - if direction in nat: - default_values = defaults(base + [direction, 'rule']) - if 'rule' in nat[direction]: - for rule in nat[direction]['rule']: - 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 ip6 raw') nftable_json = json.loads(tmp) diff --git a/src/conf_mode/netns.py b/src/conf_mode/netns.py index 0924eb616..95ab83dbc 100755 --- a/src/conf_mode/netns.py +++ b/src/conf_mode/netns.py @@ -22,9 +22,9 @@ 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.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.network import get_interface_config from vyos import ConfigError from vyos import airbag airbag.enable() @@ -82,7 +82,8 @@ def verify(netns): if 'name' in netns: for name, config in netns['name'].items(): - print(name) + # no tests (yet) + pass return None diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py index 0ecb4d736..1cc23a7df 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,27 +20,31 @@ from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists -from vyos.util import call -from vyos.util import get_interface_config +from vyos.utils.process import call +from vyos.utils.permission import chmod_750 +from vyos.utils.network import get_interface_config from vyos.template import render +from vyos.template import is_ipv4 from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = r'/run/ntpd/ntpd.conf' -systemd_override = r'/etc/systemd/system/ntp.service.d/override.conf' +config_file = r'/run/chrony/chrony.conf' +systemd_override = r'/run/systemd/system/chrony.service.d/override.conf' +user_group = '_chrony' def get_config(config=None): if config: conf = config else: conf = Config() - base = ['system', 'ntp'] + base = ['service', 'ntp'] if not conf.exists(base): return None ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) ntp['config_file'] = config_file + ntp['user'] = user_group tmp = is_node_changed(conf, base + ['vrf']) if tmp: ntp.update({'restart_required': {}}) @@ -52,23 +56,36 @@ def verify(ntp): if not ntp: return None - if 'allow_clients' in ntp and 'server' not in ntp: + if 'server' not in ntp: raise ConfigError('NTP server not configured') verify_vrf(ntp) if 'interface' in ntp: # If ntpd should listen on a given interface, ensure it exists - for interface in ntp['interface']: - verify_interface_exists(interface) - - # If we run in a VRF, our interface must belong to this VRF, too - if 'vrf' in ntp: - tmp = get_interface_config(interface) - vrf_name = ntp['vrf'] - if 'master' not in tmp or tmp['master'] != vrf_name: - raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ - f'does not belong to this VRF!') + interface = ntp['interface'] + verify_interface_exists(interface) + + # If we run in a VRF, our interface must belong to this VRF, too + if 'vrf' in ntp: + tmp = get_interface_config(interface) + vrf_name = ntp['vrf'] + if 'master' not in tmp or tmp['master'] != vrf_name: + raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ + f'does not belong to this VRF!') + + if 'listen_address' in ntp: + ipv4_addresses = 0 + ipv6_addresses = 0 + for address in ntp['listen_address']: + if is_ipv4(address): + ipv4_addresses += 1 + else: + ipv6_addresses += 1 + if ipv4_addresses > 1: + raise ConfigError(f'NTP Only admits one ipv4 value for listen-address parameter ') + if ipv6_addresses > 1: + raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ') return None @@ -77,13 +94,17 @@ def generate(ntp): if not ntp: return None - render(config_file, 'ntp/ntpd.conf.j2', ntp) - render(systemd_override, 'ntp/override.conf.j2', ntp) + render(config_file, 'chrony/chrony.conf.j2', ntp, user=user_group, group=user_group) + render(systemd_override, 'chrony/override.conf.j2', ntp, user=user_group, group=user_group) + + # Ensure proper permission for chrony command socket + config_dir = os.path.dirname(config_file) + chmod_750(config_dir) return None def apply(ntp): - systemd_service = 'ntp.service' + systemd_service = 'chrony.service' # Reload systemd manager configuration call('systemctl daemon-reload') diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index e8f3cc87a..34ba2fe69 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -18,7 +18,6 @@ from sys import exit from vyos.config import Config from vyos.configdep import set_dependents, call_dependents -from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.pki import is_ca_certificate from vyos.pki import load_certificate @@ -26,9 +25,8 @@ from vyos.pki import load_public_key from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters -from vyos.util import dict_search_args -from vyos.util import dict_search_recursive -from vyos.xml import defaults +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive from vyos import ConfigError from vyos import airbag airbag.enable() @@ -51,6 +49,11 @@ sync_search = [ 'script': '/usr/libexec/vyos/conf_mode/interfaces-openvpn.py' }, { + 'keys': ['ca_certificate'], + 'path': ['interfaces', 'sstpc'], + 'script': '/usr/libexec/vyos/conf_mode/interfaces-sstpc.py' + }, + { 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], 'path': ['vpn', 'ipsec'], 'script': '/usr/libexec/vyos/conf_mode/vpn_ipsec.py' @@ -108,8 +111,7 @@ def get_config(config=None): # 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) + pki = conf.merge_defaults(pki, recursive=True) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy-local-route.py index 3f834f55c..79526f82a 100755 --- a/src/conf_mode/policy-local-route.py +++ b/src/conf_mode/policy-local-route.py @@ -24,7 +24,7 @@ from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import leaf_node_changed from vyos.template import render -from vyos.util import call +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/policy-route.py b/src/conf_mode/policy-route.py index 1d016695e..adad012de 100755 --- a/src/conf_mode/policy-route.py +++ b/src/conf_mode/policy-route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,9 +22,9 @@ 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.utils.dict import dict_search_args +from vyos.utils.process import cmd +from vyos.utils.process import run from vyos import ConfigError from vyos import airbag airbag.enable() @@ -36,7 +36,8 @@ valid_groups = [ 'address_group', 'domain_group', 'network_group', - 'port_group' + 'port_group', + 'interface_group' ] def get_config(config=None): diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index 331194fec..4df893ebf 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -19,7 +19,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render_to_string -from vyos.util import dict_search +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import frr from vyos import airbag diff --git a/src/conf_mode/protocols_babel.py b/src/conf_mode/protocols_babel.py new file mode 100755 index 000000000..104711b55 --- /dev/null +++ b/src/conf_mode/protocols_babel.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.config import config_dict_merge +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 +from vyos.utils.dict import dict_search +from vyos.template import render_to_string +from vyos import ConfigError +from vyos import frr +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['protocols', 'babel'] + babel = 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: + babel['interface_removed'] = list(interfaces_removed) + + # Bail out early if configuration tree does not exist + if not conf.exists(base): + babel.update({'deleted' : ''}) + return babel + + # We have gathered the dict representation of the CLI, but there are default + # values which we need to update into the dictionary retrieved. + default_values = conf.get_config_defaults(base, key_mangling=('-', '_'), + get_first_key=True, + recursive=True) + + # merge in default values + babel = config_dict_merge(default_values, babel) + + # We also need some additional information from the config, prefix-lists + # and route-maps for instance. They will be used in verify(). + # + # XXX: one MUST always call this without the key_mangling() option! See + # vyos.configverify.verify_common_route_maps() for more information. + tmp = conf.get_config_dict(['policy']) + # Merge policy dict into "regular" config dict + babel = dict_merge(tmp, babel) + return babel + +def verify(babel): + if not babel: + return None + + # verify distribute_list + if "distribute_list" in babel: + acl_keys = { + "ipv4": [ + "distribute_list.ipv4.access_list.in", + "distribute_list.ipv4.access_list.out", + ], + "ipv6": [ + "distribute_list.ipv6.access_list.in", + "distribute_list.ipv6.access_list.out", + ] + } + prefix_list_keys = { + "ipv4": [ + "distribute_list.ipv4.prefix_list.in", + "distribute_list.ipv4.prefix_list.out", + ], + "ipv6":[ + "distribute_list.ipv6.prefix_list.in", + "distribute_list.ipv6.prefix_list.out", + ] + } + for address_family in ["ipv4", "ipv6"]: + for iface_key in babel["distribute_list"].get(address_family, {}).get("interface", {}).keys(): + acl_keys[address_family].extend([ + f"distribute_list.{address_family}.interface.{iface_key}.access_list.in", + f"distribute_list.{address_family}.interface.{iface_key}.access_list.out" + ]) + prefix_list_keys[address_family].extend([ + f"distribute_list.{address_family}.interface.{iface_key}.prefix_list.in", + f"distribute_list.{address_family}.interface.{iface_key}.prefix_list.out" + ]) + + for address_family, keys in acl_keys.items(): + for key in keys: + acl = dict_search(key, babel) + if acl: + verify_access_list(acl, babel, version='6' if address_family == 'ipv6' else '') + + for address_family, keys in prefix_list_keys.items(): + for key in keys: + prefix_list = dict_search(key, babel) + if prefix_list: + verify_prefix_list(prefix_list, babel, version='6' if address_family == 'ipv6' else '') + + +def generate(babel): + if not babel or 'deleted' in babel: + return None + + babel['new_frr_config'] = render_to_string('frr/babeld.frr.j2', babel) + return None + +def apply(babel): + babel_daemon = 'babeld' + + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + frr_cfg.load_configuration(babel_daemon) + frr_cfg.modify_section('^router babel', stop_pattern='^exit', remove_stop_mark=True) + + for key in ['interface', 'interface_removed']: + if key not in babel: + continue + for interface in babel[key]: + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + + if 'new_frr_config' in babel: + frr_cfg.add_before(frr.default_add_before, babel['new_frr_config']) + frr_cfg.commit_configuration(babel_daemon) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index 0436abaf9..dab784662 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -17,12 +17,10 @@ import os 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 -from vyos.xml import defaults +from vyos.utils.network import is_ipv6_link_local from vyos import ConfigError from vyos import frr from vyos import airbag @@ -41,18 +39,7 @@ def get_config(config=None): if not conf.exists(base): return bfd - # We have gathered the dict representation of the CLI, but there are - # default options which we need to update into the dictionary retrived. - # XXX: T2665: we currently have no nice way for defaults under tag - # nodes, thus we load the defaults "by hand" - default_values = defaults(base + ['peer']) - if 'peer' in bfd: - for peer in bfd['peer']: - bfd['peer'][peer] = dict_merge(default_values, bfd['peer'][peer]) - - if 'profile' in bfd: - for profile in bfd['profile']: - bfd['profile'][profile] = dict_merge(default_values, bfd['profile'][profile]) + bfd = conf.merge_defaults(bfd, recursive=True) return bfd diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index ff568d470..00015023c 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-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,22 +14,22 @@ # 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 sys import argv from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import node_changed 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 -from vyos.util import dict_search -from vyos.validate import is_addr_assigned +from vyos.utils.dict import dict_search +from vyos.utils.network import get_interface_vrf +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import frr from vyos import airbag @@ -52,18 +52,37 @@ def get_config(config=None): bgp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # Remove per interface MPLS configuration - get a list if changed + # nodes under the interface tagNode + interfaces_removed = node_changed(conf, base + ['interface']) + if interfaces_removed: + bgp['interface_removed'] = list(interfaces_removed) + # 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: bgp.update({'vrf' : vrf}) - + if vrf: + bgp.update({'vrf' : vrf}) + # We can not delete the BGP VRF instance if there is a L3VNI configured + tmp = ['vrf', 'name', vrf, 'vni'] + if conf.exists(tmp): + bgp.update({'vni' : conf.return_value(tmp)}) + # We can safely delete ourself from the dependent vrf list + if vrf in bgp['dependent_vrfs']: + del bgp['dependent_vrfs'][vrf] + + bgp['dependent_vrfs'].update({'default': {'protocols': { + 'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True)}}}) if not conf.exists(base): + # If bgp instance is deleted then mark it bgp.update({'deleted' : ''}) - if not vrf: - # We are running in the default VRF context, thus we can not delete - # our main BGP instance if there are dependent BGP VRF instances. - bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], - key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) return bgp # We also need some additional information from the config, prefix-lists @@ -74,9 +93,91 @@ def get_config(config=None): tmp = conf.get_config_dict(['policy']) # Merge policy dict into "regular" config dict bgp = dict_merge(tmp, bgp) - return bgp + +def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) -> bool: + """ + :param search_vrf_name: search vrf name in import list + :type search_vrf_name: str + :param afi_name: afi/safi name + :type afi_name: str + :param vrfs_config: configuration dependents vrfs + :type vrfs_config: dict + :return: if vrf in import list retrun true else false + :rtype: bool + """ + for vrf_name, vrf_config in vrfs_config.items(): + import_list = dict_search( + f'protocols.bgp.address_family.{afi_name}.import.vrf', + vrf_config) + if import_list: + if search_vrf_name in import_list: + return True + return False + +def verify_vrf_import_options(afi_config: dict) -> bool: + """ + Search if afi contains one of options + :param afi_config: afi/safi + :type afi_config: dict + :return: if vrf contains rd and route-target options return true else false + :rtype: bool + """ + options = [ + f'rd.vpn.export', + f'route_target.vpn.import', + f'route_target.vpn.export', + f'route_target.vpn.both' + ] + for option in options: + if dict_search(option, afi_config): + return True + return False + +def verify_vrf_import(vrf_name: str, vrfs_config: dict, afi_name: str) -> bool: + """ + Verify if vrf exists and contain options + :param vrf_name: name of VRF + :type vrf_name: str + :param vrfs_config: dependent vrfs config + :type vrfs_config: dict + :param afi_name: afi/safi name + :type afi_name: str + :return: if vrf contains rd and route-target options return true else false + :rtype: bool + """ + if vrf_name != 'default': + verify_vrf({'vrf': vrf_name}) + if dict_search(f'{vrf_name}.protocols.bgp.address_family.{afi_name}', + vrfs_config): + afi_config = \ + vrfs_config[vrf_name]['protocols']['bgp']['address_family'][ + afi_name] + if verify_vrf_import_options(afi_config): + return True + return False + +def verify_vrflist_import(afi_name: str, afi_config: dict, vrfs_config: dict) -> bool: + """ + Call function to verify + if scpecific vrf contains rd and route-target + options return true else false + + :param afi_name: afi/safi name + :type afi_name: str + :param afi_config: afi/safi configuration + :type afi_config: dict + :param vrfs_config: dependent vrfs config + :type vrfs_config:dict + :return: if vrf contains rd and route-target options return true else false + :rtype: bool + """ + for vrf_name in afi_config['import']['vrf']: + if verify_vrf_import(vrf_name, vrfs_config, afi_name): + return True + return False + def verify_remote_as(peer_config, bgp_config): if 'remote_as' in peer_config: return peer_config['remote_as'] @@ -102,28 +203,61 @@ def verify_remote_as(peer_config, bgp_config): return None def verify_afi(peer_config, bgp_config): + # If address_family configured under neighboor if 'address_family' in peer_config: return True + # If address_family configured under peer-group + # if neighbor interface configured + peer_group_name = '' + if dict_search('interface.peer_group', peer_config): + peer_group_name = peer_config['interface']['peer_group'] + # if neighbor IP configured. if 'peer_group' in peer_config: peer_group_name = peer_config['peer_group'] + if peer_group_name: tmp = dict_search(f'peer_group.{peer_group_name}.address_family', bgp_config) if tmp: return True - return False def verify(bgp): - if not bgp or 'deleted' in bgp: - if 'dependent_vrfs' in bgp: - for vrf, vrf_options in bgp['dependent_vrfs'].items(): - if dict_search('protocols.bgp', vrf_options) != None: - raise ConfigError('Cannot delete default BGP instance, ' \ - 'dependent VRF instance(s) exist!') + if 'deleted' in bgp: + if 'vrf' in bgp: + # Cannot delete vrf if it exists in import vrf list in other vrfs + for tmp_afi in ['ipv4_unicast', 'ipv6_unicast']: + if verify_vrf_as_import(bgp['vrf'], tmp_afi, bgp['dependent_vrfs']): + raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \ + 'unconfigure "import vrf" commands!') + # We can not delete the BGP instance if a L3VNI instance exists + if 'vni' in bgp: + raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \ + f'unconfigure VNI "{bgp["vni"]}" first!') + else: + # We are running in the default VRF context, thus we can not delete + # our main BGP instance if there are dependent BGP VRF instances. + if 'dependent_vrfs' in bgp: + for vrf, vrf_options in bgp['dependent_vrfs'].items(): + if vrf != 'default': + if dict_search('protocols.bgp', vrf_options): + raise ConfigError('Cannot delete default BGP instance, ' \ + 'dependent VRF instance(s) exist!') return None if 'system_as' not in bgp: raise ConfigError('BGP system-as number must be defined!') + # Verify vrf on interface and bgp section + if 'interface' in bgp: + for interface in bgp['interface']: + error_msg = f'Interface "{interface}" belongs to different VRF instance' + tmp = get_interface_vrf(interface) + if 'vrf' in bgp: + if bgp['vrf'] != tmp: + vrf = bgp['vrf'] + raise ConfigError(f'{error_msg} "{vrf}"!') + elif tmp != 'default': + raise ConfigError(f'{error_msg} "{tmp}"!') + # Common verification for both peer-group and neighbor statements for neighbor in ['neighbor', 'peer_group']: # bail out early if there is no neighbor or peer-group statement @@ -140,6 +274,11 @@ def verify(bgp): raise ConfigError(f'Specified peer-group "{peer_group}" for '\ f'neighbor "{neighbor}" does not exist!') + if 'local_role' in peer_config: + #Ensure Local Role has only one value. + if len(peer_config['local_role']) > 1: + raise ConfigError(f'Only one local role can be specified for peer "{peer}"!') + if 'local_as' in peer_config: if len(peer_config['local_as']) > 1: raise ConfigError(f'Only one local-as number can be specified for peer "{peer}"!') @@ -312,6 +451,11 @@ def verify(bgp): raise ConfigError('Missing mandatory configuration option for '\ f'global administrative distance {key}!') + # TCP keepalive requires all three parameters to be set + if dict_search('parameters.tcp_keepalive', bgp) != None: + if not {'idle', 'interval', 'probes'} <= set(bgp['parameters']['tcp_keepalive']): + raise ConfigError('TCP keepalive incomplete - idle, keepalive and probes must be set') + # Address Family specific validation if 'address_family' in bgp: for afi, afi_config in bgp['address_family'].items(): @@ -324,9 +468,44 @@ def verify(bgp): 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']) + vrf_name = bgp['vrf'] if dict_search('vrf', bgp) else 'default' + # Verify if currant VRF contains rd and route-target options + # and does not exist in import list in other VRFs + if dict_search(f'rd.vpn.export', afi_config): + if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): + raise ConfigError( + 'Command "import vrf" conflicts with "rd vpn export" command!') + if not dict_search('parameters.router_id', bgp): + Warning(f'BGP "router-id" is required when using "rd" and "route-target"!') + + if dict_search('route_target.vpn.both', afi_config): + if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): + raise ConfigError( + 'Command "import vrf" conflicts with "route-target vpn both" command!') + + if dict_search('route_target.vpn.import', afi_config): + if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): + raise ConfigError( + 'Command "import vrf conflicts" with "route-target vpn import" command!') + + if dict_search('route_target.vpn.export', afi_config): + if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): + raise ConfigError( + 'Command "import vrf" conflicts with "route-target vpn export" command!') + + # Verify if VRFs in import do not contain rd + # and route-target options + if dict_search('import.vrf', afi_config) is not None: + # Verify if VRF with import does not contain rd + # and route-target options + if verify_vrf_import_options(afi_config): + raise ConfigError( + 'Please unconfigure "import vrf" commands before using vpn commands in the same VRF!') + # Verify if VRFs in import list do not contain rd + # and route-target options + if verify_vrflist_import(afi, afi_config, bgp['dependent_vrfs']): + raise ConfigError( + 'Please unconfigure import vrf commands before using vpn commands in dependent VRFs!') # FRR error: please unconfigure vpn to vrf commands before # using import vrf commands @@ -339,6 +518,14 @@ def verify(bgp): tmp = dict_search(f'route_map.vpn.{export_import}', afi_config) if tmp: verify_route_map(tmp, bgp) + # Checks only required for L2VPN EVPN + if afi in ['l2vpn_evpn']: + if 'vni' in afi_config: + for vni, vni_config in afi_config['vni'].items(): + if 'rd' in vni_config and 'advertise_all_vni' not in afi_config: + raise ConfigError('BGP EVPN "rd" requires "advertise-all-vni" to be set!') + if 'route_target' in vni_config and 'advertise_all_vni' not in afi_config: + raise ConfigError('BGP EVPN "route-target" requires "advertise-all-vni" to be set!') return None @@ -346,26 +533,15 @@ def generate(bgp): if not bgp or 'deleted' in bgp: return None - 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 def apply(bgp): bgp_daemon = 'bgpd' - zebra_daemon = 'zebra' # Save original configuration prior to starting any commit actions frr_cfg = frr.FRRConfig() - # 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.]+', 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 # will be either empty (default VRF) or contain the "vrf <name" statement vrf = '' @@ -373,6 +549,14 @@ def apply(bgp): vrf = ' vrf ' + bgp['vrf'] frr_cfg.load_configuration(bgp_daemon) + + # Remove interface specific config + for key in ['interface', 'interface_removed']: + if key not in bgp: + continue + for interface in bgp[key]: + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + 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']) diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py index c1a1a45e1..609b39065 100755 --- a/src/conf_mode/protocols_eigrp.py +++ b/src/conf_mode/protocols_eigrp.py @@ -69,8 +69,6 @@ def get_config(config=None): # Merge policy dict into "regular" config dict eigrp = dict_merge(tmp, eigrp) - import pprint - pprint.pprint(eigrp) return eigrp def verify(eigrp): @@ -80,24 +78,14 @@ def generate(eigrp): if not eigrp or 'deleted' in eigrp: return None - eigrp['protocol'] = 'eigrp' # required for frr/vrf.route-map.frr.j2 - eigrp['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.j2', eigrp) eigrp['frr_eigrpd_config'] = render_to_string('frr/eigrpd.frr.j2', eigrp) def apply(eigrp): eigrp_daemon = 'eigrpd' - zebra_daemon = 'zebra' # Save original configuration prior to starting any commit actions frr_cfg = frr.FRRConfig() - # 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 eigrp route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') - if 'frr_zebra_config' in eigrp: - frr_cfg.add_before(frr.default_add_before, eigrp['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) - # 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 = '' diff --git a/src/conf_mode/protocols_failover.py b/src/conf_mode/protocols_failover.py index 048ba7a89..e7e44db84 100755 --- a/src/conf_mode/protocols_failover.py +++ b/src/conf_mode/protocols_failover.py @@ -19,10 +19,8 @@ import json from pathlib import Path from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag @@ -31,7 +29,7 @@ airbag.enable() service_name = 'vyos-failover' service_conf = Path(f'/run/{service_name}.conf') -systemd_service = '/etc/systemd/system/vyos-failover.service' +systemd_service = '/run/systemd/system/vyos-failover.service' rt_proto_failover = '/etc/iproute2/rt_protos.d/failover.conf' @@ -42,15 +40,12 @@ def get_config(config=None): conf = Config() base = ['protocols', 'failover'] - failover = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + failover = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) # Set default values only if we set config - if failover.get('route'): - for route, route_config in failover.get('route').items(): - for next_hop, next_hop_config in route_config.get('next_hop').items(): - default_values = defaults(base + ['route']) - failover['route'][route]['next_hop'][next_hop] = dict_merge( - default_values['next_hop'], failover['route'][route]['next_hop'][next_hop]) + if failover.get('route') is not None: + failover = conf.merge_defaults(failover, recursive=True) return failover diff --git a/src/conf_mode/protocols_igmp.py b/src/conf_mode/protocols_igmp.py index 65cc2beba..435189025 100755 --- a/src/conf_mode/protocols_igmp.py +++ b/src/conf_mode/protocols_igmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,7 +21,8 @@ from sys import exit from vyos import ConfigError from vyos.config import Config -from vyos.util import call, process_named_running +from vyos.utils.process import process_named_running +from vyos.utils.process import call from vyos.template import render from signal import SIGTERM @@ -101,7 +102,7 @@ def verify(igmp): # Check, is this multicast group for intfc in igmp['ifaces']: for gr_addr in igmp['ifaces'][intfc]['gr_join']: - if IPv4Address(gr_addr) < IPv4Address('224.0.0.0'): + if not IPv4Address(gr_addr).is_multicast: raise ConfigError(gr_addr + " not a multicast group") def generate(igmp): diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index cb8ea3be4..e00c58ee4 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -25,10 +25,9 @@ from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_interface_exists from vyos.ifconfig import Interface -from vyos.util import dict_search -from vyos.util import get_interface_config +from vyos.utils.dict import dict_search +from vyos.utils.network import get_interface_config from vyos.template import render_to_string -from vyos.xml import defaults from vyos import ConfigError from vyos import frr from vyos import airbag @@ -64,19 +63,14 @@ def get_config(config=None): if interfaces_removed: isis['interface_removed'] = list(interfaces_removed) - # Bail out early if configuration tree does not exist + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. if not conf.exists(base): isis.update({'deleted' : ''}) return isis - # 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) # merge in default values - isis = dict_merge(default_values, isis) + isis = conf.merge_defaults(isis, recursive=True) # We also need some additional information from the config, prefix-lists # and route-maps for instance. They will be used in verify(). @@ -129,7 +123,7 @@ def verify(isis): vrf = isis['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}!') + raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') # If md5 and plaintext-password set at the same time for password in ['area_password', 'domain_password']: @@ -203,7 +197,7 @@ def verify(isis): if list(set(global_range) & set(local_range)): raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') - + # Check for a blank or invalid value per prefix if dict_search('segment_routing.prefix', isis): for prefix, prefix_config in isis['segment_routing']['prefix'].items(): @@ -218,7 +212,7 @@ def verify(isis): if dict_search('segment_routing.prefix', isis): for prefix, prefix_config in isis['segment_routing']['prefix'].items(): if 'absolute' in prefix_config: - if ("explicit_null" in prefix_config['absolute']) and ("no_php_flag" in prefix_config['absolute']): + if ("explicit_null" in prefix_config['absolute']) and ("no_php_flag" in prefix_config['absolute']): raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ f'and no-php-flag configured at the same time.') elif 'index' in prefix_config: @@ -232,25 +226,15 @@ def generate(isis): if not isis or 'deleted' in isis: return None - 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): isis_daemon = 'isisd' - zebra_daemon = 'zebra' # Save original configuration prior to starting any commit actions frr_cfg = frr.FRRConfig() - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - 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 # will be either empty (default VRF) or contain the "vrf <name" statement vrf = '' @@ -264,7 +248,7 @@ def apply(isis): if key not in isis: continue for interface in isis[key]: - frr_cfg.modify_section(f'^interface {interface}{vrf}', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) if 'frr_isisd_config' in isis: frr_cfg.add_before(frr.default_add_before, isis['frr_isisd_config']) diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 73af6595b..177a43444 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -21,9 +21,9 @@ from sys import exit from glob import glob from vyos.config import Config from vyos.template import render_to_string -from vyos.util import dict_search -from vyos.util import read_file -from vyos.util import sysctl_write +from vyos.utils.dict import dict_search +from vyos.utils.file import read_file +from vyos.utils.system import sysctl_write from vyos.configverify import verify_interface_exists from vyos import ConfigError from vyos import frr diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index d28ced4fd..5ec0bc9e5 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-2023 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,8 +19,8 @@ import os from vyos.config import Config from vyos.configdict import node_changed from vyos.template import render -from vyos.util import process_named_running -from vyos.util import run +from vyos.utils.process import process_named_running +from vyos.utils.process import run from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 0582d32be..cddd3765e 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,6 +20,7 @@ from sys import exit from sys import argv from vyos.config import Config +from vyos.config import config_dict_merge from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps @@ -27,9 +28,8 @@ 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 -from vyos.xml import defaults +from vyos.utils.dict import dict_search +from vyos.utils.network import get_interface_config from vyos import ConfigError from vyos import frr from vyos import airbag @@ -65,17 +65,15 @@ def get_config(config=None): if interfaces_removed: ospf['interface_removed'] = list(interfaces_removed) - # Bail out early if configuration tree does not exist + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. if not conf.exists(base): ospf.update({'deleted' : ''}) return ospf # 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) + default_values = conf.get_config_defaults(**ospf.kwargs, recursive=True) # We have to cleanup the default dict, as default values could enable features # which are not explicitly enabled on the CLI. Example: default-information @@ -84,60 +82,27 @@ def get_config(config=None): # need to check this first and probably drop that key. if dict_search('default_information.originate', ospf) is None: del default_values['default_information'] - if dict_search('area.area_type.nssa', ospf) is None: - del default_values['area']['area_type']['nssa'] if 'mpls_te' not in ospf: del default_values['mpls_te'] + if 'graceful_restart' not in ospf: + del default_values['graceful_restart'] + for area_num in default_values.get('area', []): + if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None: + del default_values['area'][area_num]['area_type']['nssa'] - 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 + for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']: if dict_search(f'redistribute.{protocol}', ospf) is None: del default_values['redistribute'][protocol] - # XXX: T2665: we currently have no nice way for defaults under tag nodes, - # clean them out and add them manually :( - del default_values['neighbor'] - del default_values['area']['virtual_link'] - del default_values['interface'] - - # merge in remaining default values - ospf = dict_merge(default_values, ospf) - - if 'neighbor' in ospf: - default_values = defaults(base + ['neighbor']) - for neighbor in ospf['neighbor']: - ospf['neighbor'][neighbor] = dict_merge(default_values, ospf['neighbor'][neighbor]) - - if 'area' in ospf: - default_values = defaults(base + ['area', 'virtual-link']) - for area, area_config in ospf['area'].items(): - if 'virtual_link' in area_config: - 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]) + for interface in ospf.get('interface', []): + # We need to reload the defaults on every pass b/c of + # hello-multiplier dependency on dead-interval + # If hello-multiplier is set, we need to remove the default from + # dead-interval. + if 'hello_multiplier' in ospf['interface'][interface]: + del default_values['interface'][interface]['dead_interval'] - if 'interface' in ospf: - for interface in ospf['interface']: - # We need to reload the defaults on every pass b/c of - # hello-multiplier dependency on dead-interval - default_values = defaults(base + ['interface']) - # If hello-multiplier is set, we need to remove the default from - # dead-interval. - if 'hello_multiplier' in ospf['interface'][interface]: - del default_values['dead_interval'] - - 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]) + ospf = config_dict_merge(default_values, ospf) # We also need some additional information from the config, prefix-lists # and route-maps for instance. They will be used in verify(). @@ -196,7 +161,7 @@ def verify(ospf): vrf = ospf['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}!') + raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') # Segment routing checks if dict_search('segment_routing.global_block', ospf): @@ -234,7 +199,7 @@ def verify(ospf): if list(set(global_range) & set(local_range)): raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') - + # Check for a blank or invalid value per prefix if dict_search('segment_routing.prefix', ospf): for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): @@ -250,31 +215,28 @@ def verify(ospf): raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ f'and no-php-flag configured at the same time.') + # Check route summarisation + if 'summary_address' in ospf: + for prefix, prefix_options in ospf['summary_address'].items(): + if {'tag', 'no_advertise'} <= set(prefix_options): + raise ConfigError(f'Can not set both "tag" and "no-advertise" for Type-5 '\ + f'and Type-7 route summarisation of "{prefix}"!') + return None def generate(ospf): if not ospf or 'deleted' in ospf: return None - 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): ospf_daemon = 'ospfd' - zebra_daemon = 'zebra' # Save original configuration prior to starting any commit actions frr_cfg = frr.FRRConfig() - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - 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 # will be either empty (default VRF) or contain the "vrf <name" statement vrf = '' @@ -288,10 +250,11 @@ def apply(ospf): if key not in ospf: continue for interface in ospf[key]: - frr_cfg.modify_section(f'^interface {interface}{vrf}', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) if 'frr_ospfd_config' in ospf: frr_cfg.add_before(frr.default_add_before, ospf['frr_ospfd_config']) + frr_cfg.commit_configuration(ospf_daemon) return None diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index ee4eaf59d..5b1adce30 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,6 +20,7 @@ from sys import exit from sys import argv from vyos.config import Config +from vyos.config import config_dict_merge from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps @@ -27,9 +28,8 @@ 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.utils.dict import dict_search +from vyos.utils.network import get_interface_config from vyos import ConfigError from vyos import frr from vyos import airbag @@ -64,17 +64,16 @@ def get_config(config=None): if interfaces_removed: ospfv3['interface_removed'] = list(interfaces_removed) - # Bail out early if configuration tree does not exist + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. 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) + default_values = conf.get_config_defaults(**ospfv3.kwargs, + recursive=True) # We have to cleanup the default dict, as default values could enable features # which are not explicitly enabled on the CLI. Example: default-information @@ -83,13 +82,13 @@ def get_config(config=None): # need to check this first and probably drop that key. if dict_search('default_information.originate', ospfv3) is None: del default_values['default_information'] + if 'graceful_restart' not in ospfv3: + del default_values['graceful_restart'] - # XXX: T2665: we currently have no nice way for defaults under tag nodes, - # clean them out and add them manually :( - del default_values['interface'] + default_values.pop('interface', {}) # merge in remaining default values - ospfv3 = dict_merge(default_values, ospfv3) + ospfv3 = config_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(). @@ -117,6 +116,10 @@ def verify(ospfv3): 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 'range' in area_config: + for range, range_config in area_config['range'].items(): + if {'not_advertise', 'advertise'} <= range_config.keys(): + raise ConfigError(f'"not-advertise" and "advertise" for "range {range}" cannot be both configured at the same time!') if 'interface' in ospfv3: for interface, interface_config in ospfv3['interface'].items(): @@ -134,7 +137,7 @@ def verify(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}!') + raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') return None @@ -164,7 +167,7 @@ def apply(ospfv3): 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) + frr_cfg.modify_section(f'^interface {interface}', 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']) diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 78df9b6f8..0aaa0d2c6 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,7 +21,8 @@ from sys import exit from vyos.config import Config from vyos import ConfigError -from vyos.util import call, process_named_running +from vyos.utils.process import process_named_running +from vyos.utils.process import call from vyos.template import render from signal import SIGTERM diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index c78d90396..bd47dfd00 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -24,8 +24,7 @@ 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 -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.dict import dict_search from vyos.template import render_to_string from vyos import ConfigError from vyos import frr @@ -55,9 +54,7 @@ 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) - # merge in remaining default values - rip = dict_merge(default_values, rip) + rip = conf.merge_defaults(rip, recursive=True) # We also need some additional information from the config, prefix-lists # and route-maps for instance. They will be used in verify(). diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index 21ff710b3..dd1550033 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -23,8 +23,7 @@ from vyos.configdict import dict_merge from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_access_list from vyos.configverify import verify_prefix_list -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.dict import dict_search from vyos.template import render_to_string from vyos import ConfigError from vyos import frr @@ -45,9 +44,7 @@ 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) - # merge in remaining default values - ripng = dict_merge(default_values, ripng) + ripng = conf.merge_defaults(ripng, recursive=True) # We also need some additional information from the config, prefix-lists # and route-maps for instance. They will be used in verify(). diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index 62ea9c878..05e876f3b 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -19,10 +19,8 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render_to_string -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import frr from vyos import airbag @@ -43,8 +41,7 @@ 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) - rpki = dict_merge(default_values, rpki) + rpki = conf.merge_defaults(rpki, recursive=True) return rpki diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index 58e202928..5def8d645 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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 @@ -25,12 +25,15 @@ 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 from vyos.template import render_to_string from vyos import ConfigError from vyos import frr from vyos import airbag airbag.enable() +config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf' + def get_config(config=None): if config: conf = config @@ -44,7 +47,7 @@ def get_config(config=None): base_path = ['protocols', 'static'] # eqivalent of the C foo ? 'a' : 'b' statement base = vrf and ['vrf', 'name', vrf, 'protocols', 'static'] or base_path - static = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + static = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) # Assign the name of our VRF context if vrf: static['vrf'] = vrf @@ -94,25 +97,22 @@ def verify(static): def generate(static): if not static: return None + + # Put routing table names in /etc/iproute2/rt_tables + render(config_file, 'iproute2/static.conf.j2', static) static['new_frr_config'] = render_to_string('frr/staticd.frr.j2', static) return None def apply(static): static_daemon = 'staticd' - zebra_daemon = 'zebra' # Save original configuration prior to starting any commit actions frr_cfg = frr.FRRConfig() - - # 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.commit_configuration(zebra_daemon) frr_cfg.load_configuration(static_daemon) if 'vrf' in static: vrf = static['vrf'] - frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit-vrf', remove_stop_mark=True) else: frr_cfg.modify_section(r'^ip route .*') frr_cfg.modify_section(r'^ipv6 route .*') diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py index 6afdf31f3..7f6ae3680 100755 --- a/src/conf_mode/protocols_static_multicast.py +++ b/src/conf_mode/protocols_static_multicast.py @@ -21,7 +21,7 @@ from sys import exit from vyos import ConfigError from vyos.config import Config -from vyos.util import call +from vyos.utils.process import call from vyos.template import render from vyos import airbag diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index dbe3be225..ad4121a49 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright (C) 2023 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,15 +14,63 @@ # 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 netifaces import interfaces +from vyos.base import Warning from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents from vyos.configdict import dict_merge -from vyos.xml import defaults +from vyos.ifconfig import Section +from vyos.qos import CAKE +from vyos.qos import DropTail +from vyos.qos import FairQueue +from vyos.qos import FQCodel +from vyos.qos import Limiter +from vyos.qos import NetEm +from vyos.qos import Priority +from vyos.qos import RandomDetect +from vyos.qos import RateLimiter +from vyos.qos import RoundRobin +from vyos.qos import TrafficShaper +from vyos.qos import TrafficShaperHFSC +from vyos.utils.process import call +from vyos.utils.dict import dict_search_recursive from vyos import ConfigError from vyos import airbag airbag.enable() +map_vyops_tc = { + 'cake' : CAKE, + 'drop_tail' : DropTail, + 'fair_queue' : FairQueue, + 'fq_codel' : FQCodel, + 'limiter' : Limiter, + 'network_emulator' : NetEm, + 'priority_queue' : Priority, + 'random_detect' : RandomDetect, + 'rate_control' : RateLimiter, + 'round_robin' : RoundRobin, + 'shaper' : TrafficShaper, + 'shaper_hfsc' : TrafficShaperHFSC, +} + +def get_shaper(qos, interface_config, direction): + policy_name = interface_config[direction] + # An interface might have a QoS configuration, search the used + # configuration referenced by this. Path will hold the dict element + # referenced by the config, as this will be of sort: + # + # ['policy', 'drop_tail', 'foo-dtail'] <- we are only interested in + # drop_tail as the policy/shaper type + _, path = next(dict_search_recursive(qos, policy_name)) + shaper_type = path[1] + shaper_config = qos['policy'][shaper_type][policy_name] + + return (map_vyops_tc[shaper_type], shaper_config) + def get_config(config=None): if config: conf = config @@ -32,48 +80,156 @@ def get_config(config=None): 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]) + qos = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + for ifname in interfaces(): + if_node = Section.get_config_path(ifname) + + if not if_node: + continue + + path = f'interfaces {if_node}' + if conf.exists(f'{path} mirror') or conf.exists(f'{path} redirect'): + type_node = path.split(" ")[1] # return only interface type node + set_dependents(type_node, conf, ifname.split(".")[0]) + + for policy in qos.get('policy', []): + if policy in ['random_detect']: + for rd_name in list(qos['policy'][policy]): + # There are eight precedence levels - ensure all are present + # to be filled later down with the appropriate default values + default_precedence = {'precedence' : { '0' : {}, '1' : {}, '2' : {}, '3' : {}, + '4' : {}, '5' : {}, '6' : {}, '7' : {} }} + qos['policy']['random_detect'][rd_name] = dict_merge( + default_precedence, qos['policy']['random_detect'][rd_name]) + + qos = conf.merge_defaults(qos, recursive=True) + + for policy in qos.get('policy', []): + for p_name, p_config in qos['policy'][policy].items(): + if 'precedence' in p_config: + # precedence settings are a bit more complex as they are + # calculated under specific circumstances: + for precedence in p_config['precedence']: + max_thr = int(qos['policy'][policy][p_name]['precedence'][precedence]['maximum_threshold']) + if 'minimum_threshold' not in qos['policy'][policy][p_name]['precedence'][precedence]: + qos['policy'][policy][p_name]['precedence'][precedence]['minimum_threshold'] = str( + int((9 + int(precedence)) * max_thr) // 18); + + if 'queue_limit' not in qos['policy'][policy][p_name]['precedence'][precedence]: + qos['policy'][policy][p_name]['precedence'][precedence]['queue_limit'] = \ + str(int(4 * max_thr)) - # 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: + if not qos or 'interface' not in qos: return None # network policy emulator # reorder rerquires delay to be set + if 'policy' in qos: + for policy_type in qos['policy']: + for policy, policy_config in qos['policy'][policy_type].items(): + # a policy with it's given name is only allowed to exist once + # on the system. This is because an interface selects a policy + # for ingress/egress traffic, and thus there can only be one + # policy with a given name. + # + # We check if the policy name occurs more then once - error out + # if this is true + counter = 0 + for _, path in dict_search_recursive(qos['policy'], policy): + counter += 1 + if counter > 1: + raise ConfigError(f'Conflicting policy name "{policy}", already in use!') + + if 'class' in policy_config: + for cls, cls_config in policy_config['class'].items(): + # bandwidth is not mandatory for priority-queue - that is why this is on the exception list + if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin']: + raise ConfigError(f'Bandwidth must be defined for policy "{policy}" class "{cls}"!') + if 'match' in cls_config: + for match, match_config in cls_config['match'].items(): + if {'ip', 'ipv6'} <= set(match_config): + raise ConfigError(f'Can not use both IPv6 and IPv4 in one match ({match})!') + + if policy_type in ['random_detect']: + if 'precedence' in policy_config: + for precedence, precedence_config in policy_config['precedence'].items(): + max_tr = int(precedence_config['maximum_threshold']) + if {'maximum_threshold', 'minimum_threshold'} <= set(precedence_config): + min_tr = int(precedence_config['minimum_threshold']) + if min_tr >= max_tr: + raise ConfigError(f'Policy "{policy}" uses min-threshold "{min_tr}" >= max-threshold "{max_tr}"!') + + if {'maximum_threshold', 'queue_limit'} <= set(precedence_config): + queue_lim = int(precedence_config['queue_limit']) + if queue_lim < max_tr: + raise ConfigError(f'Policy "{policy}" uses queue-limit "{queue_lim}" < max-threshold "{max_tr}"!') + if policy_type in ['priority_queue']: + if 'default' not in policy_config: + raise ConfigError(f'Policy {policy} misses "default" class!') + if 'default' in policy_config: + if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin']: + raise ConfigError('Bandwidth not defined for default traffic!') + + # we should check interface ingress/egress configuration after verifying that + # the policy name is used only once - this makes the logic easier! + for interface, interface_config in qos['interface'].items(): + for direction in ['egress', 'ingress']: + # bail out early if shaper for given direction is not used at all + if direction not in interface_config: + continue + + policy_name = interface_config[direction] + if 'policy' not in qos or list(dict_search_recursive(qos['policy'], policy_name)) == []: + raise ConfigError(f'Selected QoS policy "{policy_name}" does not exist!') + + shaper_type, shaper_config = get_shaper(qos, interface_config, direction) + tmp = shaper_type(interface).get_direction() + if direction not in tmp: + raise ConfigError(f'Selected QoS policy on interface "{interface}" only supports "{tmp}"!') - raise ConfigError('123') return None def generate(qos): + if not qos or 'interface' not in qos: + return None + return None def apply(qos): + # Always delete "old" shapers first + for interface in interfaces(): + # Ignore errors (may have no qdisc) + call(f'tc qdisc del dev {interface} parent ffff:') + call(f'tc qdisc del dev {interface} root') + + call_dependents() + + if not qos or 'interface' not in qos: + return None + + for interface, interface_config in qos['interface'].items(): + if not os.path.exists(f'/sys/class/net/{interface}'): + # When shaper is bound to a dialup (e.g. PPPoE) interface it is + # possible that it is yet not availbale when to QoS code runs. + # Skip the configuration and inform the user + Warning(f'Interface "{interface}" does not exist!') + continue + + for direction in ['egress', 'ingress']: + # bail out early if shaper for given direction is not used at all + if direction not in interface_config: + continue + + shaper_type, shaper_config = get_shaper(qos, interface_config, direction) + tmp = shaper_type(interface) + tmp.update(shaper_config, direction) + return None if __name__ == '__main__': diff --git a/src/conf_mode/salt-minion.py b/src/conf_mode/salt-minion.py index 00b889a11..a8fce8e01 100755 --- a/src/conf_mode/salt-minion.py +++ b/src/conf_mode/salt-minion.py @@ -22,12 +22,10 @@ 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 -from vyos.util import chown -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.permission import chown from vyos import ConfigError from vyos import airbag @@ -55,8 +53,7 @@ def get_config(config=None): 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) + salt = conf.merge_defaults(salt, recursive=True) if not conf.exists(base): return None diff --git a/src/conf_mode/service_config_sync.py b/src/conf_mode/service_config_sync.py new file mode 100755 index 000000000..4b8a7f6ee --- /dev/null +++ b/src/conf_mode/service_config_sync.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 pathlib import Path + +from vyos.config import Config +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +service_conf = Path(f'/run/config_sync_conf.conf') +post_commit_dir = '/run/scripts/commit/post-hooks.d' +post_commit_file_src = '/usr/libexec/vyos/vyos_config_sync.py' +post_commit_file = f'{post_commit_dir}/vyos_config_sync' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'config-sync'] + if not conf.exists(base): + return None + config = conf.get_config_dict(base, get_first_key=True, + with_recursive_defaults=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config: + return None + + if 'mode' not in config: + raise ConfigError(f'config-sync mode is mandatory!') + + for option in ['secondary', 'section']: + if option not in config: + raise ConfigError(f"config-sync '{option}' is not configured!") + + if 'address' not in config['secondary']: + raise ConfigError(f'secondary address is mandatory!') + if 'key' not in config['secondary']: + raise ConfigError(f'secondary key is mandatory!') + + +def generate(config): + if not config: + + if os.path.exists(post_commit_file): + os.unlink(post_commit_file) + + if service_conf.exists(): + service_conf.unlink() + + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + # Create post commit dir + if not os.path.isdir(post_commit_dir): + os.makedirs(post_commit_dir) + + # Symlink from helpers to post-commit + if not os.path.exists(post_commit_file): + os.symlink(post_commit_file_src, post_commit_file) + + return None + + +def apply(config): + 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/service_console-server.py b/src/conf_mode/service_console-server.py index ee4fe42ab..b112add3f 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -20,14 +20,12 @@ from sys import exit from psutil import process_iter from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError config_file = '/run/conserver/conserver.cf' -dropbear_systemd_file = '/etc/systemd/system/dropbear@{port}.service.d/override.conf' +dropbear_systemd_file = '/run/systemd/system/dropbear@{port}.service.d/override.conf' def get_config(config=None): if config: @@ -49,11 +47,7 @@ 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 + ['device']) - if 'device' in proxy: - for device in proxy['device']: - tmp = dict_merge(default_values, proxy['device'][device]) - proxy['device'][device] = tmp + proxy = conf.merge_defaults(proxy, recursive=True) return proxy diff --git a/src/conf_mode/service_event_handler.py b/src/conf_mode/service_event_handler.py index 5440d1056..5028ef52f 100755 --- a/src/conf_mode/service_event_handler.py +++ b/src/conf_mode/service_event_handler.py @@ -18,7 +18,8 @@ import json from pathlib import Path from vyos.config import Config -from vyos.util import call, dict_search +from vyos.utils.dict import dict_search +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index c58f8db9a..276a71fcb 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,10 +19,8 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() @@ -30,6 +28,7 @@ airbag.enable() config_file = r'/run/fastnetmon/fastnetmon.conf' networks_list = r'/run/fastnetmon/networks_list' excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' +attack_dir = '/var/log/fastnetmon_attacks' def get_config(config=None): if config: @@ -40,11 +39,9 @@ def get_config(config=None): if not conf.exists(base): return None - fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - default_values = defaults(base) - fastnetmon = dict_merge(default_values, fastnetmon) + fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return fastnetmon @@ -55,8 +52,11 @@ def verify(fastnetmon): if 'mode' not in fastnetmon: raise ConfigError('Specify operating mode!') - if 'listen_interface' not in fastnetmon: - raise ConfigError('Specify interface(s) for traffic capture') + if fastnetmon.get('mode') == 'mirror' and 'listen_interface' not in fastnetmon: + raise ConfigError("Incorrect settings for 'mode mirror': must specify interface(s) for traffic mirroring") + + if fastnetmon.get('mode') == 'sflow' and 'listen_address' not in fastnetmon.get('sflow', {}): + raise ConfigError("Incorrect settings for 'mode sflow': must specify sFlow 'listen-address'") if 'alert_script' in fastnetmon: if os.path.isfile(fastnetmon['alert_script']): @@ -74,6 +74,10 @@ def generate(fastnetmon): return None + # Create dir for log attack details + if not os.path.exists(attack_dir): + os.mkdir(attack_dir) + render(config_file, 'ids/fastnetmon.j2', fastnetmon) render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index e9afd6a55..b70e32373 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-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import jmespath from sys import exit @@ -23,15 +24,98 @@ from vyos.configdict import get_accel_dict from vyos.configverify import verify_accel_ppp_base_service from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.util import call -from vyos.util import dict_search +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() + ipoe_conf = '/run/accel-pppd/ipoe.conf' ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets' + +def get_pools_in_order(data: dict) -> list: + """Return a list of dictionaries representing pool data in the order + in which they should be allocated. Pool must be defined before we can + use it with 'next-pool' option. + + Args: + data: A dictionary of pool data, where the keys are pool names and the + values are dictionaries containing the 'subnet' key and the optional + 'next_pool' key. + + Returns: + list: A list of dictionaries + + Raises: + ValueError: If a 'next_pool' key references a pool name that + has not been defined. + ValueError: If a circular reference is found in the 'next_pool' keys. + + Example: + config_data = { + ... 'first-pool': { + ... 'next_pool': 'second-pool', + ... 'subnet': '192.0.2.0/25' + ... }, + ... 'second-pool': { + ... 'next_pool': 'third-pool', + ... 'subnet': '203.0.113.0/25' + ... }, + ... 'third-pool': { + ... 'subnet': '198.51.100.0/24' + ... }, + ... 'foo': { + ... 'subnet': '100.64.0.0/24', + ... 'next_pool': 'second-pool' + ... } + ... } + + % get_pools_in_order(config_data) + [{'third-pool': {'subnet': '198.51.100.0/24'}}, + {'second-pool': {'next_pool': 'third-pool', 'subnet': '203.0.113.0/25'}}, + {'first-pool': {'next_pool': 'second-pool', 'subnet': '192.0.2.0/25'}}, + {'foo': {'next_pool': 'second-pool', 'subnet': '100.64.0.0/24'}}] + """ + pools = [] + unresolved_pools = {} + + for pool, pool_config in data.items(): + if 'next_pool' not in pool_config: + pools.insert(0, {pool: pool_config}) + else: + unresolved_pools[pool] = pool_config + + while unresolved_pools: + resolved_pools = [] + + for pool, pool_config in unresolved_pools.items(): + next_pool_name = pool_config['next_pool'] + + if any(p for p in pools if next_pool_name in p): + index = next( + (i for i, p in enumerate(pools) if next_pool_name in p), + None) + pools.insert(index + 1, {pool: pool_config}) + resolved_pools.append(pool) + elif next_pool_name in unresolved_pools: + # next pool not yet resolved + pass + else: + raise ValueError( + f"Pool '{next_pool_name}' not defined in configuration data" + ) + + if not resolved_pools: + raise ValueError("Circular reference in configuration data") + + for pool in resolved_pools: + unresolved_pools.pop(pool) + + return pools + + def get_config(config=None): if config: conf = config @@ -43,6 +127,19 @@ def get_config(config=None): # retrieve common dictionary keys ipoe = get_accel_dict(conf, base, ipoe_chap_secrets) + + if jmespath.search('client_ip_pool.name', ipoe): + dict_named_pools = jmespath.search('client_ip_pool.name', ipoe) + # Multiple named pools require ordered values T5099 + ipoe['ordered_named_pools'] = get_pools_in_order(dict_named_pools) + # T5099 'next-pool' option + if jmespath.search('client_ip_pool.name.*.next_pool', ipoe): + for pool, pool_config in ipoe['client_ip_pool']['name'].items(): + if 'next_pool' in pool_config: + ipoe['first_named_pool'] = pool + ipoe['first_named_pool_subnet'] = pool_config + break + return ipoe @@ -53,10 +150,24 @@ def verify(ipoe): if 'interface' not in ipoe: raise ConfigError('No IPoE interface configured') - for interface in ipoe['interface']: + for interface, iface_config in ipoe['interface'].items(): verify_interface_exists(interface) + if 'client_subnet' in iface_config and 'vlan' in iface_config: + raise ConfigError('Option "client-subnet" incompatible with "vlan"!' + 'Use "ipoe client-ip-pool" instead.') #verify_accel_ppp_base_service(ipoe, local_users=False) + # IPoE server does not have 'gateway' option in the CLI + # we cannot use configverify.py verify_accel_ppp_base_service for ipoe-server + + if dict_search('authentication.mode', ipoe) == 'radius': + if not dict_search('authentication.radius.server', ipoe): + raise ConfigError('RADIUS authentication requires at least one server') + + for server in dict_search('authentication.radius.server', ipoe): + radius_config = ipoe['authentication']['radius']['server'][server] + if 'key' not in radius_config: + raise ConfigError(f'Missing RADIUS secret key for server "{server}"') if 'client_ipv6_pool' in ipoe: if 'delegate' in ipoe['client_ipv6_pool'] and 'prefix' not in ipoe['client_ipv6_pool']: diff --git a/src/conf_mode/service_mdns-repeater.py b/src/conf_mode/service_mdns-repeater.py index 2383a53fb..a2c90b537 100755 --- a/src/conf_mode/service_mdns-repeater.py +++ b/src/conf_mode/service_mdns-repeater.py @@ -23,7 +23,7 @@ from netifaces import ifaddresses, interfaces, AF_INET from vyos.config import Config from vyos.ifconfig.vrrp import VRRP from vyos.template import render -from vyos.util import call +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index aafece47a..40eb13e23 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,21 +15,20 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import socket import json from sys import exit from shutil import rmtree from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.ifconfig import Section from vyos.template import render -from vyos.util import call -from vyos.util import chown -from vyos.util import cmd -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.process import cmd from vyos import ConfigError from vyos import airbag airbag.enable() @@ -38,7 +37,7 @@ cache_dir = f'/etc/telegraf/.cache' config_telegraf = f'/run/telegraf/telegraf.conf' custom_scripts_dir = '/etc/telegraf/custom_scripts' syslog_telegraf = '/etc/rsyslog.d/50-telegraf.conf' -systemd_override = '/etc/systemd/system/telegraf.service.d/10-override.conf' +systemd_override = '/run/systemd/system/telegraf.service.d/10-override.conf' def get_nft_filter_chains(): """ Get nft chains for table filter """ @@ -57,6 +56,13 @@ def get_nft_filter_chains(): return chain_list +def get_hostname() -> str: + try: + hostname = socket.getfqdn() + except socket.gaierror: + hostname = socket.gethostname() + return hostname + def get_config(config=None): if config: conf = config @@ -75,10 +81,10 @@ 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) - monitoring = dict_merge(default_values, monitoring) + monitoring = conf.merge_defaults(monitoring, recursive=True) monitoring['custom_scripts_dir'] = custom_scripts_dir + monitoring['hostname'] = get_hostname() monitoring['interfaces_ethernet'] = Section.interfaces('ethernet', vlan=False) monitoring['nft_chains'] = get_nft_filter_chains() diff --git a/src/conf_mode/service_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py new file mode 100755 index 000000000..98d8a32ca --- /dev/null +++ b/src/conf_mode/service_monitoring_zabbix-agent.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +service_name = 'zabbix-agent2' +service_conf = f'/run/zabbix/{service_name}.conf' +systemd_override = r'/run/systemd/system/zabbix-agent2.service.d/10-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'monitoring', 'zabbix-agent'] + + if not conf.exists(base): + return None + + config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + + # Cut the / from the end, /tmp/ => /tmp + if 'directory' in config and config['directory'].endswith('/'): + config['directory'] = config['directory'][:-1] + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if config is None: + return + + if 'server' not in config: + raise ConfigError('Server is required!') + + +def generate(config): + # bail out early - looks like removal from running config + if config is None: + # Remove old config and return + config_files = [service_conf, systemd_override] + for file in config_files: + if os.path.isfile(file): + os.unlink(file) + + return None + + # Write configuration file + render(service_conf, 'zabbix-agent/zabbix-agent.conf.j2', config) + render(systemd_override, 'zabbix-agent/10-override.conf.j2', config) + + return None + + +def apply(config): + call('systemctl daemon-reload') + if config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 600ba4e92..aace267a7 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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 @@ -24,8 +24,8 @@ from vyos.configdict import is_node_changed from vyos.configverify import verify_accel_ppp_base_service from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.util import call -from vyos.util import dict_search +from vyos.utils.process import call +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -71,8 +71,9 @@ def verify(pppoe): # local ippool and gateway settings config checks if not (dict_search('client_ip_pool.subnet', pppoe) or + (dict_search('client_ip_pool.name', pppoe) or (dict_search('client_ip_pool.start', pppoe) and - dict_search('client_ip_pool.stop', pppoe))): + dict_search('client_ip_pool.stop', pppoe)))): print('Warning: No PPPoE client pool defined') if dict_search('authentication.radius.dynamic_author.server', pppoe): diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 1b8377a4a..dbb47de4e 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -19,10 +19,8 @@ import os from sys import exit from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() @@ -35,40 +33,9 @@ def get_config(config=None): else: conf = Config() base = ['service', 'router-advert'] - rtradv = 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_interface_values = defaults(base + ['interface']) - # we deal with prefix, route defaults later on - if 'prefix' in default_interface_values: - del default_interface_values['prefix'] - if 'route' in default_interface_values: - del default_interface_values['route'] - - default_prefix_values = defaults(base + ['interface', 'prefix']) - default_route_values = defaults(base + ['interface', 'route']) - - if 'interface' in rtradv: - for interface in rtradv['interface']: - rtradv['interface'][interface] = dict_merge( - default_interface_values, rtradv['interface'][interface]) - - if 'prefix' in rtradv['interface'][interface]: - for prefix in rtradv['interface'][interface]['prefix']: - rtradv['interface'][interface]['prefix'][prefix] = dict_merge( - default_prefix_values, rtradv['interface'][interface]['prefix'][prefix]) - - if 'route' in rtradv['interface'][interface]: - for route in rtradv['interface'][interface]['route']: - rtradv['interface'][interface]['route'][route] = dict_merge( - default_route_values, rtradv['interface'][interface]['route'][route]) - - if 'name_server' in rtradv['interface'][interface]: - # always use a list when dealing with nameservers - eases the template generation - if isinstance(rtradv['interface'][interface]['name_server'], str): - rtradv['interface'][interface]['name_server'] = [ - rtradv['interface'][interface]['name_server']] + rtradv = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return rtradv @@ -93,6 +60,10 @@ def verify(rtradv): if not (int(valid_lifetime) >= int(preferred_lifetime)): raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime') + if 'name_server' in interface_config: + if len(interface_config['name_server']) > 3: + raise ConfigError('No more then 3 IPv6 name-servers supported!') + if 'name_server_lifetime' in interface_config: # man page states: # The maximum duration how long the RDNSS entries are used for name diff --git a/src/conf_mode/service_sla.py b/src/conf_mode/service_sla.py index e7c3ca59c..ba5e645f0 100755 --- a/src/conf_mode/service_sla.py +++ b/src/conf_mode/service_sla.py @@ -19,23 +19,19 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge from vyos.template import render -from vyos.util import call -from vyos.xml import defaults +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() - owamp_config_dir = '/etc/owamp-server' owamp_config_file = f'{owamp_config_dir}/owamp-server.conf' -systemd_override_owamp = r'/etc/systemd/system/owamp-server.d/20-override.conf' +systemd_override_owamp = r'/run/systemd/system/owamp-server.d/20-override.conf' twamp_config_dir = '/etc/twamp-server' twamp_config_file = f'{twamp_config_dir}/twamp-server.conf' -systemd_override_twamp = r'/etc/systemd/system/twamp-server.d/20-override.conf' - +systemd_override_twamp = r'/run/systemd/system/twamp-server.d/20-override.conf' def get_config(config=None): if config: @@ -46,11 +42,9 @@ def get_config(config=None): if not conf.exists(base): return None - sla = 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) - sla = dict_merge(default_values, sla) + sla = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) # Ignore default XML values if config doesn't exists # Delete key from dict diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py index c798fd515..cf26bf9ce 100755 --- a/src/conf_mode/service_upnp.py +++ b/src/conf_mode/service_upnp.py @@ -23,12 +23,10 @@ from ipaddress import IPv4Network from ipaddress import IPv6Network from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.util import call +from vyos.utils.process 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() @@ -47,10 +45,7 @@ def get_config(config=None): 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]) + upnpd = conf.merge_defaults(upnpd, recursive=True) uuidgen = uuid.uuid1() upnpd.update({'uuid': uuidgen}) diff --git a/src/conf_mode/service_webproxy.py b/src/conf_mode/service_webproxy.py index 41a1deaa3..12ae4135e 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -20,14 +20,13 @@ from shutil import rmtree from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.config import config_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 -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.permission import chmod_755 +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.utils.network import is_addr_assigned from vyos.base import Warning from vyos import ConfigError from vyos import airbag @@ -125,7 +124,8 @@ def get_config(config=None): 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) + default_values = conf.get_config_defaults(**proxy.kwargs, + recursive=True) # if no authentication method is supplied, no need to add defaults if not dict_search('authentication.method', proxy): @@ -138,16 +138,7 @@ def get_config(config=None): proxy['squidguard_conf'] = squidguard_config_file proxy['squidguard_db_dir'] = squidguard_db_dir - # XXX: T2665: blend in proper cache-peer default values later - default_values.pop('cache_peer') - proxy = dict_merge(default_values, proxy) - - # XXX: T2665: blend in proper cache-peer default values - if 'cache_peer' in proxy: - default_values = defaults(base + ['cache-peer']) - for peer in proxy['cache_peer']: - proxy['cache_peer'][peer] = dict_merge(default_values, - proxy['cache_peer'][peer]) + proxy = config_dict_merge(default_values, proxy) return proxy @@ -246,7 +237,7 @@ def apply(proxy): if os.path.exists(squidguard_db_dir): chmod_755(squidguard_db_dir) - call('systemctl restart squid.service') + call('systemctl reload-or-restart squid.service') return None diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 5cd24db32..7882f8510 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -26,12 +26,11 @@ 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.util import call -from vyos.util import chmod_755 -from vyos.util import dict_search -from vyos.validate import is_addr_assigned +from vyos.utils.process import call +from vyos.utils.permission import chmod_755 +from vyos.utils.dict import dict_search +from vyos.utils.network 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() @@ -40,7 +39,7 @@ 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' -systemd_override = r'/etc/systemd/system/snmpd.service.d/override.conf' +systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' systemd_service = 'snmpd.service' def get_config(config=None): @@ -70,29 +69,12 @@ 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) - - # 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) + snmp = conf.merge_defaults(snmp, recursive=True) 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 + # that are not configured on this system. See https://vyos.dev/T850 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']) @@ -101,38 +83,6 @@ def get_config(config=None): 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): @@ -158,14 +108,22 @@ def verify(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 'vrf' in snmp: + vrf_name = snmp['vrf'] + if not is_addr_assigned(address, vrf_name) and address not in ['::1','127.0.0.1']: + raise ConfigError(f'SNMP listen address "{address}" not configured in vrf "{vrf_name}"!') + elif not is_addr_assigned(address): + raise ConfigError(f'SNMP listen address "{address}" not configured in default vrf!') 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!') + if 'oid_enable' in snmp: + Warning(f'Custom OIDs are enabled and may lead to system instability and high resource consumption') + + verify_vrf(snmp) # bail out early if SNMP v3 is not configured diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 8746cc701..ee5e1eca2 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -21,18 +21,16 @@ from syslog import syslog from syslog import LOG_INFO from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.util import call +from vyos.utils.process import call from vyos.template import render -from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() config_file = r'/run/sshd/sshd_config' -systemd_override = r'/etc/systemd/system/ssh.service.d/override.conf' +systemd_override = r'/run/systemd/system/ssh.service.d/override.conf' sshguard_config_file = '/etc/sshguard/sshguard.conf' sshguard_whitelist = '/etc/sshguard/whitelist' @@ -57,8 +55,8 @@ 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) - ssh = dict_merge(default_values, ssh) + ssh = conf.merge_defaults(ssh, recursive=True) + # pass config file path - used in override template ssh['config_file'] = config_file diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 0c5063ed3..5e4e5ec28 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-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,12 +18,14 @@ from sys import exit 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.configverify import verify_route_map +from vyos.template import render_to_string +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.utils.system import sysctl_write from vyos import ConfigError +from vyos import frr from vyos import airbag airbag.enable() @@ -34,19 +36,33 @@ def get_config(config=None): conf = Config() base = ['system', 'ip'] - opt = 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) - opt = dict_merge(default_values, opt) + opt = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + # When working with FRR we need to know the corresponding address-family + opt['afi'] = 'ip' + + # We also need the route-map information from the config + # + # XXX: one MUST always call this without the key_mangling() option! See + # vyos.configverify.verify_common_route_maps() for more information. + tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], + get_first_key=True)}} + # Merge policy dict into "regular" config dict + opt = dict_merge(tmp, opt) return opt def verify(opt): - pass + if 'protocol' in opt: + for protocol, protocol_options in opt['protocol'].items(): + if 'route_map' in protocol_options: + verify_route_map(protocol_options['route_map'], opt) + return def generate(opt): - pass + opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt) + return def apply(opt): # Apply ARP threshold values @@ -78,6 +94,38 @@ def apply(opt): value = '1' if (tmp != None) else '0' sysctl_write('net.ipv4.fib_multipath_hash_policy', value) + # configure TCP options (defaults as of Linux 6.4) + tmp = dict_search('tcp.mss.probing', opt) + if tmp is None: + value = 0 + elif tmp == 'on-icmp-black-hole': + value = 1 + elif tmp == 'force': + value = 2 + else: + # Shouldn't happen + raise ValueError("TCP MSS probing is neither 'on-icmp-black-hole' nor 'force'!") + sysctl_write('net.ipv4.tcp_mtu_probing', value) + + tmp = dict_search('tcp.mss.base', opt) + value = '1024' if (tmp is None) else tmp + sysctl_write('net.ipv4.tcp_base_mss', value) + + tmp = dict_search('tcp.mss.floor', opt) + value = '48' if (tmp is None) else tmp + sysctl_write('net.ipv4.tcp_mtu_probe_floor', value) + + zebra_daemon = 'zebra' + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + # 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 \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') + if 'frr_zebra_config' in opt: + frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config']) + frr_cfg.commit_configuration(zebra_daemon) + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system-ipv6.py index 26aacf46b..e40ed38e2 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-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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,13 @@ import os from sys import exit 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.configverify import verify_route_map +from vyos.template import render_to_string +from vyos.utils.dict import dict_search +from vyos.utils.system import sysctl_write +from vyos.utils.file import write_file from vyos import ConfigError +from vyos import frr from vyos import airbag airbag.enable() @@ -34,20 +36,33 @@ def get_config(config=None): conf = Config() base = ['system', 'ipv6'] - opt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + opt = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=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) - opt = dict_merge(default_values, opt) + # When working with FRR we need to know the corresponding address-family + opt['afi'] = 'ipv6' + # We also need the route-map information from the config + # + # XXX: one MUST always call this without the key_mangling() option! See + # vyos.configverify.verify_common_route_maps() for more information. + tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], + get_first_key=True)}} + # Merge policy dict into "regular" config dict + opt = dict_merge(tmp, opt) return opt def verify(opt): - pass + if 'protocol' in opt: + for protocol, protocol_options in opt['protocol'].items(): + if 'route_map' in protocol_options: + verify_route_map(protocol_options['route_map'], opt) + return def generate(opt): - pass + opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt) + return def apply(opt): # configure multipath @@ -78,6 +93,17 @@ def apply(opt): if name == 'accept_dad': write_file(os.path.join(root, name), value) + zebra_daemon = 'zebra' + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + # 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'ipv6 protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') + if 'frr_zebra_config' in opt: + frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config']) + frr_cfg.commit_configuration(zebra_daemon) + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py index a521c9834..65fa04417 100755 --- a/src/conf_mode/system-login-banner.py +++ b/src/conf_mode/system-login-banner.py @@ -18,7 +18,7 @@ from sys import exit from copy import deepcopy from vyos.config import Config -from vyos.util import write_file +from vyos.utils.file import write_file from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index e26b81e3d..02c97afaa 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-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,44 +16,73 @@ import os -from crypt import crypt -from crypt import METHOD_SHA512 +from passlib.hosts import linux_context from psutil import users 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 from vyos.configverify import verify_vrf +from vyos.defaults import directories 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 +from vyos.utils.dict import dict_search +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import rc_cmd +from vyos.utils.process import run +from vyos.utils.process import DEVNULL from vyos import ConfigError from vyos import airbag airbag.enable() autologout_file = "/etc/profile.d/autologout.sh" +limits_file = "/etc/security/limits.d/10-vyos.conf" radius_config_file = "/etc/pam_radius_auth.conf" +tacacs_pam_config_file = "/etc/tacplus_servers" +tacacs_nss_config_file = "/etc/tacplus_nss.conf" +nss_config_file = "/etc/nsswitch.conf" + +# Minimum UID used when adding system users +MIN_USER_UID: int = 1000 +# Maximim UID used when adding system users +MAX_USER_UID: int = 59999 +# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec +MAX_RADIUS_TIMEOUT: int = 50 +# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout) +MAX_RADIUS_COUNT: int = 8 +# Maximum number of supported TACACS servers +MAX_TACACS_COUNT: int = 8 + +# List of local user accounts that must be preserved +SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1', + 'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6', + 'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11', + 'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15'] def get_local_users(): """Return list of dynamically allocated users (see Debian Policy Manual)""" local_users = [] for s_user in getpwall(): - uid = getpwnam(s_user.pw_name).pw_uid - if uid in range(1000, 29999): - if s_user.pw_name not in ['radius_user', 'radius_priv_user']: - local_users.append(s_user.pw_name) + if getpwnam(s_user.pw_name).pw_uid < MIN_USER_UID: + continue + if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID: + continue + if s_user.pw_name in SYSTEM_USER_SKIP_LIST: + continue + local_users.append(s_user.pw_name) return local_users +def get_shadow_password(username): + with open('/etc/shadow') as f: + for user in f.readlines(): + items = user.split(":") + if username == items[0]: + return items[1] + return None def get_config(config=None): if config: @@ -62,7 +91,9 @@ def get_config(config=None): conf = Config() base = ['system', 'login'] login = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, get_first_key=True) + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) # users no longer existing in the running configuration need to be deleted local_users = get_local_users() @@ -70,18 +101,9 @@ def get_config(config=None): if 'user' in login: cli_users = list(login['user']) - # 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. - default_values = defaults(base + ['user']) - for user in login['user']: - login['user'][user] = dict_merge(default_values, login['user'][user]) - - # 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. - default_values = defaults(base + ['radius', 'server']) - for server in dict_search('radius.server', login) or []: - login['radius']['server'][server] = dict_merge(default_values, - login['radius']['server'][server]) + # prune TACACS global defaults if not set by user + if login.from_defaults(['tacacs']): + del login['tacacs'] # create a list of all users, cli and users all_users = list(set(local_users + cli_users)) @@ -95,9 +117,13 @@ def get_config(config=None): def verify(login): if 'rm_users' in login: - cur_user = os.environ['SUDO_USER'] - if cur_user in login['rm_users']: - raise ConfigError(f'Attempting to delete current user: {cur_user}') + # This check is required as the script is also executed from vyos-router + # init script and there is no SUDO_USER environment variable available + # during system boot. + if 'SUDO_USER' in os.environ: + cur_user = os.environ['SUDO_USER'] + if cur_user in login['rm_users']: + raise ConfigError(f'Attempting to delete current user: {cur_user}') if 'user' in login: system_users = getpwall() @@ -105,7 +131,7 @@ def verify(login): # Linux system users range up until UID 1000, we can not create a # VyOS CLI user which already exists as system user for s_user in system_users: - if s_user.pw_name == user and s_user.pw_uid < 1000: + if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID: raise ConfigError(f'User "{user}" can not be created, conflict with local system account!') for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): @@ -114,22 +140,34 @@ def verify(login): if 'key' not in pubkey_options: raise ConfigError(f'Missing key for public-key "{pubkey}"!') + if {'radius', 'tacacs'} <= set(login): + raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') + # At lease one RADIUS server must not be disabled if 'radius' in login: if 'server' not in login['radius']: raise ConfigError('No RADIUS server defined!') - + sum_timeout: int = 0 + radius_servers_count: int = 0 fail = True for server, server_config in dict_search('radius.server', login).items(): if 'key' not in server_config: raise ConfigError(f'RADIUS server "{server}" requires key!') - - if 'disabled' not in server_config: + if 'disable' not in server_config: + sum_timeout += int(server_config['timeout']) + radius_servers_count += 1 fail = False - continue + if fail: raise ConfigError('All RADIUS servers are disabled') + if radius_servers_count > MAX_RADIUS_COUNT: + raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!') + + if sum_timeout > MAX_RADIUS_TIMEOUT: + raise ConfigError('Sum of RADIUS servers timeouts ' + 'has to be less or eq 50 sec') + verify_vrf(login['radius']) if 'source_address' in login['radius']: @@ -144,6 +182,27 @@ def verify(login): if ipv6_count > 1: raise ConfigError('Only one IPv6 source-address can be set!') + if 'tacacs' in login: + tacacs_servers_count: int = 0 + fail = True + for server, server_config in dict_search('tacacs.server', login).items(): + if 'key' not in server_config: + raise ConfigError(f'TACACS server "{server}" requires key!') + if 'disable' not in server_config: + tacacs_servers_count += 1 + fail = False + + if fail: + raise ConfigError('All RADIUS servers are disabled') + + if tacacs_servers_count > MAX_TACACS_COUNT: + raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') + + verify_vrf(login['tacacs']) + + if 'max_login_session' in login and 'timeout' not in login: + raise ConfigError('"login timeout" must be configured!') + return None @@ -153,17 +212,17 @@ def generate(login): for user, user_config in login['user'].items(): tmp = dict_search('authentication.plaintext_password', user_config) if tmp: - encrypted_password = crypt(tmp, METHOD_SHA512) + encrypted_password = linux_context.hash(tmp) login['user'][user]['authentication']['encrypted_password'] = encrypted_password del login['user'][user]['authentication']['plaintext_password'] # remove old plaintext password and set new encrypted password env = os.environ.copy() - env['vyos_libexec_dir'] = '/usr/libexec/vyos' + env['vyos_libexec_dir'] = directories['base'] # Set default commands for re-adding user with encrypted password - del_user_plain = f"system login user '{user}' authentication plaintext-password" - add_user_encrypt = f"system login user '{user}' authentication encrypted-password '{encrypted_password}'" + del_user_plain = f"system login user {user} authentication plaintext-password" + add_user_encrypt = f"system login user {user} authentication encrypted-password '{encrypted_password}'" lvl = env['VYATTA_EDIT_LEVEL'] # We're in config edit level, for example "edit system login" @@ -182,11 +241,13 @@ def generate(login): add_user_encrypt = add_user_encrypt[len(lvl):] add_user_encrypt = " ".join(add_user_encrypt) - call(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) - call(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) + ret, out = rc_cmd(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) + if ret: raise ConfigError(out) + ret, out = rc_cmd(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) + if ret: raise ConfigError(out) else: try: - if getspnam(user).sp_pwdp == dict_search('authentication.encrypted_password', user_config): + if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): # If the current encrypted bassword matches the encrypted password # from the config - do not update it. This will remove the encrypted # value from the system logs. @@ -197,6 +258,7 @@ def generate(login): except: pass + ### RADIUS based user authentication if 'radius' in login: render(radius_config_file, 'login/pam_radius_auth.conf.j2', login, permission=0o600, user='root', group='root') @@ -204,6 +266,32 @@ def generate(login): if os.path.isfile(radius_config_file): os.unlink(radius_config_file) + ### TACACS+ based user authentication + if 'tacacs' in login: + render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login, + permission=0o644, user='root', group='root') + render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login, + permission=0o644, user='root', group='root') + else: + if os.path.isfile(tacacs_pam_config_file): + os.unlink(tacacs_pam_config_file) + if os.path.isfile(tacacs_nss_config_file): + os.unlink(tacacs_nss_config_file) + + + + # NSS must always be present on the system + render(nss_config_file, 'login/nsswitch.conf.j2', login, + permission=0o644, user='root', group='root') + + # /etc/security/limits.d/10-vyos.conf + if 'max_login_session' in login: + render(limits_file, 'login/limits.j2', login, + permission=0o644, user='root', group='root') + else: + if os.path.isfile(limits_file): + os.unlink(limits_file) + if 'timeout' in login: render(autologout_file, 'login/autologout.j2', login, permission=0o755, user='root', group='root') @@ -219,7 +307,7 @@ def apply(login): for user, user_config in login['user'].items(): # make new user using vyatta shell and make home directory (-m), # default group of 100 (users) - command = 'useradd --create-home --no-user-group' + command = 'useradd --create-home --no-user-group ' # check if user already exists: if user in get_local_users(): # update existing account @@ -283,44 +371,23 @@ def apply(login): # 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): + while run(f'userdel {user}', stderr=DEVNULL): sleep(0.250) except Exception as e: raise ConfigError(f'Deleting user "{user}" raised exception: {e}') - # - # RADIUS configuration - # - env = os.environ.copy() - env['DEBIAN_FRONTEND'] = 'noninteractive' - try: - if 'radius' in login: - # Enable RADIUS in PAM - cmd('pam-auth-update --package --enable radius', env=env) - # Make NSS system aware of RADIUS - # This fancy snipped was copied from old Vyatta code - command = "sed -i -e \'/\smapname/b\' \ - -e \'/^passwd:/s/\s\s*/&mapuid /\' \ - -e \'/^passwd:.*#/s/#.*/mapname &/\' \ - -e \'/^passwd:[^#]*$/s/$/ mapname &/\' \ - -e \'/^group:.*#/s/#.*/ mapname &/\' \ - -e \'/^group:[^#]*$/s/: */&mapname /\' \ - /etc/nsswitch.conf" - else: - # Disable RADIUS in PAM - cmd('pam-auth-update --package --remove radius', env=env) - # Drop RADIUS from NSS NSS system - # This fancy snipped was copied from old Vyatta code - command = "sed -i -e \'/^passwd:.*mapuid[ \t]/s/mapuid[ \t]//\' \ - -e \'/^passwd:.*[ \t]mapname/s/[ \t]mapname//\' \ - -e \'/^group:.*[ \t]mapname/s/[ \t]mapname//\' \ - -e \'s/[ \t]*$//\' \ - /etc/nsswitch.conf" - - cmd(command) - except Exception as e: - raise ConfigError(f'RADIUS configuration failed: {e}') + # Enable RADIUS in PAM configuration + pam_cmd = '--remove' + if 'radius' in login: + pam_cmd = '--enable' + cmd(f'pam-auth-update --package {pam_cmd} radius') + + # Enable/Disable TACACS in PAM configuration + pam_cmd = '--remove' + if 'tacacs' in login: + pam_cmd = '--enable' + cmd(f'pam-auth-update --package {pam_cmd} tacplus') return None diff --git a/src/conf_mode/system-logs.py b/src/conf_mode/system-logs.py index c71938a79..8ad4875d4 100755 --- a/src/conf_mode/system-logs.py +++ b/src/conf_mode/system-logs.py @@ -19,11 +19,9 @@ 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 +from vyos.utils.dict import dict_search airbag.enable() # path to logrotate configs @@ -38,11 +36,9 @@ def get_config(config=None): 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) + logs_config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return logs_config diff --git a/src/conf_mode/system-option.py b/src/conf_mode/system-option.py index 36dbf155b..d92121b3d 100755 --- a/src/conf_mode/system-option.py +++ b/src/conf_mode/system-option.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2023 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,19 +21,24 @@ from sys import exit from time import sleep from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.configverify import verify_source_interface from vyos.template import render -from vyos.util import cmd -from vyos.util import is_systemd_service_running -from vyos.validate import is_addr_assigned -from vyos.xml import defaults +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_running +from vyos.utils.network import is_addr_assigned +from vyos.utils.network import is_intf_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() curlrc_config = r'/etc/curlrc' -ssh_config = r'/etc/ssh/ssh_config' +ssh_config = r'/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf' systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target' +time_format_to_locale = { + '12-hour': 'en_US.UTF-8', + '24-hour': 'en_GB.UTF-8' +} + def get_config(config=None): if config: @@ -41,12 +46,9 @@ def get_config(config=None): else: conf = Config() base = ['system', 'option'] - options = 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) - options = dict_merge(default_values, options) + options = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return options @@ -68,8 +70,17 @@ def verify(options): if 'ssh_client' in options: config = options['ssh_client'] if 'source_address' in config: + address = config['source_address'] if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with give address specified!') + raise ConfigError('No interface with address "{address}" configured!') + + if 'source_interface' in config: + verify_source_interface(config) + if 'source_address' in config: + address = config['source_address'] + interface = config['source_interface'] + if not is_intf_addr_assigned(interface, address): + raise ConfigError(f'Address "{address}" not assigned on interface "{interface}"!') return None @@ -132,6 +143,11 @@ def apply(options): else: cmd('systemctl disable root-partition-auto-resize.service') + # Time format 12|24-hour + if 'time_format' in options: + time_format = time_format_to_locale.get(options['time_format']) + cmd(f'localectl set-locale LC_TIME={time_format}') + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py index 20132456c..07fbb0734 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system-syslog.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,253 +15,82 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re -from pathlib import Path from sys import exit from vyos.config import Config -from vyos import ConfigError -from vyos.util import run +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.utils.process import call from vyos.template import render - +from vyos import ConfigError from vyos import airbag airbag.enable() +rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf' +logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' +systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf' + def get_config(config=None): if config: - c = config + conf = config else: - c = Config() - if not c.exists('system syslog'): + conf = Config() + base = ['system', 'syslog'] + if not conf.exists(base): return None - c.set_level('system syslog') - - config_data = { - 'files': {}, - 'console': {}, - 'hosts': {}, - 'user': {} - } - - # - # /etc/rsyslog.d/vyos-rsyslog.conf - # 'set system syslog global' - # - config_data['files'].update( - { - 'global': { - 'log-file': '/var/log/messages', - 'selectors': '*.notice;local7.debug', - 'max-files': '5', - 'preserver_fqdn': False - } - } - ) - - if c.exists('global marker'): - config_data['files']['global']['marker'] = True - if c.exists('global marker interval'): - config_data['files']['global'][ - 'marker-interval'] = c.return_value('global marker interval') - if c.exists('global facility'): - config_data['files']['global'][ - 'selectors'] = generate_selectors(c, 'global facility') - if c.exists('global archive size'): - config_data['files']['global']['max-size'] = int( - c.return_value('global archive size')) * 1024 - if c.exists('global archive file'): - config_data['files']['global'][ - 'max-files'] = c.return_value('global archive file') - if c.exists('global preserve-fqdn'): - config_data['files']['global']['preserver_fqdn'] = True - - # - # set system syslog file - # - - if c.exists('file'): - filenames = c.list_nodes('file') - for filename in filenames: - config_data['files'].update( - { - filename: { - 'log-file': '/var/log/user/' + filename, - 'max-files': '5', - 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog-generated-' + filename, - 'selectors': '*.err', - 'max-size': 262144 - } - } - ) - - if c.exists('file ' + filename + ' facility'): - config_data['files'][filename]['selectors'] = generate_selectors( - c, 'file ' + filename + ' facility') - if c.exists('file ' + filename + ' archive size'): - config_data['files'][filename]['max-size'] = int( - c.return_value('file ' + filename + ' archive size')) * 1024 - if c.exists('file ' + filename + ' archive files'): - config_data['files'][filename]['max-files'] = c.return_value( - 'file ' + filename + ' archive files') - # set system syslog console - if c.exists('console'): - config_data['console'] = { - '/dev/console': { - 'selectors': '*.err' - } - } + syslog = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) - for f in c.list_nodes('console facility'): - if c.exists('console facility ' + f + ' level'): - config_data['console'] = { - '/dev/console': { - 'selectors': generate_selectors(c, 'console facility') - } - } + syslog.update({ 'logrotate' : logrotate_conf }) - # set system syslog host - if c.exists('host'): - rhosts = c.list_nodes('host') - proto = 'udp' - for rhost in rhosts: - for fac in c.list_nodes('host ' + rhost + ' facility'): - if c.exists('host ' + rhost + ' facility ' + fac + ' protocol'): - proto = c.return_value( - 'host ' + rhost + ' facility ' + fac + ' protocol') - else: - proto = 'udp' + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: syslog.update({'restart_required': {}}) - config_data['hosts'].update( - { - rhost: { - 'selectors': generate_selectors(c, 'host ' + rhost + ' facility'), - 'proto': proto - } - } - ) - if c.exists('host ' + rhost + ' port'): - config_data['hosts'][rhost][ - 'port'] = c.return_value(['host', rhost, 'port']) + syslog = conf.merge_defaults(syslog, recursive=True) + if syslog.from_defaults(['global']): + del syslog['global'] - # set system syslog host x.x.x.x format octet-counted - if c.exists('host ' + rhost + ' format octet-counted'): - config_data['hosts'][rhost]['oct_count'] = True - else: - config_data['hosts'][rhost]['oct_count'] = False + return syslog - # set system syslog user - if c.exists('user'): - usrs = c.list_nodes('user') - for usr in usrs: - config_data['user'].update( - { - usr: { - 'selectors': generate_selectors(c, 'user ' + usr + ' facility') - } - } - ) - - return config_data - - -def generate_selectors(c, config_node): -# protocols and security are being mapped here -# for backward compatibility with old configs -# security and protocol mappings can be removed later - nodes = c.list_nodes(config_node) - selectors = "" - for node in nodes: - lvl = c.return_value(config_node + ' ' + node + ' level') - if lvl == None: - lvl = "err" - if lvl == 'all': - lvl = '*' - if node == 'all' and node != nodes[-1]: - selectors += "*." + lvl + ";" - elif node == 'all': - selectors += "*." + lvl - elif node != nodes[-1]: - if node == 'protocols': - node = 'local7' - if node == 'security': - node = 'auth' - selectors += node + "." + lvl + ";" - else: - if node == 'protocols': - node = 'local7' - if node == 'security': - node = 'auth' - selectors += node + "." + lvl - return selectors - - -def generate(c): - if c == None: +def verify(syslog): + if not syslog: return None - conf = '/etc/rsyslog.d/vyos-rsyslog.conf' - render(conf, 'syslog/rsyslog.conf.j2', c) + verify_vrf(syslog) - # cleanup current logrotate config files - logrotate_files = Path('/etc/logrotate.d/').glob('vyos-rsyslog-generated-*') - for file in logrotate_files: - file.unlink() +def generate(syslog): + if not syslog: + if os.path.exists(rsyslog_conf): + os.unlink(rsyslog_conf) + if os.path.exists(logrotate_conf): + os.unlink(logrotate_conf) - # eventually write for each file its own logrotate file, since size is - # defined it shouldn't matter - 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): - if c == None: return None - # may be obsolete - # /etc/rsyslog.conf is generated somewhere and copied over the original (exists in /opt/vyatta/etc/rsyslog.conf) - # it interferes with the global logging, to make sure we are using a single base, template is enforced here - # - if not os.path.islink('/etc/rsyslog.conf'): - os.remove('/etc/rsyslog.conf') - os.symlink( - '/usr/share/vyos/templates/rsyslog/rsyslog.conf', '/etc/rsyslog.conf') + render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog) + render(systemd_override, 'rsyslog/override.conf.j2', syslog) + render(logrotate_conf, 'rsyslog/logrotate.j2', syslog) - # /var/log/vyos-rsyslog were the old files, we may want to clean those up, but currently there - # is a chance that someone still needs it, so I don't automatically remove - # them - # + # Reload systemd manager configuration + call('systemctl daemon-reload') + return None - if c == None: +def apply(syslog): + systemd_socket = 'syslog.socket' + systemd_service = 'syslog.service' + if not syslog: + call(f'systemctl stop {systemd_service} {systemd_socket}') return None - fac = [ - '*', 'auth', 'authpriv', 'cron', 'daemon', 'kern', 'lpr', 'mail', 'mark', 'news', 'protocols', 'security', - 'syslog', 'user', 'uucp', 'local0', 'local1', 'local2', 'local3', 'local4', 'local5', 'local6', 'local7'] - lvl = ['emerg', 'alert', 'crit', 'err', - 'warning', 'notice', 'info', 'debug', '*'] - - for conf in c: - if c[conf]: - for item in c[conf]: - for s in c[conf][item]['selectors'].split(";"): - f = re.sub("\..*$", "", s) - if f not in fac: - raise ConfigError( - 'Invalid facility ' + s + ' set in ' + conf + ' ' + item) - l = re.sub("^.+\.", "", s) - if l not in lvl: - raise ConfigError( - 'Invalid logging level ' + s + ' set in ' + conf + ' ' + item) - + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in syslog: + systemd_action = 'restart' -def apply(c): - if not c: - return run('systemctl stop syslog.service') - return run('systemctl restart syslog.service') + call(f'systemctl {systemd_action} {systemd_service}') + return None if __name__ == '__main__': try: diff --git a/src/conf_mode/system-timezone.py b/src/conf_mode/system-timezone.py index 3d98ba774..cd3d4b229 100755 --- a/src/conf_mode/system-timezone.py +++ b/src/conf_mode/system-timezone.py @@ -20,7 +20,7 @@ import os from copy import deepcopy from vyos.config import Config from vyos import ConfigError -from vyos.util import call +from vyos.utils.process import call from vyos import airbag airbag.enable() diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index e922edc4e..ebf9a113b 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,12 +19,10 @@ import re from pathlib import Path from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.util import call -from vyos.util import read_file -from vyos.util import write_file +from vyos.utils.process import call +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.template import render -from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() @@ -45,16 +43,12 @@ def get_config(config=None): if 'device' not in console: return console - # convert CLI values to system values - default_values = defaults(base + ['device']) for device, device_config in console['device'].items(): if 'speed' not in device_config and device.startswith('hvc'): # XEN console has a different default console speed console['device'][device]['speed'] = 38400 - else: - # Merge in XML defaults - the proper way to do it - console['device'][device] = dict_merge(default_values, - console['device'][device]) + + console = conf.merge_defaults(console, recursive=True) return console diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py index 1af0055f6..fb252238a 100755 --- a/src/conf_mode/system_frr.py +++ b/src/conf_mode/system_frr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,7 +22,9 @@ from vyos import airbag from vyos.config import Config from vyos.logger import syslog from vyos.template import render_to_string -from vyos.util import read_file, write_file, run +from vyos.utils.file import read_file +from vyos.utils.file import write_file +from vyos.utils.process import run airbag.enable() # path to daemons config and config status files diff --git a/src/conf_mode/system_lcd.py b/src/conf_mode/system_lcd.py index 3341dd738..eb88224d1 100755 --- a/src/conf_mode/system_lcd.py +++ b/src/conf_mode/system_lcd.py @@ -19,8 +19,8 @@ import os from sys import exit from vyos.config import Config -from vyos.util import call -from vyos.util import find_device_file +from vyos.utils.process import call +from vyos.utils.system import find_device_file from vyos.template import render from vyos import ConfigError from vyos import airbag diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py new file mode 100755 index 000000000..2df1bbb7a --- /dev/null +++ b/src/conf_mode/system_sflow.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.network import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +hsflowd_conf_path = '/run/sflow/hsflowd.conf' +systemd_service = 'hsflowd.service' +systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'sflow'] + if not conf.exists(base): + return None + + sflow = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return sflow + + +def verify(sflow): + if not sflow: + return None + + # Check if configured sflow agent-address exist in the system + if 'agent_address' in sflow: + tmp = 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 at least one interface is configured + if 'interface' not in sflow: + raise ConfigError( + 'sFlow requires at least one interface to be configured!') + + # Check if at least one server is configured + if 'server' not in sflow: + raise ConfigError('You need to configure at least one sFlow server!') + + # return True if all checks were passed + return True + + +def generate(sflow): + if not sflow: + return None + + render(hsflowd_conf_path, 'sflow/hsflowd.conf.j2', sflow) + render(systemd_override, 'sflow/override.conf.j2', sflow) + # Reload systemd manager configuration + call('systemctl daemon-reload') + + +def apply(sflow): + if not sflow: + # Stop flow-accounting daemon and remove configuration file + call(f'systemctl stop {systemd_service}') + if os.path.exists(hsflowd_conf_path): + os.unlink(hsflowd_conf_path) + return + + # Start/reload flow-accounting daemon + call(f'systemctl restart {systemd_service}') + + +if __name__ == '__main__': + try: + config = get_config() + verify(config) + generate(config) + apply(config) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py index 2e0004ffa..f6b02023d 100755 --- a/src/conf_mode/system_sysctl.py +++ b/src/conf_mode/system_sysctl.py @@ -20,7 +20,7 @@ from sys import exit from vyos.config import Config from vyos.template import render -from vyos.util import cmd +from vyos.utils.process import cmd from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/system_update_check.py b/src/conf_mode/system_update_check.py index 08ecfcb81..8d641a97d 100755 --- a/src/conf_mode/system_update_check.py +++ b/src/conf_mode/system_update_check.py @@ -22,7 +22,7 @@ from pathlib import Path from sys import exit from vyos.config import Config -from vyos.util import call +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/tftp_server.py b/src/conf_mode/tftp_server.py index c5daccb7f..3ad346e2e 100755 --- a/src/conf_mode/tftp_server.py +++ b/src/conf_mode/tftp_server.py @@ -24,14 +24,12 @@ 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 -from vyos.util import chmod_755 -from vyos.validate import is_addr_assigned -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.permission import chmod_755 +from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() @@ -48,11 +46,9 @@ def get_config(config=None): if not conf.exists(base): return None - tftpd = 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) - tftpd = dict_merge(default_values, tftpd) + tftpd = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return tftpd def verify(tftpd): diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index b79e9847a..fa271cbdb 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021-2023 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,6 +17,7 @@ import ipaddress import os import re +import jmespath from sys import exit from time import sleep @@ -26,7 +27,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists -from vyos.configdict import dict_merge +from vyos.defaults import directories from vyos.ifconfig import Interface from vyos.pki import encode_public_key from vyos.pki import load_private_key @@ -38,12 +39,11 @@ from vyos.template import ip_from_cidr from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.template import render -from vyos.validate import is_ipv6_link_local -from vyos.util import call -from vyos.util import dict_search -from vyos.util import dict_search_args -from vyos.util import run -from vyos.xml import defaults +from vyos.utils.network import is_ipv6_link_local +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.process import call +from vyos.utils.process import run from vyos import ConfigError from vyos import airbag airbag.enable() @@ -52,8 +52,6 @@ dhcp_wait_attempts = 2 dhcp_wait_sleep = 1 swanctl_dir = '/etc/swanctl' -ipsec_conf = '/etc/ipsec.conf' -ipsec_secrets = '/etc/ipsec.secrets' charon_conf = '/etc/strongswan.d/charon.conf' charon_dhcp_conf = '/etc/strongswan.d/charon/dhcp.conf' charon_radius_conf = '/etc/strongswan.d/charon/eap-radius.conf' @@ -70,7 +68,6 @@ KEY_PATH = f'{swanctl_dir}/private/' CA_PATH = f'{swanctl_dir}/x509ca/' CRL_PATH = f'{swanctl_dir}/x509crl/' -DHCP_BASE = '/var/lib/dhcp/dhclient' DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting' def get_config(config=None): @@ -85,79 +82,23 @@ def get_config(config=None): # retrieve common dictionary keys ipsec = 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) - # XXX: T2665: we must safely remove default values for tag nodes, those are - # added in a more fine grained way later on - del default_values['esp_group'] - del default_values['ike_group'] - del default_values['remote_access'] - ipsec = dict_merge(default_values, ipsec) - - if 'esp_group' in ipsec: - default_values = defaults(base + ['esp-group']) - for group in ipsec['esp_group']: - ipsec['esp_group'][group] = dict_merge(default_values, - ipsec['esp_group'][group]) - if 'ike_group' in ipsec: - default_values = defaults(base + ['ike-group']) - # proposal is a tag node which may come with individual defaults per node - if 'proposal' in default_values: - del default_values['proposal'] - - for group in ipsec['ike_group']: - ipsec['ike_group'][group] = dict_merge(default_values, - ipsec['ike_group'][group]) - - if 'proposal' in ipsec['ike_group'][group]: - default_values = defaults(base + ['ike-group', 'proposal']) - for proposal in ipsec['ike_group'][group]['proposal']: - ipsec['ike_group'][group]['proposal'][proposal] = dict_merge(default_values, - ipsec['ike_group'][group]['proposal'][proposal]) - - # XXX: T2665: we can not safely rely on the defaults() when there are - # tagNodes in place, it is better to blend in the defaults manually. - if dict_search('remote_access.connection', ipsec): - default_values = defaults(base + ['remote-access', 'connection']) - for rw in ipsec['remote_access']['connection']: - ipsec['remote_access']['connection'][rw] = dict_merge(default_values, - ipsec['remote_access']['connection'][rw]) - - # XXX: T2665: we can not safely rely on the defaults() when there are - # tagNodes in place, it is better to blend in the defaults manually. - if dict_search('remote_access.radius.server', ipsec): - # Fist handle the "base" stuff like RADIUS timeout - default_values = defaults(base + ['remote-access', 'radius']) - if 'server' in default_values: - del default_values['server'] - ipsec['remote_access']['radius'] = dict_merge(default_values, - ipsec['remote_access']['radius']) - - # Take care about individual RADIUS servers implemented as tagNodes - this - # requires special treatment - default_values = defaults(base + ['remote-access', 'radius', 'server']) - for server in ipsec['remote_access']['radius']['server']: - ipsec['remote_access']['radius']['server'][server] = dict_merge(default_values, - ipsec['remote_access']['radius']['server'][server]) + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) ipsec['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + no_tag_node_value_mangle=True, + get_first_key=True) tmp = conf.get_config_dict(l2tp_base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + no_tag_node_value_mangle=True, + get_first_key=True) if tmp: - ipsec['l2tp'] = tmp - l2tp_defaults = defaults(l2tp_base) - ipsec['l2tp'] = dict_merge(l2tp_defaults, ipsec['l2tp']) + ipsec['l2tp'] = conf.merge_defaults(tmp, recursive=True) ipsec['l2tp_outside_address'] = conf.return_value(['vpn', 'l2tp', 'remote-access', 'outside-address']) ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024' ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1' @@ -210,6 +151,12 @@ def verify(ipsec): if not ipsec: return None + if 'authentication' in ipsec: + if 'psk' in ipsec['authentication']: + for psk, psk_config in ipsec['authentication']['psk'].items(): + if 'id' not in psk_config or 'secret' not in psk_config: + raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"') + if 'interfaces' in ipsec : for ifname in ipsec['interface']: verify_interface_exists(ifname) @@ -419,8 +366,9 @@ def verify(ipsec): dhcp_interface = peer_conf['dhcp_interface'] verify_interface_exists(dhcp_interface) + dhcp_base = directories['isc_dhclient_dir'] - if not os.path.exists(f'{DHCP_BASE}_{dhcp_interface}.conf'): + if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}") address = get_dhcp_address(dhcp_interface) @@ -441,7 +389,7 @@ def verify(ipsec): if dict_search('options.disable_route_autoinstall', ipsec) == None: - Warning('It\'s recommended to use ipsec vty with the next command\n[set vpn ipsec option disable-route-autoinstall]') + Warning('It\'s recommended to use ipsec vti with the next command\n[set vpn ipsec option disable-route-autoinstall]') if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] @@ -526,8 +474,7 @@ def generate(ipsec): cleanup_pki_files() if not ipsec: - for config_file in [ipsec_conf, ipsec_secrets, charon_dhcp_conf, - charon_radius_conf, interface_conf, swanctl_conf]: + for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]: if os.path.isfile(config_file): os.unlink(config_file) render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes}) @@ -536,6 +483,8 @@ def generate(ipsec): if ipsec['dhcp_no_address']: with open(DHCP_HOOK_IFLIST, 'w') as f: f.write(" ".join(ipsec['dhcp_no_address'].values())) + elif os.path.exists(DHCP_HOOK_IFLIST): + os.unlink(DHCP_HOOK_IFLIST) for path in [swanctl_dir, CERT_PATH, CA_PATH, CRL_PATH, PUBKEY_PATH]: if not os.path.exists(path): @@ -593,9 +542,15 @@ def generate(ipsec): ipsec['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough + # auth psk <tag> dhcp-interface <xxx> + if jmespath.search('authentication.psk.*.dhcp_interface', ipsec): + for psk, psk_config in ipsec['authentication']['psk'].items(): + if 'dhcp_interface' in psk_config: + for iface in psk_config['dhcp_interface']: + id = get_dhcp_address(iface) + if id: + ipsec['authentication']['psk'][psk]['id'].append(id) - 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) @@ -610,25 +565,12 @@ def resync_nhrp(ipsec): if tmp > 0: print('ERROR: failed to reapply NHRP settings!') -def wait_for_vici_socket(timeout=5, sleep_interval=0.1): - start_time = time() - test_command = f'sudo socat -u OPEN:/dev/null UNIX-CONNECT:{vici_socket}' - while True: - if (start_time + timeout) < time(): - return None - result = run(test_command) - if result == 0: - return True - sleep(sleep_interval) - def apply(ipsec): - systemd_service = 'strongswan-starter.service' + systemd_service = 'strongswan.service' if not ipsec: call(f'systemctl stop {systemd_service}') else: call(f'systemctl reload-or-restart {systemd_service}') - if wait_for_vici_socket(): - call('sudo swanctl -q') resync_nhrp(ipsec) diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 27e78db99..6232ce64a 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,10 +26,10 @@ from ipaddress import ip_network from vyos.config import Config from vyos.template import is_ipv4 from vyos.template import render -from vyos.util import call -from vyos.util import get_half_cpus -from vyos.util import check_port_availability -from vyos.util import is_listen_port_bind_service +from vyos.utils.process import call +from vyos.utils.system import get_half_cpus +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service from vyos import ConfigError from vyos import airbag @@ -58,8 +58,12 @@ default_config_data = { 'ppp_echo_failure' : '3', 'ppp_echo_interval' : '30', 'ppp_echo_timeout': '0', + 'ppp_ipv6_accept_peer_intf_id': False, + 'ppp_ipv6_intf_id': None, + 'ppp_ipv6_peer_intf_id': None, 'radius_server': [], 'radius_acct_inter_jitter': '', + 'radius_acct_interim_interval': None, 'radius_acct_tmo': '3', 'radius_max_try': '3', 'radius_timeout': '3', @@ -187,6 +191,9 @@ def get_config(config=None): # advanced radius-setting conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['accounting-interim-interval']): + l2tp['radius_acct_interim_interval'] = conf.return_value(['accounting-interim-interval']) + if conf.exists(['acct-interim-jitter']): l2tp['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) @@ -314,6 +321,15 @@ def get_config(config=None): if conf.exists(['ppp-options', 'ipv6']): l2tp['ppp_ipv6'] = conf.return_value(['ppp-options', 'ipv6']) + if conf.exists(['ppp-options', 'ipv6-accept-peer-intf-id']): + l2tp['ppp_ipv6_accept_peer_intf_id'] = True + + if conf.exists(['ppp-options', 'ipv6-intf-id']): + l2tp['ppp_ipv6_intf_id'] = conf.return_value(['ppp-options', 'ipv6-intf-id']) + + if conf.exists(['ppp-options', 'ipv6-peer-intf-id']): + l2tp['ppp_ipv6_peer_intf_id'] = conf.return_value(['ppp-options', 'ipv6-peer-intf-id']) + return l2tp diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index af3c51efc..a039172c4 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 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,19 +17,18 @@ import os from sys import exit +from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render -from vyos.util import call -from vyos.util import check_port_availability -from vyos.util import is_systemd_service_running -from vyos.util import is_listen_port_bind_service -from vyos.util import dict_search -from vyos.xml import defaults +from vyos.utils.process import call +from vyos.utils.network import check_port_availability +from vyos.utils.process import is_systemd_service_running +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.dict import dict_search from vyos import ConfigError -from crypt import crypt, mksalt, METHOD_SHA512 +from passlib.hash import sha512_crypt from time import sleep from vyos import airbag @@ -44,34 +43,25 @@ radius_servers = cfg_dir + '/radius_servers' # Generate hash from user cleartext password def get_hash(password): - return crypt(password, mksalt(METHOD_SHA512)) + return sha512_crypt.hash(password) -def get_config(): - conf = Config() +def get_config(config=None): + if config: + conf = config + else: + conf = Config() base = ['vpn', 'openconnect'] if not conf.exists(base): return None - ocserv = 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) - ocserv = dict_merge(default_values, ocserv) - - if 'mode' in ocserv["authentication"] and "local" in ocserv["authentication"]["mode"]: - # workaround a "know limitation" - https://phabricator.vyos.net/T2665 - del ocserv['authentication']['local_users']['username']['otp'] - if not ocserv["authentication"]["local_users"]["username"]: - raise ConfigError('openconnect mode local required at least one user') - default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] - for user, params in ocserv['authentication']['local_users']['username'].items(): - # Not every configuration requires OTP settings - if ocserv['authentication']['local_users']['username'][user].get('otp'): - ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp']) + ocserv = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) if ocserv: ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + no_tag_node_value_mangle=True, + get_first_key=True) return ocserv @@ -85,12 +75,26 @@ def verify(ocserv): not is_listen_port_bind_service(int(port), 'ocserv-main'): raise ConfigError(f'"{proto}" port "{port}" is used by another service') + # Check accounting + if "accounting" in ocserv: + if "mode" in ocserv["accounting"] and "radius" in ocserv["accounting"]["mode"]: + if not origin["accounting"]['radius']['server']: + raise ConfigError('Openconnect accounting mode radius requires at least one RADIUS server') + if "authentication" not in ocserv or "mode" not in ocserv["authentication"]: + raise ConfigError('Accounting depends on OpenConnect authentication configuration') + elif "radius" not in ocserv["authentication"]["mode"]: + raise ConfigError('RADIUS accounting must be used with RADIUS authentication') + # Check authentication if "authentication" in ocserv: if "mode" in ocserv["authentication"]: - if "local" in ocserv["authentication"]["mode"]: - if "radius" in ocserv["authentication"]["mode"]: + if ("local" in ocserv["authentication"]["mode"] and + "radius" in ocserv["authentication"]["mode"]): raise ConfigError('OpenConnect authentication modes are mutually-exclusive, remove either local or radius from your configuration') + if "radius" in ocserv["authentication"]["mode"]: + if not ocserv["authentication"]['radius']['server']: + raise ConfigError('Openconnect authentication mode radius requires at least one RADIUS server') + if "local" in ocserv["authentication"]["mode"]: if not ocserv["authentication"]["local_users"]: raise ConfigError('openconnect mode local required at least one user') if not ocserv["authentication"]["local_users"]["username"]: @@ -113,6 +117,19 @@ def verify(ocserv): users_wo_pswd.append(user) if users_wo_pswd: raise ConfigError(f'password required for users:\n{users_wo_pswd}') + + # Validate that if identity-based-config is configured all child config nodes are set + if 'identity_based_config' in ocserv["authentication"]: + if 'disabled' not in ocserv["authentication"]["identity_based_config"]: + Warning("Identity based configuration files is a 3rd party addition. Use at your own risk, this might break the ocserv daemon!") + if 'mode' not in ocserv["authentication"]["identity_based_config"]: + raise ConfigError('OpenConnect radius identity-based-config enabled but mode not selected') + elif 'group' in ocserv["authentication"]["identity_based_config"]["mode"] and "radius" not in ocserv["authentication"]["mode"]: + raise ConfigError('OpenConnect config-per-group must be used with radius authentication') + if 'directory' not in ocserv["authentication"]["identity_based_config"]: + raise ConfigError('OpenConnect identity-based-config enabled but directory not set') + if 'default_config' not in ocserv["authentication"]["identity_based_config"]: + raise ConfigError('OpenConnect identity-based-config enabled but default-config not set') else: raise ConfigError('openconnect authentication mode required') else: @@ -166,10 +183,18 @@ def generate(ocserv): return None if "radius" in ocserv["authentication"]["mode"]: - # Render radius client configuration - render(radius_cfg, 'ocserv/radius_conf.j2', ocserv["authentication"]["radius"]) - # Render radius servers - render(radius_servers, 'ocserv/radius_servers.j2', ocserv["authentication"]["radius"]) + if dict_search(ocserv, 'accounting.mode.radius'): + # Render radius client configuration + render(radius_cfg, 'ocserv/radius_conf.j2', ocserv) + merged_servers = ocserv["accounting"]["radius"]["server"] | ocserv["authentication"]["radius"]["server"] + # Render radius servers + # Merge the accounting and authentication servers into a single dictionary + render(radius_servers, 'ocserv/radius_servers.j2', {'server': merged_servers}) + else: + # Render radius client configuration + render(radius_cfg, 'ocserv/radius_conf.j2', ocserv) + # Render radius servers + 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"]: diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index 7550c411e..d542f57fe 100755 --- a/src/conf_mode/vpn_pptp.py +++ b/src/conf_mode/vpn_pptp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,7 +23,8 @@ from sys import exit from vyos.config import Config from vyos.template import render -from vyos.util import call, get_half_cpus +from vyos.utils.system import get_half_cpus +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag @@ -37,6 +38,7 @@ default_pptp = { 'local_users' : [], 'radius_server' : [], 'radius_acct_inter_jitter': '', + 'radius_acct_interim_interval': None, 'radius_acct_tmo' : '30', 'radius_max_try' : '3', 'radius_timeout' : '30', @@ -44,6 +46,8 @@ default_pptp = { 'radius_nas_ip' : '', 'radius_source_address' : '', 'radius_shaper_attr' : '', + 'radius_shaper_enable': False, + 'radius_shaper_multiplier': '', 'radius_shaper_vendor': '', 'radius_dynamic_author' : '', 'chap_secrets_file': pptp_chap_secrets, # used in Jinja2 template @@ -143,6 +147,9 @@ def get_config(config=None): # advanced radius-setting conf.set_level(base_path + ['authentication', 'radius']) + if conf.exists(['accounting-interim-interval']): + pptp['radius_acct_interim_interval'] = conf.return_value(['accounting-interim-interval']) + if conf.exists(['acct-interim-jitter']): pptp['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) @@ -183,15 +190,18 @@ def get_config(config=None): pptp['radius_dynamic_author'] = dae + # Rate limit + if conf.exists(['rate-limit', 'attribute']): + pptp['radius_shaper_attr'] = conf.return_value(['rate-limit', 'attribute']) + if conf.exists(['rate-limit', 'enable']): - pptp['radius_shaper_attr'] = 'Filter-Id' - c_attr = ['rate-limit', 'enable', 'attribute'] - if conf.exists(c_attr): - pptp['radius_shaper_attr'] = conf.return_value(c_attr) - - c_vendor = ['rate-limit', 'enable', 'vendor'] - if conf.exists(c_vendor): - pptp['radius_shaper_vendor'] = conf.return_value(c_vendor) + pptp['radius_shaper_enable'] = True + + if conf.exists(['rate-limit', 'multiplier']): + pptp['radius_shaper_multiplier'] = conf.return_value(['rate-limit', 'multiplier']) + + if conf.exists(['rate-limit', 'vendor']): + pptp['radius_shaper_vendor'] = conf.return_value(['rate-limit', 'vendor']) conf.set_level(base_path) if conf.exists(['client-ip-pool']): diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 2949ab290..e98d8385b 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -25,11 +25,11 @@ from vyos.configverify import verify_accel_ppp_base_service from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render -from vyos.util import call -from vyos.util import check_port_availability -from vyos.util import dict_search -from vyos.util import is_listen_port_bind_service -from vyos.util import write_file +from vyos.utils.process import call +from vyos.utils.network import check_port_availability +from vyos.utils.dict import dict_search +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.file import write_file from vyos import ConfigError from vyos import airbag airbag.enable() diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py new file mode 100755 index 000000000..82c2f236e --- /dev/null +++ b/src/conf_mode/vpp.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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 psutil import virtual_memory + +from pathlib import Path +from re import search as re_search, MULTILINE as re_M + +from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents +from vyos.configdict import node_changed +from vyos.ifconfig import Section +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.process import call +from vyos.utils.process import rc_cmd +from vyos.utils.system import sysctl_read +from vyos.utils.system import sysctl_apply +from vyos.template import render + +from vyos import ConfigError +from vyos import airbag +from vyos.vpp import VPPControl +from vyos.vpp import HostControl + +airbag.enable() + +service_name = 'vpp' +service_conf = Path(f'/run/vpp/{service_name}.conf') +systemd_override = '/run/systemd/system/vpp.service.d/10-override.conf' + +# Free memory required for VPP +# 2 GB for hugepages + 1 GB for other services +MIN_AVAILABLE_MEMORY: int = 3 * 1024**3 + + +def _get_pci_address_by_interface(iface) -> str: + rc, out = rc_cmd(f'ethtool -i {iface}') + # if ethtool command was successful + if rc == 0 and out: + regex_filter = r'^bus-info: (?P<address>\w+:\w+:\w+\.\w+)$' + re_obj = re_search(regex_filter, out, re_M) + # if bus-info with PCI address found + if re_obj: + address = re_obj.groupdict().get('address', '') + return address + # use VPP - maybe interface already attached to it + vpp_control = VPPControl(attempts=20, interval=500) + pci_addr = vpp_control.get_pci_addr(iface) + if pci_addr: + return pci_addr + # raise error if PCI address was not found + raise ConfigError(f'Cannot find PCI address for interface {iface}') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['vpp'] + base_ethernet = ['interfaces', 'ethernet'] + + # find interfaces removed from VPP + removed_ifaces = [] + tmp = node_changed(conf, base + ['interface']) + if tmp: + for removed_iface in tmp: + pci_address: str = _get_pci_address_by_interface(removed_iface) + removed_ifaces.append({ + 'iface_name': removed_iface, + 'iface_pci_addr': pci_address + }) + # add an interface to a list of interfaces that need + # to be reinitialized after the commit + set_dependents('ethernet', conf, removed_iface) + + if not conf.exists(base): + return {'removed_ifaces': removed_ifaces} + + config = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + if 'interface' in config: + for iface, iface_config in config['interface'].items(): + # add an interface to a list of interfaces that need + # to be reinitialized after the commit + set_dependents('ethernet', conf, iface) + + # Get PCI address auto + if iface_config['pci'] == 'auto': + config['interface'][iface]['pci'] = _get_pci_address_by_interface(iface) + + config['other_interfaces'] = conf.get_config_dict(base_ethernet, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + if removed_ifaces: + config['removed_ifaces'] = removed_ifaces + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config or (len(config) == 1 and 'removed_ifaces' in config): + return None + + if 'interface' not in config: + raise ConfigError('"interface" is required but not set!') + + if 'cpu' in config: + if 'corelist_workers' in config['cpu'] and 'main_core' not in config[ + 'cpu']: + raise ConfigError('"cpu main-core" is required but not set!') + + memory_available: int = virtual_memory().available + if memory_available < MIN_AVAILABLE_MEMORY: + raise ConfigError( + 'Not enough free memory to start VPP:\n' + f'available: {round(memory_available / 1024**3, 1)}GB\n' + f'required: {round(MIN_AVAILABLE_MEMORY / 1024**3, 1)}GB') + + +def generate(config): + if not config or (len(config) == 1 and 'removed_ifaces' in config): + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + render(service_conf, 'vpp/startup.conf.j2', config) + render(systemd_override, 'vpp/override.conf.j2', config) + + # apply default sysctl values from + # https://github.com/FDio/vpp/blob/v23.06/src/vpp/conf/80-vpp.conf + sysctl_config: dict[str, str] = { + 'vm.nr_hugepages': '1024', + 'vm.max_map_count': '3096', + 'vm.hugetlb_shm_group': '0', + 'kernel.shmmax': '2147483648' + } + # we do not want to reduce `kernel.shmmax` + kernel_shmnax_current: str = sysctl_read('kernel.shmmax') + if int(kernel_shmnax_current) > int(sysctl_config['kernel.shmmax']): + sysctl_config['kernel.shmmax'] = kernel_shmnax_current + + if not sysctl_apply(sysctl_config): + raise ConfigError('Cannot configure sysctl parameters for VPP') + + return None + + +def apply(config): + if not config or (len(config) == 1 and 'removed_ifaces' in config): + call(f'systemctl stop {service_name}.service') + else: + call('systemctl daemon-reload') + call(f'systemctl restart {service_name}.service') + + # Initialize interfaces removed from VPP + for iface in config.get('removed_ifaces', []): + host_control = HostControl() + # rescan PCI to use a proper driver + host_control.pci_rescan(iface['iface_pci_addr']) + # rename to the proper name + iface_new_name: str = host_control.get_eth_name(iface['iface_pci_addr']) + host_control.rename_iface(iface_new_name, iface['iface_name']) + + if 'interface' in config: + # connect to VPP + # must be performed multiple attempts because API is not available + # immediately after the service restart + vpp_control = VPPControl(attempts=20, interval=500) + for iface, _ in config['interface'].items(): + # Create lcp + if iface not in Section.interfaces(): + vpp_control.lcp_pair_add(iface, iface) + + # reinitialize interfaces, but not during the first boot + if boot_configuration_complete(): + call_dependents() + + +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/vrf.py b/src/conf_mode/vrf.py index 1b4156895..37625142c 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2023 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,16 +20,21 @@ from sys import exit from json import loads from vyos.config import Config +from vyos.configdict import dict_merge from vyos.configdict import node_changed +from vyos.configverify import verify_route_map from vyos.ifconfig import Interface from vyos.template import render -from vyos.util import call -from vyos.util import cmd -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.template import render_to_string +from vyos.utils.dict import dict_search +from vyos.utils.network import get_interface_config +from vyos.utils.network import get_vrf_members +from vyos.utils.network import interface_exists +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import popen +from vyos.utils.process import run +from vyos.utils.system import sysctl_write from vyos import ConfigError from vyos import frr from vyos import airbag @@ -99,6 +104,20 @@ def get_config(config=None): routes = vrf_routing(conf, name) if routes: vrf['vrf_remove'][name]['route'] = routes + # We also need the route-map information from the config + # + # XXX: one MUST always call this without the key_mangling() option! See + # vyos.configverify.verify_common_route_maps() for more information. + tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], + get_first_key=True)}} + + # L3VNI setup is done via vrf_vni.py as it must be de-configured (on node + # deletetion prior to the BGP process. Tell the Jinja2 template no VNI + # setup is needed + vrf.update({'no_vni' : ''}) + + # Merge policy dict into "regular" config dict + vrf = dict_merge(tmp, vrf) return vrf def verify(vrf): @@ -113,41 +132,54 @@ def verify(vrf): f'static routes installed!') if 'name' in vrf: - reserved_names = ["add", "all", "broadcast", "default", "delete", "dev", "get", "inet", "mtu", "link", "type", - "vrf"] + reserved_names = ["add", "all", "broadcast", "default", "delete", "dev", + "get", "inet", "mtu", "link", "type", "vrf"] table_ids = [] - for name, config in vrf['name'].items(): + for name, vrf_config in vrf['name'].items(): # Reserved VRF names if name in reserved_names: raise ConfigError(f'VRF name "{name}" is reserved and connot be used!') # table id is mandatory - if 'table' not in config: + if 'table' not in vrf_config: raise ConfigError(f'VRF "{name}" table id is mandatory!') # routing table id can't be changed - OS restriction - if os.path.isdir(f'/sys/class/net/{name}'): + if interface_exists(name): tmp = str(dict_search('linkinfo.info_data.table', get_interface_config(name))) - if tmp and tmp != config['table']: + if tmp and tmp != vrf_config['table']: raise ConfigError(f'VRF "{name}" table id modification not possible!') - # VRf routing table ID must be unique on the system - if config['table'] in table_ids: + # VRF routing table ID must be unique on the system + if 'table' in vrf_config and vrf_config['table'] in table_ids: raise ConfigError(f'VRF "{name}" table id is not unique!') - table_ids.append(config['table']) + table_ids.append(vrf_config['table']) + + tmp = dict_search('ip.protocol', vrf_config) + if tmp != None: + for protocol, protocol_options in tmp.items(): + if 'route_map' in protocol_options: + verify_route_map(protocol_options['route_map'], vrf) + + tmp = dict_search('ipv6.protocol', vrf_config) + if tmp != None: + for protocol, protocol_options in tmp.items(): + if 'route_map' in protocol_options: + verify_route_map(protocol_options['route_map'], vrf) return None def generate(vrf): - render(config_file, 'vrf/vrf.conf.j2', vrf) + # Render iproute2 VR helper names + render(config_file, 'iproute2/vrf.conf.j2', vrf) # Render nftables zones config - render(nft_vrf_config, 'firewall/nftables-vrf-zones.j2', vrf) + # Render VRF Kernel/Zebra route-map filters + vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) return None - def apply(vrf): # Documentation # @@ -165,12 +197,23 @@ def apply(vrf): 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 link delete dev {tmp}') + if interface_exists(tmp): + # T5492: deleting a VRF instance may leafe processes running + # (e.g. dhclient) as there is a depedency ordering issue in the CLI. + # We need to ensure that we stop the dhclient processes first so + # a proper DHCLP RELEASE message is sent + for interface in get_vrf_members(tmp): + vrf_iface = Interface(interface) + vrf_iface.set_dhcp(False) + vrf_iface.set_dhcpv6(False) + # Remove nftables conntrack zone map item nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' cmd(f'nft {nft_del_element}') + # Delete the VRF Kernel interface + call(f'ip link delete dev {tmp}') + if 'name' in vrf: # Separate VRFs in conntrack table # check if table already exists @@ -215,7 +258,7 @@ def apply(vrf): for name, config in vrf['name'].items(): table = config['table'] - if not os.path.isdir(f'/sys/class/net/{name}'): + if not interface_exists(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}') @@ -251,6 +294,17 @@ def apply(vrf): nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' cmd(f'nft {nft_add_element}') + # Apply FRR filters + zebra_daemon = 'zebra' + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + # The route-map used for the FIB (zebra) is part of the zebra daemon + frr_cfg.load_configuration(zebra_daemon) + frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True) + if 'frr_zebra_config' in vrf: + frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config']) + frr_cfg.commit_configuration(zebra_daemon) # return to default lookup preference when no VRF is configured if 'name' not in vrf: diff --git a/src/conf_mode/vrf_vni.py b/src/conf_mode/vrf_vni.py index 585fdbebf..23b341079 100755..100644 --- a/src/conf_mode/vrf_vni.py +++ b/src/conf_mode/vrf_vni.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2023 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,36 +19,75 @@ from sys import exit from vyos.config import Config from vyos.template import render_to_string +from vyos.utils.dict import dict_search 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() - base = ['vrf'] - vrf = conf.get_config_dict(base, get_first_key=True) + vrf_name = None + if len(argv) > 1: + vrf_name = argv[1] + else: + return None + + # Using duplicate L3VNIs makes no sense - it's also forbidden in FRR, + # thus VyOS CLI must deny this, too. Instead of getting only the dict for + # the requested VRF and den comparing it with depenent VRfs to not have any + # duplicate we will just grad ALL VRFs by default but only render/apply + # the configuration for the requested VRF - that makes the code easier and + # hopefully less error prone + vrf = conf.get_config_dict(['vrf'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + + # Store name of VRF we are interested in for FRR config rendering + vrf.update({'only_vrf' : vrf_name}) + return vrf def verify(vrf): + if not vrf: + return + + if len(argv) < 2: + raise ConfigError('VRF parameter not specified when valling vrf_vni.py') + + if 'name' in vrf: + vni_ids = [] + for name, vrf_config in vrf['name'].items(): + # VRF VNI (Virtual Network Identifier) must be unique on the system + if 'vni' in vrf_config: + if vrf_config['vni'] in vni_ids: + raise ConfigError(f'VRF "{name}" VNI is not unique!') + vni_ids.append(vrf_config['vni']) + return None def generate(vrf): - vrf['new_frr_config'] = render_to_string('frr/vrf-vni.frr.j2', vrf) + if not vrf: + return + + vrf['new_frr_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) return None def apply(vrf): + frr_daemon = 'zebra' + # add configuration to FRR frr_cfg = frr.FRRConfig() frr_cfg.load_configuration(frr_daemon) - frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True) - if 'new_frr_config' in vrf: + # There is only one VRF inside the dict as we read only one in get_config() + if vrf and 'only_vrf' in vrf: + vrf_name = vrf['only_vrf'] + frr_cfg.modify_section(f'^vrf {vrf_name}', stop_pattern='^exit-vrf', remove_stop_mark=True) + if vrf and 'new_frr_config' in vrf: frr_cfg.add_before(frr.default_add_before, vrf['new_frr_config']) frr_cfg.commit_configuration(frr_daemon) |