diff options
Diffstat (limited to 'src/conf_mode')
132 files changed, 7829 insertions, 1281 deletions
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 18d660a4e..19ff0da34 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -29,12 +29,17 @@ from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.ifconfig import Interface +from vyos.container import restart_network +from vyos.utils.configfs import delete_cli_node +from vyos.utils.configfs import add_cli_node from vyos.utils.cpu import get_core_count from vyos.utils.file import write_file +from vyos.utils.dict import dict_search from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import gen_mac +from vyos.utils.network import get_host_identity from vyos.utils.network import interface_exists from vyos.template import bracketize_ipv6 from vyos.template import inc_ip @@ -114,6 +119,10 @@ def verify(container): # Add new container if 'name' in container: + net_dict = {} + net_dict['mac'] = {} + net_dict['address'] = {} + for name, container_config in container['name'].items(): # Container image is a mandatory option if 'image' not in container_config: @@ -121,7 +130,7 @@ def verify(container): # Check if requested container image exists locally. If it does not # exist locally - inform the user. This is required as there is a - # shared container image storage accross all VyOS images. A user can + # shared container image storage across all VyOS images. A user can # delete a container image from the system, boot into another version # of VyOS and then it would fail to boot. This is to prevent any # configuration error when container images are deleted from the @@ -152,6 +161,13 @@ def verify(container): if 'name_server' in container_config and 'no_name_server' not in container['network'][network_name]: raise ConfigError(f'Setting name server has no effect when attached container network has DNS enabled!') + mac = dict_search(f'network.{network_name}.mac', container_config) + if mac: + if mac in net_dict['mac'].keys(): + raise ConfigError(f'MAC address "{mac}" is already used by container "{net_dict["mac"][mac]}"!') + if mac != 'auto': + net_dict['mac'][mac] = name + if 'address' in container_config['network'][network_name]: cnt_ipv4 = 0 cnt_ipv6 = 0 @@ -161,13 +177,13 @@ def verify(container): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] cnt_ipv4 += 1 - except: + except Exception: raise ConfigError(f'Network "{network_name}" does not contain an IPv4 prefix!') elif is_ipv6(address): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] cnt_ipv6 += 1 - except: + except Exception: raise ConfigError(f'Network "{network_name}" does not contain an IPv6 prefix!') # Specified container IP address must belong to network prefix @@ -179,6 +195,10 @@ def verify(container): raise ConfigError(f'IP address "{address}" can not be used for a container, ' \ 'reserved for the container engine!') + if address in net_dict['address'].keys(): + raise ConfigError(f'IP address "{address}" is already used by container "{net_dict["address"][address]}"!') + net_dict['address'][address] = name + 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)!') @@ -260,22 +280,59 @@ def verify(container): # Add new network if 'network' in container: for network, network_config in container['network'].items(): - v4_prefix = 0 - v6_prefix = 0 + net_dict = {'ipv4_pfx_len': 0, 'ipv6_pfx_len': 0, 'ipv4_gateway_len': 0, 'ipv6_gateway_len': 0} + # If ipv4-prefix not defined for user-defined network if 'prefix' not in network_config: raise ConfigError(f'prefix for network "{network}" must be defined!') for prefix in network_config['prefix']: if is_ipv4(prefix): - v4_prefix += 1 + net_dict['ipv4_pfx_len'] += 1 + net_dict['ipv4_prefix'] = prefix elif is_ipv6(prefix): - v6_prefix += 1 - - if v4_prefix > 1: + net_dict['ipv6_pfx_len'] += 1 + net_dict['ipv6_prefix'] = prefix + + for gateway in network_config.get('gateway', []): + if is_ipv4(gateway): + net_dict['ipv4_gateway_len'] += 1 + net_dict['ipv4_gateway'] = gateway + elif is_ipv6(gateway): + net_dict['ipv6_gateway_len'] += 1 + net_dict['ipv6_gateway'] = gateway + + if net_dict['ipv4_pfx_len'] > 1: raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!') - if v6_prefix > 1: + if net_dict['ipv6_pfx_len'] > 1: raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!') + if net_dict['ipv4_gateway_len'] > 1: + raise ConfigError(f'Only one IPv4 gateway can be defined for network "{network}"!') + if net_dict['ipv6_gateway_len'] > 1: + raise ConfigError(f'Only one IPv6 gateway can be defined for network "{network}"!') + + if net_dict.get('ipv4_prefix') and net_dict.get('ipv4_gateway'): + if ip_address(net_dict['ipv4_gateway']) not in ip_network(net_dict['ipv4_prefix']): + raise ConfigError(f'IPv4 gateway "{net_dict["ipv4_gateway"]}" is not in the IPv4 prefix "{net_dict["ipv4_prefix"]}"!') + if net_dict.get('ipv6_prefix') and net_dict.get('ipv6_gateway'): + if ip_address(net_dict['ipv6_gateway']) not in ip_network(net_dict['ipv6_prefix']): + raise ConfigError(f'IPv6 gateway "{net_dict["ipv6_gateway"]}" is not in the IPv6 prefix "{net_dict["ipv6_prefix"]}"!') + if net_dict.get('ipv4_gateway') and not net_dict.get('ipv4_prefix'): + raise ConfigError(f'IPv4 gateway configured but no IPv4 prefix defined for network "{network}"!') + if net_dict.get('ipv6_gateway') and not net_dict.get('ipv6_prefix'): + raise ConfigError(f'IPv6 gateway configured but no IPv6 prefix defined for network "{network}"!') + + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + parent = dict_search('macvlan.parent', type_config) + if not parent: + raise ConfigError(f'MACVLAN networks must have a parent interface!') + if not interface_exists(parent): + raise ConfigError(f'MACVLAN parent interface "{parent}" does not exist!') + if not dict_search('macvlan.mode', type_config): + raise ConfigError(f'MACVLAN networks must have a mode configured!') + if dict_search('vrf', network_config): + raise ConfigError(f'MACVLAN networks do not support direct VRF assignment!') # Verify VRF exists verify_vrf(network_config) @@ -304,18 +361,19 @@ def verify(container): return None -def generate_run_arguments(name, container_config): +def generate_run_arguments(name, container_config, host_ident): image = container_config['image'] cpu_quota = container_config['cpu_quota'] memory = container_config['memory'] shared_memory = container_config['shared_memory'] restart = container_config['restart'] + log_driver = container_config['log_driver'] # Add sysctl options sysctl_opt = '' if 'sysctl' in container_config and 'parameter' in container_config['sysctl']: for k, v in container_config['sysctl']['parameter'].items(): - sysctl_opt += f" --sysctl {k}={v['value']}" + sysctl_opt += f" --sysctl \"{k}={v['value']}\"" # Add capability options. Should be in uppercase capabilities = '' @@ -324,6 +382,11 @@ def generate_run_arguments(name, container_config): cap = cap.upper().replace('-', '_') capabilities += f' --cap-add={cap}' + # Grant root capabilities to the container + privileged = '' + if 'privileged' in container_config: + privileged = '--privileged' + # Add a host device to the container /dev/x:/dev/x device = '' if 'device' in container_config: @@ -397,13 +460,17 @@ def generate_run_arguments(name, container_config): if 'allow_host_pid' in container_config: host_pid = '--pid host' - name_server = '' + name_server = [] if 'name_server' in container_config: for ns in container_config['name_server']: - name_server += f'--dns {ns}' + name_server.append(f'--dns {ns}') + if name_server: + name_server = ' '.join(name_server) + else: + name_server = '' - container_base_cmd = f'--detach --interactive --tty --replace {capabilities} --cpus {cpu_quota} {sysctl_opt} ' \ - f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ + container_base_cmd = f'--detach --interactive --tty --replace {capabilities} {privileged} --cpus {cpu_quota} {sysctl_opt} ' \ + f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} --log-driver={log_driver} ' \ f'--name {name} {hostname} {device} {port} {name_server} {volume} {tmpfs} {env_opt} {label} {uid} {host_pid}' entrypoint = '' @@ -412,6 +479,24 @@ def generate_run_arguments(name, container_config): entrypoint = json_write(container_config['entrypoint'].split()).replace('"', """) entrypoint = f'--entrypoint '{entrypoint}'' + healthcheck = ' --no-healthcheck' + if 'health_check' in container_config: + healthcheck = '' + if 'command' in container_config['health_check']: + health_cmd = container_config['health_check']['command'] + healthcheck += f' --health-cmd="{health_cmd}"' + if 'interval' in container_config['health_check']: + health_int = container_config['health_check']['interval'] + if health_int != 'disable': + health_int = f'{health_int}s' + healthcheck += f' --health-interval={health_int}' + if 'timeout' in container_config['health_check']: + health_to = container_config['health_check']['timeout'] + healthcheck += f' --health-timeout={health_to}s' + if 'retry' in container_config['health_check']: + health_rt = container_config['health_check']['retry'] + healthcheck += f' --health-retries={health_rt}' + command = '' if 'command' in container_config: command = container_config['command'].strip() @@ -420,21 +505,50 @@ def generate_run_arguments(name, container_config): if 'arguments' in container_config: command_arguments = container_config['arguments'].strip() + net = '' if 'allow_host_networks' in container_config: - 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' 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}' + net = '--net host' + else: + ip_param = '' + addr_info = '' + networks = ",".join(container_config['network']) + for network in container_config['network']: + network_name = network + 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}' + + addr_info = ''.join(container_config['network'][network]['address']) - return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() + get_mac = dict_search(f'network.{network_name}.mac', container_config) + if get_mac == 'auto' or get_mac is None: + mac_add = gen_mac(name, addr_info, host_ident) + else: + mac_add = get_mac + + mac_address = f'--mac-address {mac_add}' + + # Replace mac-auto with the generated mac address + if get_mac == 'auto': + mac_config_path = [ + 'container', + 'name', + name, + 'network', + network_name, + 'mac', + ] + + delete_cli_node(mac_config_path) + add_cli_node(mac_config_path, value=mac_add) + + net = f'--net {networks} {ip_param} {mac_address}' + + return f'{container_base_cmd} {healthcheck} {net} {entrypoint} {image} {command} {command_arguments}'.strip() def generate(container): @@ -447,11 +561,22 @@ def generate(container): if 'network' in container: for network, network_config in container['network'].items(): + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + net_interface = dict_search('macvlan.parent', type_config) + driver = 'macvlan' + mode = dict_search('macvlan.mode', type_config) + elif dict_search('bridge', type_config) is not None: + net_interface = f'pod-{network}' + driver = 'bridge' + else: + net_interface = f'pod-{network}' + driver = 'bridge' tmp = { 'name': network, 'id': sha256(f'{network}'.encode()).hexdigest(), - 'driver': 'bridge', - 'network_interface': f'pod-{network}', + 'driver': driver, + 'network_interface': net_interface, 'subnets': [], 'ipv6_enabled': False, 'internal': False, @@ -460,6 +585,7 @@ def generate(container): 'driver': 'host-local' }, 'options': { + **({'mode': mode} if driver == 'macvlan' else {}), 'mtu': '1500' } } @@ -471,11 +597,26 @@ def generate(container): tmp['options']['mtu'] = network_config['mtu'] for prefix in network_config['prefix']: - net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)} - tmp['subnets'].append(net) + gateway4, gateway6 = None, None + if dict_search('gateway', network_config): + for gw in network_config['gateway']: + if is_ipv6(gw): + gateway6 = gw + else: + gateway4 = gw + + if is_ipv6(prefix) and not gateway6: + gateway6 = inc_ip(prefix, 1) + elif not gateway4: + gateway4 = inc_ip(prefix, 1) if is_ipv6(prefix): tmp['ipv6_enabled'] = True + net = {'subnet': prefix, 'gateway': gateway6} + else: + net = {'subnet': prefix, 'gateway': gateway4} + + tmp['subnets'].append(net) write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2)) @@ -484,12 +625,13 @@ def generate(container): render(config_storage, 'container/storage.conf.j2', container) if 'name' in container: + host_ident = get_host_identity() for name, container_config in container['name'].items(): if 'disable' in container_config: continue file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') - run_args = generate_run_arguments(name, container_config) + run_args = generate_run_arguments(name, container_config, host_ident) render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, }, formater=lambda _: _.replace(""", '"').replace("'", "'")) @@ -521,7 +663,7 @@ def apply(container): if run(f'podman image exists {image}') != 0: # container image does not exist locally - user already got - # informed by a WARNING in verfiy() - bail out early + # informed by a WARNING in verify() - bail out early continue if 'disable' in container_config: @@ -541,21 +683,8 @@ def apply(container): 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 interface_exists(network_name): - tmp = Interface(network_name) - tmp.set_vrf(network_config.get('vrf', '')) - tmp.add_ipv6_eui64_address('fe80::/64') - + # Re-Start network and assign it to given VRF if requested. + restart_network(container) return None diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index cebe57092..4a2706a03 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,19 +17,23 @@ import os import re +from glob import glob + from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import is_node_changed -from vyos.configdiff import get_config_diff, Diff +from vyos.configdiff import Diff, get_config_diff from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_interface_exists from vyos.ethtool import Ethtool from vyos.firewall import fqdn_config_parse -from vyos.firewall import geoip_update +from vyos.geoip import geoip_refresh, geoip_update from vyos.template import render +from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive +from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import rc_cmd @@ -37,7 +41,6 @@ from vyos.utils.network import get_vrf_members from vyos.utils.network import get_interface_vrf from vyos import ConfigError from vyos import airbag -from pathlib import Path from subprocess import run as subp_run airbag.enable() @@ -77,42 +80,24 @@ snmp_event_source = 1 snmp_trap_mib = 'VYATTA-TRAP-MIB' snmp_trap_name = 'mgmtEventTrap' -def geoip_updated(conf, firewall): - diff = get_config_diff(conf) - node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True) - - out = { - 'name': [], - 'ipv6_name': [], - 'deleted_name': [], - 'deleted_ipv6_name': [] - } - updated = False +def geoip_sets(firewall): + out = {'name': [], 'ipv6_name': []} - for key, path in dict_search_recursive(firewall, 'geoip'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + for _, path in dict_search_recursive(firewall, 'geoip'): if (path[0] == 'ipv4'): - out['name'].append(set_name) + out['name'].append(f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}') elif (path[0] == 'ipv6'): - set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' - out['ipv6_name'].append(set_name) - - updated = True + out['ipv6_name'].append(f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}') - if 'delete' in node_diff: - for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): - 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'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' - out['deleted_ipv6_name'].append(set_name) - updated = True + return out - if updated: - return out - - return False +def geoip_updated(conf): + D = get_config_diff(conf, key_mangling=('-', '_')) + diff = D.get_child_nodes_diff(['firewall'], + expand_nodes=Diff.ADD | Diff.DELETE, + recursive=True) + return any(any(dict_search_recursive(diff.get(section, {}), 'geoip')) + for section in ('add', 'delete')) def get_config(config=None): if config: @@ -132,7 +117,11 @@ def get_config(config=None): # Update nat and policy-route as firewall groups were updated set_dependents('group_resync', conf) - firewall['geoip_updated'] = geoip_updated(conf, firewall) + firewall['geoip_sets'] = geoip_sets(firewall) + firewall['geoip_updated'] = geoip_updated(conf) + firewall['policy'] = conf.get_config_dict( + ['policy'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) fqdn_config_parse(firewall, 'firewall') @@ -143,19 +132,23 @@ def get_config(config=None): for local_zone, local_zone_conf in firewall['zone'].items(): if 'local_zone' not in local_zone_conf: # Get physical interfaces assigned to the zone if vrf is used: - if 'vrf' in local_zone_conf['member']: + local_zone_member = local_zone_conf.get('member', {}) + if 'vrf' in local_zone_member: local_zone_conf['vrf_interfaces'] = {} - for vrf_name in local_zone_conf['member']['vrf']: + for vrf_name in local_zone_member['vrf']: local_zone_conf['vrf_interfaces'][vrf_name] = ','.join(get_vrf_members(vrf_name)) continue local_zone_conf['from_local'] = {} + local_zone_conf['default_local'] = {} for zone, zone_conf in firewall['zone'].items(): - if zone == local_zone or 'from' not in zone_conf: + if zone == local_zone: continue - if local_zone in zone_conf['from']: + if 'from' in zone_conf and local_zone in zone_conf['from']: local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] + elif 'default_firewall' in zone_conf: + local_zone_conf['default_local'][zone] = zone_conf['default_firewall'] set_dependents('conntrack', conf) @@ -194,6 +187,42 @@ def verify_jump_target(firewall, hook, jump_target, family, recursive=False): targets_seen.append(target) +def is_node_empty(rule_conf): + is_empty_list = [] + is_empty_list.append([ + ['add_address_to_group'], + ['connection_status'], + ['destination'], + ['destination', 'group'], + ['destination', 'geoip'], + ['fragment'], + ['gre'], + ['gre', 'flags'], + ['hop_limit'], + ['icmp'], + ['icmpv6'], + ['inbound_interface'], + ['ipsec'], + ['limit'], + ['log_options'], + ['outbound_interface'], + ['set'], + ['source'], + ['source', 'group'], + ['source', 'geoip'], + ['tcp'], + ['tcp', 'flags'], + ['time'], + ['ttl'], + ['vlan'] + ]) + + for node in is_empty_list[0]: + if dict_search_args(rule_conf, *node) == {}: + return True, node + + return False, None + def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -205,7 +234,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'jump' not in rule_conf['action']: raise ConfigError('jump-target defined, but action jump needed and it is not defined') target = rule_conf['jump_target'] - if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. + if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. verify_jump_target(firewall, hook, target, family, recursive=True) else: verify_jump_target(firewall, hook, target, family, recursive=False) @@ -218,6 +247,8 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if not dict_search_args(firewall, 'flowtable', offload_target): raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system') + elif 'offload_target' in rule_conf: + Warning('offload-target is specified but action is not set to "offload"') if rule_conf['action'] != 'synproxy' and 'synproxy' in rule_conf: raise ConfigError('"synproxy" option allowed only for action synproxy') @@ -229,6 +260,24 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if rule_conf.get('protocol', {}) != 'tcp': raise ConfigError('For action "synproxy" the protocol must be set to TCP') + if 'state' in rule_conf: + disable_conntrack = dict_search(f'{family}.{hook}.{priority}.disable_conntrack', firewall) + conntrack_disabled_list = [] + + # Check if conntrack is disabled in the input or output chain + for nft_chain in ['input', 'output']: + if dict_search(f'{family}.{nft_chain}.filter.disable_conntrack', firewall) == {}: + conntrack_disabled_list.append(nft_chain) + + # If conntrack is disabled in the input or output chain, + # state cannot be matched in the input or output chain + if hook in ['input', 'output'] and conntrack_disabled_list: + raise ConfigError(f'state cannot be matched in {hook} when conntrack is disabled in input or output chains') + # If conntrack is disabled in the forward chain, + # state cannot be matched in the forward chain + if hook == 'forward' and disable_conntrack == {}: + raise ConfigError(f'state cannot be matched in {hook} when conntrack is disabled in {hook} chain') + 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') @@ -242,6 +291,11 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') + node_empty, node_name = is_node_empty(rule_conf) + if node_empty: + tmp = ' '.join(node_name).replace('_', '-') + raise ConfigError(f'Configuration node {tmp} may not be empty') + if 'limit' in rule_conf: if 'rate' in rule_conf['limit']: rate_int = re.sub(r'\D', '', rule_conf['limit']['rate']) @@ -268,12 +322,12 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if dict_search_args(rule_conf, 'gre', 'flags', 'checksum') is None: # There is no builtin match in nftables for the GRE key, so we need to do a raw lookup. - # The offset of the key within the packet shifts depending on the C-flag. - # 99% of the time, nobody will have checksums enabled - it's usually a manual config option. - # We can either assume it is unset unless otherwise directed + # The offset of the key within the packet shifts depending on the C-flag. + # 99% of the time, nobody will have checksums enabled - it's usually a manual config option. + # We can either assume it is unset unless otherwise directed # (confusing, requires doco to explain why it doesn't work sometimes) - # or, demand an explicit selection to be made for this specific match rule. - # This check enforces the latter. The user is free to create rules for both cases. + # or, demand an explicit selection to be made for this specific match rule. + # This check enforces the latter. The user is free to create rules for both cases. raise ConfigError('Matching GRE tunnel key requires an explicit checksum flag match. For most cases, use "gre flags checksum unset"') if dict_search_args(rule_conf, 'gre', 'flags', 'key', 'unset') is not None: @@ -286,7 +340,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if gre_inner_value < 0 or gre_inner_value > 65535: raise ConfigError('inner-proto outside valid ethertype range 0-65535') except ValueError: - pass # Symbolic constant, pre-validated before reaching here. + pass # Symbolic constant, pre-validated before reaching here. tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: @@ -437,6 +491,35 @@ def verify(firewall): for ifname in interfaces: verify_hardware_offload(ifname) + if dict_search_args(firewall, 'global_options', 'geoip', 'provider') == 'maxmind': + geoip_options = dict_search_args(firewall, 'global_options', 'geoip') + required_keys = ['maxmind_account_id', 'maxmind_license_key'] + if not all(key in geoip_options for key in required_keys): + raise ConfigError('MaxMind GeoIP provider requires maxmind-account-id and maxmind-license-key') + + if dict_search('global_options.state_policy', firewall) is not None: + # Generate list of chains where conntrack is disabled + conntrack_disabled_list = [] + for inet_family in ['ipv4', 'ipv6']: + for nft_chain in ['input', 'forward', 'output']: + if dict_search(f'{inet_family}.{nft_chain}.filter.disable_conntrack', firewall) == {}: + conntrack_disabled_list.append(f'{inet_family}-{nft_chain}') + + # If conntrack is disabled in any chain, + # print a warning message + if conntrack_disabled_list: + Warning(f'global-state: conntrack is disabled in the following chains: {", ".join(conntrack_disabled_list)}') + + if 'offload' in firewall.get('global_options', {}).get('state_policy', {}): + offload_path = firewall['global_options']['state_policy']['offload'] + if 'offload_target' not in offload_path: + raise ConfigError('offload-target must be specified') + + offload_target = offload_path['offload_target'] + + if not dict_search_args(firewall, 'flowtable', offload_target): + raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system') + if 'group' in firewall: for group_type in nested_group_types: if group_type in firewall['group']: @@ -449,6 +532,9 @@ def verify(firewall): if 'url' not in group: raise ConfigError(f'remote-group {group_name} must have a url configured') + offload_chains_v4 = set() + offload_chains_v6 = set() + for family in ['ipv4', 'ipv6', 'bridge']: if family in firewall: for chain in ['name','forward','input','output', 'prerouting']: @@ -468,6 +554,12 @@ def verify(firewall): for rule_id, rule_conf in priority_conf['rule'].items(): verify_rule(firewall, family, chain, priority, rule_id, rule_conf) + if chain == 'name' and rule_conf['action'] == 'offload': + if family == 'ipv4': + offload_chains_v4.add(priority) + elif family == 'ipv6': + offload_chains_v6.add(priority) + local_zone = False zone_interfaces = [] zone_vrf = [] @@ -541,6 +633,27 @@ def verify(firewall): 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 'local_zone' in zone_conf or 'local_zone' in firewall['zone'][from_zone]: + if (v4_name and v4_name in offload_chains_v4) or \ + (v6_name and v6_name in offload_chains_v6): + raise ConfigError('Cannot use a firewall chain with offloading on local zone') + + if 'default_firewall' in zone_conf: + v4_name = dict_search_args(zone_conf, 'default_firewall', 'name') + if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(zone_conf, 'default_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 default-firewall') + + if (v4_name and v4_name in offload_chains_v4) or \ + (v6_name and v6_name in offload_chains_v6): + raise ConfigError('Cannot use a chain with offloading for zone default-firewall') + return None def generate(firewall): @@ -616,18 +729,18 @@ def apply(firewall): domain_action = 'restart' if dict_search_args(firewall, 'group', 'remote_group') or dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items(): text = f'# Automatically generated by firewall.py\nThis file indicates that vyos-domain-resolver service is used by the firewall.\n' - Path(domain_resolver_usage).write_text(text) + write_file(domain_resolver_usage, text) else: - Path(domain_resolver_usage).unlink(missing_ok=True) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if os.path.exists(domain_resolver_usage): + os.unlink(domain_resolver_usage) + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') - if firewall['geoip_updated']: - # Call helper script to Update set contents - if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: + if firewall['geoip_sets']['name'] or firewall['geoip_sets']['ipv6_name']: + if firewall['geoip_updated'] or not geoip_refresh(): print('Updating GeoIP. Please wait...') - geoip_update(firewall) + geoip_update(firewall=firewall, policy=firewall['policy']) return None diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index c726db8b2..175929547 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-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -25,7 +25,7 @@ from ipaddress import IPv6Interface from vyos.base import Warning from vyos.config import Config -from vyos.configdict import leaf_node_changed +from vyos.configdict import node_changed from vyos.ifconfig.vrrp import VRRP from vyos.template import render from vyos.template import is_ipv4 @@ -59,7 +59,7 @@ def get_config(config=None): if conf.exists(conntrack_path): ha['conntrack_sync_group'] = conf.return_value(conntrack_path) - if leaf_node_changed(conf, base + ['vrrp', 'snmp']): + if node_changed(conf, base + ['vrrp', 'snmp']): ha.update({'restart_required': {}}) return ha @@ -188,6 +188,15 @@ def _validate_health_check(group, group_config): # to avoid generating useless config statements in keepalived.conf del group_config["health_check"] + if 'timeout' in group_config.get('health_check', {}): + interval = int(group_config['health_check']['interval']) + timeout = int(group_config['health_check']['timeout']) + if timeout < interval: + Warning( + f'Health check timeout ({timeout}s) is less than interval ({interval}s) ' + f'for VRRP group "{group}", script may be killed before completion' + ) + def generate(ha): if not ha or 'disable' in ha: diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py index 84316c16e..55581d6ad 100755 --- a/src/conf_mode/interfaces_bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -30,11 +30,11 @@ from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf +from vyos.ethtool import Ethtool from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.ifconfig import BondIf from vyos.ifconfig.ethernet import EthernetIf -from vyos.ifconfig import Section from vyos.utils.assertion import assert_mac from vyos.utils.dict import dict_search from vyos.utils.dict import dict_to_paths_values @@ -44,6 +44,7 @@ from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured from vyos.configdep import set_dependents from vyos.configdep import call_dependents +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag airbag.enable() @@ -68,7 +69,7 @@ def get_bond_mode(mode): def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -78,7 +79,7 @@ def get_config(config=None): base = ['interfaces', 'bonding'] ifname, bond = get_interface_dict(conf, base, with_pki=True) - # To make our own life easier transfor the list of member interfaces + # To make our own life easier transform the list of member interfaces # into a dictionary - we will use this to add additional information # later on for each member if 'member' in bond and 'interface' in bond['member']: @@ -104,7 +105,6 @@ def get_config(config=None): conf.set_level(['interfaces']) if interfaces_removed: - bond['shutdown_required'] = {} if 'member' not in bond: bond['member'] = {} @@ -114,8 +114,7 @@ def get_config(config=None): # ethernet commit again in apply function # to apply options under ethernet section set_dependents('ethernet', conf, interface) - section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists([section, interface, 'disable']): + if conf.exists(['ethernet', interface, 'disable']): tmp[interface] = {'disable': ''} else: tmp[interface] = {} @@ -141,17 +140,10 @@ def get_config(config=None): # Check if member interface is a new member if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]): - bond['shutdown_required'] = {} bond['member']['interface'][interface].update({'new_added' : {}}) - # Check if member interface is disabled - conf.set_level(['interfaces']) - - section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists([section, interface, 'disable']): - if tmp: bond['member']['interface'][interface].update({'disable': ''}) - - conf.set_level(old_level) + if 'disable' in interface_ethernet_config: + bond['member']['interface'][interface].update({'disable': ''}) # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') @@ -175,6 +167,12 @@ def get_config(config=None): tmp = has_vrf_configured(conf, interface) if tmp: bond['member']['interface'][interface].update({'has_vrf' : ''}) + # Protocols static arp dependency + if 'static_arp' in bond: + set_dependents('static_arp', conf) + + bond['vpp_ifaces'] = cli_ifaces_list(conf) + return bond @@ -210,7 +208,7 @@ def verify(bond): bond_name = bond['ifname'] if dict_search('member.interface', bond): for interface, interface_config in bond['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bond, ' + error_msg = f'Cannot add interface "{interface}" to bond, ' if interface == 'lo': raise ConfigError('Loopback interface "lo" can not be added to a bond') @@ -244,6 +242,27 @@ def verify(bond): continue raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!') + iface_base = interface.split('.')[0] # get the parent interface name + if iface_base in bond['vpp_ifaces']: + raise ConfigError( + error_msg + 'it is already configured as VPP interface' + ) + + if mtu := bond.get('mtu'): + mtu = int(mtu) + max_mtu = int(EthernetIf(interface).get_max_mtu()) + min_mtu = int(EthernetIf(interface).get_min_mtu()) + if mtu > max_mtu: + raise ConfigError('Configured MTU is greater then member '\ + f'interface "{interface}" maximum of {max_mtu}!') + if mtu < min_mtu: + raise ConfigError('Configured MTU is less then member '\ + f'interface "{interface}" minimum of {min_mtu}!') + + # not all ethernet drivers support interface bonding + if not Ethtool(interface).check_bonding(): + raise ConfigError(error_msg + 'driver is not supported!') + if 'primary' in bond: if bond['primary'] not in bond['member']['interface']: raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') @@ -279,7 +298,7 @@ def apply(bond): else: b.update(bond) - if dict_search('member.interface_remove', bond): + if dict_search('member.interface_remove', bond) or 'static_arp' in bond: try: call_dependents() except ConfigError: diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index aff93af2a..206d89d84 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -25,6 +25,7 @@ from vyos.configdict import has_vlan_subinterface_configured from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import BridgeIf from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured @@ -32,6 +33,7 @@ from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag @@ -39,7 +41,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -110,6 +112,11 @@ def get_config(config=None): elif interface.startswith('wlan') and interface_exists(interface): set_dependents('wlan', conf, interface) + if interface.startswith('vtun'): + _, tmp_config = get_interface_dict(conf, ['interfaces', 'openvpn'], interface) + tmp = tmp_config.get('device_type') == 'tap' + bridge['member']['interface'][interface].update({'valid_ovpn' : tmp}) + # delete empty dictionary keys - no need to run code paths if nothing is there to do if 'member' in bridge: if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: @@ -118,6 +125,12 @@ def get_config(config=None): if len(bridge['member']) == 0: del bridge['member'] + # Protocols static arp dependency + if 'static_arp' in bridge: + set_dependents('static_arp', conf) + + bridge['vpp_ifaces'] = cli_ifaces_list(conf) + return bridge def verify(bridge): @@ -136,13 +149,14 @@ def verify(bridge): verify_dhcpv6(bridge) verify_vrf(bridge) + verify_mtu_ipv6(bridge) verify_mirror_redirect(bridge) ifname = bridge['ifname'] if dict_search('member.interface', bridge): for interface, interface_config in bridge['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bridge, ' + error_msg = f'Cannot add interface "{interface}" to bridge, ' if interface == 'lo': raise ConfigError('Loopback interface "lo" can not be added to a bridge') @@ -165,6 +179,9 @@ def verify(bridge): if 'has_vrf' in interface_config: raise ConfigError(error_msg + 'it has a VRF assigned!') + if 'bpdu_guard' in interface_config and 'root_guard' in interface_config: + raise ConfigError(error_msg + 'bpdu-guard and root-guard cannot be configured at the same time!') + if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') @@ -173,6 +190,15 @@ def verify(bridge): if option in interface_config: raise ConfigError('Can not use VLAN options on non VLAN aware bridge') + if interface.startswith('vtun') and not interface_config['valid_ovpn']: + raise ConfigError(error_msg + 'OpenVPN device-type must be set to "tap"') + + iface_base = interface.split('.')[0] # get the parent interface name + if iface_base in bridge['vpp_ifaces']: + raise ConfigError( + error_msg + 'it is already configured as VPP interface' + ) + if 'enable_vlan' in bridge: if dict_search('vif.1', bridge): raise ConfigError(f'VLAN 1 sub interface cannot be set for VLAN aware bridge {ifname}, and VLAN 1 is always the parent interface') @@ -200,12 +226,20 @@ def apply(bridge): if 'interface' in bridge['member']: tmp.extend(bridge['member']['interface']) - for interface in tmp: - if interface.startswith(tuple(['vxlan', 'wlan'])) and interface_exists(interface): - try: - call_dependents() - except ConfigError: - raise ConfigError(f'Error updating member interface {interface} configuration after changing bridge!') + # collect member interfaces that require dependent updates + interfaces_need_update = [ + iface + for iface in tmp + if iface.startswith(('vxlan', 'wlan')) and interface_exists(iface) + ] + + if interfaces_need_update or 'static_arp' in bridge: + try: + call_dependents() + except ConfigError: + raise ConfigError( + 'Error updating member interface configuration after changing bridge!' + ) return None diff --git a/src/conf_mode/interfaces_dummy.py b/src/conf_mode/interfaces_dummy.py index db768b94d..c35511199 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-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -29,7 +29,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py index 41c89fdf8..10b778eea 100755 --- a/src/conf_mode/interfaces_ethernet.py +++ b/src/conf_mode/interfaces_ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,8 +20,11 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdict import get_flowtable_interfaces from vyos.configverify import verify_address from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_interface_exists @@ -33,6 +36,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_bond_bridge_member from vyos.configverify import verify_eapol from vyos.ethtool import Ethtool +from vyos.netlink import coalesce from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.ifconfig import EthernetIf @@ -42,6 +46,8 @@ from vyos.utils.dict import dict_to_paths_values from vyos.utils.dict import dict_set from vyos.utils.dict import dict_delete from vyos.utils.process import is_systemd_service_running +from vyos.vpp.config_verify import verify_vpp_remove_interface +from vyos.vpp.control_vpp import VPPControl from vyos import ConfigError from vyos import airbag airbag.enable() @@ -132,7 +138,7 @@ def update_bond_options(conf: Config, eth_conf: dict) -> list: def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -153,7 +159,7 @@ def get_config(config=None): max_mtu = EthernetIf(ifname).get_max_mtu() if max_mtu < int(ethernet['mtu']): ethernet['mtu'] = str(max_mtu) - except: + except Exception: pass if 'is_bond_member' in ethernet: @@ -168,6 +174,27 @@ def get_config(config=None): tmp = is_node_changed(conf, base + [ifname, 'evpn']) if tmp: ethernet.update({'frr_dict' : get_frrender_dict(conf)}) + ethernet['flowtable_interfaces'] = get_flowtable_interfaces(conf) + + vpp_config = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + if vpp_config: + ethernet['vpp'] = vpp_config + ethernet['vpp']['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Protocols static arp dependency + if 'static_arp' in ethernet: + set_dependents('static_arp', conf) + return ethernet def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): @@ -181,11 +208,11 @@ def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): raise ConfigError( - 'Speed/Duplex missmatch. Must be both auto or manually configured') + 'Speed/Duplex mismatch. Must be both auto or manually configured') if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': # We need to verify if the requested speed and duplex setting is - # supported by the underlaying NIC. + # supported by the underlying NIC. speed = ethernet['speed'] duplex = ethernet['duplex'] if not ethtool.check_speed_duplex(speed, duplex): @@ -238,6 +265,26 @@ def verify_ring_buffer(ethernet: dict, ethtool: Ethtool): f'size of "{max_tx}" bytes!') +def verify_coalesce(ethernet: dict, ethtool: Ethtool): + """ + Verify coalesce settings + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'interrupt_coalescing' in ethernet: + if not ethtool.check_coalesce(): + raise ConfigError('Driver does not fully support coalesce configuration!') + + for param in coalesce.get_all_params(): + if param in ethernet['interrupt_coalescing']: + if not ethtool.check_coalesce(param): + param_name = param.replace('_', '-') + msg = f'Driver does not support "{param_name}" coalesce setting!' + raise ConfigError(msg) + + def verify_offload(ethernet: dict, ethtool: Ethtool): """ Verify offloading capabilities @@ -248,7 +295,7 @@ def verify_offload(ethernet: dict, ethtool: Ethtool): """ if dict_search('offload.rps', ethernet) != None: if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'): - raise ConfigError('Interface does not suport RPS!') + raise ConfigError('Interface does not support RPS!') driver = ethtool.get_driver_name() # T3342 - Xen driver requires special treatment if driver == 'vif': @@ -256,6 +303,20 @@ def verify_offload(ethernet: dict, ethtool: Ethtool): raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ 'for MTU size larger then 1500 bytes') +def verify_mac_change(ethernet: dict, ethtool: Ethtool): + """ + Verify if ethernet card driver supports changing the interface MAC address. + AWS ENA driver has no support for MAC address changes. + + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'mac' not in ethernet: + return None + if not ethtool.check_mac_change(): + raise ConfigError(f'Driver does not support changing MAC address!') def verify_allowedbond_changes(ethernet: dict): """ @@ -269,54 +330,99 @@ def verify_allowedbond_changes(ethernet: dict): f' on interface "{ethernet["ifname"]}".' \ f' Interface is a bond member') +def verify_flowtable(ethernet: dict): + ifname = ethernet['ifname'] + + if 'deleted' in ethernet and ifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{ifname}", still referenced on a flowtable') + + if 'vif_remove' in ethernet: + for vif in ethernet['vif_remove']: + vifname = f'{ifname}.{vif}' + + if vifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifname}", still referenced on a flowtable') + + if 'vif_s_remove' in ethernet: + for vifs in ethernet['vif_s_remove']: + vifsname = f'{ifname}.{vifs}' + + if vifsname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifsname}", still referenced on a flowtable') + + if 'vif_s' in ethernet: + for vifs, vifs_conf in ethernet['vif_s'].items(): + if 'vif_c_delete' in vifs_conf: + for vifc in vifs_conf['vif_c_delete']: + vifcname = f'{ifname}.{vifs}.{vifc}' + + if vifcname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifcname}", still referenced on a flowtable') + +def verify_vpp_remove_vif(ethernet: dict): + """Ensure that VIF interfaces being removed are not used by VPP features""" + ifname = ethernet['ifname'] + vpp_config = ethernet.get('vpp') + + if not vpp_config: + return + + vlan_names = [ + f'{ifname}.{vif_id}' + for vif_group in ['vif_remove', 'vif_s_remove'] + for vif_id in ethernet.get(vif_group, []) + ] + + for vlan in vlan_names: + verify_vpp_remove_interface(vlan, vpp_config) + def verify(ethernet): + verify_flowtable(ethernet) + verify_vpp_remove_vif(ethernet) + if 'deleted' in ethernet: return None - if 'is_bond_member' in ethernet: - verify_bond_member(ethernet) - else: - verify_ethernet(ethernet) - -def verify_bond_member(ethernet): - """ - Verification function for ethernet interface which is in bonding - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - """ ifname = ethernet['ifname'] - verify_interface_exists(ethernet, ifname) + verify_interface_exists(ethernet, ifname, state_required=True) verify_eapol(ethernet) verify_mirror_redirect(ethernet) + # No need to check speed and duplex keys as both have default values ethtool = Ethtool(ifname) verify_speed_duplex(ethernet, ethtool) verify_flow_control(ethernet, ethtool) verify_ring_buffer(ethernet, ethtool) verify_offload(ethernet, ethtool) + verify_mac_change(ethernet, ethtool) + verify_coalesce(ethernet, ethtool) + + if 'is_bond_member' in ethernet: + verify_bond_member(ethernet, ethtool) + else: + verify_ethernet(ethernet, ethtool) + + +def verify_bond_member(ethernet: dict, ethtool: Ethtool) -> None: + """ + Verification function for ethernet interface which is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ verify_allowedbond_changes(ethernet) + return None -def verify_ethernet(ethernet): +def verify_ethernet(ethernet: dict, ethtool: Ethtool) -> None: """ Verification function for simple ethernet interface :param ethernet: dictionary which is received from get_interface_dict :type ethernet: dict """ - ifname = ethernet['ifname'] - verify_interface_exists(ethernet, ifname) verify_mtu(ethernet) verify_mtu_ipv6(ethernet) verify_dhcpv6(ethernet) verify_address(ethernet) verify_vrf(ethernet) verify_bond_bridge_member(ethernet) - verify_eapol(ethernet) - verify_mirror_redirect(ethernet) - ethtool = Ethtool(ifname) - # No need to check speed and duplex keys as both have default values. - verify_speed_duplex(ethernet, ethtool) - verify_flow_control(ethernet, ethtool) - verify_ring_buffer(ethernet, ethtool) - verify_offload(ethernet, ethtool) # use common function to verify VLAN configuration verify_vlan_config(ethernet) return None @@ -329,11 +435,44 @@ def generate(ethernet): def apply(ethernet): if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'): FRRender().apply() - e = EthernetIf(ethernet['ifname']) + ifname = ethernet['ifname'] + e = EthernetIf(ifname) if 'deleted' in ethernet: e.remove() else: e.update(ethernet) + if 'static_arp' in ethernet: + call_dependents() + + vpp_iface_config = dict_search(f'vpp.settings.interface.{ifname}', ethernet) + if vpp_iface_config is not None and is_systemd_service_running('vpp.service'): + vpp_api = VPPControl() + + # Enable ip4-dhcp-client-detect feature for DHCP-configured interfaces. + # This feature is required for VPP to process DHCP packets and assign addresses. + if 'dhcp' in ethernet.get('address', []): + vpp_api.enable_dhcp_client(ifname) + else: + vpp_api.disable_dhcp_client(ifname) + + # Enable ip6-icmp-ra-punt feature for DHCPv6-configured interfaces. + if 'dhcpv6' in ethernet.get('address', []) or ( + 'autoconf' in ethernet.get('ipv6', {}).get('address', {}) + ): + vpp_api.enable_icmpv6_ra_punt(ifname) + else: + vpp_api.disable_icmpv6_ra_punt(ifname) + + # If the interface is managed by the VPP DPDK driver, synchronize runtime + # parameters between Linux and the corresponding VPP LCP interface + # Find LCP pair + lcp_pair = vpp_api.lcp_pair_find(vpp_name_hw=ifname) + # Sync MTU to VPP LCP pair interface + if lcp_pair: + lcp_name = lcp_pair.get('vpp_name_kernel') + mtu = e.get_mtu() + vpp_api.set_iface_mtu(lcp_name, mtu) + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 1c5b4d0e7..faaa7b848 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,8 @@ from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_address @@ -34,7 +36,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -51,6 +53,10 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, cli_option]): geneve.update({'rebuild_required': {}}) + # Protocols static arp dependency + if 'static_arp' in geneve: + set_dependents('static_arp', conf) + return geneve def verify(geneve): @@ -90,6 +96,9 @@ def apply(geneve): g = GeneveIf(**geneve) g.update(geneve) + if 'static_arp' in geneve: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_input.py b/src/conf_mode/interfaces_input.py index ad248843d..d41610b6d 100755 --- a/src/conf_mode/interfaces_input.py +++ b/src/conf_mode/interfaces_input.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,7 +26,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index f0a70436e..85438b6c5 100755 --- a/src/conf_mode/interfaces_l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,8 @@ from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import leaf_node_changed from vyos.configverify import verify_address @@ -37,7 +39,7 @@ k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6'] def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -56,6 +58,10 @@ def get_config(config=None): tmp = leaf_node_changed(conf, base + [ifname, 'session-id']) l2tpv3.update({'session_id': tmp[0]}) + # Protocols static arp dependency + if 'static_arp' in l2tpv3: + set_dependents('static_arp', conf) + return l2tpv3 def verify(l2tpv3): @@ -100,6 +106,9 @@ def apply(l2tpv3): l = L2TPv3If(**l2tpv3) l.update(l2tpv3) + if 'static_arp' in l2tpv3: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_loopback.py b/src/conf_mode/interfaces_loopback.py index a784e9ec2..c19ea162e 100755 --- a/src/conf_mode/interfaces_loopback.py +++ b/src/conf_mode/interfaces_loopback.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,7 +26,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_macsec.py b/src/conf_mode/interfaces_macsec.py index 3ede4377a..3c043e11e 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,6 +19,8 @@ import os from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configdict import is_source_interface @@ -53,7 +55,7 @@ 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 + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -71,13 +73,17 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'security']): macsec.update({'shutdown_required': {}}) - if is_node_changed(conf, base + [ifname, 'source_interface']): + if is_node_changed(conf, base + [ifname, 'source-interface']): macsec.update({'shutdown_required': {}}) if 'source_interface' in macsec: tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + # Protocols static arp dependency + if 'static_arp' in macsec: + set_dependents('static_arp', conf) + return macsec @@ -148,11 +154,11 @@ def verify(macsec): if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad - # and 802.1q) - we need to check the underlaying MTU if our configured + # and 802.1q) - we need to check the underlying MTU if our configured # MTU is at least 40 bytes less then the MTU of our physical interface. lower_mtu = Interface(macsec['source_interface']).get_mtu() if lower_mtu < (int(macsec['mtu']) + 40): - raise ConfigError('MACsec overhead does not fit into underlaying device MTU,\n' \ + raise ConfigError('MACsec overhead does not fit into underlying device MTU,\n' \ f'{lower_mtu} bytes is too small!') return None @@ -193,6 +199,9 @@ def apply(macsec): if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: call(f'systemctl reload-or-restart {systemd_service}') + if 'static_arp' in macsec: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index a9b4e570d..d6b63ae2a 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -78,9 +78,31 @@ otp_file = '/config/auth/openvpn/{ifname}-otp-secrets' secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567') service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf' +def _only_client_config_changed(conf, base, ifname): + """ + Return True when the sole diff under this interface is a change to + `server.client` entries (i.e. CCD files). + """ + + iface_path = base + [ifname] + diff = get_config_diff(conf) + + def _has_only_changes(path, node): + changes = diff.node_changed_children(path) + return len(changes) == 1 and changes[0] == node + + # Something outside of 'server' also changed - not a CCD-only change + if _has_only_changes(iface_path, 'server'): + # Something outside of 'server.client' also changed - not a CCD-only change + if _has_only_changes(iface_path + ['server'], 'client'): + return True + + return False + + def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -117,6 +139,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'enable-dco']): openvpn.update({'restart_required': {}}) + # Detect changes that are limited to per-client CCD entries (T6478). + # OpenVPN reads client-config-dir files at connect time, so adding or + # updating them requires neither a SIGHUP nor a service restart. + if 'restart_required' not in openvpn and openvpn['mode'] == 'server': + openvpn['client_only_changed'] = _only_client_config_changed(conf, base, ifname) + # 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) @@ -168,6 +196,12 @@ def is_ec_private_key(pki, cert_name): key = load_private_key(pki_cert['private']['key']) return isinstance(key, ec.EllipticCurvePrivateKey) + +def verify_data_ciphers_fallback(openvpn): + if openvpn['mode'] != 'site-to-site': + if dict_search('encryption.data_ciphers_fallback', openvpn): + raise ConfigError('Cipher fallback is valid only in site-to-site mode') + def verify_pki(openvpn): pki = openvpn['pki'] interface = openvpn['ifname'] @@ -361,6 +395,11 @@ def verify(openvpn): if dict_search('encryption.data_ciphers', openvpn): raise ConfigError('Cipher negotiation can only be used in client or server mode') + if not dict_search('encryption.cipher', openvpn) and \ + not dict_search('encryption.data_ciphers_fallback', openvpn): + raise ConfigError('Must define "encryption cipher" or "encryption ' \ + 'data-ciphers-fallback" for site-to-site encryption!') + else: # checks for client-server or site-to-site bridged if 'local_address' in openvpn or 'remote_address' in openvpn: @@ -615,6 +654,8 @@ def verify(openvpn): verify_bond_bridge_member(openvpn) verify_mirror_redirect(openvpn) + verify_data_ciphers_fallback(openvpn) + return None def generate_pki_files(openvpn): @@ -734,7 +775,7 @@ def generate(openvpn): # create client config directory on demand makedir(ccd_dir, user, group) - # Fix file permissons for keys + # Fix file permissions for keys generate_pki_files(openvpn) # Generate User/Password authentication file @@ -785,7 +826,7 @@ def apply(openvpn): VTunIf(interface).remove() # dynamically load/unload DCO Kernel extension if requested - dco_module = 'ovpn_dco_v2' + dco_module = 'ovpn' if 'module_load_dco' in openvpn: check_kmod(dco_module) else: @@ -805,7 +846,7 @@ def apply(openvpn): # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - if not openvpn.get('no_restart_crl'): + if not openvpn.get('no_restart_crl') and not openvpn.get('client_only_changed'): action = 'reload-or-restart' if 'restart_required' in openvpn: action = 'restart' diff --git a/src/conf_mode/interfaces_pppoe.py b/src/conf_mode/interfaces_pppoe.py index 412676c7d..1fb2b7278 100755 --- a/src/conf_mode/interfaces_pppoe.py +++ b/src/conf_mode/interfaces_pppoe.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -36,7 +36,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -49,9 +49,18 @@ def get_config(config=None): # We should only terminate the PPPoE session if critical parameters change. # All parameters that can be changed on-the-fly (like interface description) # should not lead to a reconnect! - for options in ['access-concentrator', 'connect-on-demand', 'service-name', - 'source-interface', 'vrf', 'no-default-route', - 'authentication', 'host_uniq']: + for options in [ + 'access-concentrator', + 'connect-on-demand', + 'service-name', + 'source-interface', + 'vrf', + 'no-default-route', + 'authentication', + 'host-uniq', + 'dhcpv6-options', + 'ipv6', + ]: 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 446beffd3..6a4219343 100755 --- a/src/conf_mode/interfaces_pseudo-ethernet.py +++ b/src/conf_mode/interfaces_pseudo-ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,8 +17,9 @@ from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents 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 is_node_changed from vyos.configverify import verify_vrf @@ -27,6 +28,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_mtu_parent +from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import MACVLANIf from vyos.utils.network import interface_exists @@ -37,7 +39,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -60,6 +62,10 @@ def get_config(config=None): tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) + # Protocols static arp dependency + if 'static_arp' in peth: + set_dependents('static_arp', conf) + return peth def verify(peth): @@ -71,6 +77,7 @@ def verify(peth): verify_vrf(peth) verify_address(peth) verify_mtu_parent(peth, peth['parent']) + verify_mtu_ipv6(peth) verify_mirror_redirect(peth) # use common function to verify VLAN configuration verify_vlan_config(peth) @@ -93,6 +100,9 @@ def apply(peth): p = MACVLANIf(**peth) p.update(peth) + if 'static_arp' in peth: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_sstpc.py b/src/conf_mode/interfaces_sstpc.py index b9d7a74fb..50d3d1cb3 100755 --- a/src/conf_mode/interfaces_sstpc.py +++ b/src/conf_mode/interfaces_sstpc.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -37,7 +37,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py index ee1436e49..053c831d1 100755 --- a/src/conf_mode/interfaces_tunnel.py +++ b/src/conf_mode/interfaces_tunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -37,7 +37,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py index cb6104f59..00fc9cce9 100755 --- a/src/conf_mode/interfaces_virtual-ethernet.py +++ b/src/conf_mode/interfaces_virtual-ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,17 +19,21 @@ from sys import exit from vyos import ConfigError from vyos import airbag from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VethIf +from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -42,10 +46,14 @@ def get_config(config=None): # We need to know all other veth related interfaces as veth requires a 1:1 # mapping for the peer-names. The Linux kernel automatically creates both # interfaces, the local one and the peer-name, but VyOS also needs a peer - # interfaces configrued on the CLI so we can assign proper IP addresses etc. + # interfaces configured on the CLI so we can assign proper IP addresses etc. veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + # Protocols static arp dependency + if 'static_arp' in veth: + set_dependents('static_arp', conf) + return veth @@ -62,6 +70,7 @@ def verify(veth): return None verify_vrf(veth) + verify_mtu_ipv6(veth) verify_address(veth) if 'peer_name' not in veth: @@ -74,7 +83,7 @@ def verify(veth): raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ 'is not configured!') - if veth['other_interfaces'][peer_name]['peer_name'] != ifname: + if dict_search(f'other_interfaces.{peer_name}.peer_name', veth) != ifname: raise ConfigError( f'Configuration mismatch between "{ifname}" and "{peer_name}"!') @@ -99,6 +108,9 @@ def apply(veth): p = VethIf(**veth) p.update(veth) + if 'static_arp' in veth: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_vti.py b/src/conf_mode/interfaces_vti.py index 20629c6c1..b4652d727 100755 --- a/src/conf_mode/interfaces_vti.py +++ b/src/conf_mode/interfaces_vti.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,6 +20,7 @@ from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VTIIf from vyos import ConfigError from vyos import airbag @@ -27,7 +28,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -40,6 +41,7 @@ def get_config(config=None): def verify(vti): verify_vrf(vti) + verify_mtu_ipv6(vti) verify_mirror_redirect(vti) return None diff --git a/src/conf_mode/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py index 256b65708..819920009 100755 --- a/src/conf_mode/interfaces_vxlan.py +++ b/src/conf_mode/interfaces_vxlan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,8 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import leaf_node_changed from vyos.configdict import is_node_changed @@ -40,7 +42,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -66,7 +68,8 @@ def get_config(config=None): vxlan.update({'vlan_to_vni_removed': {}}) for vlan in tmp: vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) - vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) + if vni: + vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation @@ -82,6 +85,10 @@ def get_config(config=None): if len(vxlan['other_tunnels']) == 0: del vxlan['other_tunnels'] + # Protocols static arp dependency + if 'static_arp' in vxlan: + set_dependents('static_arp', conf) + return vxlan def verify(vxlan): @@ -94,7 +101,7 @@ def verify(vxlan): if 'group' in vxlan: if 'source_interface' not in vxlan: - raise ConfigError('Multicast VXLAN requires an underlaying interface') + raise ConfigError('Multicast VXLAN requires an underlying interface') if 'remote' in vxlan: raise ConfigError('Both group and remote cannot be specified') verify_source_interface(vxlan) @@ -118,7 +125,7 @@ def verify(vxlan): if dict_search('parameters.vni_filter', tunnel_config) != None: other_vni_filter = True break - # eqivalent of the C foo ? 'a' : 'b' statement + # equivalent of the C foo ? 'a' : 'b' statement vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False # If either one is enabled, so must be the other. Both can be off and both can be on if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): @@ -137,7 +144,7 @@ def verify(vxlan): if 'source_interface' in vxlan: # VXLAN adds at least an overhead of 50 byte - we need to check the - # underlaying device if our VXLAN package is not going to be fragmented! + # underlying device if our VXLAN package is not going to be fragmented! vxlan_overhead = 50 if 'source_address' in vxlan and is_ipv6(vxlan['source_address']): # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 @@ -152,8 +159,10 @@ def verify(vxlan): lower_mtu = Interface(vxlan['source_interface']).get_mtu() if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): - raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ - f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') + Warning( + f'Underlying device MTU is too small ({lower_mtu} ' + f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)' + ) # Check for mixed IPv4 and IPv6 addresses protocol = None @@ -248,6 +257,9 @@ def apply(vxlan): v = VXLANIf(**vxlan) v.update(vxlan) + if 'static_arp' in vxlan: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 192937dba..92e3a239d 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,6 +14,9 @@ # 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 glob import glob from sys import exit from vyos.config import Config @@ -31,17 +34,17 @@ from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import WireGuardIf from vyos.utils.kernel import check_kmod from vyos.utils.network import check_port_availability +from vyos.utils.network import get_vrf_tableid from vyos.utils.network import is_wireguard_key_pair from vyos.utils.process import call from vyos import ConfigError from vyos import airbag -from pathlib import Path airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -73,6 +76,16 @@ def get_config(config=None): else: wireguard['is_source_interface'] = tmp + if is_node_changed(conf, base + [ifname, 'fwmark']) or is_node_changed( + conf, base + [ifname, 'vrf'] + ): + wireguard['fwmark_vrf_changed'] = {} + prev = conf.get_config_dict( + base + [ifname], effective=True, key_mangling=('-', '_'), get_first_key=True + ) + wireguard['prev_fwmark'] = prev.get('fwmark') + wireguard['prev_vrf'] = prev.get('vrf') + return wireguard @@ -97,7 +110,7 @@ def verify(wireguard): if 'port' in wireguard and 'port_changed' in wireguard: listen_port = int(wireguard['port']) - if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: + if check_port_availability(None, listen_port, protocol='udp') is not True: raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' 'cannot be used for the interface!') @@ -145,21 +158,36 @@ def generate(wireguard): def apply(wireguard): check_kmod('wireguard') - if 'rebuild_required' in wireguard or 'deleted' in wireguard: - wg = WireGuardIf(**wireguard) - # WireGuard only supports peer removal based on the configured public-key, - # by deleting the entire interface this is the shortcut instead of parsing - # out all peers and removing them one by one. - # - # Peer reconfiguration will always come with a short downtime while the - # WireGuard interface is recreated (see below) - wg.remove() + wg = WireGuardIf(**wireguard) - # Create the new interface if required - if 'deleted' not in wireguard: - wg = WireGuardIf(**wireguard) + if 'deleted' in wireguard: + wg.remove() + else: wg.update(wireguard) + # delete old fwmark-based ip rule if fwmark or VRF was changed + if 'fwmark_vrf_changed' in wireguard or 'deleted' in wireguard: + prev_fwmark = wireguard.get('prev_fwmark') + prev_vrf = wireguard.get('prev_vrf') + if prev_fwmark is not None and prev_vrf is not None: + table_id = get_vrf_tableid(prev_vrf) + if table_id is not None: + for afi in ['-4', '-6']: + call( + f'ip {afi} rule del pref 1998 fwmark {prev_fwmark} table {table_id}' + ) + + # Add ip rule to route fwmark-marked WireGuard tunnel packets into the + # correct VRF routing table. This is required for VRF-bound WireGuard + # interfaces with fwmark set, so that outgoing encapsulated packets use the + # proper VRF routes (otherwise, they may be unroutable or use the main table). + if wireguard.get('fwmark', '0') != '0' and 'vrf' in wireguard: + table_id = get_vrf_tableid(wireguard['vrf']) + for afi in ['-4', '-6']: + call( + f'ip {afi} rule add pref 1998 fwmark {wireguard["fwmark"]} table {table_id}' + ) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] ## DOMAIN RESOLVER @@ -168,12 +196,12 @@ def apply(wireguard): from vyos.utils.file import write_file text = f'# Automatically generated by interfaces_wireguard.py\nThis file indicates that vyos-domain-resolver service is used by the interfaces_wireguard.\n' - text += "intefaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) - Path(domain_resolver_usage).write_text(text) + text += "interfaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) write_file(domain_resolver_usage, text) else: - Path(domain_resolver_usage).unlink(missing_ok=True) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if os.path.exists(domain_resolver_usage): + os.unlink(domain_resolver_usage) + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py index d24675ee6..68aa71474 100755 --- a/src/conf_mode/interfaces_wireless.py +++ b/src/conf_mode/interfaces_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,6 +22,8 @@ from netaddr import EUI, mac_unix_expanded from time import sleep from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import dict_merge from vyos.configverify import verify_address @@ -34,10 +36,14 @@ from vyos.ifconfig import WiFiIf from vyos.template import render from vyos.utils.dict import dict_search from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import is_module_loaded +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import is_systemd_service_active from vyos.utils.process import is_systemd_service_running from vyos.utils.network import interface_exists +from vyos.base import Warning from vyos import ConfigError from vyos import airbag airbag.enable() @@ -48,6 +54,8 @@ 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' +mt7915e_conf = f'/etc/modprobe.d/mt7915e.conf' + country_code_path = ['system', 'wireless', 'country-code'] def find_other_stations(conf, base, ifname): @@ -75,7 +83,7 @@ def find_other_stations(conf, base, ifname): def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -133,6 +141,10 @@ def get_config(config=None): wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi) wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi) + # Protocols static arp dependency + if 'static_arp' in wifi: + set_dependents('static_arp', conf) + return wifi def verify(wifi): @@ -191,7 +203,7 @@ def verify(wifi): elif 'wpa' in wifi['security']: wpa = wifi['security']['wpa'] if not any(i in ['passphrase', 'radius'] for i in wpa): - raise ConfigError('Misssing WPA key or RADIUS server') + raise ConfigError('Missing WPA key or RADIUS server') if 'username' in wpa: if 'passphrase' not in wpa: @@ -226,7 +238,9 @@ def verify(wifi): phy = wifi['physical_device'] if phy in wifi['station_interfaces']: if len(wifi['station_interfaces'][phy]) > 0: - raise ConfigError('Only one station per wireless physical interface possible!') + raise ConfigError( + 'Only one station per wireless physical interface possible!' + ) verify_address(wifi) verify_vrf(wifi) @@ -314,6 +328,54 @@ def apply(wifi): w = WiFiIf(**wifi) w.update(wifi) + # Set up the mt7915e module according to new wifi configuration. + # For this card, the decision is made for the 5GHz/6GHz-capable phy: + # 5GHz uses VHT (802.11ac) op_modes and 6GHz uses HE (802.11ax) + # op_modes. The card does not support WiFi-7 (802.11be). + # There is a race condition in the order the two phys on that card + # are configured. Sometimes, the 5/6GHz phy is configured first and + # the 2.4GHz phy is configured last, which would overwrite the module + # parameter definition. Only the Wi-Fi configuration for the 5/6GHz phy + # must write the file! + # + # Only if the mt7915e module is loaded (card present)... + if is_module_loaded('mt7915e'): + # op_modes as configured in interfaces_wireless.xml.in + five_ghz_op_modes_vht = ['0', '1', '2', '3'] + six_ghz_op_modes_he = ['131', '132', '133', '134', '135'] + # Make sure to act only when VHT or HE modes are used + module_options = '' + if 'capabilities' in wifi: + mt7915e_options_string = 'options mt7915e' + if 'he' in wifi['capabilities']: + if 'channel_set_width' in wifi['capabilities']['he']: + if wifi['capabilities']['he']['channel_set_width'] in six_ghz_op_modes_he: + # 6GHz band required (802.11ax, WiFi-6e) + module_options = f'{mt7915e_options_string} enable_6ghz=1' + if 'vht' in wifi['capabilities']: + if 'channel_set_width' in wifi['capabilities']['vht']: + if wifi['capabilities']['vht']['channel_set_width'] in five_ghz_op_modes_vht: + # 5GHz band required... + module_options = f'{mt7915e_options_string} enable_6ghz=0' + + tmp = None + if os.path.isfile(mt7915e_conf): + tmp = read_file(mt7915e_conf) + + # Write the module config, so that there always is a valid module + # config which is mandatory to load the mt7916 firmware. + write_file(mt7915e_conf, module_options, mode=0o644) + # Issue warning if module options have changed. Warning is necessary + # even if this is the first time this module is configured, + # firmware must be reloaded. + if tmp != module_options: + Warning('Change to firmware 5GHz/6GHz configuration detected. '\ + 'The system must be rebooted to correctly reload the ' \ + 'mt7916 firmware. The card will not work otherwise!') + # Instead of reboot - can we unload and re-load the driver? + elif os.path.isfile(mt7915e_conf): + os.remove(mt7915e_conf) + # Enable/Disable interface - interface is always placed in # administrative down state in WiFiIf class if 'disable' not in wifi: @@ -331,6 +393,9 @@ def apply(wifi): elif wifi['type'] == 'station': call(f'systemctl start wpa_supplicant@{interface}.service') + if 'static_arp' in wifi: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py index 230eb14d6..ad6c806ad 100755 --- a/src/conf_mode/interfaces_wwan.py +++ b/src/conf_mode/interfaces_wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,14 +20,18 @@ from sys import exit from time import sleep from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_authentication from vyos.configverify import verify_interface_exists from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WWANIf from vyos.utils.dict import dict_search +from vyos.utils.network import is_wwan_connected from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import DEVNULL @@ -42,7 +46,7 @@ cron_script = '/etc/cron.d/vyos-wwan' def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -85,6 +89,10 @@ def get_config(config=None): if len(wwan['other_interfaces']) == 0: del wwan['other_interfaces'] + # Protocols static arp dependency + if 'static_arp' in wwan: + set_dependents('static_arp', conf) + return wwan def verify(wwan): @@ -98,6 +106,7 @@ def verify(wwan): verify_interface_exists(wwan, ifname) verify_authentication(wwan) verify_vrf(wwan) + verify_mtu_ipv6(wwan) verify_mirror_redirect(wwan) return None @@ -135,14 +144,20 @@ def apply(wwan): break sleep(0.250) - if 'shutdown_required' in wwan: + if 'shutdown_required' in wwan or (not is_wwan_connected(wwan['ifname'])): # we only need the modem number. wwan0 -> 0, wwan1 -> 1 modem = wwan['ifname'].lstrip('wwan') base_cmd = f'mmcli --modem {modem}' # Number of bearers is limited - always disconnect first - cmd(f'{base_cmd} --simple-disconnect') + call(f'{base_cmd} --simple-disconnect') w = WWANIf(wwan['ifname']) + + # We cannot proceed with the configuration if the modem is not detected - so we bail out + # and wait for the next cronjob run to re-apply the configuration. + if not w.exists(wwan['ifname']): + return None + if 'deleted' in wwan or 'disable' in wwan: w.remove() @@ -157,7 +172,7 @@ def apply(wwan): return None - if 'shutdown_required' in wwan: + if 'shutdown_required' in wwan or (not is_wwan_connected(wwan['ifname'])): ip_type = 'ipv4' slaac = dict_search('ipv6.address.autoconf', wwan) != None if 'address' in wwan: @@ -176,6 +191,10 @@ def apply(wwan): call(command, stdout=DEVNULL) w.update(wwan) + + if 'static_arp' in wwan: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py index 5fd1beec9..2a4f206f5 100644 --- a/src/conf_mode/load-balancing_haproxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,6 +19,7 @@ import os from sys import exit from shutil import rmtree +from vyos.defaults import systemd_services from vyos.config import Config from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate @@ -26,6 +27,7 @@ from vyos.utils.dict import dict_search 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.network import is_addr_assigned from vyos.pki import find_chain from vyos.pki import load_certificate from vyos.pki import load_private_key @@ -39,7 +41,6 @@ airbag.enable() load_balancing_dir = '/run/haproxy' load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' -systemd_service = 'haproxy.service' systemd_override = '/run/systemd/system/haproxy.service.d/10-override.conf' def get_config(config=None): @@ -65,18 +66,44 @@ def verify(lb): return None if 'backend' not in lb or 'service' not in lb: - raise ConfigError(f'"service" and "backend" must be configured!') + raise ConfigError('Both "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') + # Check if bind 'listen-address:port' are used by another service + listen_addresses = front_config.get('listen_address') or {} + listen_port = int(front_config['port']) + if listen_addresses: + for listen_address in listen_addresses: + # Remove the interface name if present in the listen address + if '%' in listen_address: + listen_address, *_ = listen_address.split('%', maxsplit=1) + + if not is_addr_assigned(listen_address): + raise ConfigError( + f'listen-address "{listen_address}" not assigned on any interface!' + ) + + port_availability = check_port_availability( + listen_address, listen_port, 'tcp' + ) + port_bind_service = is_listen_port_bind_service( + listen_port, 'haproxy', address=listen_address + ) + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" on address "{listen_address}" is used by another service' + ) + else: + # Verify listen port for all IP addresses + port_availability = check_port_availability(None, listen_port, 'tcp') + port_bind_service = is_listen_port_bind_service(listen_port, 'haproxy') + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" is used by another service' + ) if 'http_compression' in front_config: if front_config['mode'] != 'http': @@ -85,16 +112,19 @@ def verify(lb): raise ConfigError(f'service {front} must have at least one mime-type configured to use' f'http_compression!') + for cert in dict_search('ssl.certificate', front_config) or []: + verify_pki_certificate(lb, cert) + for back, back_config in lb['backend'].items(): if 'http_check' in back_config: http_check = back_config['http_check'] if 'expect' in http_check and 'status' in http_check['expect'] and 'string' in http_check['expect']: - raise ConfigError(f'"expect status" and "expect string" can not be configured together!') + raise ConfigError('"expect status" and "expect string" can not be configured together!') if 'health_check' in back_config: if back_config['mode'] != 'tcp': raise ConfigError(f'backend "{back}" can only be configured with {back_config["health_check"]} ' + - f'health-check whilst in TCP mode!') + 'health-check whilst in TCP mode!') if 'http_check' in back_config: raise ConfigError(f'backend "{back}" cannot be configured with both http-check and health-check!') @@ -112,24 +142,19 @@ def verify(lb): if {'no_verify', 'ca_certificate'} <= set(back_config['ssl']): raise ConfigError(f'backend {back} cannot have both ssl options no-verify and ca-certificate set!') + tmp = dict_search('ssl.ca_certificate', back_config) + if tmp: verify_pki_ca_certificate(lb, tmp) + # Check if http-response-headers are configured in any frontend/backend where mode != http for group in ['service', 'backend']: for config_name, config in lb[group].items(): if 'http_response_headers' in config and config['mode'] != 'http': raise ConfigError(f'{group} {config_name} must be set to http mode to use http_response_headers!') - for front, front_config in lb['service'].items(): - for cert in dict_search('ssl.certificate', front_config) or []: - verify_pki_certificate(lb, cert) - - for back, back_config in lb['backend'].items(): - tmp = dict_search('ssl.ca_certificate', back_config) - if tmp: verify_pki_ca_certificate(lb, tmp) - def generate(lb): if not lb: - # Delete /run/haproxy/haproxy.cfg + # Delete generated config files config_files = [load_balancing_conf_file, systemd_override] for file in config_files: if os.path.isfile(file): @@ -144,8 +169,8 @@ def generate(lb): if not os.path.isdir(load_balancing_dir): os.mkdir(load_balancing_dir) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in lb['pki']['ca'].values()} if 'ca' in lb['pki'] else {} + loaded_ca_certs = {load_certificate(cert_data['certificate']) + for _, cert_data in dict_search('pki.ca', lb, default={}).items()} # SSL Certificates for frontend for front, front_config in lb['service'].items(): @@ -193,12 +218,11 @@ def generate(lb): return None def apply(lb): + action = 'stop' + if lb: + action = 'reload-or-restart' call('systemctl daemon-reload') - if not lb: - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') - + call(f'systemctl {action} {systemd_services["haproxy"]}') return None diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 92d9acfba..3f2433fa1 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) 2023-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,7 @@ from sys import exit from vyos.config import Config from vyos.configdep import set_dependents, call_dependents +from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos import ConfigError from vyos import airbag @@ -25,6 +26,13 @@ airbag.enable() service = 'vyos-wan-load-balance.service' +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group' +] + def get_config(config=None): if config: conf = config @@ -38,6 +46,10 @@ def get_config(config=None): get_first_key=True, with_recursive_defaults=True) + if lb: + lb['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + # prune limit key if not set by user for rule in lb.get('rule', []): if lb.from_defaults(['rule', rule, 'limit']): @@ -89,6 +101,43 @@ def verify(lb): for direction in ['source', 'destination']: if direction in rule_conf: + side_conf = rule_conf[direction] + + 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 in ['port_group']: + if 'port' in side_conf: + raise ConfigError(f'{error_group} and port cannot both be defined') + + if group_name and group_name[0] == '!': + group_name = group_name[1:] + + group_obj = dict_search_args(lb['firewall_group'], group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on load-balancing wan rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members!') + + if dict_search_args(side_conf, 'group', 'port_group'): + if 'protocol' not in rule_conf: + raise ConfigError('Protocol must be defined if specifying a port-group') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') + if 'port' in rule_conf[direction]: if 'protocol' not in rule_conf: raise ConfigError(f'Protocol required to specify port on load-balancing wan rule {rule_id}') @@ -101,9 +150,9 @@ def generate(lb): def apply(lb): if not lb: - cmd(f'sudo systemctl stop {service}') + cmd(f'systemctl stop {service}') else: - cmd(f'sudo systemctl restart {service}') + cmd(f'systemctl restart {service}') call_dependents() diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 504b3e82a..8763da886 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,14 +16,13 @@ import os +from glob import glob from sys import exit -from pathlib import Path from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.template import render -from vyos.template import is_ip_network from vyos.utils.kernel import check_kmod from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args @@ -31,7 +30,6 @@ from vyos.utils.file import write_file from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.process import call -from vyos.utils.network import is_addr_assigned from vyos.utils.network import interface_exists from vyos.firewall import fqdn_config_parse from vyos import ConfigError @@ -176,12 +174,6 @@ def verify(nat): 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) - if addr != None and addr != 'masquerade' and not is_ip_network(addr): - for ip in addr.split('-'): - if not is_addr_assigned(ip): - Warning(f'IP address {ip} does not exist on the system!') - # common rule verification verify_rule(config, err_msg, nat['firewall_group']) @@ -265,9 +257,9 @@ def apply(nat): text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' write_file(domain_resolver_usage, text) elif os.path.exists(domain_resolver_usage): - Path(domain_resolver_usage).unlink(missing_ok=True) + os.unlink(domain_resolver_usage) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py index df501ce7f..b7d0b586d 100755 --- a/src/conf_mode/nat64.py +++ b/src/conf_mode/nat64.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,174 +14,199 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# pylint: disable=empty-docstring,missing-module-docstring - import csv import os import re +import sys -from ipaddress import IPv6Network, IPv6Address +from ipaddress import IPv6Network +from ipaddress import IPv6Address from json import dumps as json_write from vyos import ConfigError from vyos import airbag from vyos.config import Config -from vyos.configdict import is_node_changed +from vyos.config import ConfigDict +from vyos.configdiff import get_config_diff from vyos.utils.dict import dict_search 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 cmd from vyos.utils.process import run +from vyos.utils.system import sysctl_read airbag.enable() -INSTANCE_REGEX = re.compile(r"instance-(\d+)") -JOOL_CONFIG_DIR = "/run/jool" - +INSTANCE_REGEX = re.compile(r'instance-(\d+)') +JOOL_CONFIG_DIR = '/run/jool' +base = ['nat64'] -def get_config(config: Config | None = None) -> None: +def get_config(config: Config | None = None) -> ConfigDict: if config is None: config = Config() - base = ["nat64"] - nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True) + nat64 = config.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) + + config_diff = get_config_diff(config) + # get_config_dict returns an instance of ConfigDict + setattr(nat64, 'config_diff', config_diff) return nat64 def verify(nat64) -> None: - check_kmod(["jool"]) - base_src = ["nat64", "source", "rule"] + check_kmod(['jool']) + config_diff = getattr(nat64, 'config_diff') + + base_rule = base + ['source', 'rule'] # Load in existing instances so we can destroy any unknown - lines = cmd("jool instance display --csv").splitlines() + lines = cmd('jool instance display --csv').splitlines() for _, instance, _ in csv.reader(lines): match = INSTANCE_REGEX.fullmatch(instance) if not match: - # FIXME: Instances that don't match should be ignored but WARN'ed to the user + # to fix: Instances that don't match should be ignored but WARN'ed to the user continue num = match.group(1) - rules = nat64.setdefault("source", {}).setdefault("rule", {}) + rules = nat64.setdefault('source', {}).setdefault('rule', {}) # Mark it for deletion if num not in rules: - rules[num] = {"deleted": True} + rules[num] = {'deleted': True} continue # If the user changes the mode, recreate the instance else Jool fails with: # Jool error: Sorry; you can't change an instance's framework for now. - if is_node_changed(config, base_src + [f"instance-{num}", "mode"]): - rules[num]["recreate"] = True + if config_diff.is_node_changed(base_rule + [f'instance-{num}', 'mode']): + rules[num]['recreate'] = True # If the user changes the pool6, recreate the instance else Jool fails with: # Jool error: Sorry; you can't change a NAT64 instance's pool6 for now. - if dict_search("source.prefix", rules[num]) and is_node_changed( - config, - base_src + [num, "source", "prefix"], + if dict_search('source.prefix', rules[num]) and config_diff.is_node_changed( + base_rule + [num, 'source', 'prefix'], ): - rules[num]["recreate"] = True + rules[num]['recreate'] = True if not nat64: # nothing left to do return - if dict_search("source.rule", nat64): + # https://nicmx.github.io/Jool/en/usr-flags-pool4.html#port-range + # Jool is incapable of ensuring pool4 does not intersect with other defined + # port ranges; this validation is the operator’s responsibility. + tmp = sysctl_read(['net', 'ipv4', 'ip_local_port_range']) + ephemeral_port_min, ephemeral_port_max = map(int, tmp.split()) + + if dict_search('source.rule', nat64): # Ensure only 1 netfilter instance per namespace nf_rules = filter( - lambda i: "deleted" not in i and i.get('mode') == "netfilter", - nat64["source"]["rule"].values(), + lambda i: 'deleted' not in i and i.get('mode') == 'netfilter', + nat64['source']['rule'].values(), ) next(nf_rules, None) # Discard the first element if next(nf_rules, None) is not None: raise ConfigError( - "Jool permits only 1 NAT64 netfilter instance (per network namespace)" + 'Jool permits only 1 NAT64 netfilter instance (per network namespace)' ) - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: continue # Verify that source.prefix is set and is a /96 - if not dict_search("source.prefix", instance): - raise ConfigError(f"Source NAT64 rule {rule} missing source prefix") - src_prefix = IPv6Network(instance["source"]["prefix"]) + if not dict_search('source.prefix', instance): + raise ConfigError(f'Source NAT64 rule {rule} missing source prefix') + src_prefix = IPv6Network(instance['source']['prefix']) if src_prefix.prefixlen != 96: - raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96") + raise ConfigError(f'Source NAT64 rule {rule} source prefix must be /96') if (int(src_prefix[0]) & int(IPv6Address('0:0:0:0:ff00::'))) != 0: raise ConfigError( f'Source NAT64 rule {rule} source prefix is not RFC6052-compliant: ' 'bits 64 to 71 (9th octet) must be zeroed' ) - pools = dict_search("translation.pool", instance) + pools = dict_search('translation.pool', instance) if pools: for num, pool in pools.items(): - if "address" not in pool: - raise ConfigError( - f"Source NAT64 rule {rule} translation pool " - f"{num} missing address/prefix" - ) - if "port" not in pool: + error_msg = f'Source NAT64 rule {rule} translation pool {num}' + if 'address' not in pool: + raise ConfigError(f'{error_msg} missing address/prefix') + if 'port' not in pool: + raise ConfigError(f'{error_msg} missing port(-range)') + # Split the provided ports, it's either a single port or start-end + tmp = pool['port'].split('-') + if len(tmp) == 1: # single port + pool_min = pool_max = int(tmp[0]) + elif len(tmp) == 2: # port range with start-stop + pool_min, pool_max = map(int, tmp) + else: + raise ConfigError('Invalid port range, this should not happen!') + + # Inclusive overlap check between nat64 translation ports and + # the Linux Kernel ephemeral port range + # overlap if pool_min <= ephemeral_port_max and ephemeral_port_min <= pool_max + if pool_min <= ephemeral_port_max and ephemeral_port_min <= pool_max: raise ConfigError( - f"Source NAT64 rule {rule} translation pool " - f"{num} missing port(-range)" + f'{error_msg} port range {pool_min}-{pool_max} overlaps with ' + f'local ephemeral range {ephemeral_port_min}-{ephemeral_port_max}' ) - def generate(nat64) -> None: if not nat64: return os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) - if dict_search("source.rule", nat64): - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + if dict_search('source.rule', nat64): + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: # Delete the unused instance file - os.unlink(os.path.join(JOOL_CONFIG_DIR, f"instance-{rule}.json")) + os.unlink(os.path.join(JOOL_CONFIG_DIR, f'instance-{rule}.json')) continue - name = f"instance-{rule}" + name = f'instance-{rule}' config = { - "instance": name, - "framework": "netfilter", - "global": { - "pool6": instance["source"]["prefix"], - "manually-enabled": "disable" not in instance, + 'instance': name, + 'framework': 'netfilter', + 'global': { + 'pool6': instance['source']['prefix'], + 'manually-enabled': 'disable' not in instance, }, # "bib": [], } - if "description" in instance: - config["comment"] = instance["description"] + if 'description' in instance: + config['comment'] = instance['description'] - if dict_search("translation.pool", instance): + if dict_search('translation.pool', instance): pool4 = [] # mark mark = '' - if dict_search("match.mark", instance): - mark = instance["match"]["mark"] + if dict_search('match.mark', instance): + mark = instance['match']['mark'] - for pool in instance["translation"]["pool"].values(): - if "disable" in pool: + for pool in instance['translation']['pool'].values(): + if 'disable' in pool: continue - protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp") + protos = pool.get('protocol', {}).keys() or ('tcp', 'udp', 'icmp') for proto in protos: obj = { - "protocol": proto.upper(), - "prefix": pool["address"], - "port range": pool["port"], + 'protocol': proto.upper(), + 'prefix': pool['address'], + 'port range': pool['port'], } if mark: - obj["mark"] = int(mark) - if "description" in pool: - obj["comment"] = pool["description"] + obj['mark'] = int(mark) + if 'description' in pool: + obj['comment'] = pool['description'] pool4.append(obj) if pool4: - config["pool4"] = pool4 + config['pool4'] = pool4 write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2)) @@ -191,30 +216,30 @@ def apply(nat64) -> None: unload_kmod(['jool']) return - if dict_search("source.rule", nat64): + if dict_search('source.rule', nat64): # Deletions first to avoid conflicts - for rule, instance in nat64["source"]["rule"].items(): - if not any(k in instance for k in ("deleted", "recreate")): + for rule, instance in nat64['source']['rule'].items(): + if not any(k in instance for k in ('deleted', 'recreate')): continue - ret = run(f"jool instance remove instance-{rule}") + ret = run(f'jool instance remove instance-{rule}') if ret != 0: raise ConfigError( - f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})" + f'Failed to remove nat64 source rule {rule} (jool instance instance-{rule})' ) # Now creations - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: continue - name = f"instance-{rule}" - ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json") + name = f'instance-{rule}' + ret = run(f'jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json') if ret != 0: - raise ConfigError(f"Failed to set jool instance {name}") + raise ConfigError(f'Failed to set jool instance {name}') -if __name__ == "__main__": +if __name__ == '__main__': try: c = get_config() verify(c) @@ -222,4 +247,4 @@ if __name__ == "__main__": apply(c) except ConfigError as e: print(e) - exit(1) + sys.exit(1) diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 95dfae3a5..c3637c6b9 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -93,6 +93,14 @@ def verify(nat): if not is_ipv6(prefix): raise ConfigError(f'{err_msg} source-prefix not specified') + if 'source' in config and 'group' in config['source']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['source']['group'])) > 1: + raise ConfigError('Only one source address-group, network-group or domain-group can be specified') + + if 'destination' in config and 'group' in config['destination']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['destination']['group'])) > 1: + raise ConfigError('Only one destination address-group, network-group or domain-group can be specified') + if dict_search('destination.rule', nat): for rule, config in dict_search('destination.rule', nat).items(): err_msg = f'Destination NAT66 configuration error in rule {rule}:' @@ -108,9 +116,13 @@ def verify(nat): if not interface_exists(interface_name): Warning(f'Interface "{interface_name}" for destination NAT66 rule "{rule}" does not exist!') + if 'source' in config and 'group' in config['source']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['source']['group'])) > 1: + raise ConfigError('Only one source address-group, network-group or domain-group can be specified') + if 'destination' in config and 'group' in config['destination']: if len({'address_group', 'network_group', 'domain_group'} & set(config['destination']['group'])) > 1: - raise ConfigError('Only one address-group, network-group or domain-group can be specified') + raise ConfigError('Only one destination address-group, network-group or domain-group can be specified') return None diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py index 3484e5873..312688b53 100755 --- a/src/conf_mode/nat_cgnat.py +++ b/src/conf_mode/nat_cgnat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/netns.py b/src/conf_mode/netns.py index b57e46a0d..5a3c4e7fa 100755 --- a/src/conf_mode/netns.py +++ b/src/conf_mode/netns.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 724f97555..356a8dd89 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,6 +19,7 @@ import os from sys import argv from sys import exit +from vyos.base import Message from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents @@ -27,6 +28,8 @@ from vyos.configdict import node_changed from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories +from vyos.defaults import internal_ports +from vyos.defaults import systemd_services from vyos.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate @@ -41,10 +44,13 @@ from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive +from vyos.utils.dict import dict_set_nested from vyos.utils.file import read_file +from vyos.utils.network import check_port_availability from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -59,6 +65,10 @@ sync_search = [ 'path': ['service', 'https'], }, { + 'keys': ['key'], + 'path': ['service', 'ssh'], + }, + { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], }, @@ -73,6 +83,7 @@ sync_search = [ { 'keys': ['certificate', 'ca_certificate'], 'path': ['load_balancing', 'haproxy'], + 'orig_path': ['load-balancing', 'haproxy'], }, { 'keys': ['key'], @@ -115,26 +126,65 @@ def certbot_delete(certificate): if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'): cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}') -def certbot_request(name: str, config: dict, dry_run: bool=True): +def certbot_request(name: str, config: dict, dry_run: bool=True) -> None: # We do not call certbot when booting the system - there is no need to do so and # request new certificates during boot/image upgrade as the certbot configuration # is stored persistent under /config - thus we do not open the door to transient # errors if not boot_configuration_complete(): - return + return None domains = '--domains ' + ' --domains '.join(config['domain_name']) tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\ f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ f'{domains}' + + listen_address = None if 'listen_address' in config: - tmp += f' --http-01-address {config["listen_address"]}' - # verify() does not need to actually request a cert but only test for plausability + listen_address = config['listen_address'] + + # When ACME is used behind a reverse proxy, we always bind to localhost + # whatever the CLI listen-address is configured for. + if ('used_by' in config and 'haproxy' in config['used_by'] and + is_systemd_service_running(systemd_services['haproxy']) and + not check_port_availability(listen_address, 80)): + tmp += f' --http-01-address 127.0.0.1 --http-01-port {internal_ports["certbot_haproxy"]}' + elif listen_address: + tmp += f' --http-01-address {listen_address}' + + # verify() does not need to actually request a cert but only test for plausibility if dry_run: tmp += ' --dry-run' - cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!') + cmd(tmp, raising=ConfigError, message=f'Certbot request failed for "{name}"!') + return None + +def certbot_renew(config: dict, force: bool=False) -> None: + """ Renew all certificates managed via certbot """ + tmp = f'certbot renew --no-random-sleep-on-renew ' \ + f'--config-dir {vyos_certbot_dir}' + + # Determine services using ACME based certificates + pre_hook_services = [] + for used_by, _ in dict_search_recursive(config, 'used_by'): + pre_hook_services.extend(used_by) + # Remove duplicate items from list + pre_hook_services = list(set(pre_hook_services)) + # Automatically add services in use to pre_hook_services depending on service + # name in vyos.defaults.systemd_services + if pre_hook_services: + services = [] + for service in pre_hook_services: + if service in systemd_services: + services.append(systemd_services[service]) + tmp += ' --pre-hook "systemctl stop ' + ' '.join(services) + '"' + + if force: + tmp += ' --force-renewal' + + print(cmd(tmp, raising=ConfigError, message=f'Certbot renew failed!')) + return None def get_config(config=None): if config: @@ -149,21 +199,25 @@ def get_config(config=None): if len(argv) > 1 and argv[1] == 'certbot_renew': pki['certbot_renew'] = {} - - changed_keys = ['ca', 'certificate', 'dh', 'key-pair', 'openssh', 'openvpn'] - + elif len(argv) > 1 and argv[1] == 'certbot_renew_force': + pki['certbot_renew'] = {'force': {}} + + # Walk through the list of sync_translate mapping and build a list + # which is later used to check if the node was changed in the CLI config + changed_keys = [] + for value in sync_translate.values(): + if value not in changed_keys: + changed_keys.append(value) + # Check for changes to said given keys in the CLI config for key in changed_keys: tmp = node_changed(conf, base + [key], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) + if tmp: + dict_set_nested(f'changed.{key.replace("-", "_")}', tmp, pki) - if 'changed' not in pki: - pki.update({'changed':{}}) - - pki['changed'].update({key.replace('-', '_') : tmp}) - - # We only merge on the defaults of there is a configuration at all + # We only merge on the defaults if there is a configuration at all if conf.exists(base): # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**pki.kwargs, recursive=True) # remove ACME default configuration if unused by CLI if 'certificate' in pki: @@ -183,9 +237,14 @@ def get_config(config=None): for name, cert_config in pki['certificate'].items(): if 'acme' in cert_config: renew.append(name) - # If triggered externally by certbot, certificate key is not present in changed - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'certificate' : renew}) + if renew: + # Get the current list of changed certificates + tmp = pki.get('changed', {}).get('certificate', []) + # and extend it with the list of ACME based certificates + tmp += renew + # remove any duplicates if necessary + tmp = set(tmp) + dict_set_nested('changed.certificate', tmp, pki) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! @@ -218,9 +277,13 @@ def get_config(config=None): if isinstance(found_name, str) and found_name != item_name: continue - path = search['path'] - path_str = ' '.join(path + found_path) - #print(f'PKI: Updating config: {path_str} {item_name}') + # prefer orig_path over path when unmangling is needed + path = search.get('orig_path', search.get('path')) + # Only enable this for debug purposes - otherwise we will always + # print this message for ACME certificates during renew tests - + # even if they are not due for renew! + # path_str = ' '.join(path + found_path) + # print(f'Updating configuration: "{path_str} {item_name}"') if path[0] == 'interfaces': ifname = found_path[0] @@ -230,6 +293,34 @@ def get_config(config=None): if not D.node_changed_presence(path): set_dependents(path[1], conf) + # Check PKI certificates if they are auto-generated by ACME. If they are, + # traverse the current configuration and determine the service where the + # certificate is used by. + # Required to check if we might need to run certbot behind a reverse proxy. + if 'certificate' in pki: + for name, cert_config in pki['certificate'].items(): + if 'acme' not in cert_config: + continue + if not dict_search('system.load_balancing.haproxy', pki): + continue + # Determine which service depends on ACME issued certificates + # We only need to add services blocking the default certbot ports + # 80 and 443. For instance there won't be a conflict with strongSwan + # as it runs on different ports. + used_by = [] + # We start with HAProxy + for cert_list, _ in dict_search_recursive( + pki['system']['load_balancing']['haproxy'], 'certificate'): + if name in cert_list: + used_by.append('haproxy') + # Check if OpenConnect consumes an ACME certificate + tmp = dict_search('system.vpn.openconnect.ssl.certificate', pki) + if tmp and tmp in cert_list: + used_by.append('openconnect') + + if used_by: + pki['certificate'][name]['acme'].update({'used_by': used_by}) + return pki def is_valid_certificate(raw_data): @@ -321,9 +412,23 @@ def verify(pki): raise ConfigError(f'An email address is required to request '\ f'certificate for "{name}" via ACME!') + listen_address = None + if 'listen_address' in cert_conf['acme']: + listen_address = cert_conf['acme']['listen_address'] + + if 'used_by' not in cert_conf['acme']: + # A call to check_port_availability() will always fail during system + # boot when listen_address is set and the address is not yet assigned + # to an interface. This happens b/c PKI subsystem is called prior + # to any interface - e.g. ethernet - and thus the OS will always + # be unable to bind() a socket() to a non existing IP address. + if boot_configuration_complete() and not check_port_availability(listen_address, 80): + raise ConfigError('Port 80 is already in use and not available '\ + f'to provide ACME challenge for "{name}"!') + + # Only run the ACME command if something on this entity changed, + # as this is time intensive if 'certbot_renew' not in pki: - # Only run the ACME command if something on this entity changed, - # as this is time intensive tmp = dict_search('changed.certificate', pki) if tmp != None and name in tmp: certbot_request(name, cert_conf['acme']) @@ -366,7 +471,8 @@ def verify(pki): if 'country' in default_values: country = default_values['country'] if len(country) != 2 or not country.isalpha(): - raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + raise ConfigError('Invalid default country value. '\ + 'Value must be 2 alpha characters.') if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the @@ -374,27 +480,35 @@ def verify(pki): for search in sync_search: for key in search['keys']: changed_key = sync_translate[key] - if changed_key not in pki['changed']: continue - for item_name in pki['changed'][changed_key]: node_present = False if changed_key == 'openvpn': node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) else: node_present = dict_search_args(pki, changed_key, item_name) + # If the node is still present, we can skip the check + # as we are not deleting it + if node_present: + continue - if not node_present: - search_dict = dict_search_args(pki['system'], *search['path']) - - if not search_dict: - continue + search_dict = dict_search_args(pki['system'], *search['path']) + if not search_dict: + continue - for found_name, found_path in dict_search_recursive(search_dict, key): - if found_name == item_name: - path_str = " ".join(search['path'] + found_path) - raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"') + for found_name, found_path in dict_search_recursive(search_dict, key): + # Check if the name matches either by string compare, or being + # part of a list + if ((isinstance(found_name, str) and found_name == item_name) or + (isinstance(found_name, list) and item_name in found_name)): + # We do not support _ in CLI paths - this is only a convenience + # as we mangle all - to _, now it's time to reverse this! + path_str = ' '.join(search['path'] + found_path).replace('_','-') + object = changed_key.replace('_','-') + tmp = f'Embedded PKI {object} with name "{item_name}" is still '\ + f'in use by CLI path "{path_str}"' + raise ConfigError(tmp) return None @@ -428,7 +542,8 @@ def generate(pki): # Certbot renewal only needs to re-trigger the services to load up the # new PEM file if 'certbot_renew' in pki: - return None + force = 'force' in pki['certbot_renew'] + return certbot_renew(config=pki, force=force) certbot_list = [] certbot_list_on_disk = [] @@ -448,7 +563,7 @@ def generate(pki): # the PEM files on disk. We need to add the certificate to # certbot_list_on_disk to automatically import the CA chain certbot_list_on_disk.append(name) - # We alredy had an ACME managed certificate on the system, but + # We already had an ACME managed certificate on the system, but # something changed in the configuration elif changed_certificates != None and name in changed_certificates: # Delete old ACME certificate first @@ -490,7 +605,7 @@ def generate(pki): if not ca_cert_present: tmp = dict_search_args(pki, 'ca', f'{autochain_prefix}{cert}', 'certificate') if not bool(tmp) or tmp != cert_chain_base64: - print(f'Adding/replacing automatically imported CA certificate for "{cert}" ...') + Message(f'Add/replace automatically imported CA certificate for "{cert}" ...') add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64) return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index a90e33e81..171ef23fd 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,6 +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 re from sys import exit from vyos.config import Config @@ -24,9 +25,20 @@ from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search from vyos.utils.process import is_systemd_service_running from vyos import ConfigError +from vyos.base import Warning from vyos import airbag airbag.enable() +# Sanity checks for large-community-list regex: +# * Require complete 3-tuples, no blank members. Catch missed & doubled colons. +# * Permit appropriate community separators (whitespace, underscore) +# * Permit common regex between tuples while requiring at least one separator +# (eg, "1:1:1_.*_4:4:4", matching "1:1:1 4:4:4" and "1:1:1 2:2:2 4:4:4", +# but not "1:1:13 24:4:4") +# Best practice: stick with basic patterns, mind your wildcards and whitespace. +# Regex that doesn't match this pattern will be allowed with a warning. +large_community_regex_pattern = r'([^: _]+):([^: _]+):([^: _]+)([ _]([^:]+):([^: _]+):([^: _]+))*' + def community_action_compatibility(actions: dict) -> bool: """ Check compatibility of values in community and large community sections @@ -119,7 +131,7 @@ def verify(config_dict): if 'rule' not in instance_config: continue - # human readable instance name (hypen instead of underscore) + # human readable instance name (hyphen instead of underscore) policy_hr = policy_type.replace('_', '-') entries = [] for rule, rule_config in instance_config['rule'].items(): @@ -147,10 +159,34 @@ def verify(config_dict): if 'regex' not in rule_config: raise ConfigError(f'A regex {mandatory_error}') + if policy_type == 'large_community_list': + if not re.fullmatch(large_community_regex_pattern, rule_config['regex']): + Warning(f'"policy large-community-list {instance} rule {rule} regex" does not follow expected form and may not match as expected.') + if policy_type in ['prefix_list', 'prefix_list6']: if 'prefix' not in rule_config: raise ConfigError(f'A prefix {mandatory_error}') + mask_len = int(rule_config['prefix'].split('/')[1]) + ge = dict_search('ge', rule_config) + le = dict_search('le', rule_config) + + if ge and int(ge) < mask_len: + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "ge" ({ge}) must be >= ' + f'prefix length ({mask_len})' + ) + if le and int(le) < mask_len: + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "le" ({le}) must be >= ' + f'prefix length ({mask_len})' + ) + if ge and le and int(ge) > int(le): + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "ge" ({ge}) must be <= ' + f'"le" ({le})' + ) + if rule_config in entries: raise ConfigError( f'Rule "{rule}" contains a duplicate prefix definition!') diff --git a/src/conf_mode/policy_local-route.py b/src/conf_mode/policy_local-route.py index 9be2bc227..23aadfade 100755 --- a/src/conf_mode/policy_local-route.py +++ b/src/conf_mode/policy_local-route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -299,8 +299,8 @@ def apply(pbr): if 'rule' in pbr_route: for rule, rule_config in pbr_route['rule'].items(): - # VRFs get configred as route table alias names for iproute2 and only - # one 'set' can get past validation. Either can be fed to lookup. + # VRFs get configured as route table alias names for iproute2 and only + # one 'set' can get past validation. Either can be fed to lookup. vrf = rule_config['set'].get('vrf', '') if vrf == 'default': table_or_vrf = 'main' diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index 223175b8a..3cfdad913 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,13 +21,17 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdiff import Diff, get_config_diff from vyos.template import render from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.network import get_vrf_tableid +from vyos.utils.network import interface_exists from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf +from vyos.geoip import geoip_refresh, geoip_update from vyos import ConfigError from vyos import airbag airbag.enable() @@ -43,6 +47,28 @@ valid_groups = [ 'interface_group' ] +def geoip_updated(conf): + D = get_config_diff(conf, key_mangling=('-', '_')) + for path in (['policy', 'route'], ['policy', 'route6']): + diff = D.get_child_nodes_diff(path, + expand_nodes=Diff.ADD | Diff.DELETE, + recursive=True) + if any(any(dict_search_recursive(diff.get(section, {}), 'geoip')) + for section in ('add', 'delete')): + return True + return False + +def geoip_sets(policy): + out = {'name': [], 'ipv6_name': []} + + for _, path in dict_search_recursive(policy, 'geoip'): + if (path[0] == 'route'): + out['name'].append(f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}') + elif (path[0] == 'route6'): + out['ipv6_name'].append(f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}') + + return out + def get_config(config=None): if config: conf = config @@ -60,6 +86,12 @@ def get_config(config=None): if 'dynamic_group' in policy['firewall_group']: del policy['firewall_group']['dynamic_group'] + policy['geoip_sets'] = geoip_sets(policy) + policy['geoip_updated'] = geoip_updated(conf) + policy['firewall'] = conf.get_config_dict( + ['firewall'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, get_first_key=True) + return policy def verify_rule(policy, name, rule_conf, ipv6, rule_id): @@ -89,6 +121,11 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id): if 'vrf' in rule_conf['set'] and 'table' in rule_conf['set']: raise ConfigError(f'{name} rule {rule_id}: Cannot set both forwarding route table and VRF') + if 'vrf' in rule_conf['set']: + vrf = rule_conf['set']['vrf'] + if vrf != 'default' and not interface_exists(vrf): + raise ConfigError(f'{name} rule {rule_id}: VRF "{vrf}" does not exist') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -203,6 +240,11 @@ def apply(policy): apply_table_marks(policy) + if policy['geoip_sets']['name'] or policy['geoip_sets']['ipv6_name']: + if policy['geoip_updated'] or not geoip_refresh(): + print('Updating GeoIP. Please wait...') + geoip_update(firewall=policy['firewall'], policy=policy) + return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_babel.py b/src/conf_mode/protocols_babel.py index 80a847af8..a683031bd 100755 --- a/src/conf_mode/protocols_babel.py +++ b/src/conf_mode/protocols_babel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index d3bc3e961..953611f24 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 53e83c3b4..53561a9a3 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -52,7 +52,7 @@ def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) :type afi_name: str :param vrfs_config: configuration dependents vrfs :type vrfs_config: dict - :return: if vrf in import list retrun true else false + :return: if vrf in import list return true else false :rtype: bool """ for vrf_name, vrf_config in vrfs_config.items(): @@ -155,7 +155,7 @@ 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 configured under neighbor if 'address_family' in peer_config: return True @@ -183,8 +183,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - bgp = vrf and config_dict['vrf']['name'][vrf]['protocols']['bgp'] or config_dict['bgp'] + # equivalent of the C foo ? 'a' : 'b' statement + bgp = vrf and dict_search(f'vrf.name.{vrf}.protocols.bgp', + config_dict) or config_dict['bgp'] bgp['policy'] = config_dict['policy'] if 'deleted' in bgp: @@ -276,7 +277,7 @@ def verify(config_dict): raise ConfigError(f'Only one local-as number can be specified for peer "{peer}"!') # Neighbor local-as override can not be the same as the local-as - # we use for this BGP instane! + # we use for this BGP instance! asn = list(peer_config['local_as'].keys())[0] if asn == bgp['system_as']: raise ConfigError('Cannot have local-as same as system-as number') @@ -286,11 +287,11 @@ def verify(config_dict): raise ConfigError(f'Neighbor "{peer}" has local-as specified which is '\ 'the same as remote-as, this is not allowed!') - # ttl-security and ebgp-multihop can't be used in the same configration + # ttl-security and ebgp-multihop can't be used in the same configuration if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config: raise ConfigError('You can not set both ebgp-multihop and ttl-security hops') - # interface and ebgp-multihop can't be used in the same configration + # interface and ebgp-multihop can't be used in the same configuration if 'ebgp_multihop' in peer_config and 'interface' in peer_config: raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\ f'neighbor "{peer}"') @@ -316,6 +317,7 @@ def verify(config_dict): Warning(f'BGP neighbor "{peer}" requires address-family!') # Peer-group member cannot override remote-as of peer-group + peer_group = None if 'peer_group' in peer_config: peer_group = peer_config['peer_group'] if 'remote_as' in peer_config and 'remote_as' in bgp['peer_group'][peer_group]: @@ -331,6 +333,27 @@ def verify(config_dict): if 'remote_as' in peer_config['interface']['v6only'] and 'remote_as' in bgp['peer_group'][peer_group]: raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!') + for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec', + 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', + 'l2vpn_evpn']: + if dict_search( + f'address_family.{afi}.route_reflector_client', + peer_config, + ) == {} or ( + peer_group + and dict_search( + f'peer_group.{peer_group}.address_family.{afi}.route_reflector_client', + bgp, + ) + == {} + ): + peer_as = verify_remote_as(peer_config, bgp) + if peer_as != 'internal' and peer_as != bgp['system_as']: + raise ConfigError('route-reflector-client only supported for iBGP peers') + else: + # It doesn’t make sense to check the remote-as of a peer group. + pass + # Only checks for ipv4 and ipv6 neighbors # Check if neighbor address is assigned as system interface address vrf_error_msg = f' in default VRF!' @@ -372,13 +395,13 @@ def verify(config_dict): if 'conditionally_advertise' in afi_config: if 'advertise_map' not in afi_config['conditionally_advertise']: - raise ConfigError('Must speficy advertise-map when conditionally-advertise is in use!') + raise ConfigError('Must specify advertise-map when conditionally-advertise is in use!') # Verify advertise-map (which is a route-map) exists verify_route_map(afi_config['conditionally_advertise']['advertise_map'], bgp) if ('exist_map' not in afi_config['conditionally_advertise'] and 'non_exist_map' not in afi_config['conditionally_advertise']): - raise ConfigError('Must either speficy exist-map or non-exist-map when ' \ + raise ConfigError('Must either specify exist-map or non-exist-map when ' \ 'conditionally-advertise is in use!') if {'exist_map', 'non_exist_map'} <= set(afi_config['conditionally_advertise']): @@ -394,7 +417,7 @@ def verify(config_dict): # T4332: bgp deterministic-med cannot be disabled while addpath-tx-bestpath-per-AS is in use if 'addpath_tx_per_as' in afi_config: if dict_search('parameters.deterministic_med', bgp) == None: - raise ConfigError('addpath-tx-per-as requires BGP deterministic-med paramtere to be set!') + raise ConfigError('addpath-tx-per-as requires BGP deterministic-med parameter to be set!') # Validate if configured Prefix list exists if 'prefix_list' in afi_config: @@ -412,16 +435,7 @@ def verify(config_dict): if tmp in afi_config['route_map']: verify_route_map(afi_config['route_map'][tmp], bgp) - if 'route_reflector_client' in afi_config: - peer_group_as = peer_config.get('remote_as') - - if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): - raise ConfigError('route-reflector-client only supported for iBGP peers') - else: - if 'peer_group' in peer_config: - peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp) - if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): - raise ConfigError('route-reflector-client only supported for iBGP peers') + # route-reflector-client verification has been moved to neighbor-only part # T5833 not all AFIs are supported for VRF if 'vrf' in bgp and 'address_family' in peer_config: @@ -464,6 +478,20 @@ def verify(config_dict): if not {'idle', 'interval', 'probes'} <= set(bgp['parameters']['tcp_keepalive']): raise ConfigError('TCP keepalive incomplete - idle, keepalive and probes must be set') + # Validate BGP update-delay: 'establish-wait' requires 'max-delay' and must not exceed it + if dict_search('parameters.update_delay', bgp) != None: + update_delay = dict_search('parameters.update_delay.max_delay', bgp) + establish_wait = dict_search('parameters.update_delay.establish_wait', bgp) + if establish_wait is not None: + if update_delay is None: + raise ConfigError( + 'BGP update-delay establish-wait requires max-delay to be set!' + ) + if int(establish_wait) > int(update_delay): + raise ConfigError( + 'BGP update-delay establish-wait cannot be greater than max-delay!' + ) + # Address Family specific validation if 'address_family' in bgp: for afi, afi_config in bgp['address_family'].items(): @@ -523,11 +551,15 @@ def verify(config_dict): raise ConfigError( 'Please unconfigure import vrf commands before using vpn commands in dependent VRFs!') + # Verify if the route-map exists + if dict_search('route_map.vrf.import', afi_config) is not None: + verify_route_map(afi_config['route_map']['vrf']['import'], bgp) + if (dict_search('route_map.vrf.import', afi_config) is not None or dict_search('import.vrf', afi_config) is not None): # FRR error: please unconfigure vpn to vrf commands before # using import vrf commands - if ('vpn' in afi_config['import'] + if (dict_search('import.vpn', afi_config) is not None or dict_search('export.vpn', afi_config) is not None): raise ConfigError('Please unconfigure VPN to VRF commands before '\ 'using "import vrf" commands!') @@ -537,7 +569,6 @@ def verify(config_dict): raise ConfigError('Please unconfigure route-map VPN to VRF commands before '\ 'using "import vrf" commands!') - # Verify that the export/import route-maps do exist for export_import in ['export', 'import']: tmp = dict_search(f'route_map.vpn.{export_import}', afi_config) diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py index 324ff883f..92e34237c 100755 --- a/src/conf_mode/protocols_eigrp.py +++ b/src/conf_mode/protocols_eigrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,6 +20,7 @@ from sys import argv from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_vrf +from vyos.utils.dict import dict_search from vyos.utils.process import is_systemd_service_running from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict @@ -43,8 +44,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - eigrp = vrf and config_dict['vrf']['name'][vrf]['protocols']['eigrp'] or config_dict['eigrp'] + # equivalent of the C foo ? 'a' : 'b' statement + eigrp = vrf and dict_search(f'vrf.name.{vrf}.protocols.eigrp', + config_dict) or config_dict['eigrp'] eigrp['policy'] = config_dict['policy'] if 'system_as' not in eigrp: diff --git a/src/conf_mode/protocols_failover.py b/src/conf_mode/protocols_failover.py index e7e44db84..752bd6011 100755 --- a/src/conf_mode/protocols_failover.py +++ b/src/conf_mode/protocols_failover.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,12 +15,15 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import json +import os from pathlib import Path +from sys import argv from vyos.config import Config from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag @@ -28,9 +31,24 @@ airbag.enable() service_name = 'vyos-failover' -service_conf = Path(f'/run/{service_name}.conf') +service_conf_dir = Path(f'/run/{service_name}.conf.d/') systemd_service = '/run/systemd/system/vyos-failover.service' -rt_proto_failover = '/etc/iproute2/rt_protos.d/failover.conf' +rt_proto_failover = Path('/etc/iproute2/rt_protos.d/failover.conf') + + +def get_vrf_name(): + if argv and len(argv) > 1: + return argv[1] + return None + + +def get_service_conf_path(): + vrf_name = get_vrf_name() + if vrf_name: + filename = f'vrf-{vrf_name}.conf' + else: + filename = 'default.conf' + return service_conf_dir / filename def get_config(config=None): @@ -39,7 +57,14 @@ def get_config(config=None): else: conf = Config() - base = ['protocols', 'failover'] + vrf_name = get_vrf_name() + if vrf_name: + base = ['vrf', 'name', vrf_name] + else: + base = [] + + base += ['protocols', 'failover'] + failover = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) @@ -47,6 +72,9 @@ def get_config(config=None): if failover.get('route') is not None: failover = conf.merge_defaults(failover, recursive=True) + if failover: + failover['vrf_context'] = vrf_name + return failover def verify(failover): @@ -57,51 +85,106 @@ def verify(failover): if 'route' not in failover: raise ConfigError(f'Failover "route" is mandatory!') - for route, route_config in failover['route'].items(): - if not route_config.get('next_hop'): - raise ConfigError(f'Next-hop for "{route}" is mandatory!') - - for next_hop, next_hop_config in route_config.get('next_hop').items(): - if 'interface' not in next_hop_config: - raise ConfigError(f'Interface for route "{route}" next-hop "{next_hop}" is mandatory!') - - if not next_hop_config.get('check'): - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') + def _verify_route_item(item_config, item_description, interface_mandatory): + if interface_mandatory and 'interface' not in item_config: + raise ConfigError( + f'Interface for route "{route}" {item_description} is mandatory!' + ) + + if not item_config.get('check'): + raise ConfigError(f'Check target for {item_description} is mandatory!') + + if 'target' not in item_config['check']: + raise ConfigError(f'Check target for {item_description} is mandatory!') + + check_type = item_config['check']['type'] + if check_type == 'tcp' and 'port' not in item_config['check']: + raise ConfigError( + f'Check port for {item_description} and type TCP is mandatory!' + ) + + errors = { + 'icmp': {}, + 'tcp': { + 'interface': 'Check target "interface" option does nothing for type TCP. Use "vrf" if needed', + }, + 'arp': { + 'vrf': 'Check target "vrf" option is incompatible with type ARP, use "interface" option if needed', + }, + } + + for target, target_config in item_config['check']['target'].items(): + for key, msg in errors[check_type].items(): + if key in target_config: + raise ConfigError(msg) - if 'target' not in next_hop_config['check']: - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') - - check_type = next_hop_config['check']['type'] - if check_type == 'tcp' and 'port' not in next_hop_config['check']: - raise ConfigError(f'Check port for next-hop "{next_hop}" and type TCP is mandatory!') + for route, route_config in failover['route'].items(): + if not route_config.get('next_hop') and not route_config.get('dhcp_interface'): + raise ConfigError( + f'Either next-hop or dhcp-interface for "{route}" is mandatory!' + ) + + if route_config.get('next_hop'): + for next_hop, next_hop_config in route_config.get('next_hop').items(): + _verify_route_item( + next_hop_config, f'next-hop "{next_hop}"', interface_mandatory=True + ) + + if route_config.get('dhcp_interface'): + for dhcp_interface, dhcp_interface_config in route_config.get( + 'dhcp_interface' + ).items(): + _verify_route_item( + dhcp_interface_config, + f'dhcp-interface "{dhcp_interface}"', + interface_mandatory=False, + ) return None + def generate(failover): + service_conf = get_service_conf_path() if not failover: service_conf.unlink(missing_ok=True) + try: + os.rmdir(service_conf_dir) + # Ignore if directory doesn't exist + # or not empty (probably configs for other VRFs are there) + except (FileNotFoundError, OSError): + pass return None # Add own rt_proto 'failover' # Helps to detect all own routes 'proto failover' - with open(rt_proto_failover, 'w') as f: - f.write('111 failover\n') + rt_proto_failover.write_text('111 failover\n') + + service_conf_dir.mkdir(exist_ok=True) # Write configuration file conf_json = json.dumps(failover, indent=4) service_conf.write_text(conf_json) - render(systemd_service, 'protocols/systemd_vyos_failover_service.j2', failover) + render( + systemd_service, + 'protocols/systemd_vyos_failover_service.j2', + {'config_dir': str(service_conf_dir)}, + ) return None def apply(failover): - if not failover: + # If directory is removed - we can stop the service + if not service_conf_dir.is_dir(): call(f'systemctl stop {service_name}.service') - call('ip route flush protocol failover') - else: call('systemctl daemon-reload') - call(f'systemctl restart {service_name}.service') - call(f'ip route flush protocol failover') + # Otherwise even if `failover` is False, service is + # still needed for other VRFs. + else: + # Daemon watches for configuration updates, so we need only + # to start it if it is not started yet + if not is_systemd_service_running(service_name): + call('systemctl daemon-reload') + call(f'systemctl start {service_name}.service') return None diff --git a/src/conf_mode/protocols_igmp-proxy.py b/src/conf_mode/protocols_igmp-proxy.py index 9a07adf05..7f53882b0 100755 --- a/src/conf_mode/protocols_igmp-proxy.py +++ b/src/conf_mode/protocols_igmp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,6 +21,7 @@ from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_interface_exists +from vyos.defaults import config_files from vyos.template import render from vyos.utils.process import call from vyos.utils.dict import dict_search @@ -28,7 +29,7 @@ from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = r'/etc/igmpproxy.conf' +config_file = config_files['igmp_proxy'] def get_config(config=None): if config: @@ -87,18 +88,18 @@ def generate(igmp_proxy): return None render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy) - return None def apply(igmp_proxy): + service_name = 'igmpproxy.service' if not igmp_proxy or 'disable' in igmp_proxy: - # IGMP Proxy support is removed in the commit - call('systemctl stop igmpproxy.service') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call('systemctl restart igmpproxy.service') + # IGMP Proxy support is removed in the commit + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + return None + call(f'systemctl restart {service_name}') return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 1c994492e..3812515a1 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -47,8 +47,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - isis = vrf and config_dict['vrf']['name'][vrf]['protocols']['isis'] or config_dict['isis'] + # equivalent of the C foo ? 'a' : 'b' statement + isis = vrf and dict_search(f'vrf.name.{vrf}.protocols.isis', + config_dict) or config_dict['isis'] isis['policy'] = config_dict['policy'] if 'deleted' in isis: @@ -68,7 +69,7 @@ def verify(config_dict): if 'interface' not in isis: raise ConfigError('Interface used for routing updates is mandatory!') - for interface in isis['interface']: + for interface, interface_config in isis['interface'].items(): verify_interface_exists(isis, interface) # Interface MTU must be >= configured lsp-mtu mtu = Interface(interface).get_mtu() @@ -90,6 +91,27 @@ def verify(config_dict): if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') + # Fast reroute validation + # LFA and TI-LFA of the same level can not be configured on the same interface + # To configure Remote LFA, LFA of the same level should be configured on this interface. + if 'fast_reroute' in interface_config: + isis_frr_config = interface_config['fast_reroute'] + levels = ['level_1', 'level_2'] + if 'lfa' and 'ti_lfa' in isis_frr_config: + for isis_level in levels: + if ((dict_search(f'lfa.{isis_level}.enable', isis_frr_config) is not None) + and (dict_search(f'ti_lfa.{isis_level}', isis_frr_config) is not None)): + raise ConfigError( + f'LFA and TI-LFA at the "{str(isis_level).replace("_","-")}" ' + f'can not be configured on the same interface "{interface}"!') + if 'remote_lfa' in isis_frr_config: + for isis_level in levels: + if ((dict_search(f'remote_lfa.{isis_level}', isis_frr_config) is not None) + and (dict_search(f'lfa.{isis_level}.enable', isis_frr_config) is None)): + raise ConfigError( + f'To configure Remote LFA, LFA at the same level ' + f'should be configured on interface "{interface}"!') + # If md5 and plaintext-password set at the same time for password in ['area_password', 'domain_password']: if password in isis: @@ -230,6 +252,22 @@ def verify(config_dict): if int(len(isis['fast_reroute']['lfa']['remote']['prefix_list'].items())) > 1: raise ConfigError(f'LFA remote prefix-list has more than one configured. Cannot have more than one configured.') + # Check for lsp-timers violations + # Must be in sync with FRR yang limitations in yang/frr-isisd.yang + if int(isis['lsp_gen_interval']) >= int(isis['lsp_refresh_interval']): + raise ConfigError(f'lsp-gen-interval must be less then lsp-refresh-interval') + if int(isis['max_lsp_lifetime']) < int(isis['lsp_refresh_interval']) + 300: + raise ConfigError( + f'max-lsp-lifetime must be greater or equal to lsp-refresh-interval + 300' + ) + + # Check IS-IS SRv6 + if dict_search('segment_routing.srv6', isis): + # The interface used to install SRv6 SIDs in the Linux data plane. + # https://docs.frrouting.org/en/stable-10.2/isisd.html#clicmd-interface-NAME + if not dict_search('segment_routing.srv6.interface', isis): + raise ConfigError('Missing interface used for installing SRv6 SIDs') + return None def generate(config_dict): diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 33d9a6dae..841e5406f 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -85,21 +85,21 @@ def apply(config_dict): labels = '0' if 'interface' in mpls: labels = '1048575' - sysctl_write('net.mpls.platform_labels', labels) + sysctl_write(['net', 'mpls', 'platform_labels'], labels) # Check for changes in global MPLS options if 'parameters' in mpls: # Choose whether to copy IP TTL to MPLS header TTL if 'no_propagate_ttl' in mpls['parameters']: - sysctl_write('net.mpls.ip_ttl_propagate', 0) + sysctl_write(['net', 'mpls', 'ip_ttl_propagate'], 0) # Choose whether to limit maximum MPLS header TTL if 'maximum_ttl' in mpls['parameters']: ttl = mpls['parameters']['maximum_ttl'] - sysctl_write('net.mpls.default_ttl', ttl) + sysctl_write(['net', 'mpls', 'default_ttl'], ttl) else: # Set default global MPLS options if not defined. - sysctl_write('net.mpls.ip_ttl_propagate', 1) - sysctl_write('net.mpls.default_ttl', 255) + sysctl_write(['net', 'mpls', 'ip_ttl_propagate'], 1) + sysctl_write(['net', 'mpls', 'default_ttl'], 255) # Enable and disable MPLS processing on interfaces per configuration if 'interface' in mpls: @@ -112,20 +112,17 @@ def apply(config_dict): interface_state = read_file(f'/proc/sys/net/mpls/conf/{system_interface}/input') if '1' in interface_state: if system_interface not in mpls['interface']: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 0) elif '0' in interface_state: if system_interface in mpls['interface']: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 1) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 1) else: system_interfaces = [] # If MPLS interfaces are not configured, set MPLS processing disabled for interface in glob('/proc/sys/net/mpls/conf/*'): system_interfaces.append(os.path.basename(interface)) for system_interface in system_interfaces: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 0) return None diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index ac92c9d99..3901b20ba 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-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -92,7 +92,7 @@ def verify(config_dict): nbma_list.append(nbma_ip) else: raise ConfigError( - f'Nbma address {nbma_ip} cannot be maped to several tunnel-ip') + f'Nbma address {nbma_ip} cannot be mapped to several tunnel-ip') return None diff --git a/src/conf_mode/protocols_openfabric.py b/src/conf_mode/protocols_openfabric.py index 7df11fb20..f490d28bf 100644 --- a/src/conf_mode/protocols_openfabric.py +++ b/src/conf_mode/protocols_openfabric.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index c06c0aafc..b20cea25a 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,7 @@ from sys import exit from sys import argv +from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_route_map @@ -48,8 +49,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - ospf = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospf'] or config_dict['ospf'] + # equivalent of the C foo ? 'a' : 'b' statement + ospf = vrf and dict_search(f'vrf.name.{vrf}.protocols.ospf', + config_dict) or config_dict['ospf'] ospf['policy'] = config_dict['policy'] verify_common_route_maps(ospf) @@ -60,20 +62,30 @@ def verify(config_dict): # Validate if configured Access-list exists if 'area' in ospf: - networks = [] - for area, area_config in ospf['area'].items(): - if 'import_list' in area_config: - acl_import = area_config['import_list'] - if acl_import: verify_access_list(acl_import, ospf) - if 'export_list' in area_config: - acl_export = area_config['export_list'] - if acl_export: verify_access_list(acl_export, ospf) - - if 'network' in area_config: - for network in area_config['network']: - if network in networks: - raise ConfigError(f'Network "{network}" already defined in different area!') - networks.append(network) + networks = [] + for area, area_config in ospf['area'].items(): + # Implemented as warning to not break existing configurations + if area == '0' and dict_search('area_type.nssa', area_config) != None: + Warning('You cannot configure NSSA to backbone!') + # Implemented as warning to not break existing configurations + if area == '0' and dict_search('area_type.stub', area_config) != None: + Warning('You cannot configure STUB to backbone!') + # Implemented as warning to not break existing configurations + if len(area_config['area_type']) > 1: + Warning(f'Only one area-type is supported for area "{area}"!') + + if 'import_list' in area_config: + if acl_import := area_config['import_list']: + verify_access_list(acl_import, ospf) + if 'export_list' in area_config: + if acl_export := area_config['export_list']: + verify_access_list(acl_export, ospf) + + if 'network' in area_config: + for network in area_config['network']: + if network in networks: + raise ConfigError(f'Network "{network}" already defined in different area!') + networks.append(network) if 'interface' in ospf: for interface, interface_config in ospf['interface'].items(): @@ -90,8 +102,17 @@ def verify(config_dict): if 'area' in ospf and 'area' in interface_config: for area, area_config in ospf['area'].items(): if 'network' in area_config: - raise ConfigError('Can not use OSPF interface area and area ' \ - 'network configuration at the same time!') + raise ConfigError('Can not use OSPF "interface area" and ' \ + '"area network" configuration at the same time!') + + # FRR only allows a single authentication mode (MD5, NULL or plaintext) + # at a time. Prevent users from defining more than one authentication mode. + if 'authentication' in interface_config: + auth_keys = set(interface_config['authentication']) + exclusive_auth_keys = {'md5', 'null', 'plaintext_password'} + if len(auth_keys & exclusive_auth_keys) >= 2: + raise ConfigError('Can not use multiple authentication modes ' + f'simultaneously for interface "{interface}"!') # If interface specific options are set, we must ensure that the # interface is bound to our requesting VRF. Due to the VyOS diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index 2563eb7d5..acf6cadfb 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -48,8 +48,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - ospfv3 = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospfv3'] or config_dict['ospfv3'] + # equivalent of the C foo ? 'a' : 'b' statement + ospfv3 = vrf and dict_search(f'vrf.name.{vrf}.protocols.ospfv3', + config_dict) or config_dict['ospfv3'] ospfv3['policy'] = config_dict['policy'] verify_common_route_maps(ospfv3) diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 632099964..bb55aada0 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py index 03a79139a..f7803246a 100755 --- a/src/conf_mode/protocols_pim6.py +++ b/src/conf_mode/protocols_pim6.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index ec9dfbb8b..c6adcde5b 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index 9a9ac8ec8..e5babf2e8 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index ef0250e3d..81039d3da 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,7 @@ import os from glob import glob from sys import exit +from sys import argv from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict @@ -25,6 +26,7 @@ from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.pki import wrap_openssh_public_key from vyos.pki import wrap_openssh_private_key +from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.file import write_file from vyos.utils.process import is_systemd_service_running @@ -39,13 +41,19 @@ def get_config(config=None): conf = config else: conf = Config() - return get_frrender_dict(conf) + return get_frrender_dict(conf, argv) def verify(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # equivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and dict_search(f'vrf.name.{vrf}.protocols.rpki', + config_dict) or config_dict['rpki'] if 'cache' in rpki: preferences = [] @@ -79,7 +87,13 @@ def generate(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # equivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and dict_search(f'vrf.name.{vrf}.protocols.rpki', + config_dict) or config_dict['rpki'] if 'cache' in rpki: for cache, cache_config in rpki['cache'].items(): diff --git a/src/conf_mode/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py index f2bd42a79..b9689557e 100755 --- a/src/conf_mode/protocols_segment-routing.py +++ b/src/conf_mode/protocols_segment-routing.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit +from sys import argv from vyos.config import Config from vyos.configdict import list_diff @@ -35,7 +36,7 @@ def get_config(config=None): else: conf = Config() - return get_frrender_dict(conf) + return get_frrender_dict(conf, argv) def verify(config_dict): if not has_frr_protocol_in_dict(config_dict, 'segment_routing'): @@ -45,13 +46,68 @@ def verify(config_dict): if 'srv6' in sr: srv6_enable = False - if 'interface' in sr: - for interface, interface_config in sr['interface'].items(): - if 'srv6' in interface_config: - srv6_enable = True - break + for _, interface_config in dict_search('interface', sr, {}).items(): + if 'srv6' in interface_config: + srv6_enable = True + break if not srv6_enable: raise ConfigError('SRv6 should be enabled on at least one interface!') + + # Check for database import having more than one protocol + if tmp := dict_search('traffic_engineering.database_import_protocol', sr): + if {'isis', 'ospf'} <= set(tmp.keys()): + raise ConfigError('SR-TE database import: IS-IS and OSPF are mutually exclusive!') + + for segment_list in dict_search('traffic_engineering.segment_list', sr, []): + sl_data = dict_search(f'traffic_engineering.segment_list.{segment_list}', sr) + indices = sl_data.get('index') if sl_data else None + + if indices is None: + raise ConfigError(f'SR-TE segment list "{segment_list}": '\ + 'at least one index is required!') + + for index, index_data in indices.items(): + error_msg = f'SR-TE segment list "{segment_list}", index "{index}"' + nai = index_data.get('nai') + mpls = index_data.get('mpls') + if not nai and not mpls: + raise ConfigError(f'{error_msg}: "mpls" or "nai" is required!') + + if nai: + if 'adjacency' in nai and 'prefix' in nai: + raise ConfigError(f'{error_msg}: "prefix" and "adjacency" are mutually exclusive!') + + for nai_type in ('adjacency', 'prefix'): + nai_data = nai.get(nai_type) + if not nai_data: + continue + + if 'ipv4' in nai_data and 'ipv6' in nai_data: + raise ConfigError(f'{error_msg}, nai {nai_type}: "ipv4" and "ipv6" are ' + 'mutually exclusive!') + + for af, af_config in nai_data.items(): + af_ctx = f'{error_msg}, nai {nai_type} {af}' + if nai_type == 'adjacency': + has_src = 'source_identifier' in af_config + has_dst = 'destination_identifier' in af_config + if has_src != has_dst: + missing = 'destination-identifier' if has_src else 'source-identifier' + raise ConfigError(f'{af_ctx}: "{missing}" is required!') + else: + if 'prefix_identifier' not in af_config: + raise ConfigError(f'{af_ctx}: "prefix-identifier" is required!') + + for pfx, pfx_data in af_config['prefix_identifier'].items(): + pfx_ctx = f'{af_ctx}, prefix "{pfx}"' + if 'algorithm' not in pfx_data: + raise ConfigError(f'{pfx_ctx}: "algorithm" is required!') + + if alg := pfx_data.get('algorithm'): + if {'spf', 'strict_spf'} <= set(alg.keys()): + raise ConfigError(f'{pfx_ctx}: "spf" and "strict-spf" ' + 'are mutually exclusive!') + return None def generate(config_dict): @@ -70,24 +126,24 @@ def apply(config_dict): for interface in list_diff(current_interfaces, sr_interfaces): # Disable processing of IPv6-SR packets - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '0') for interface, interface_config in sr.get('interface', {}).items(): # Accept or drop SR-enabled IPv6 packets on this interface if 'srv6' in interface_config: - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '1') # Define HMAC policy for ingress SR-enabled packets on this interface # It's a redundant check as HMAC has a default value - but better safe # then sorry tmp = dict_search('srv6.hmac', interface_config) if tmp == 'accept': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '0') elif tmp == 'drop': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '1') elif tmp == 'ignore': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '-1') else: - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '0') if config_dict and not is_systemd_service_running('vyos-configd.service'): FRRender().apply() diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index 1b9e51167..d84cfd77f 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,7 @@ from ipaddress import IPv4Network from sys import exit from sys import argv +import os from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict @@ -24,13 +25,17 @@ from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_vrf from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file from vyos.utils.process import is_systemd_service_running from vyos.template import render from vyos import ConfigError from vyos import airbag +from vyos import defaults airbag.enable() config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf' +DHCP_HOOK_IFLIST = defaults.static_route_dhcp_interfaces_path def get_config(config=None): if config: @@ -48,8 +53,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] + # equivalent of the C foo ? 'a' : 'b' statement + static = vrf and dict_search(f'vrf.name.{vrf}.protocols.static', + config_dict) or config_dict['static'] static['policy'] = config_dict['policy'] verify_common_route_maps(static) @@ -89,8 +95,25 @@ def generate(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] + # equivalent of the C foo ? 'a' : 'b' statement + static = vrf and dict_search(f'vrf.name.{vrf}.protocols.static', + config_dict) or config_dict['static'] + + # Collect interfaces that have DHCP configuration for DHCP hooks + dhcp_interfaces = set() + + # Check for DHCP interfaces in route configurations + if 'route' in static: + for prefix, prefix_options in static['route'].items(): + if 'dhcp_interface' in prefix_options: + for interface_name in prefix_options['dhcp_interface']: + dhcp_interfaces.add(interface_name) + + # Write the interface list for DHCP hooks or clean up if empty + if dhcp_interfaces: + write_file(DHCP_HOOK_IFLIST, " ".join(dhcp_interfaces)) + elif os.path.exists(DHCP_HOOK_IFLIST): + os.unlink(DHCP_HOOK_IFLIST) # Put routing table names in /etc/iproute2/rt_tables render(config_file, 'iproute2/static.conf.j2', static) diff --git a/src/conf_mode/protocols_static_arp.py b/src/conf_mode/protocols_static_arp.py index b141f1141..87dc5229e 100755 --- a/src/conf_mode/protocols_static_arp.py +++ b/src/conf_mode/protocols_static_arp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_static_neighbor-proxy.py b/src/conf_mode/protocols_static_neighbor-proxy.py index 8a1ea1df9..bda737e75 100755 --- a/src/conf_mode/protocols_static_neighbor-proxy.py +++ b/src/conf_mode/protocols_static_neighbor-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/protocols_traffic_engineering.py b/src/conf_mode/protocols_traffic_engineering.py new file mode 100755 index 000000000..925585158 --- /dev/null +++ b/src/conf_mode/protocols_traffic_engineering.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.utils.process import is_systemd_service_running +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + return get_frrender_dict(conf) + + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'traffic_engineering'): + return None + + te = config_dict['traffic_engineering'] + + group_by_bit_position = {} + if 'admin_group' in te: + for admin_group, admin_group_data in te['admin_group'].items(): + if 'bit_position' not in admin_group_data: + raise ConfigError( + f'Missing required "bit-position" in group {admin_group}' + ) + if admin_group_data['bit_position'] in group_by_bit_position: + other = group_by_bit_position[admin_group_data['bit_position']] + raise ConfigError( + f'Two admin-groups cannot have same bit positions! Conflicting groups: {admin_group} and {other}' + ) + group_by_bit_position[admin_group_data['bit_position']] = admin_group + + all_groups = group_by_bit_position.values() + + if 'interface' in te: + for interface, interface_data in te['interface'].items(): + if 'admin_group' not in interface_data: + continue + for grp in interface_data['admin_group']: + if grp not in all_groups: + raise ConfigError( + f'Unknown admin-group "{grp}" set for interface "{interface}"' + ) + + return None + + +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) + return None + + +def apply(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'traffic_engineering'): + return None + + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + 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/qos.py b/src/conf_mode/qos.py index 59e307a39..35b9c0aa2 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,7 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit -from netifaces import interfaces + +from netifaces import interfaces # pylint: disable = no-name-in-module from vyos.base import Warning from vyos.config import Config @@ -85,7 +86,13 @@ def _clean_conf_dict(conf): } """ if isinstance(conf, dict): - return {node: _clean_conf_dict(val) for node, val in conf.items() if val != {} and _clean_conf_dict(val) != {}} + preserve_empty_nodes = {'syn', 'ack'} + + return { + node: _clean_conf_dict(val) + for node, val in conf.items() + if (val != {} and _clean_conf_dict(val) != {}) or node in preserve_empty_nodes + } else: return conf @@ -357,7 +364,7 @@ def apply(qos): for interface, interface_config in qos['interface'].items(): if not verify_interface_exists(qos, interface, state_required=True, warning_only=True): # 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. + # possible that it is yet not available when to QoS code runs. # Skip the configuration and inform the user via warning_only=True continue diff --git a/src/conf_mode/service_aws_glb.py b/src/conf_mode/service_aws_glb.py index d1ed5a07b..aa5ec5ebe 100755 --- a/src/conf_mode/service_aws_glb.py +++ b/src/conf_mode/service_aws_glb.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_broadcast-relay.py b/src/conf_mode/service_broadcast-relay.py index d35954718..b3f38dd21 100755 --- a/src/conf_mode/service_broadcast-relay.py +++ b/src/conf_mode/service_broadcast-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,7 +17,7 @@ import os from glob import glob -from netifaces import AF_INET +from socket import AF_INET from sys import exit from vyos.config import Config diff --git a/src/conf_mode/service_config-sync.py b/src/conf_mode/service_config-sync.py index 4b8a7f6ee..32001ce57 100755 --- a/src/conf_mode/service_config-sync.py +++ b/src/conf_mode/service_config-sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_conntrack-sync.py b/src/conf_mode/service_conntrack-sync.py index 3a233a172..5eb4ca0e5 100755 --- a/src/conf_mode/service_conntrack-sync.py +++ b/src/conf_mode/service_conntrack-sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py index b83c6dfb1..595d7888a 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -46,7 +46,7 @@ def get_config(config=None): # 'stop_bits': '2'}}} # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. proxy = conf.merge_defaults(proxy, recursive=True) return proxy diff --git a/src/conf_mode/service_dhcp-relay.py b/src/conf_mode/service_dhcp-relay.py index 37d708847..255e2b143 100755 --- a/src/conf_mode/service_dhcp-relay.py +++ b/src/conf_mode/service_dhcp-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,8 +20,8 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.base import Warning from vyos.utils.process import call from vyos.utils.dict import dict_search from vyos import ConfigError @@ -61,15 +61,19 @@ def verify(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>') + + for interface in relay['interface']: + verify_interface_exists(relay, interface, warning_only=True) 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') + for iface_type in ['upstream_interface', 'listen_interface']: + for interface in relay.get(iface_type, []): + verify_interface_exists(relay, interface, warning_only=True) + return None def generate(relay): diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 5a729af74..24df20bb7 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,23 +15,28 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import re + +from sys import exit +from sys import argv from glob import glob from ipaddress import ip_address from ipaddress import ip_network from netaddr import IPRange -from sys import exit from vyos.config import Config +from vyos.kea import kea_test_config from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive from vyos.utils.file import chmod_775 -from vyos.utils.file import chown from vyos.utils.file import makedir from vyos.utils.file import write_file +from vyos.utils.permission import chown from vyos.utils.process import call from vyos.utils.network import interface_exists from vyos.utils.network import is_subnet_connected @@ -41,16 +46,53 @@ from vyos import airbag airbag.enable() -ctrl_config_file = '/run/kea/kea-ctrl-agent.conf' -ctrl_socket = '/run/kea/dhcp4-ctrl-socket' -config_file = '/run/kea/kea-dhcp4.conf' -lease_file = '/config/dhcp/dhcp4-leases.csv' -lease_file_glob = '/config/dhcp/dhcp4-leases*' +ctrl_socket = '' +config_file = '' +config_file_d2 = '' +lease_file = '' +lease_file_glob = '' + +ca_cert_file = '' +cert_file = '' +cert_key_file = '' + user_group = '_kea' -ca_cert_file = '/run/kea/kea-failover-ca.pem' -cert_file = '/run/kea/kea-failover.pem' -cert_key_file = '/run/kea/kea-failover-key.pem' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override global vars when vrf is enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = f'/run/kea/dhcp4-{vrf_name}-ctrl-socket' + config_file = f'/run/kea/kea-{vrf_name}-dhcp4.conf' + config_file_d2 = f'/run/kea/kea-{vrf_name}-dhcp-ddns.conf' + lease_file = f'/config/dhcp/dhcp4-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp4-{vrf_name}-leases*' + + ca_cert_file = f'/run/kea/kea-{vrf_name}-failover-ca.pem' + cert_file = f'/run/kea/kea-{vrf_name}-failover.pem' + cert_key_file = f'/run/kea/kea-{vrf_name}-failover-key.pem' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = '/run/kea/dhcp4-ctrl-socket' + config_file = '/run/kea/kea-dhcp4.conf' + config_file_d2 = '/run/kea/kea-dhcp-ddns.conf' + lease_file = '/config/dhcp/dhcp4-leases.csv' + lease_file_glob = '/config/dhcp/dhcp4-leases*' + + ca_cert_file = '/run/kea/kea-failover-ca.pem' + cert_file = '/run/kea/kea-failover.pem' + cert_key_file = '/run/kea/kea-failover-key.pem' def dhcp_slice_range(exclude_list, range_dict): @@ -125,7 +167,19 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['service', 'dhcp-server'] + + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcp-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcp-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None @@ -137,6 +191,10 @@ def get_config(config=None): with_recursive_defaults=True, ) + # add vrf context if present + if argv and len(argv) > 1: + dhcp['vrf_context'] = argv[1] + if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): if 'subnet' in network_config: @@ -169,8 +227,20 @@ def get_config(config=None): no_tag_node_value_mangle=True, ) + if bool(list(dict_search_recursive(dhcp, 'ping_check'))): + dhcp['any_ping_check'] = True + return dhcp +def verify_ddns_domain_servers(domain_type, domain): + if 'dns_server' in domain: + invalid_servers = [] + for server_no, server_config in domain['dns_server'].items(): + if 'address' not in server_config: + invalid_servers.append(server_no) + if len(invalid_servers) > 0: + raise ConfigError(f'{domain_type} DNS servers {", ".join(invalid_servers)} in DDNS configuration need to have an IP address') + return None def verify(dhcp): # bail out early - looks like removal from running config @@ -222,6 +292,12 @@ def verify(dhcp): f'DHCP static-route "{route}" requires router to be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in subnet_config: + client_class = subnet_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist') + # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] @@ -231,6 +307,12 @@ def verify(dhcp): f'DHCP range "{range}" start and stop address must be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in range_config: + client_class = range_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist') + # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): @@ -423,6 +505,42 @@ def verify(dhcp): if not interface_exists(interface): raise ConfigError(f'listen-interface "{interface}" does not exist') + if 'dynamic_dns_update' in dhcp: + ddns = dhcp['dynamic_dns_update'] + if 'tsig_key' in ddns: + invalid_keys = [] + for tsig_key_name, tsig_key_config in ddns['tsig_key'].items(): + if not ('algorithm' in tsig_key_config and 'secret' in tsig_key_config): + invalid_keys.append(tsig_key_name) + if len(invalid_keys) > 0: + raise ConfigError(f'Both algorithm and secret need to be set for TSIG keys: {", ".join(invalid_keys)}') + + if 'forward_domain' in ddns: + verify_ddns_domain_servers('Forward', ddns['forward_domain']) + + if 'reverse_domain' in ddns: + verify_ddns_domain_servers('Reverse', ddns['reverse_domain']) + + if 'client_class' in dhcp: + # Check client class values are valid + for class_name, class_config in dhcp['client_class'].items(): + if 'relay_agent_information' in class_config: + relay_agent_information_config = class_config['relay_agent_information'] + # Compile a regex that will scan for valid inputs. Input can be + # either hex in the form 0x0123456789ABCDEF or a string that + # does *not* start with 0x. i.e. 0xHELLOWORLD is bad + pattern = re.compile(r'^(?:0x[0-9A-Fa-f]+|(?!0x).+)$') + + if 'circuit_id' in relay_agent_information_config: + circuit_id = relay_agent_information_config['circuit_id'] + if not pattern.match(circuit_id): + raise ConfigError(f'Invalid circuit-id "{circuit_id}" must be either text literal or hex string starting with 0x') + + if 'remote_id' in relay_agent_information_config: + remote_id = relay_agent_information_config['remote_id'] + if not pattern.match(remote_id): + raise ConfigError(f'Invalid remote-id "{remote_id}" must be either text literal or hex string starting with 0x') + return None @@ -480,25 +598,31 @@ def generate(dhcp): dhcp['high_availability']['ca_cert_file'] = ca_cert_file render( - ctrl_config_file, - 'dhcp-server/kea-ctrl-agent.conf.j2', - dhcp, - user=user_group, - group=user_group, - ) - render( config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp, user=user_group, group=user_group, ) + if 'dynamic_dns_update' in dhcp: + render( + config_file_d2, + 'dhcp-server/kea-dhcp-ddns.conf.j2', + dhcp, + user=user_group, + group=user_group + ) return None def apply(dhcp): - services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + services = [f'isc-kea-dhcp4-server@{vrf_name}', f'isc-kea-dhcp-ddns-server@{vrf_name}'] + else: + services = ['isc-kea-dhcp4-server', 'isc-kea-dhcp-ddns-server'] if not dhcp or 'disable' in dhcp: for service in services: @@ -509,13 +633,14 @@ def apply(dhcp): return None + result, output = kea_test_config('kea-dhcp4', config_file) + if not result: + raise ConfigError(f'Unexpected error with Kea configuration:\n{output}') + for service in services: action = 'restart' - if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: - action = 'stop' - - if service == 'kea-ctrl-agent' and 'high_availability' not in dhcp: + if 'isc-kea-dhcp-ddns-server' in service and 'dynamic_dns_update' not in dhcp: action = 'stop' call(f'systemctl {action} {service}.service') diff --git a/src/conf_mode/service_dhcpv6-relay.py b/src/conf_mode/service_dhcpv6-relay.py index 6537ca3c2..4547b608c 100755 --- a/src/conf_mode/service_dhcpv6-relay.py +++ b/src/conf_mode/service_dhcpv6-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 7af88007c..01bbf3096 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,42 +16,94 @@ import os +from sys import exit +from sys import argv + from glob import glob from ipaddress import ip_address from ipaddress import ip_network -from sys import exit from vyos.config import Config +from vyos.kea import kea_test_config from vyos.template import render from vyos.utils.process import call from vyos.utils.file import chmod_775 -from vyos.utils.file import chown from vyos.utils.file import makedir from vyos.utils.file import write_file from vyos.utils.dict import dict_search from vyos.utils.network import is_subnet_connected +from vyos.utils.permission import chown from vyos import ConfigError from vyos import airbag + airbag.enable() -config_file = '/run/kea/kea-dhcp6.conf' -ctrl_socket = '/run/kea/dhcp6-ctrl-socket' -lease_file = '/config/dhcp/dhcp6-leases.csv' -lease_file_glob = '/config/dhcp/dhcp6-leases*' + +config_file = '' +ctrl_socket = '' +lease_file = '' +lease_file_glob = '' + user_group = '_kea' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override some of the global vars + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = f'/run/kea/kea-{vrf_name}-dhcp6.conf' + ctrl_socket = f'/run/kea/dhcp6-{vrf_name}-ctrl-socket' + lease_file = f'/config/dhcp/dhcp6-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp6-{vrf_name}-leases*' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = '/run/kea/kea-dhcp6.conf' + ctrl_socket = '/run/kea/dhcp6-ctrl-socket' + lease_file = '/config/dhcp/dhcp6-leases.csv' + lease_file_glob = '/config/dhcp/dhcp6-leases*' + + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['service', 'dhcpv6-server'] + + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcpv6-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcpv6-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None - dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + dhcpv6 = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + # add vrf context if present + if argv and len(argv) > 1: + dhcpv6['vrf_context'] = argv[1] + return dhcpv6 def verify(dhcpv6): @@ -144,16 +196,21 @@ def verify(dhcpv6): if 'prefix_length' not in prefix_config: raise ConfigError('Length of delegated IPv6 prefix must be configured') - if prefix_config['prefix_length'] > prefix_config['delegated_length']: + prefix_len = prefix_config['prefix_length'] + prefix_obj = None + + if prefix_len > prefix_config['delegated_length']: raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') + try: + prefix_obj = ip_network(f'{prefix}/{prefix_len}') + except ValueError: + raise ConfigError('Invalid prefix-length for delegated prefix') + if 'excluded_prefix' in prefix_config: if 'excluded_prefix_length' not in prefix_config: raise ConfigError('Length of excluded IPv6 prefix must be configured') - prefix_len = prefix_config['prefix_length'] - prefix_obj = ip_network(f'{prefix}/{prefix_len}') - excluded_prefix = prefix_config['excluded_prefix'] excluded_len = prefix_config['excluded_prefix_length'] excluded_obj = ip_network(f'{excluded_prefix}/{excluded_len}') @@ -169,13 +226,18 @@ def verify(dhcpv6): for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ipv6_address' in mapping_config: # Static address must be in subnet - if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): - raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') + for address in mapping_config['ipv6_address']: + if ip_address(address) not in ip_network(subnet): + raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') + + if ('ipv6_address' not in mapping_config and 'ipv6_prefix' not in mapping_config): + raise ConfigError('Either IPv6 address or IPv6 prefix must be set for static mapping ' + f'"{mapping}" within shared-network "{network}, {subnet}"!') - if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ - ('mac' in mapping_config and 'duid' in mapping_config): - raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' - f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ + ('mac' in mapping_config and 'duid' in mapping_config): + raise ConfigError('Either MAC address or Client identifier (DUID) is required for ' + f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') if 'option' in subnet_config: if 'vendor_option' in subnet_config['option']: @@ -188,22 +250,22 @@ def verify(dhcpv6): subnets.append(subnet) - # DHCPv6 requires at least one configured address range or one static mapping - # (FIXME: is not actually checked right now?) + # DHCPv6 requires at least one configured address range or one static mapping + # (FIXME: is not actually checked right now?) - # There must be one subnet connected to a listen interface if network is not disabled. - if 'disable' not in network_config: - if is_subnet_connected(subnet): - listen_ok = True + # There must be one subnet connected to a listen interface if network is not disabled. + if 'disable' not in network_config: + if is_subnet_connected(subnet): + listen_ok = True - # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping - # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" - net = ip_network(subnet) - for n in subnets: - net2 = ip_network(n) - if (net != net2): - if net.overlaps(net2): - raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping + # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" + net = ip_network(subnet) + for n in subnets: + net2 = ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) if not listen_ok: raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ @@ -239,8 +301,14 @@ def generate(dhcpv6): return None def apply(dhcpv6): + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + service_name = f'isc-kea-dhcp6-server@{vrf_name}.service' + else: + service_name = 'isc-kea-dhcp6-server.service' + # bail out early - looks like removal from running config - service_name = 'kea-dhcp6-server.service' if not dhcpv6 or 'disable' in dhcpv6: # DHCP server is removed in the commit call(f'systemctl stop {service_name}') @@ -248,6 +316,10 @@ def apply(dhcpv6): os.unlink(config_file) return None + result, output = kea_test_config('kea-dhcp6', config_file) + if not result: + raise ConfigError(f'Unexpected error with Kea configuration:\n{output}') + call(f'systemctl restart {service_name}') return None diff --git a/src/conf_mode/service_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py index 5f5303856..b321d5f51 100755 --- a/src/conf_mode/service_dns_dynamic.py +++ b/src/conf_mode/service_dns_dynamic.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py index e3bdbc9f8..cd0c6a38a 100755 --- a/src/conf_mode/service_dns_forwarding.py +++ b/src/conf_mode/service_dns_forwarding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -366,6 +366,13 @@ def apply(dns): hc.add_name_server_tags_recursor(['dhcp-' + interface, 'dhcpv6-' + interface ]) + # add dhcp interfaces + if 'dhcp' in dns: + for interface in dns['dhcp']: + if interface_exists(interface): + hc.add_name_server_tags_recursor(['dhcp-' + interface, + 'dhcpv6-' + interface ]) + # hostsd will generate the forward-zones file # the list and keys() are required as get returns a dict, not list hc.delete_forward_zones(list(hc.get_forward_zones().keys())) diff --git a/src/conf_mode/service_event-handler.py b/src/conf_mode/service_event-handler.py index 5028ef52f..1b9e7ff53 100755 --- a/src/conf_mode/service_event-handler.py +++ b/src/conf_mode/service_event-handler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 9e58b4c72..13a4930fd 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -28,6 +28,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate from vyos.configverify import verify_pki_dh_parameters +from vyos.configdiff import get_config_diff from vyos.defaults import api_config_state from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key @@ -68,17 +69,25 @@ def get_config(config=None): # store path to API config file for later use in templates https['api_config_state'] = api_config_state - # get fully qualified system hsotname + # get fully qualified system hostname https['hostname'] = socket.getfqdn() # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**https.kwargs, recursive=True) if 'api' not in https or 'graphql' not in https['api']: del default_values['api'] # merge CLI and default dictionary https = config_dict_merge(default_values, https) + + # some settings affecting nginx will require a restart: + # for example, a reload will not suffice when binding the listen address + # after nginx has started and dropped privileges; add flag here + diff = get_config_diff(conf) + children_changed = diff.node_changed_children(base) + https['nginx_restart_required'] = bool(set(children_changed) != set(['api'])) + return https def verify(https): @@ -98,18 +107,24 @@ def verify(https): Warning('No certificate specified, using build-in self-signed certificates. '\ 'Do not use them in a production environment!') - # Check if server port is already in use by a different appliaction + # Check if server port is already in use by a different application listen_address = ['0.0.0.0'] port = int(https['port']) if 'listen_address' in https: listen_address = https['listen_address'] - for address in listen_address: - if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'): - raise ConfigError(f'TCP port "{port}" is used by another service!') - verify_vrf(https) + vrf = https.get('vrf', None) + for address in listen_address: + if (not check_port_availability(address, port, 'tcp', vrf=vrf) + and not is_listen_port_bind_service(port, 'nginx')): + vrf_error_msg = '' + if vrf: + vrf_error_msg = f' in vrf "{vrf}"' + raise ConfigError(f'TCP port "{port}"{vrf_error_msg} is already ' \ + 'used by another service!') + # Verify API server settings, if present if 'api' in https: keys = dict_search('api.keys.id', https) @@ -208,7 +223,10 @@ def apply(https): elif is_systemd_service_active(http_api_service_name): call(f'systemctl stop {http_api_service_name}') - call(f'systemctl reload-or-restart {https_service_name}') + if https['nginx_restart_required']: + call(f'systemctl restart {https_service_name}') + else: + call(f'systemctl reload-or-restart {https_service_name}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_ids_ddos-protection.py b/src/conf_mode/service_ids_ddos-protection.py deleted file mode 100755 index 276a71fcb..000000000 --- a/src/conf_mode/service_ids_ddos-protection.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/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/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: - conf = config - else: - conf = Config() - base = ['service', 'ids', 'ddos-protection'] - if not conf.exists(base): - return None - - fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return fastnetmon - -def verify(fastnetmon): - if not fastnetmon: - return None - - if 'mode' not in fastnetmon: - raise ConfigError('Specify operating mode!') - - 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']): - # Check script permissions - if not os.access(fastnetmon['alert_script'], os.X_OK): - raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) - else: - raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) - -def generate(fastnetmon): - if not fastnetmon: - for file in [config_file, networks_list]: - if os.path.isfile(file): - os.unlink(file) - - 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) - return None - -def apply(fastnetmon): - systemd_service = 'fastnetmon.service' - if not fastnetmon: - # Stop fastnetmon service if removed - 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/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index a14d4b5b6..360254828 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -29,6 +29,7 @@ from vyos.accel_ppp_util import verify_accel_ppp_name_servers from vyos.accel_ppp_util import verify_accel_ppp_wins_servers from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import verify_accel_ppp_authentication +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag @@ -58,6 +59,9 @@ def get_config(config=None): ) ipoe['server_type'] = 'ipoe' + + ipoe['vpp_ifaces'] = cli_ifaces_list(conf) + return ipoe @@ -69,6 +73,13 @@ def verify(ipoe): raise ConfigError('No IPoE interface configured') for interface, iface_config in ipoe['interface'].items(): + if ipoe.get('vpp_ifaces'): + base_interface = interface.split('.')[0] + if base_interface in ipoe['vpp_ifaces']: + raise ConfigError( + f'{interface} is a VPP interface and cannot be used for IPoE!' + ) + verify_interface_exists(ipoe, interface, warning_only=True) if 'client_subnet' in iface_config and 'vlan' in iface_config: raise ConfigError( @@ -88,6 +99,12 @@ def verify(ipoe): 'Can configure username with Lua script only for RADIUS authentication' ) + if dict_search('external_dhcp.dhcp_relay', iface_config): + if not dict_search('external_dhcp.giaddr', iface_config): + raise ConfigError( + f'"external-dhcp dhcp-relay" requires "giaddr" to be set for interface {interface}' + ) + verify_accel_ppp_authentication(ipoe, local_users=False) verify_accel_ppp_ip_pool(ipoe) verify_accel_ppp_name_servers(ipoe) diff --git a/src/conf_mode/service_lldp.py b/src/conf_mode/service_lldp.py index 04b1db880..50e9a49e6 100755 --- a/src/conf_mode/service_lldp.py +++ b/src/conf_mode/service_lldp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_mdns_repeater.py b/src/conf_mode/service_mdns_repeater.py index b0ece031c..a6d9d0224 100755 --- a/src/conf_mode/service_mdns_repeater.py +++ b/src/conf_mode/service_mdns_repeater.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,7 +18,9 @@ import os from json import loads from sys import exit -from netifaces import ifaddresses, AF_INET, AF_INET6 +from socket import AF_INET +from socket import AF_INET6 +from netifaces import ifaddresses # pylint: disable = no-name-in-module from vyos.config import Config from vyos.configverify import verify_interface_exists @@ -58,7 +60,7 @@ def verify(mdns): if not mdns or 'disable' in mdns: return None - # We need at least two interfaces to repeat mDNS advertisments + # We need at least two interfaces to repeat mDNS advertisements if 'interface' not in mdns or len(mdns['interface']) < 2: raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') diff --git a/src/conf_mode/service_monitoring_network_event.py b/src/conf_mode/service_monitoring_network_event.py index 104e6ce23..8ae831b66 100644 --- a/src/conf_mode/service_monitoring_network_event.py +++ b/src/conf_mode/service_monitoring_network_event.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -43,7 +43,7 @@ def get_config(config=None): no_tag_node_value_mangle=True) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. monitoring = conf.merge_defaults(monitoring, recursive=True) return monitoring diff --git a/src/conf_mode/service_monitoring_prometheus.py b/src/conf_mode/service_monitoring_prometheus.py index 9a07d8593..b02f9f154 100755 --- a/src/conf_mode/service_monitoring_prometheus.py +++ b/src/conf_mode/service_monitoring_prometheus.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,6 +23,7 @@ from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag @@ -48,9 +49,21 @@ def get_config(config=None): if not conf.exists(base): return None - monitoring = conf.get_config_dict( - base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True - ) + monitoring = {} + exporters = { + 'node_exporter': base + ['node-exporter'], + 'frr_exporter': base + ['frr-exporter'], + 'blackbox_exporter': base + ['blackbox-exporter'], + } + + for exporter_name, exporter_base in exporters.items(): + if conf.exists(exporter_base): + monitoring[exporter_name] = conf.get_config_dict( + exporter_base, + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + ) tmp = is_node_changed(conf, base + ['node-exporter', 'vrf']) if tmp: @@ -161,11 +174,14 @@ def apply(monitoring): # Reload systemd manager configuration call('systemctl daemon-reload') if not monitoring or 'node_exporter' not in monitoring: - call(f'systemctl stop {node_exporter_systemd_service}') + if is_systemd_service_active(node_exporter_systemd_service): + call(f'systemctl stop {node_exporter_systemd_service}') if not monitoring or 'frr_exporter' not in monitoring: - call(f'systemctl stop {frr_exporter_systemd_service}') + if is_systemd_service_active(frr_exporter_systemd_service): + call(f'systemctl stop {frr_exporter_systemd_service}') if not monitoring or 'blackbox_exporter' not in monitoring: - call(f'systemctl stop {blackbox_exporter_systemd_service}') + if is_systemd_service_active(blackbox_exporter_systemd_service): + call(f'systemctl stop {blackbox_exporter_systemd_service}') if not monitoring: return diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index db870aae5..2271f240f 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -80,7 +80,7 @@ def get_config(config=None): if tmp: monitoring.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. monitoring = conf.merge_defaults(monitoring, recursive=True) monitoring['custom_scripts_dir'] = custom_scripts_dir @@ -198,7 +198,7 @@ def generate(monitoring): chown(cache_dir, 'telegraf', 'telegraf') - # Create custome scripts dir + # Create custom scripts dir if not os.path.exists(custom_scripts_dir): os.mkdir(custom_scripts_dir) diff --git a/src/conf_mode/service_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py index f17146a8d..5f3a8d4b5 100755 --- a/src/conf_mode/service_monitoring_zabbix-agent.py +++ b/src/conf_mode/service_monitoring_zabbix-agent.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_ndp-proxy.py b/src/conf_mode/service_ndp-proxy.py index 024ad79f2..672f98c71 100755 --- a/src/conf_mode/service_ndp-proxy.py +++ b/src/conf_mode/service_ndp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -48,18 +48,33 @@ def verify(ndpp): if not ndpp: return None - if 'interface' in ndpp: - for interface, interface_config in ndpp['interface'].items(): - verify_interface_exists(ndpp, interface) + if 'interface' not in ndpp: + return None + + for interface, interface_config in ndpp['interface'].items(): + if 'disable' in interface_config: + continue + + verify_interface_exists(ndpp, interface) + + if 'prefix' not in interface_config: + continue + + for prefix, prefix_config in interface_config['prefix'].items(): + if 'disable' in prefix_config: + continue + + mode = prefix_config.get('mode') + prefix_interface = prefix_config.get('interface') - if 'rule' in interface_config: - for rule, rule_config in interface_config['rule'].items(): - if rule_config['mode'] == 'interface' and 'interface' not in rule_config: - raise ConfigError(f'Rule "{rule}" uses interface mode but no interface defined!') + if mode == 'interface': + if not prefix_interface: + raise ConfigError(f'Prefix "{prefix}" uses interface mode but no interface defined!') + verify_interface_exists(ndpp, prefix_interface) + continue - if rule_config['mode'] != 'interface' and 'interface' in rule_config: - if interface_config['mode'] != 'interface' and 'interface' in interface_config: - raise ConfigError(f'Rule "{rule}" does not use interface mode, thus interface can not be defined!') + if prefix_interface: + raise ConfigError(f'Prefix "{prefix}" does not use interface mode, thus interface can not be defined!') return None diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 32563aa0e..e734eeb76 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,6 +21,7 @@ from vyos.config import config_dict_merge from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists +from vyos.netlink import timestamp from vyos.utils.process import call from vyos.utils.permission import chmod_750 from vyos.utils.network import get_interface_config @@ -51,7 +52,7 @@ def get_config(config=None): if tmp: ntp.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**ntp.kwargs, recursive=True) # Only defined PTP default port, if PTP feature is in use if 'ptp' not in ntp: @@ -65,9 +66,6 @@ def verify(ntp): if not ntp: return None - if 'server' not in ntp: - raise ConfigError('NTP server not configured') - verify_vrf(ntp) if 'interface' in ntp: @@ -105,6 +103,35 @@ def verify(ntp): else: break + if 'timestamp' in ntp: + for iface, iface_config in ntp['timestamp'].get('interface', {}).items(): + rx_filter = iface_config.get('receive_filter') + if iface != 'all': + verify_interface_exists(ntp, iface) + if rx_filter and rx_filter != 'none': + if iface == 'all': + any_supported = False + for real_iface in os.listdir('/sys/class/net'): + supported = timestamp.get_hw_timestamp_filters(real_iface) + if rx_filter in supported or 'all' in supported: + any_supported = True + break + if not any_supported: + raise ConfigError( + f'No interface supports hardware timestamp receive-filter "{rx_filter}"' + ) + else: + supported = timestamp.get_hw_timestamp_filters(iface) + if not supported: + raise ConfigError( + f'Interface "{iface}" does not support hardware timestamping' + ) + if rx_filter not in supported and 'all' not in supported: + raise ConfigError( + f'Interface "{iface}" does not support hardware timestamp ' + f'receive-filter "{rx_filter}", supported: {", ".join(sorted(supported))}' + ) + return None def generate(ntp): diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index ac697c509..ab9f8421c 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,10 +20,13 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict -from vyos.configdict import is_node_changed +from vyos.configdict import is_node_changed, node_changed +from vyos.configdiff import Diff from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_virtual_interface_exists from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active from vyos.utils.dict import dict_search from vyos.accel_ppp_util import verify_accel_ppp_name_servers from vyos.accel_ppp_util import verify_accel_ppp_wins_servers @@ -32,12 +35,19 @@ from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import get_pools_in_order from vyos import ConfigError from vyos import airbag +from vyos.vpp.control_vpp import VPPControl airbag.enable() pppoe_conf = r'/run/accel-pppd/pppoe.conf' pppoe_chap_secrets = r'/run/accel-pppd/pppoe.chap-secrets' + +def base_ifname(ifname): + # Get the base interface name without VLAN + return ifname.split('.')[0] + + def convert_pado_delay(pado_delay): new_pado_delay = {'delays_without_sessions': [], 'delays_with_sessions': []} @@ -54,12 +64,41 @@ def get_config(config=None): else: conf = Config() base = ['service', 'pppoe-server'] - if not conf.exists(base): - return None # retrieve common dictionary keys pppoe = get_accel_dict(conf, base, pppoe_chap_secrets) + vpp_interface_base = ['vpp', 'settings', 'interface'] + vpp_bond_interface_base = ['interfaces', 'vpp', 'bonding'] + if conf.exists(vpp_interface_base) and is_systemd_service_active('vpp.service'): + vpp_ifaces = conf.get_config_dict( + vpp_interface_base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + vpp_bond_ifaces = conf.get_config_dict( + vpp_bond_interface_base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + vpp_ifaces = vpp_ifaces | vpp_bond_ifaces + pppoe['vpp_ifaces'] = vpp_ifaces + for interface in pppoe.get('interface', {}): + if base_ifname(interface) in vpp_ifaces: + pppoe['interface'][interface]['vpp_cp'] = {} + + pppoe['vpp_cp_interfaces'] = [ + ifname + for ifname, iface_conf in pppoe.get('interface', {}).items() + if 'vpp_cp' in iface_conf + ] + + if not conf.exists(base): + pppoe['remove'] = True + return pppoe + if dict_search('client_ip_pool', pppoe): # Multiple named pools require ordered values T5099 pppoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', pppoe)) @@ -68,12 +107,30 @@ def get_config(config=None): pado_delay = dict_search('pado_delay', pppoe) pppoe['pado_delay'] = convert_pado_delay(pado_delay) - # reload-or-restart does not implemented in accel-ppp + # reload-or-restart is not implemented in accel-ppp # use this workaround until it will be implemented # https://phabricator.accel-ppp.org/T3 - conditions = [is_node_changed(conf, base + ['client-ip-pool']), - is_node_changed(conf, base + ['client-ipv6-pool']), - is_node_changed(conf, base + ['interface'])] + changed_vpp_ifaces = node_changed( + conf, vpp_interface_base, expand_nodes=Diff.DELETE | Diff.ADD + ) + changed_vpp_bond_ifaces = node_changed( + conf, + vpp_bond_interface_base, + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + all_changed_vpp_ifaces = set(changed_vpp_ifaces) | set(changed_vpp_bond_ifaces) + conditions = [ + is_node_changed(conf, base + ['client-ip-pool']), + is_node_changed(conf, base + ['client-ipv6-pool']), + is_node_changed(conf, base + ['interface']), + is_node_changed(conf, base + ['authentication', 'radius']), + is_node_changed(conf, base + ['authentication', 'mode']), + any( + base_ifname(iface) in all_changed_vpp_ifaces + for iface in pppoe.get('interface', {}) + ), + ] if any(conditions): pppoe.update({'restart_required': {}}) pppoe['server_type'] = 'pppoe' @@ -108,7 +165,7 @@ def verify_pado_delay(pppoe): ) def verify(pppoe): - if not pppoe: + if 'remove' in pppoe: return None verify_accel_ppp_authentication(pppoe) @@ -122,7 +179,20 @@ def verify(pppoe): # Check is interface exists in the system for interface, interface_config in pppoe['interface'].items(): - verify_interface_exists(pppoe, interface, warning_only=True) + # Interfaces integrated with the control-plane in VPP must exist in the system + warning_only = 'vpp_cp' not in interface_config + if '.' in interface: + verify_interface_func = verify_virtual_interface_exists + else: + verify_interface_func = verify_interface_exists + verify_interface_func(pppoe, interface, warning_only=warning_only) + + if 'vlan_mon' in interface_config and base_ifname(interface) in pppoe.get( + 'vpp_ifaces', {} + ): + raise ConfigError( + f'Cannot set option "vlan-mon": interface {interface} is integrated with control-plane!' + ) if 'vlan_mon' in interface_config and not 'vlan' in interface_config: raise ConfigError('Option "vlan-mon" requires "vlan" to be set!') @@ -131,7 +201,7 @@ def verify(pppoe): def generate(pppoe): - if not pppoe: + if 'remove' in pppoe: return None render(pppoe_conf, 'accel-ppp/pppoe.config.j2', pppoe) @@ -144,7 +214,15 @@ def generate(pppoe): def apply(pppoe): systemd_service = 'accel-ppp@pppoe.service' - if not pppoe: + + # delete pppoe mapping in vpp + if 'vpp_ifaces' in pppoe: + vpp = VPPControl() + mapping = vpp.get_pppoe_interface_mapping() + for dp_index, cp_index in mapping.items(): + vpp.delete_pppoe_mapping(dp_index, cp_index) + + if 'remove' in pppoe: call(f'systemctl stop {systemd_service}') for file in [pppoe_conf, pppoe_chap_secrets]: if os.path.exists(file): @@ -156,6 +234,14 @@ def apply(pppoe): else: call(f'systemctl reload-or-restart {systemd_service}') + # add pppoe mapping in vpp + vpp_cp_ifaces_add = pppoe.get('vpp_cp_interfaces', []) + if vpp_cp_ifaces_add: + vpp = VPPControl() + for iface in vpp_cp_ifaces_add: + vpp.map_pppoe_interface(iface) + + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 88d767bb8..86b0f8dd5 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -64,6 +64,9 @@ 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 'base_interface' in prefix_config and prefix != '::/64': + raise ConfigError('Prefix base-interface can only be used together with the wildcard prefix "::/64"') + if 'nat64prefix' in interface_config: nat64_supported_lengths = [32, 40, 48, 56, 64, 96] for prefix, prefix_config in interface_config['nat64prefix'].items(): diff --git a/src/conf_mode/service_salt-minion.py b/src/conf_mode/service_salt-minion.py index edf74b0c0..f035485d3 100755 --- a/src/conf_mode/service_salt-minion.py +++ b/src/conf_mode/service_salt-minion.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,7 +20,7 @@ from socket import gethostname from sys import exit from urllib3 import PoolManager -from vyos.base import Warning +from vyos.base import Warning, DeprecationWarning from vyos.config import Config from vyos.configverify import verify_interface_exists from vyos.template import render @@ -52,7 +52,7 @@ def get_config(config=None): if 'id' not in salt: salt['id'] = gethostname() # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. salt = conf.merge_defaults(salt, recursive=True) if not conf.exists(base): @@ -66,6 +66,8 @@ def verify(salt): if not salt: return None + DeprecationWarning('Salt minion integration is deprecated and will be removed in future VyOS versions') + if 'hash' in salt and salt['hash'] == 'sha1': Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!') diff --git a/src/conf_mode/service_sla.py b/src/conf_mode/service_sla.py index ba5e645f0..0a7b81073 100755 --- a/src/conf_mode/service_sla.py +++ b/src/conf_mode/service_sla.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index c64c59af7..00993d269 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,12 +15,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import contextlib from sys import exit from vyos.base import Warning 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.defaults import systemd_services from vyos.snmpv3_hashgen import plaintext_to_md5 @@ -33,6 +35,8 @@ from vyos.utils.dict import dict_search from vyos.utils.network import is_addr_assigned from vyos.utils.process import call from vyos.utils.permission import chmod_755 +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.version import get_version_data from vyos import ConfigError from vyos import airbag @@ -46,6 +50,34 @@ default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' systemd_service = systemd_services['snmpd'] + +def _get_engine_boots_and_bump(reset=False): + """ + Read, increment, persist, and return engineBoots counter. + Uses /config/snmp/engineboots.count as persistent storage + across reboots. + + If the 'reset' flag is set, zero will be stored without reading the current state. + """ + persist_count_file = '/config/snmp/engineboots.count' + + # Ensure directory exists atomically + os.makedirs(os.path.dirname(persist_count_file), exist_ok=True) + + count = 0 + + if not reset: + # Read current count, default to 0 on first run or corruption + raw = read_file(persist_count_file, defaultonfailure=str(count)) + with contextlib.suppress(ValueError): + count = int(raw) + + # Persist new value with increment immediately because snmpd will increase + # it automatically after restart the service + write_file(persist_count_file, str(count + 1)) + + return count + def get_config(config=None): if config: conf = config @@ -72,7 +104,7 @@ def get_config(config=None): snmp['vyos_user_pass'] = random(16) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. snmp = conf.merge_defaults(snmp, recursive=True) if 'listen_address' in snmp: @@ -98,6 +130,26 @@ def get_config(config=None): snmp['script_extensions']['extension_name'][key]['script'] = script_path + # Per RFC 3414 section 2.3 we should reset the engineID to 0: + # > Note, that whenever the local value of snmpEngineID is + # > changed (e.g., through discovery) or when secure communications are + # > first established with an authoritative SNMP engine, the local values + # > of snmpEngineBoots and latestReceivedEngineTime should be set to + # > zero. + # It requires to track changing of this value and reset engineBoots. + if is_node_changed(conf, base + ['v3', 'engineid']): + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + effective=True, + ) + current_engineid = dict_search('v3.engineid', snmp) + prev_engineid = dict_search('v3.engineid', effective_config) + if prev_engineid and current_engineid != prev_engineid: + snmp.update({'engineid_changed': {}}) + return snmp @@ -210,6 +262,12 @@ def generate(snmp): if 'deleted' in snmp: return None + # RFC 3414 compliant: + # - increments by 1 on every snmpd start + # - reset to zero if engineID was changed + with_reset = 'engineid_changed' in snmp + snmp['engine_boots'] = _get_engine_boots_and_bump(reset=with_reset) + if 'v3' in snmp: # SNMPv3 uses a hashed password. If CLI defines a plaintext password, # we will hash it in the background and replace the CLI node! diff --git a/src/conf_mode/service_ssh.py b/src/conf_mode/service_ssh.py index 759f87bb2..15d9d37ba 100755 --- a/src/conf_mode/service_ssh.py +++ b/src/conf_mode/service_ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,21 +16,26 @@ import os +from copy import deepcopy from sys import exit from syslog import syslog from syslog import LOG_INFO +from vyos.base import DeprecationWarning from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_pki_openssh_key +from vyos.defaults import config_files +from vyos.defaults import SSH_DSA_DEPRECATION_WARNING from vyos.utils.process import call +from vyos.utils.process import rc_cmd from vyos.template import render from vyos import ConfigError from vyos import airbag -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate +from vyos.pki import encode_public_key +from vyos.pki import load_openssh_public_key +from vyos.utils.dict import dict_search_recursive from vyos.utils.file import write_file airbag.enable() @@ -44,8 +49,14 @@ key_rsa = '/etc/ssh/ssh_host_rsa_key' key_dsa = '/etc/ssh/ssh_host_dsa_key' key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' -trusted_user_ca_key = '/etc/ssh/trusted_user_ca_key' +trusted_user_ca = config_files['sshd_user_ca'] +login_motd_dsa_warning = r'/run/motd.d/91-vyos-ssh-dsa-deprecation-warning' + +# As of OpenSSH 9.8p1 in Debian trixie, DSA keys are no longer supported +deprecated_algos = ['ssh-dss', 'ssh-dss-cert-v01@openssh.com'] +SSH_DSA_DEPRECATION_WARNING: str = f'{SSH_DSA_DEPRECATION_WARNING} '\ +'The following hostkey-algorithms are in use:' def get_config(config=None): if config: @@ -55,27 +66,38 @@ def get_config(config=None): base = ['service', 'ssh'] if not conf.exists(base): return None - - ssh = conf.get_config_dict( - base, key_mangling=('-', '_'), get_first_key=True, with_pki=True - ) + ssh = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_pki=True) tmp = is_node_changed(conf, base + ['vrf']) if tmp: ssh.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. ssh = conf.merge_defaults(ssh, recursive=True) - # pass config file path - used in override template - ssh['config_file'] = config_file - # Ignore default XML values if config doesn't exists # Delete key from dict if not conf.exists(base + ['dynamic-protection']): del ssh['dynamic_protection'] + # See if any user has specified a list of principal names that are accepted + # for certificate authentication. + tmp = conf.get_config_dict(['system', 'login', 'user'], + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + + for value, _ in dict_search_recursive(tmp, 'principal'): + # Only enable principal handling if SSH trusted-user-ca is set + if 'trusted_user_ca' in ssh: + ssh['has_principals'] = {} + # We do only need to execute this code path once as we need to know + # if any one of the local users has a principal set or not - this + # accounts for the entire system. + break + return ssh @@ -86,15 +108,12 @@ def verify(ssh): if 'rekey' in ssh and 'data' not in ssh['rekey']: raise ConfigError('Rekey data is required!') - if 'trusted_user_ca_key' in ssh: - if 'ca_certificate' not in ssh['trusted_user_ca_key']: - raise ConfigError('CA certificate is required for TrustedUserCAKey') + if 'trusted_user_ca' in ssh: + verify_pki_openssh_key(ssh, ssh['trusted_user_ca']) - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - verify_pki_ca_certificate(ssh, ca_key_name) - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - if 'certificate' not in pki_ca_cert or not pki_ca_cert['certificate']: - raise ConfigError(f"CA certificate '{ca_key_name}' is not valid or missing") + if 'hostkey_algorithm' in ssh: + tmp = [algo for algo in ssh['hostkey_algorithm'] if algo in deprecated_algos] + if tmp: DeprecationWarning(f'{SSH_DSA_DEPRECATION_WARNING} {", ".join(tmp)}') verify_vrf(ssh) return None @@ -108,7 +127,7 @@ def generate(ssh): return None # This usually happens only once on a fresh system, SSH keys need to be - # freshly generted, one per every system! + # freshly generated, one per every system! if not os.path.isfile(key_rsa): syslog(LOG_INFO, 'SSH RSA host key not found, generating new key!') call(f'ssh-keygen -q -N "" -t rsa -f {key_rsa}') @@ -119,26 +138,27 @@ def generate(ssh): syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!') call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') - if 'trusted_user_ca_key' in ssh: - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - loaded_ca_certs = { - load_certificate(c['certificate']) - for c in ssh['pki']['ca'].values() - if 'certificate' in c - } - - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - write_file( - trusted_user_ca_key, '\n'.join(encode_certificate(c) for c in ca_full_chain) - ) - elif os.path.exists(trusted_user_ca_key): - os.unlink(trusted_user_ca_key) + if 'trusted_user_ca' in ssh: + key_name = ssh['trusted_user_ca'] + openssh_cert = ssh['pki']['openssh'][key_name] + loaded_ca_cert = load_openssh_public_key(openssh_cert['public']['key'], + openssh_cert['public']['type']) + tmp = encode_public_key(loaded_ca_cert, encoding='OpenSSH', + key_format='OpenSSH') + write_file(trusted_user_ca, tmp, trailing_newline=True) + else: + if os.path.exists(trusted_user_ca): + os.unlink(trusted_user_ca) render(config_file, 'ssh/sshd_config.j2', ssh) + # Generate MOTD informing the user(s) for possible deprecated SSH hostkey-algorithm + tmp = deepcopy(ssh) + tmp['ssh_dsa_deprecation_warning'] = f'DEPRECATION WARNING: {SSH_DSA_DEPRECATION_WARNING}' + tmp['deprecated_algos'] = deprecated_algos + render(login_motd_dsa_warning, 'ssh/motd_ssh_dsa_warning.j2', tmp, + permission=0o644, user='root', group='root') + if 'dynamic_protection' in ssh: render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh) @@ -154,6 +174,11 @@ def apply(ssh): call(f'systemctl stop {systemd_service_sshguard}') return None + # Verify generated sshd configuration is correct + rc, out = rc_cmd(f'/usr/sbin/sshd -t -f {config_file}') + if rc: + raise ConfigError(f'Unexpected error with SSH configuration! {out}') + if 'dynamic_protection' not in ssh: call(f'systemctl stop {systemd_service_sshguard}') else: diff --git a/src/conf_mode/service_stunnel.py b/src/conf_mode/service_stunnel.py index 8ec762548..5ea5b88b4 100644 --- a/src/conf_mode/service_stunnel.py +++ b/src/conf_mode/service_stunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,7 +19,7 @@ from shutil import rmtree from sys import exit -from netifaces import AF_INET +from socket import AF_INET from psutil import net_if_addrs from vyos.config import Config diff --git a/src/conf_mode/service_suricata.py b/src/conf_mode/service_suricata.py index 1ce170145..728c5607e 100755 --- a/src/conf_mode/service_suricata.py +++ b/src/conf_mode/service_suricata.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_tftp-server.py b/src/conf_mode/service_tftp-server.py index 5b7303c40..dc5ec5674 100755 --- a/src/conf_mode/service_tftp-server.py +++ b/src/conf_mode/service_tftp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_webproxy.py b/src/conf_mode/service_webproxy.py index 12ae4135e..eb45f8fcb 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -123,7 +123,7 @@ def get_config(config=None): proxy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**proxy.kwargs, recursive=True) diff --git a/src/conf_mode/system_acceleration.py b/src/conf_mode/system_acceleration.py index d2cf44ff0..3e7a06465 100755 --- a/src/conf_mode/system_acceleration.py +++ b/src/conf_mode/system_acceleration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -70,11 +70,12 @@ def verify(qat): # PCI id | Chipset # 19e2 -> C3xx # 37c8 -> C62x + # 37c9 -> C62xvf # 0435 -> DH895 # 6f54 -> D15xx # 18ee -> QAT_200XX data = re.findall( - '(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)|(8086:18ee)', output) + '(8086:19e2)|(8086:37c[8-9])|(8086:0435)|(8086:6f54)|(8086:18ee)', output) # If QAT devices found if not data: raise ConfigError('No QAT acceleration device found') diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py index a3ce66512..81a48ea50 100755 --- a/src/conf_mode/system_config-management.py +++ b/src/conf_mode/system_config-management.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,9 +19,10 @@ import sys from vyos import ConfigError from vyos.config import Config +from vyos.configverify import verify_vrf from vyos.config_mgmt import ConfigMgmt -from vyos.config_mgmt import commit_post_hook_dir, commit_hooks - +from vyos.config_mgmt import commit_post_hook_dir +from vyos.config_mgmt import commit_hooks def get_config(config=None): if config: @@ -34,10 +35,8 @@ def get_config(config=None): return None mgmt = ConfigMgmt(config=conf) - return mgmt - def verify(mgmt): if mgmt is None: return @@ -47,16 +46,16 @@ def verify(mgmt): if confirm.get('action', '') == 'reload' and 'commit_revisions' not in d: raise ConfigError('commit-confirm reload requires non-zero commit-revisions') - return + if 'commit_archive' in d: + verify_vrf(d['commit_archive']) + return def generate(mgmt): if mgmt is None: return - mgmt.initialize_revision() - def apply(mgmt): if mgmt is None: return diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py index f25ed8d10..e6710223a 100755 --- a/src/conf_mode/system_conntrack.py +++ b/src/conf_mode/system_conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -32,7 +32,6 @@ from vyos import ConfigError from vyos import airbag airbag.enable() -conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' nftables_ct_file = r'/run/nftables-ct.conf' vyos_conntrack_logger_config = r'/run/vyos-conntrack-logger.conf' @@ -169,7 +168,7 @@ def verify(conntrack): if not group_obj: Warning(f'{error_group} "{group_name}" has no members!') - Warning(f'It is prefered to define {inet} conntrack ignore rules in <firewall {inet} prerouting raw> section') + Warning(f'It is preferred to define {inet} conntrack ignore rules in <firewall {inet} prerouting raw> section') if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): @@ -204,7 +203,6 @@ def generate(conntrack): elif path[0] == 'ipv6': conntrack['ipv6_firewall_action'] = 'accept' - 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) diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index b380e0521..51c95fcac 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,9 +17,11 @@ import os from pathlib import Path +from vyos.base import Warning from vyos.config import Config from vyos.utils.process import call from vyos.utils.serial import restart_login_consoles +from vyos.utils.serial import is_tty from vyos.system import grub_util from vyos.template import render from vyos import ConfigError @@ -55,7 +57,8 @@ def verify(console): if not console or 'device' not in console: return None - for device in console['device']: + kernel_consoles: list = [] + for device, device_config in console['device'].items(): if device.startswith('usb'): # It is much easiert to work with the native ttyUSBn name when using # getty, but that name may change across reboots - depending on the @@ -65,7 +68,16 @@ def verify(console): # If the device name still starts with usbXXX no matching tty was found # and it can not be used as a serial interface if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device): - raise ConfigError(f'Device {device} does not support beeing used as tty') + raise ConfigError(f'Device "{device}" does not support being used as tty') + if not is_tty(device, warning=True): + Warning(f'Device "{device}" used for console is not a TTY!') + if 'kernel' in device_config: + if not (device.startswith('ttyS') or device.startswith('ttyAMA')): + raise ConfigError(f'Device "{device}" unsupported for Kernel boot console') + kernel_consoles.append(device) + + if len(kernel_consoles) > 1: + raise ConfigError('Only one device can be used as Kernel output console!') return None @@ -77,7 +89,10 @@ def generate(console): if 'serial-getty' in basename: os.unlink(os.path.join(root, basename)) + # Define a default console on a tty framebuffer + default_tty_console = ('tty', '0', '') if not console or 'device' not in console: + grub_util.update_serial_console(*default_tty_console) return None # replace keys in the config for ttyUSB items to use them in `apply()` later @@ -95,9 +110,12 @@ def generate(console): console['device'][device_updated] = console['device'][device] del console['device'][device] else: - raise ConfigError(f'Device {device} does not support beeing used as tty') + raise ConfigError(f'Device {device} does not support being used as tty') for device, device_config in console['device'].items(): + # Do not render getty configuration if specified device is not a TTY. + if not is_tty(device): + continue config_file = base_dir + f'/serial-getty@{device}.service' Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True) getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service' @@ -105,26 +123,22 @@ def generate(console): render(config_file, 'getty/serial-getty.service.j2', device_config) os.symlink(config_file, getty_wants_symlink) - # GRUB - # For existing serial line change speed (if necessary) - # Only applys to ttyS0 - if 'ttyS0' not in console['device']: - return None - - speed = console['device']['ttyS0']['speed'] - grub_util.update_console_speed(speed) + if 'kernel' in device_config: + # get console type ("ttyS" or "ttyAMA") from device (e.g. "ttyS0") + console_type = device.rstrip('0123456789') + console_num = device[len(console_type):] + default_tty_console = (console_type, console_num, device_config['speed']) + grub_util.update_serial_console(*default_tty_console) return None def apply(console): # Reset screen blanking call('/usr/bin/setterm -blank 0 -powersave off -powerdown 0 -term linux </dev/tty1 >/dev/tty1 2>&1') - # Reload systemd manager configuration - call('systemctl daemon-reload') - # Service control moved to vyos.utils.serial to unify checks and prompts. - # If users are connected, we want to show an informational message on completing - # the process, but not halt configuration processing with an interactive prompt. + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message on completing + # the process, but not halt configuration processing with an interactive prompt. restart_login_consoles(prompt_user=False, quiet=False) if not console: diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py index 925c4a562..3318ad465 100755 --- a/src/conf_mode/system_flow-accounting.py +++ b/src/conf_mode/system_flow-accounting.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,7 @@ import os import re +from ipaddress import ip_interface from sys import exit from vyos.config import Config @@ -24,119 +25,19 @@ from vyos.config import config_dict_merge from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import cmd -from vyos.utils.process import run +from vyos.utils.file import read_file from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag +from vyos import ipt_netflow airbag.enable() -uacctd_conf_path = '/run/pmacct/uacctd.conf' -systemd_service = 'uacctd.service' -systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' -nftables_nflog_table = 'raw' -nftables_nflog_chain = 'VYOS_PREROUTING_HOOK' -egress_nftables_nflog_table = 'inet mangle' -egress_nftables_nflog_chain = 'FORWARD' - -# get nftables rule dict for chain in table -def _nftables_get_nflog(chain, table): - # define list with rules - rules = [] - - # prepare regex for parsing rules - rule_pattern = '[io]ifname "(?P<interface>[\w\.\*\-]+)".*handle (?P<handle>[\d]+)' - rule_re = re.compile(rule_pattern) - - # run nftables, save output and split it by lines - nftables_command = f'nft -a list chain {table} {chain}' - tmp = cmd(nftables_command, message='Failed to get flows list') - # parse each line and add information to list - for current_rule in tmp.splitlines(): - if 'FLOW_ACCOUNTING_RULE' not in current_rule: - continue - current_rule_parsed = rule_re.search(current_rule) - if current_rule_parsed: - groups = current_rule_parsed.groupdict() - rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] }) - - # return list with rules - return rules - -def _nftables_config(configured_ifaces, direction, length=None): - # define list of nftables commands to modify settings - nftable_commands = [] - nftables_chain = nftables_nflog_chain - nftables_table = nftables_nflog_table - - if direction == "egress": - nftables_chain = egress_nftables_nflog_chain - nftables_table = egress_nftables_nflog_table - - # prepare extended list with configured interfaces - configured_ifaces_extended = [] - for iface in configured_ifaces: - configured_ifaces_extended.append({ 'iface': iface }) - - # get currently configured interfaces with nftables rules - active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table) - - # compare current active list with configured one and delete excessive interfaces, add missed - active_nflog_ifaces = [] - for rule in active_nflog_rules: - interface = rule['interface'] - if interface not in configured_ifaces: - table = rule['table'] - handle = rule['handle'] - nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}') - else: - active_nflog_ifaces.append({ - 'iface': interface, - }) - - # do not create new rules for already configured interfaces - for iface in active_nflog_ifaces: - if iface in active_nflog_ifaces and iface in configured_ifaces_extended: - configured_ifaces_extended.remove(iface) - - # create missed rules - for iface_extended in configured_ifaces_extended: - iface = iface_extended['iface'] - iface_prefix = "o" if direction == "egress" else "i" - rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' - nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') - # Also add IPv6 ingres logging - if nftables_table == nftables_nflog_table: - nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') - - # change nftables - for command in nftable_commands: - cmd(command, raising=ConfigError) - - -def _nftables_trigger_setup(operation: str) -> None: - """Add a dummy rule to unlock the main pmacct loop with a packet-trigger - - Args: - operation (str): 'add' or 'delete' a trigger - """ - # check if a chain exists - table_exists = False - if run('nft -snj list table ip pmacct') == 0: - table_exists = True - - if operation == 'delete' and table_exists: - nft_cmd: str = 'nft delete table ip pmacct' - cmd(nft_cmd, raising=ConfigError) - if operation == 'add' and not table_exists: - nft_cmds: list[str] = [ - 'nft add table ip pmacct', - 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }', - 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER' - ] - for nft_cmd in nft_cmds: - cmd(nft_cmd, raising=ConfigError) +ipt_netflow_conf_path = '/etc/modprobe.d/ipt_NETFLOW.conf' + +# Variable to store between generate and apply +# whether module configuration was changed +# and module reload is needed +need_reload = True def get_config(config=None): @@ -166,104 +67,129 @@ def get_config(config=None): return flow_accounting + def verify(flow_config): if not flow_config: return None - # check if collector is enabled - if 'netflow' not in flow_config and 'disable_imt' in flow_config: - raise ConfigError('You need to configure NetFlow, ' \ - 'or not set "disable-imt" for flow-accounting!') - # Check if at least one interface is configured - if 'interface' not in flow_config: + if 'netflow' not in flow_config or 'interface' not in flow_config['netflow']: raise ConfigError('Flow accounting requires at least one interface to ' \ 'be configured!') # check that all configured interfaces exists in the system - for interface in flow_config['interface']: + for interface in flow_config['netflow']['interface']: verify_interface_exists(flow_config, interface, warning_only=True) + # check if at least one NetFlow collector is configured + if 'server' not in flow_config['netflow']: + raise ConfigError('You need to configure at least one NetFlow server!') verify_vrf(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'], netflow_vrf): - tmp = flow_config['netflow']['source_address'] - raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') - - # Check if engine-id compatible with selected protocol version - if 'engine_id' in flow_config['netflow']: - v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' - v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' - engine_id = flow_config['netflow']['engine_id'] - version = flow_config['netflow']['version'] - - if flow_config['netflow']['version'] == '5': - regex_filter = re.compile(v5_filter) - if not regex_filter.search(engine_id): - raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\ - f'together with NetFlow protocol version "{version}"!') - else: - regex_filter = re.compile(v9v10_filter) - if not regex_filter.search(flow_config['netflow']['engine_id']): - raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\ - f'with NetFlow protocol version "{version}"!') + # check if vrf is defined for netflow + netflow_vrf = None + if 'vrf' in flow_config: + netflow_vrf = flow_config['vrf'] + + # Check if configured netflow server source-address exist in the system + # Check if configured netflow server source-address matches protocol of server + # Check if configured netflow server source-interface exists + for server, data in flow_config['netflow']['server'].items(): + if 'source_address' in data and 'source_interface' in data: + raise ConfigError( + f'Configured "netflow server {server}" cannot have both "source-address" and "source-interface" fields' + ) + + if 'source_address' in data: + if not is_addr_assigned(data['source_address'], netflow_vrf): + raise ConfigError( + f'Configured "netflow server {server} source-address {data["source_address"]}" does not exist on the system!' + ) + if ( + ip_interface(server).version + != ip_interface(data['source_address']).version + ): + raise ConfigError( + f'Configured "netflow server {server} source-address {data["source_address"]}" protocol doesn\'t match server protocol' + ) + + if 'source_interface' in data: + verify_interface_exists( + flow_config, data['source_interface'], warning_only=True + ) + + # Check if engine-id compatible with selected protocol version + if 'engine_id' in flow_config['netflow']: + v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' + v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' + engine_id = flow_config['netflow']['engine_id'] + version = flow_config['netflow']['version'] + + if flow_config['netflow']['version'] == '5': + regex_filter = re.compile(v5_filter) + if not regex_filter.search(engine_id): + raise ConfigError( + f'You cannot use NetFlow engine-id "{engine_id}" ' + f'together with NetFlow protocol version "{version}"!' + ) + else: + regex_filter = re.compile(v9v10_filter) + if not regex_filter.search(flow_config['netflow']['engine_id']): + raise ConfigError( + f'Can not use NetFlow engine-id "{engine_id}" together ' + f'with NetFlow protocol version "{version}"!' + ) # return True if all checks were passed return True + def generate(flow_config): if not flow_config: + if os.path.exists(ipt_netflow_conf_path): + os.unlink(ipt_netflow_conf_path) return None - render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config) - render(systemd_override, 'pmacct/override.conf.j2', flow_config) - # Reload systemd manager configuration - call('systemctl daemon-reload') + prev_config = read_file(ipt_netflow_conf_path, defaultonfailure='') -def apply(flow_config): - # Check if flow-accounting was removed and define command - if not flow_config: - _nftables_config([], 'ingress') - _nftables_config([], 'egress') + render(ipt_netflow_conf_path, 'ipt-netflow/ipt_NETFLOW.conf.j2', flow_config) + + new_config = read_file(ipt_netflow_conf_path, defaultonfailure='') - # Stop flow-accounting daemon and remove configuration file - call(f'systemctl stop {systemd_service}') - if os.path.exists(uacctd_conf_path): - os.unlink(uacctd_conf_path) + global need_reload + need_reload = prev_config != new_config - # must be done after systemctl - _nftables_trigger_setup('delete') +def apply(flow_config): + # When reloading module we need to first remove + # all iptables usage of ipt_NETFLOW + # When flow_config is disabled everything should be cleaned-up too + if need_reload or not flow_config: + ipt_netflow.stop() + + if not flow_config: + if os.path.exists(ipt_netflow_conf_path): + os.unlink(ipt_netflow_conf_path) return - # Start/reload flow-accounting daemon - call(f'systemctl restart {systemd_service}') + ingress_interfaces = [] + egress_interfaces = [] - # configure nftables rules for defined interfaces - if 'interface' in flow_config: - _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length']) + # configure iptables for defined interfaces + if 'interface' in flow_config['netflow']: + ingress_interfaces = flow_config['netflow']['interface'] # configure egress the same way if configured otherwise remove it if 'enable_egress' in flow_config: - _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length']) - else: - _nftables_config([], 'egress') + egress_interfaces = ingress_interfaces - # add a trigger for signal processing - _nftables_trigger_setup('add') + enable_ipv6 = flow_config['netflow']['version'] != '5' + if need_reload: + ipt_netflow.start(ingress_interfaces, egress_interfaces, ipv6=enable_ipv6) + else: + ipt_netflow.set_watched_iptables_interfaces( + ingress_interfaces, egress_interfaces, ipv6=enable_ipv6 + ) if __name__ == '__main__': diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py index d9ac543d0..5365ac294 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,12 +19,15 @@ from sys import exit from vyos import ConfigError from vyos.base import Warning from vyos.config import Config +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.logger import syslog from vyos.template import render_to_string from vyos.utils.boot import boot_configuration_complete from vyos.utils.file import read_file from vyos.utils.file import write_file from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import airbag airbag.enable() @@ -42,7 +45,8 @@ def get_config(config=None): frr_config = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True) - + # get FRR configuration + frr_config['frr_dict'] = get_frrender_dict(conf) return frr_config def verify(frr_config): @@ -60,7 +64,17 @@ def generate(frr_config): write_file(config_file, daemons_config_new) frr_config['config_file_changed'] = True + # profile could be automatically generated by frr in frr.conf + # and needs to be updated as it is taking precedence + if 'frr_dict' in frr_config and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(frr_config['frr_dict']) + return None + def apply(frr_config): + # applying the profile configuration if necessary + if 'frr_dict' in frr_config and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + # display warning to user if boot_configuration_complete() and frr_config.get('config_file_changed'): # Since FRR restart is not safe thing, better to give diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index fef034d1c..5a9265eba 100755 --- a/src/conf_mode/system_host-name.py +++ b/src/conf_mode/system_host-name.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -119,7 +119,7 @@ def verify(hosts): raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items(): - # Warnin user if interface does not have DHCP or DHCPv6 configured + # Warning user if interface does not have DHCP or DHCPv6 configured if not set(interface_config).intersection(['dhcp', 'dhcpv6']): Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!') @@ -175,7 +175,7 @@ def apply(config): # Restart services that use the hostname if hostname_new != hostname_old: - tmp = systemd_services['rsyslog'] + tmp = systemd_services['syslog'] call(f'systemctl restart {tmp}') # If SNMP is running, restart it too diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py index 7f3796168..6aff982d7 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -53,6 +53,11 @@ def verify(config_dict): for protocol, protocol_options in opt['protocol'].items(): if 'route_map' in protocol_options: verify_route_map(protocol_options['route_map'], opt) + + if dict_search('import_table', opt): + for table_num, import_config in opt['import_table'].items(): + if dict_search('route_map', import_config): + verify_route_map(import_config['route_map'], opt) return def generate(config_dict): @@ -70,20 +75,20 @@ def apply(config_dict): # table_size has a default value - thus the key always exists size = int(dict_search('arp.table_size', opt)) # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh3'], size) # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh2'], size // 2) # Minimum number of stored records is indicated which is not cleared - sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh1'], size // 8) # configure multipath tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_use_neigh', value) + sysctl_write(['net', 'ipv4', 'fib_multipath_use_neigh'], value) tmp = dict_search('multipath.layer4_hashing', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_hash_policy', value) + 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) @@ -96,15 +101,15 @@ def apply(config_dict): 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) + 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) + 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) + sysctl_write(['net', 'ipv4', 'tcp_mtu_probe_floor'], value) # During startup of vyos-router that brings up FRR, the service is not yet # running when this script is called first. Skip this part and wait for initial diff --git a/src/conf_mode/system_ipv6.py b/src/conf_mode/system_ipv6.py index 309869b2f..80a7a386a 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -70,17 +70,17 @@ def apply(config_dict): # configure multipath tmp = dict_search('multipath.layer4_hashing', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv6.fib_multipath_hash_policy', value) + sysctl_write(['net', 'ipv6', 'fib_multipath_hash_policy'], value) # Apply ND threshold values # table_size has a default value - thus the key always exists size = int(dict_search('neighbor.table_size', opt)) # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh3'], size) # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh2'], size // 2) # Minimum number of stored records is indicated which is not cleared - sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh1'], size // 8) # configure IPv6 strict-dad tmp = dict_search('strict_dad', opt) diff --git a/src/conf_mode/system_lcd.py b/src/conf_mode/system_lcd.py index eb88224d1..1e97414dc 100755 --- a/src/conf_mode/system_lcd.py +++ b/src/conf_mode/system_lcd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index 3fed6d273..537a87ae9 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,32 +14,39 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import re import os -import warnings +import json +from copy import deepcopy from passlib.hosts import linux_context from psutil import users -from pwd import getpwall -from pwd import getpwnam -from pwd import getpwuid from sys import exit from time import sleep from vyos.base import Warning +from vyos.base import DeprecationWarning from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configverify import verify_vrf +from vyos.defaults import SSH_DSA_DEPRECATION_WARNING from vyos.template import render from vyos.template import is_ipv4 -from vyos.utils.auth import ( - DEFAULT_PASSWORD, - EPasswdStrength, - evaluate_strength, - get_current_user -) +from vyos.utils.auth import DEFAULT_PASSWORD +from vyos.utils.auth import EPasswdStrength +from vyos.utils.auth import evaluate_strength +from vyos.utils.auth import get_current_user +from vyos.utils.auth import get_local_passwd_entries +from vyos.utils.auth import get_local_users +from vyos.utils.auth import get_user_home_dir +from vyos.utils.auth import MIN_USER_UID from vyos.utils.configfs import delete_cli_node from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search -from vyos.utils.file import chown +from vyos.utils.file import move_recursive +from vyos.utils.network import is_addr_assigned +from vyos.utils.permission import chown from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import run @@ -54,38 +61,21 @@ 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" +login_motd_dsa_warning = r'/run/motd.d/92-vyos-user-dsa-deprecation-warning' -# 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_TIMEOUT divided by 2 sec (minimum recommended timeout) MAX_RADIUS_COUNT: int = 8 # Maximum number of supported TACACS servers MAX_TACACS_COUNT: int = 8 # Minimum USER id for TACACS users MIN_TACACS_UID = 900 -# 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(min_uid=MIN_USER_UID, max_uid=MAX_USER_UID): - """Return list of dynamically allocated users (see Debian Policy Manual)""" - local_users = [] - for s_user in getpwall(): - if getpwnam(s_user.pw_name).pw_uid < min_uid: - continue - if getpwnam(s_user.pw_name).pw_uid > max_uid: - continue - if s_user.pw_name in SYSTEM_USER_SKIP_LIST: - continue - local_users.append(s_user.pw_name) - - return local_users + +# As of OpenSSH 9.8p1 in Debian trixie, DSA keys are no longer supported +SSH_DSA_DEPRECATION_WARNING: str = f'{SSH_DSA_DEPRECATION_WARNING} '\ +'The following users are using SSH-DSS keys for authentication.' + def get_shadow_password(username): with open('/etc/shadow') as f: @@ -133,6 +123,7 @@ def get_config(config=None): max_uid=MIN_TACACS_UID) + cli_users login['tacacs_min_uid'] = MIN_TACACS_UID + set_dependents('ssh', conf) return login def verify(login): @@ -145,7 +136,7 @@ def verify(login): raise ConfigError(f'Attempting to delete current user: {tmp}') if 'user' in login: - system_users = getpwall() + system_users = get_local_passwd_entries() for user, user_config in login['user'].items(): # Linux system users range up until UID 1000, we can not create a # VyOS CLI user which already exists as system user @@ -153,25 +144,46 @@ def verify(login): 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!') + plaintext_password = dict_search('authentication.plaintext_password', user_config) + if plaintext_password == DEFAULT_PASSWORD: + Warning(f'Default password used for user "{user}" - consider changing it') + # T6353: Check password for complexity using cracklib. # A user password should be sufficiently complex - plaintext_password = dict_search( - path='authentication.plaintext_password', - dict_object=user_config - ) or None - failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR] - if plaintext_password is not None: + if plaintext_password and len(plaintext_password) > 0: result = evaluate_strength(plaintext_password) if result['strength'] in failed_check_status: - Warning(result['error']) + tmp = result['error'] + Warning(f'User "{user}" - {tmp}') - for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): + for pubkey, pubkey_options in dict_search('authentication.public_keys', user_config, + default={}).items(): if 'type' not in pubkey_options: raise ConfigError(f'Missing type for public-key "{pubkey}"!') if 'key' not in pubkey_options: raise ConfigError(f'Missing key for public-key "{pubkey}"!') + if 'operator' in user_config: + op_groups = dict_search('operator.group', user_config) + if op_groups: + for og in op_groups: + if dict_search(f'operator_group.{og}', login) is None: + raise ConfigError(f'Operator group {og} does not exist') + else: + raise ConfigError(f'User {user} is configured as an operator but is not assigned to any operator groups') + + # Deprecation Warning for SSH DSS keys. + gen_header = True + if 'user' in login: + for user, user_config in login['user'].items(): + for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): + if 'type' in pubkey_options and pubkey_options['type'] == 'ssh-dss': + if gen_header: + gen_header = False + DeprecationWarning(SSH_DSA_DEPRECATION_WARNING) + print(f'User "{user}" with deprecated public-key named: {pubkey}') + if {'radius', 'tacacs'} <= set(login): raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') @@ -202,13 +214,17 @@ def verify(login): verify_vrf(login['radius']) - if 'source_address' in login['radius']: + if addresses := dict_search('radius.source_address', login): ipv4_count = 0 ipv6_count = 0 - for address in login['radius']['source_address']: + radius_vrf = dict_search('radius.vrf', login) + for address in addresses: if is_ipv4(address): ipv4_count += 1 else: ipv6_count += 1 + if not is_addr_assigned(address, vrf=radius_vrf): + Warning(f'Specified RADIUS source-address "{address}" is not assigned!') + if ipv4_count > 1: raise ConfigError('Only one IPv4 source-address can be set!') if ipv6_count > 1: @@ -225,13 +241,18 @@ def verify(login): fail = False if fail: - raise ConfigError('All RADIUS servers are disabled') + raise ConfigError('All TACACS 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 tmp := dict_search('tacacs.source_address', login): + tacacs_vrf = dict_search('tacacs.vrf', login) + if not is_addr_assigned(tmp, vrf=tacacs_vrf): + Warning(f'Specified TACACS source-address "{tmp}" is not assigned!') + if 'max_login_session' in login and 'timeout' not in login: raise ConfigError('"login timeout" must be configured!') @@ -307,6 +328,34 @@ def generate(login): if os.path.isfile(autologout_file): os.unlink(autologout_file) + # Operator groups and group membership + operator_config = {'users': {}, 'groups': {}} + if 'user' in login: + for user, user_config in login['user'].items(): + op_groups = dict_search('operator.group', user_config) + if op_groups: + operator_config['users'][user] = op_groups + + if 'operator_group' in login: + operator_config['groups'] = login['operator_group'] + + # Convert permissions strings to list + # so that the operational command runner doesn't have to + for g in operator_config['groups']: + policy = dict_search(f'command_policy.allow', operator_config['groups'][g]) + if policy is not None: + policy = list(map(lambda s: re.split(r'\s+', s), policy)) + operator_config['groups'][g]['command_policy']['allow'] = policy + + # Generate MOTD informing the user(s) for possible deprecated SSH keys + tmp = deepcopy(login) + tmp['ssh_dsa_deprecation_warning'] = f'DEPRECATION WARNING: {SSH_DSA_DEPRECATION_WARNING}' + render(login_motd_dsa_warning, 'login/motd_user_dsa_warning.j2', tmp, + permission=0o644, user='root', group='root') + + with open('/etc/vyos/operators.json', 'w') as of: + json.dump(operator_config, of) + return None @@ -332,32 +381,59 @@ def apply(login): tmp = dict_search('full_name', user_config) if tmp: command += f" --comment '{tmp}'" - tmp = dict_search('home_directory', user_config) - if tmp: command += f" --home '{tmp}'" - else: command += f" --home '/home/{user}'" + home_directory = dict_search('home_directory', user_config) + if not home_directory: + home_directory = f'/home/{user}' + command += f" --home '{home_directory}'" + + if 'operator' not in user_config: + command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea,vpp' + + command += f' {user}' - command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' try: cmd(command) # we should not rely on the value stored in user_config['home_directory'], as a # crazy user will choose username root or any other system user which will fail. # # XXX: Should we deny using root at all? - home_dir = getpwnam(user).pw_dir + home_dir = get_user_home_dir(user) # always re-render SSH keys with appropriate permissions render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', user_config, permission=0o600, formater=lambda _: _.replace(""", '"'), user=user, group='users') + + principals_file = f'{home_dir}/.ssh/authorized_principals' + if dict_search('authentication.principal', user_config): + render(principals_file, 'login/authorized_principals.j2', + user_config, permission=0o600, + formater=lambda _: _.replace(""", '"'), + user=user, group='users') + else: + if os.path.exists(principals_file): + os.unlink(principals_file) + except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') + # After invoking 'useradd' for each user, if /var/.users_backups/{user} exists, restore the + # backed up files to the newly created home directory. This reinstates the user's + # SSH environment and avoids loss of access or trust relationships due to the user + # creation process, which does not copy such custom files by default. + # + # More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265 + backup_directory = f"/var/.users_backups/{user}" + if command.startswith('useradd') and os.path.exists(backup_directory): + move_recursive(backup_directory, home_dir) + chown(home_dir, user=user, group='users', recursive=True) + # T5875: ensure UID is properly set on home directory if user is re-added # the home directory will always exist, as it's created above by --create-home, # retrieve current owner of home directory and adjust on demand dir_owner = None try: - dir_owner = getpwuid(os.stat(home_dir).st_uid).pw_name + dir_owner = get_local_passwd_entries(os.stat(home_dir).st_uid).pw_name except: pass @@ -365,14 +441,15 @@ def apply(login): chown(home_dir, user=user, recursive=True) # Generate 2FA/MFA One-Time-Pad configuration + google_auth_file = f'{home_dir}/.google_authenticator' if dict_search('authentication.otp.key', user_config): enable_otp = True - render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + render(google_auth_file, 'login/pam_otp_ga.conf.j2', user_config, permission=0o400, user=user, group='users') else: # delete configuration as it's not enabled for the user - if os.path.exists(f'{home_dir}/.google_authenticator'): - os.remove(f'{home_dir}/.google_authenticator') + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) # Lock/Unlock local user account lock_unlock = '--unlock' @@ -386,6 +463,22 @@ def apply(login): # Disable user to prevent re-login call(f'usermod -s /sbin/nologin {user}') + home_dir = get_user_home_dir(user) + # Remove SSH authorized keys file + authorized_keys_file = f'{home_dir}/.ssh/authorized_keys' + if os.path.exists(authorized_keys_file): + os.unlink(authorized_keys_file) + + # Remove SSH authorized principals file + principals_file = f'{home_dir}/.ssh/authorized_principals' + if os.path.exists(principals_file): + os.unlink(principals_file) + + # Remove Google Authenticator file + google_auth_file = f'{home_dir}/.google_authenticator' + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) + # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') @@ -424,8 +517,9 @@ def apply(login): # Enable/disable Google authenticator cmd('pam-auth-update --disable mfa-google-authenticator') if enable_otp: - cmd(f'pam-auth-update --enable mfa-google-authenticator') + cmd('pam-auth-update --enable mfa-google-authenticator') + call_dependents() return None diff --git a/src/conf_mode/system_login_banner.py b/src/conf_mode/system_login_banner.py index cdd066649..9d5fba65f 100755 --- a/src/conf_mode/system_login_banner.py +++ b/src/conf_mode/system_login_banner.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_logs.py b/src/conf_mode/system_logs.py index 8ad4875d4..f31986034 100755 --- a/src/conf_mode/system_logs.py +++ b/src/conf_mode/system_logs.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,6 +19,8 @@ from sys import exit from vyos import ConfigError from vyos import airbag from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.logger import syslog from vyos.template import render from vyos.utils.dict import dict_search @@ -35,6 +37,8 @@ def get_config(config=None): else: conf = Config() + set_dependents('syslog', conf) + base = ['system', 'logs'] logs_config = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, @@ -64,8 +68,8 @@ def generate(logs_config): def apply(logs_config): - # No further actions needed - pass + # Ensure dependent config scripts (e.g., syslog) are re-run + call_dependents() if __name__ == '__main__': diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index 064a1aa91..c1a62e7e7 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,29 +15,40 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import psutil +import re from sys import exit from time import sleep - +from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_source_interface from vyos.configverify import verify_interface_exists from vyos.system import grub_util from vyos.template import render +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.convert import range_str_to_list +from vyos.utils.convert import list_to_range_str from vyos.utils.cpu import get_cpus +from vyos.utils.cpu import get_available_cpus from vyos.utils.dict import dict_search from vyos.utils.file import write_file +from vyos.utils.file import read_file from vyos.utils.kernel import check_kmod 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.utils.system import sysctl_write from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos import ConfigError from vyos import airbag +from vyos.vpp.config_resource_checks import memory as mem_check +from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map + airbag.enable() curlrc_config = r'/etc/curlrc' @@ -54,6 +65,123 @@ tuned_profiles = { 'virtual-host': 'virtual-host', } +MANAGED_PARAMS = { + 'hugepages1g': { + 'parse': r'hugepagesz=1[Gg]\s+hugepages=(?P<hugepages1g>\d+)', + 'clean': r'hugepagesz=1[Gg](?:\s+hugepages=\d+)?', + 'build': lambda v: f'hugepagesz=1G hugepages={v}', + 'type': int, + }, + 'hugepages2m': { + 'parse': r'hugepagesz=2[Mm]\s+hugepages=(?P<hugepages2m>\d+)', + 'clean': r'hugepagesz=2[Mm](?:\s+hugepages=\d+)?', + 'build': lambda v: f'hugepagesz=2M hugepages={v}', + 'type': int, + }, + 'default_hugepagesz': { + 'parse': r'default_hugepagesz=(?P<default_hugepagesz>\S+)', + 'clean': r'default_hugepagesz=\S+', + 'type': str, + }, + 'mitigations': { + 'parse': r'mitigations=(?P<mitigations>\S+)', + 'clean': r'mitigations=\S+', + 'type': str, + }, + 'intel_idle.max_cstate': { + 'parse': r'intel_idle\.max_cstate=(?P<intel_idle_max_cstate>\d+)', + 'clean': r'intel_idle\.max_cstate=\d+', + 'build': lambda v: f'intel_idle.max_cstate={v}', + 'type': int, + }, + 'processor.max_cstate': { + 'parse': r'processor\.max_cstate=(?P<processor_max_cstate>\d+)', + 'clean': r'processor\.max_cstate=\d+', + 'build': lambda v: f'processor.max_cstate={v}', + 'type': int, + }, + 'initcall_blacklist': { + 'parse': r'initcall_blacklist=(?P<initcall_blacklist>\S+)', + 'clean': r'initcall_blacklist=\S+', + 'type': str, + }, + 'amd_pstate': { + 'parse': r'amd_pstate=(?P<amd_pstate>\S+)', + 'clean': r'amd_pstate=\S+', + 'type': str, + }, + 'quiet': { + 'parse': r'(?P<quiet>\bquiet\b)', + 'clean': r'\bquiet\b', + 'type': bool, + }, + 'nosoftlockup': { + 'parse': r'(?P<nosoftlockup>\bnosoftlockup\b)', + 'clean': r'\bnosoftlockup\b', + 'type': bool, + }, + 'panic': { + 'parse': r'panic=(?P<panic>\d+)', + 'clean': r'panic=\d+', + 'type': int, + }, + 'mce': { + 'parse': r'mce=(?P<mce>\S+)', + 'clean': r'mce=\S+', + 'type': str, + }, + 'hpet': { + 'parse': r'hpet=(?P<hpet>\S+)', + 'clean': r'hpet=\S+', + 'type': str, + }, + 'nmi_watchdog': { + 'parse': r'nmi_watchdog=(?P<nmi_watchdog>\d+)', + 'clean': r'nmi_watchdog=\d+', + 'type': int, + }, + 'isolcpus': { + 'parse': r'isolcpus=(?P<isolcpus>\S+)', + 'clean': r'isolcpus=\S+', + 'type': str, + }, + 'nohz_full': { + 'parse': r'nohz_full=(?P<nohz_full>\S+)', + 'clean': r'nohz_full=\S+', + 'type': str, + }, + 'rcu_nocbs': { + 'parse': r'rcu_nocbs=(?P<rcu_nocbs>\S+)', + 'clean': r'rcu_nocbs=\S+', + 'type': str, + }, + 'numa_balancing': { + 'parse': r'numa_balancing=(?P<numa_balancing>\S+)', + 'clean': r'numa_balancing=\S+', + 'type': str, + }, +} + +# Compiled regex pattern for parsing command line options +_parse_cmdline_pattern = re.compile( + '|'.join(v['parse'] for v in MANAGED_PARAMS.values()) +) + + +def _get_total_hugepages_and_memory(config): + unit_map = {'M': 1 << 20, 'G': 1 << 30} + + total_pages = 0 + total_bytes = 0 + + hp_sizes = config.get('hugepage_size', {}) + for size_str, hp_config in hp_sizes.items(): + pages = int(hp_config.get('hugepage_count', 0)) + total_pages += pages + total_bytes += pages * int(size_str[:-1]) * unit_map[size_str[-1]] + + return total_pages, total_bytes + def get_config(config=None): if config: @@ -68,6 +196,7 @@ def get_config(config=None): if 'performance' in options: # Update IPv4/IPv6 and sysctl options after tuned applied it's settings set_dependents('ip_ipv6', conf) + set_dependents('firewall', conf) set_dependents('sysctl', conf) return options @@ -93,10 +222,10 @@ def verify(options): if 'source_address' in config: address = config['source_address'] if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with address "{address}" configured!') + raise ConfigError(f'No interface with address "{address}" configured!') if 'source_interface' in config: - # verify_source_interface reuires key 'ifname' + # verify_source_interface requires key 'ifname' config['ifname'] = config['source_interface'] verify_source_interface(config) if 'source_address' in config: @@ -108,12 +237,70 @@ def verify(options): ) if 'kernel' in options: - cpu_vendor = get_cpus()[0]['vendor_id'] + _cpu_info = get_cpus()[0] + cpu_vendor = _cpu_info.get('vendor_id', 'unknown') if 'amd_pstate_driver' in options['kernel'] and cpu_vendor != 'AuthenticAMD': raise ConfigError( f'AMD pstate driver cannot be used with "{cpu_vendor}" CPU!' ) + isolate_cpus = dict_search('kernel.cpu.isolate_cpus', options) + if isolate_cpus: + available_cores = sorted({int(cpu['cpu']) for cpu in get_available_cpus()}) + cpus_list = range_str_to_list(isolate_cpus) + reserved_cpus = default_resource_map.get('reserved_cpu_cores') + + cpus_available = len(available_cores) - reserved_cpus + if len(cpus_list) > cpus_available: + raise ConfigError( + f'Cannot isolate {len(cpus_list)} CPUs ({isolate_cpus}): ' + f'only {cpus_available} of {len(available_cores)} physical cores ' + f'are available ({reserved_cpus} reserved for the system)' + ) + + not_available = [cpu for cpu in cpus_list if cpu not in available_cores] + if not_available: + not_available_str = list_to_range_str(not_available) + available_str = list_to_range_str(available_cores) + raise ConfigError( + f'CPU(s) {not_available_str} do not exist on this system. ' + f'Available CPUs: {available_str}' + ) + + _, hp_memory_bytes = _get_total_hugepages_and_memory( + options['kernel'].get('memory', {}) + ) + if hp_memory_bytes: + memory = psutil.virtual_memory() + memory_total_bytes = memory.total + + # Exclude hugepage usage from system "used" memory + hp_memory_used = sum( + p['memory'] for p in mem_check.get_hugepages_info().values() + ) + memory_used_bytes = memory.used - hp_memory_used + + # TODO: need to calculate how much memory is consumed for other services, tmpfs etc. + # for now we should leave at least 4 GB for system usage and other processes + min_system_reserved_gd = 4 + memory_margin_gb = 1 + reserved_bytes = max( + min_system_reserved_gd * 1024**3, + memory_used_bytes + memory_margin_gb * 1024**3, + ) + + available_for_hp_bytes = memory_total_bytes - reserved_bytes + if available_for_hp_bytes < hp_memory_bytes: + # For the error message, convert to GB and round to 1 decimal + hp_memory_gb = round(hp_memory_bytes / 1024**3, 1) + available_for_hp_gb = max(0, round(available_for_hp_bytes / 1024**3, 1)) + reserved_gb = round(reserved_bytes / 1024**3, 1) + raise ConfigError( + f'Configured hugepages require {hp_memory_gb} GB of memory, but only ' + f'{available_for_hp_gb:.1f} GB is available ' + f'({reserved_gb} GB is reserved for system usage and services)' + ) + return None @@ -122,7 +309,14 @@ def generate(options): render(ssh_config, 'system/ssh_config.j2', options) render(usb_autosuspend, 'system/40_usb_autosuspend.j2', options) + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in image_installer.py:get_cli_kernel_options(). This + # occurrence is used for having the appropriate options passed to GRUB + # when re-configuring options on the CLI. cmdline_options = [] + kernel_opts = options.get('kernel', {}) + k_cpu_opts = kernel_opts.get('cpu', {}) + k_memory_opts = kernel_opts.get('memory', {}) if 'kernel' in options: if 'disable_mitigations' in options['kernel']: cmdline_options.append('mitigations=off') @@ -133,12 +327,175 @@ def generate(options): cmdline_options.append( f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}' ) - grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) + if 'quiet' in options['kernel']: + cmdline_options.append('quiet') + + # Early reboot on kernel panic via kernel cmdline + # Keep this in sync with image_installer.py:get_cli_kernel_options() + if 'reboot_on_panic' in options: + cmdline_options.append('panic=60') + + if 'disable_hpet' in kernel_opts: + cmdline_options.append('hpet=disable') + + if 'disable_mce' in kernel_opts: + cmdline_options.append('mce=off') + + if 'disable_softlockup' in kernel_opts: + cmdline_options.append('nosoftlockup') + + # CPU options + isol_cpus = k_cpu_opts.get('isolate_cpus') + if isol_cpus: + cmdline_options.append(f'isolcpus={isol_cpus}') + + nohz_full = k_cpu_opts.get('nohz_full') + if nohz_full: + cmdline_options.append(f'nohz_full={nohz_full}') + + rcu_nocbs = k_cpu_opts.get('rcu_no_cbs') + if rcu_nocbs: + cmdline_options.append(f'rcu_nocbs={rcu_nocbs}') + + if 'disable_nmi_watchdog' in k_cpu_opts: + cmdline_options.append('nmi_watchdog=0') + + # Memory options + if 'disable_numa_balancing' in k_memory_opts: + cmdline_options.append('numa_balancing=disable') + + default_hp_size = k_memory_opts.get('default_hugepage_size') + if default_hp_size: + cmdline_options.append(f'default_hugepagesz={default_hp_size}') + + hp_sizes = k_memory_opts.get('hugepage_size') + if hp_sizes: + for size, settings in hp_sizes.items(): + cmdline_options.append(f'hugepagesz={size}') + count = settings.get('hugepage_count') + if count: + cmdline_options.append(f'hugepages={count}') + + cmdline_options_str = ' '.join(cmdline_options) + + grub_util.update_kernel_cmdline_options(cmdline_options_str) + + options['cmdline_options'] = cmdline_options_str return None +def parse_cmdline(cmdline): + """ + Parse command line parameters into a dictionary of managed parameters. + + Args: + cmdline: The command line string (e.g., from /proc/cmdline) + + Returns: + Dictionary with parsed parameters + """ + # Produce a complete template of all managed parameters with + # consistent default values before scanning the actual kernel cmdline. + result = { + k: (False if v['type'] is bool else None) for k, v in MANAGED_PARAMS.items() + } + + # Mapping from regex group names to real parameter keys + group_to_key = { + 'intel_idle_max_cstate': 'intel_idle.max_cstate', + 'processor_max_cstate': 'processor.max_cstate', + } + + # Find all matches and populate result + for match in _parse_cmdline_pattern.finditer(cmdline): + for group_name, value in match.groupdict().items(): + key = group_to_key.get(group_name, group_name) + + # skip empty values and unknown parameters + if value is None or key not in MANAGED_PARAMS: + continue + + entry = MANAGED_PARAMS[key] + + if entry['type'] is bool: + result[key] = True + elif entry['type'] is int: + result[key] = int(value) + else: + result[key] = value + + return result + + +def generate_cmdline_for_kexec(options): + """ + Build an updated kernel cmdline string based on desired options and the + currently running /proc/cmdline. + + Returns: + tuple: (kexec_required, new_cmdline) + - kexec_required (bool): True if kernel options were added, removed or modified. + - new_cmdline (str): The updated kernel command line string. + """ + # Read current cmdline and parse it + current_cmdline = read_file('/proc/cmdline').strip() + current_parsed = parse_cmdline(current_cmdline) + + # Parse desired options from options['cmdline_options'] + desired_options = options.get('cmdline_options', '') + desired_parsed = parse_cmdline(desired_options) + + # Compare dicts to define if kexec is needed + kexec_required = current_parsed != desired_parsed + if not kexec_required: + return kexec_required, current_cmdline + + # Clean managed params and surrounding whitespaces + clean_patterns = [entry['clean'] for entry in MANAGED_PARAMS.values()] + combined_pattern = ( + r'(?:(?<=^)|(?<=\s))(?:' + '|'.join(clean_patterns) + r')(?=\s|$)' + ) + cleaned = re.sub(combined_pattern, ' ', current_cmdline) + cleaned = re.sub(r'\s+', ' ', cleaned).strip() + + # Build new cmdline + parts = [] + for key, entry in MANAGED_PARAMS.items(): + val = desired_parsed[key] + if val is None or val is False: + continue + + if 'build' in entry: + parts.append(entry['build'](val)) + elif entry['type'] is bool: + parts.append(key) + else: + parts.append(f'{key}={val}') + + rebuilt = ' '.join(parts) + + new_cmdline = (cleaned + ' ' + rebuilt).strip() if cleaned else rebuilt + + return kexec_required, new_cmdline + + def apply(options): + kexec_required, cmdline_new = generate_cmdline_for_kexec(options) + if kexec_required: + if not boot_configuration_complete() and os.getenv('VYOS_CONFIGD'): + cmd( + 'kexec -l /boot/vmlinuz --initrd=/boot/initrd.img ' + f'--command-line="{cmdline_new}" --kexec-file-syscall' + ) + os.sync() + cmd('systemctl kexec') + elif boot_configuration_complete(): + Warning( + 'Kernel configuration options have changed. ' + 'To apply these changes, you must save the configuration and reboot the system!' + ) + # System bootup beep beep_service = 'vyos-beep.service' if 'startup_beep' in options: @@ -216,6 +573,34 @@ def apply(options): else: write_file(kernel_dynamic_debug, f'module {module} -p') + if 'resource_limits' in options: + total_pages, total_bytes = _get_total_hugepages_and_memory( + options.get('kernel', {}).get('memory', {}) + ) + + # Minimum recommended system values + max_map_count_min = 65530 # ensures large workload compatibility + shmmax_min = 8589934592 # 8 GiB safe default for large allocations + + max_map_count_conf = options['resource_limits'].get('max_map_count', 'auto') + shmmax_conf = options['resource_limits'].get('shmmax', 'auto') + + parameters = { + 'vm.max_map_count': ( + max(total_pages * 2, max_map_count_min) + if max_map_count_conf == 'auto' + else int(max_map_count_conf) + ), + 'kernel.shmmax': ( + max(total_bytes, shmmax_min) + if shmmax_conf == 'auto' + else int(shmmax_conf) + ), + } + + for parameter, value in parameters.items(): + sysctl_write(parameter.split('.'), value) + if __name__ == '__main__': try: diff --git a/src/conf_mode/system_proxy.py b/src/conf_mode/system_proxy.py index 079c43e7e..3843ad527 100755 --- a/src/conf_mode/system_proxy.py +++ b/src/conf_mode/system_proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index a22dac36f..d54801ecf 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,12 +19,14 @@ import os from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_vrf 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' @@ -38,17 +40,37 @@ def get_config(config=None): else: conf = Config() base = ['system', 'sflow'] + + vpp_sflow = conf.exists(['vpp', 'sflow']) + if not conf.exists(base): - return None + return { + 'remove': True, + 'vpp_sflow': vpp_sflow, + } - sflow = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) + sflow = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + sflow.update({'vpp_sflow': vpp_sflow}) + + if vpp_sflow: + set_dependents('vpp_sflow', conf) return sflow + def verify(sflow): - if not sflow: + # Check if "vpp" flag could be deleted from configuration + if sflow.get('vpp_sflow'): + if 'vpp' not in sflow or 'remove' in sflow: + raise ConfigError( + 'sFlow is still configured in VPP. ' + 'Please remove sFlow configuration from VPP before proceeding.' + ) + + if 'remove' in sflow: return None # Check if configured sflow agent-address exist in the system @@ -60,9 +82,9 @@ def verify(sflow): ) # Check if at least one interface is configured - if 'interface' not in sflow: - raise ConfigError( - 'sFlow requires at least one interface to be configured!') + # Skip this check if VPP is enabled + if 'interface' not in sflow and 'vpp' 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: @@ -71,8 +93,9 @@ def verify(sflow): verify_vrf(sflow) return None + def generate(sflow): - if not sflow: + if 'remove' in sflow: return None render(hsflowd_conf_path, 'sflow/hsflowd.conf.j2', sflow) @@ -80,8 +103,9 @@ def generate(sflow): # Reload systemd manager configuration call('systemctl daemon-reload') + def apply(sflow): - if not sflow: + if 'remove' in sflow: # Stop flow-accounting daemon and remove configuration file call(f'systemctl stop {systemd_service}') if os.path.exists(hsflowd_conf_path): @@ -91,6 +115,9 @@ def apply(sflow): # Start/reload flow-accounting daemon call(f'systemctl restart {systemd_service}') + call_dependents() + + if __name__ == '__main__': try: config = get_config() diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py index f6b02023d..8e018ec0b 100755 --- a/src/conf_mode/system_sysctl.py +++ b/src/conf_mode/system_sysctl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py index 414bd4b6b..e762efd3b 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-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,15 +15,22 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import shutil from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_vrf +from vyos.configverify import verify_pki_certificate +from vyos.configverify import verify_pki_ca_certificate from vyos.defaults import systemd_services from vyos.utils.network import is_addr_assigned from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.template import is_ipv4 from vyos.template import is_ipv6 @@ -31,11 +38,74 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +cert_dir = '/etc/rsyslog.d/certs' rsyslog_conf = '/run/rsyslog/rsyslog.conf' -logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' +logrotate_messages_conf = '/etc/logrotate.d/vyos-rsyslog' systemd_socket = 'syslog.socket' -systemd_service = systemd_services['rsyslog'] +systemd_service = systemd_services['syslog'] + + +def _cleanup_tls_certs(): + if os.path.exists(cert_dir): + shutil.rmtree(cert_dir, ignore_errors=True) + + +def _remote_has_tls(remote_options): + return 'tls' in remote_options + + +def _verify_tls_remote_options(remote, remote_options, syslog): + auth_mode = dict_search('tls.auth_mode', remote_options) + certificate = dict_search('tls.certificate', remote_options) + ca_certificate = dict_search('tls.ca_certificate', remote_options) + + if auth_mode != "anon" and not ca_certificate: + raise ConfigError( + f'Option "ca-certificate" is required for remote "{remote}" when TLS is enabled with auth-mode "{auth_mode}"!' + ) + + if certificate: + verify_pki_certificate(syslog, certificate, no_password_protected=True) + + if ca_certificate: + verify_pki_ca_certificate(syslog, ca_certificate) + + permitted_peers = dict_search('tls.permitted_peer', remote_options) + if not permitted_peers: + if auth_mode == "fingerprint": + raise ConfigError( + f'Auth mode "fingerprint" for remote "{remote}" requires "permitted-peer" to be configured!' + ) + elif auth_mode == "name": + raise ConfigError( + f'Auth mode "name" for remote "{remote}" requires "permitted-peer" to specify allowed subject names!' + ) + + +def _save_tls_certificates_for_remote(syslog, remote_options): + ca_certificate = remote_options['tls'].get('ca_certificate') + ca_cert_file_path = None + if ca_certificate: + ca_cert_file_path = os.path.join(cert_dir, f'{ca_certificate}.pem') + pki_ca = syslog['pki']['ca'][ca_certificate] + + ca_cert = wrap_certificate(pki_ca['certificate']) + write_file(ca_cert_file_path, ca_cert) + remote_options['tls']['ca_certificate_path'] = ca_cert_file_path + + cert_name = remote_options['tls'].get('certificate') + cert_file_path = cert_key_path = None + if cert_name: + cert_file_path = os.path.join(cert_dir, f'{cert_name}.pem') + cert_key_path = os.path.join(cert_dir, f'{cert_name}.key') + pki_cert = syslog['pki']['certificate'][cert_name] + + write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) + write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) + + remote_options['tls']['certificate_path'] = cert_file_path + remote_options['tls']['certificate_key_path'] = cert_key_path def get_config(config=None): if config: @@ -46,10 +116,24 @@ def get_config(config=None): if not conf.exists(base): return None - syslog = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - syslog.update({ 'logrotate' : logrotate_conf }) + syslog = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_pki=True, + ) + + syslog.update({ 'logrotate' : logrotate_messages_conf }) + + logs_config = conf.get_config_dict( + ['system', 'logs'], + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + ) + max_size_mb = dict_search('logrotate.messages.max_size', logs_config) + syslog['logrotate_size_limit'] = int(max_size_mb) * 1024 * 1024 syslog = conf.merge_defaults(syslog, recursive=True) if syslog.from_defaults(['local']): @@ -63,6 +147,11 @@ def get_config(config=None): tmp = conf.return_value(['system', 'domain-name']) syslog['preserve_fqdn']['domain_name'] = tmp + # prune 'remote <remote> tls' if it was not set by user + for remote in syslog.get('remote', {}): + if syslog.from_defaults(['remote', remote, 'tls']): + del syslog['remote'][remote]['tls'] + return syslog def verify(syslog): @@ -97,17 +186,30 @@ def verify(syslog): raise ConfigError(f'Source-address "{source_address}" does not match '\ f'address-family of remote "{remote}"!') + if _remote_has_tls(remote_options): + _verify_tls_remote_options(remote, remote_options, syslog) + + if 'protocol' in remote_options and remote_options['protocol'] == 'udp': + raise ConfigError( + f'TLS is enabled for remote "{remote}", but protocol is set to UDP. TLS is only supported with protocol TCP!' + ) + + def generate(syslog): + _cleanup_tls_certs() + if not syslog: if os.path.exists(rsyslog_conf): os.unlink(rsyslog_conf) - if os.path.exists(logrotate_conf): - os.unlink(logrotate_conf) return None + if 'remote' in syslog: + for _, remote_options in syslog['remote'].items(): + if _remote_has_tls(remote_options): + _save_tls_certificates_for_remote(syslog, remote_options) + render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog) - render(logrotate_conf, 'rsyslog/logrotate.j2', syslog) return None def apply(syslog): diff --git a/src/conf_mode/system_task-scheduler.py b/src/conf_mode/system_task-scheduler.py index 129be5d3c..c0253006a 100755 --- a/src/conf_mode/system_task-scheduler.py +++ b/src/conf_mode/system_task-scheduler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_timezone.py b/src/conf_mode/system_timezone.py index 39770fdb4..54ffb88ee 100755 --- a/src/conf_mode/system_timezone.py +++ b/src/conf_mode/system_timezone.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/system_update-check.py b/src/conf_mode/system_update-check.py index 71ac13e51..6a07d93a6 100755 --- a/src/conf_mode/system_update-check.py +++ b/src/conf_mode/system_update-check.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,10 +15,12 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import json +import requests from pathlib import Path from sys import exit +from vyos.base import Warning from vyos.config import Config from vyos.utils.process import call from vyos import ConfigError @@ -54,6 +56,21 @@ def verify(config): if 'url' not in config: raise ConfigError('URL is required!') + url = config['url'] + + # Make sure that provided URL is available and responses a valid JSON + # otherwise print warning and display type of error (connection, timeout and etc.) + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + response.json() + except requests.exceptions.RequestException as e: + error_type = type(e).__name__ + Warning( + '"system update-check url" has a valid URL but ' + f'unable to retrieve data from the server: {error_type}' + ) + def generate(config): # bail out early - looks like removal from running config diff --git a/src/conf_mode/system_watchdog.py b/src/conf_mode/system_watchdog.py new file mode 100755 index 000000000..8c050f333 --- /dev/null +++ b/src/conf_mode/system_watchdog.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# 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 pathlib import Path +from typing import Optional + +from vyos.config import Config +from vyos.base import Warning +from vyos.template import render +from vyos.utils.kernel import load_module +from vyos.utils.process import call, cmd +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +watchdog_config_dir = Path('/run/systemd/system.conf.d') +watchdog_config_file = watchdog_config_dir / 'watchdog.conf' +modules_load_directory = Path('/run/modules-load.d') +modules_load_file = modules_load_directory / 'watchdog.conf' +WATCHDOG_DEV = Path('/dev/watchdog0') +WATCHDOG_SYSFS = Path('/sys/class/watchdog/watchdog0') + + +def _get_watchdog_driver_module_name() -> Optional[str]: + """Return the kernel module name backing watchdog0, if discoverable.""" + + module_link = WATCHDOG_SYSFS / 'device/driver/module' + if not module_link.exists(): + return None + + try: + resolved = module_link.resolve() + except OSError: + return None + + # Expected to resolve to /sys/module/<module_name> + module_name = resolved.name.strip() + return module_name or None + + +def _read_sysfs_int(path: Path) -> Optional[int]: + try: + return int(path.read_text().strip()) + except (OSError, ValueError): + return None + + +def _get_watchdog_timeout_limits() -> tuple[int, int]: + """Return (min_timeout, max_timeout) from sysfs if available. + + If sysfs is unavailable (device not present/loaded yet) or zero, fall back to a + conservative common kernel max of 65535 seconds. + """ + + if not WATCHDOG_SYSFS.exists(): + return 1, 65535 + + min_timeout = _read_sysfs_int(WATCHDOG_SYSFS / 'min_timeout') + max_timeout = _read_sysfs_int(WATCHDOG_SYSFS / 'max_timeout') + + # Some drivers may not expose min/max. Fall back to sane defaults. + min_timeout = min_timeout if min_timeout and min_timeout > 0 else 1 + max_timeout = max_timeout if max_timeout and max_timeout > 0 else 65535 + + return min_timeout, max_timeout + + +def _verify_watchdog_module(module: str) -> None: + # Dry-run modprobe (-n) in quiet mode (-q) verifies availability without loading + if load_module(module, quiet=True, dry_run=True) != 0: + raise ConfigError( + f"Watchdog driver module '{module}' was not found or cannot be loaded" + ) + + # Ensure the module looks like a watchdog driver and not an arbitrary module. + # Use modinfo filename location as the heuristic. + filename = cmd(['modinfo', '-F', 'filename', module], raising=ConfigError) + filename_l = filename.strip().lower() + + # Accept modules located under drivers/watchdog, plus explicit exception for + # ipmi_watchdog which lives in drivers/char/ipmi. + is_watchdog_driver = '/watchdog/' in filename_l or filename_l.endswith( + '/ipmi_watchdog.ko' + ) + + if not is_watchdog_driver: + raise ConfigError( + f"Kernel module '{module}' does not look like a watchdog driver module (modinfo filename: {filename.strip()})" + ) + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'watchdog'] + + if not conf.exists(base): + return None + + watchdog = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + return watchdog + + +def verify(watchdog): + if watchdog is None: + return None + + module = watchdog.get('module') + device_exists = WATCHDOG_DEV.exists() + + # Require a usable watchdog: either device already present or a module provided + if not module and not device_exists: + raise ConfigError( + "No watchdog device found at /dev/watchdog0 and no module configured. " + "Use 'system watchdog module <name>' to load the required watchdog driver for your system." + ) + + # If a module is provided, ensure it exists and is a watchdog module + if module: + _verify_watchdog_module(module) + + # Validate runtime watchdog timeout against kernel driver limits if available. + # Shutdown/Reboot watchdog settings are systemd-level timers and are not + # constrained by the watchdog device driver's min/max. + if 'timeout' in watchdog: + try: + value = int(watchdog['timeout']) + except (TypeError, ValueError): + raise ConfigError("Invalid value for 'timeout'") + + min_timeout, max_timeout = _get_watchdog_timeout_limits() + if value < min_timeout: + raise ConfigError( + f"'timeout' must be >= {min_timeout} seconds (driver minimum)" + ) + if value > max_timeout: + raise ConfigError( + f"'timeout' must be <= {max_timeout} seconds (driver maximum)" + ) + + return None + + +def generate(watchdog): + # If watchdog node removed entirely, clean up everything + if watchdog is None: + watchdog_config_file.unlink(missing_ok=True) + modules_load_file.unlink(missing_ok=True) + return None + + # Persist kernel module autoload on boot if specified (even if not enabled) + module = watchdog.get('module') + if module: + try: + modules_load_directory.mkdir(parents=True, exist_ok=True) + modules_load_file.write_text(f"{module}\n") + except OSError as e: + Warning(f"Failed writing modules-load configuration: {e}") + else: + # If module option removed, drop persisted autoload file + modules_load_file.unlink(missing_ok=True) + + # Try to load kernel module if specified and /dev/watchdog0 is missing + if not WATCHDOG_DEV.exists(): + if module: + # Try to load the module using vyos call wrapper for logging/airbag integration + try: + rc = load_module(module, quiet=True, dry_run=False) + except OSError as e: + Warning( + f"Could not execute modprobe for watchdog module '{module}': {e}" + ) + else: + if rc != 0: + Warning( + f"Could not load watchdog module '{module}' (modprobe exit code {rc})" + ) + # Re-check for device + if not WATCHDOG_DEV.exists(): + Warning("/dev/watchdog0 not found. Systemd watchdog will not be enabled.") + watchdog_config_file.unlink(missing_ok=True) + return None + + # If a module was configured explicitly, warn if the actual driver module + # bound to watchdog0 differs from what the user configured. + if module and WATCHDOG_SYSFS.exists(): + actual_module = _get_watchdog_driver_module_name() + if actual_module and actual_module != module: + Warning( + f"Configured watchdog driver module '{module}' does not match watchdog0 driver module '{actual_module}'" + ) + + # Ensure the directory exists + watchdog_config_dir.mkdir(parents=True, exist_ok=True) + + # Pass through configured time values directly as seconds + render(str(watchdog_config_file), 'system/watchdog.conf.j2', watchdog) + + return None + + +def apply(watchdog): + # Reload systemd daemon to apply/unload the watchdog configuration + # The watchdog settings take immediate effect after systemd is reloaded + call('systemctl daemon-reload') + + 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/system_wireless.py b/src/conf_mode/system_wireless.py index e0ca0ab8e..2d377c50a 100644 --- a/src/conf_mode/system_wireless.py +++ b/src/conf_mode/system_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 2754314f7..f268f0861 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-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -54,6 +54,7 @@ from vyos.utils.vti_updown_db import vti_updown_db_exists from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update from vyos.utils.vti_updown_db import remove_vti_updown_db from vyos import ConfigError +from vyos.base import Warning from vyos import airbag airbag.enable() @@ -65,6 +66,7 @@ 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' charon_systemd_conf = '/etc/strongswan.d/charon-systemd.conf' +charon_logging_conf = '/etc/strongswan.d/charon-logging.conf' interface_conf = '/etc/strongswan.d/interfaces_use.conf' swanctl_conf = f'{swanctl_dir}/swanctl.conf' @@ -80,6 +82,57 @@ CRL_PATH = f'{swanctl_dir}/x509crl/' DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_interfaces' + +def _cleanup_default_prefixes(ipsec: dict, default_values: dict): + """ + Remove default local/remote prefixes from tunnels + that use 'transport' mode ESP and do not have explicit prefix definitions + """ + site_to_site = dict_search_args(ipsec, 'site_to_site', 'peer') or {} + + for peer, peer_conf in site_to_site.items(): + tunnels = peer_conf.get('tunnel') or {} + default_esp_group = peer_conf.get('default_esp_group') + + for tunnel, tunnel_conf in tunnels.items(): + # Determine ESP group name - prefer specific over default + tunnel_esp_group = tunnel_conf.get('esp_group') + esp_group_name = tunnel_esp_group or default_esp_group + + # Get default values for the tunnel + tunnel_defaults = dict_search_args( + default_values, 'site_to_site', 'peer', peer, 'tunnel', tunnel + ) + + # Skip if no defaults found or ESP group defined + # Yes, this can happen because of user misconfiguration + if tunnel_defaults is None or esp_group_name is None: + continue + + # Fetch ESP group details + esp_group_mode = dict_search_args( + ipsec, 'esp_group', esp_group_name, 'mode' + ) + + # Only act if ESP group is in transport mode + if esp_group_mode == 'transport': + + # Look for local and remote prefixes + local_prefixes = dict_search_args(tunnel_conf, 'local', 'prefix') + remote_prefixes = dict_search_args(tunnel_conf, 'remote', 'prefix') + + # Safely remove missing prefixes from defaults + # if user has not defined them but they are in defaults + if not local_prefixes: + prefix = dict_search_args(tunnel_defaults, 'local', 'prefix') + if prefix is not None: + del tunnel_defaults['local']['prefix'] + + if not remote_prefixes: + prefix = dict_search_args(tunnel_defaults, 'remote', 'prefix') + if prefix is not None: + del tunnel_defaults['remote']['prefix'] + def get_config(config=None): if config: conf = config @@ -114,6 +167,9 @@ def get_config(config=None): if 'dead_peer_detection' not in ike: del default_values['ike_group'][name]['dead_peer_detection'] + # Clean up default prefixes for ESP transport-mode tunnels + _cleanup_default_prefixes(ipsec, default_values) + ipsec = config_dict_merge(default_values, ipsec) ipsec['dhcp_interfaces'] = set() @@ -135,7 +191,7 @@ def get_config(config=None): ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024' ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1' - # Collect the interface dicts for any refernced VTI interfaces in + # Collect the interface dicts for any referenced VTI interfaces in # case we need to bring the interface up ipsec['vti_interface_dicts'] = {} @@ -207,11 +263,29 @@ def verify(ipsec): if not ipsec or 'deleted' in ipsec: return + # T8136 PPK support; keep a list of PPK IDs + ppk_ids = [] + 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"') + raise ConfigError( + f'Authentication psk "{psk}" missing "id" or "secret"' + ) + # T8136 PPK Support; Check that PPK has an ID and secret defined, and ID is unique + if 'ppk' in ipsec['authentication']: + for ppk, ppk_config in ipsec['authentication']['ppk'].items(): + if 'id' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "id"') + if 'secret' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "secret"') + for ppk_id in ppk_config['id']: + if ppk_id in ppk_ids: + raise ConfigError( + f'Authentication PPK "{ppk}" has duplicate ID "{ppk_id}" from another PPK. IDs should be unique.' + ) + ppk_ids.append(ppk_id) if 'interface' in ipsec: tmp = re.compile(dynamic_interface_pattern) @@ -391,6 +465,25 @@ def verify(ipsec): elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']: raise ConfigError(f'Requested pool "{pool}" does not exist!') + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in ra_conf['authentication']: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": post-quantum pre-shared keys require explicit IKEv2 usage.' + ) + if 'childless' not in ra_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in ra_conf: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": childless IKE SAs can only be used with IKEv2.' + ) + if 'pool' in ipsec['remote_access']: pool_networks = [] for pool, pool_config in ipsec['remote_access']['pool'].items(): @@ -599,6 +692,41 @@ def verify(ipsec): f'for ESP proposal {proposal} on tunnel {tunnel} for site-to-site peer {peer} with VPP' ) + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in peer_conf['authentication']: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Post-quantum preshared keys must be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + if 'childless' not in peer_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in peer_conf: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Childless IKE SAs be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + + # Get the referenced IKE group config + ike_group_name = peer_conf.get('ike_group') + ike_group = ipsec['ike_group'].get(ike_group_name, {}) + + # 'ikev2-reauth' only valid for IKEv2 + peer_reauth = peer_conf.get('ikev2_reauth') + reauth_ike_group_configured = ( + peer_reauth == 'inherit' and 'ikev2_reauth' in ike_group + ) + if peer_reauth == 'yes' or reauth_ike_group_configured: + if ike_group.get('key_exchange') != 'ikev2': + raise ConfigError( + 'ikev2-reauth requires key-exchange ikev2 in IKE group! ' + f'Please configure IKEv2 key-exchange in ike-group "{ike_group_name}".' + ) + def cleanup_pki_files(): for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]: @@ -656,7 +784,15 @@ def generate(ipsec): cleanup_pki_files() if not ipsec or 'deleted' in ipsec: - for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]: + delete_files = ( + charon_dhcp_conf, + charon_radius_conf, + charon_systemd_conf, + charon_logging_conf, + interface_conf, + swanctl_conf, + ) + for config_file in delete_files: if os.path.isfile(config_file): os.unlink(config_file) render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes}) @@ -696,6 +832,8 @@ def generate(ipsec): generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509']) if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + DEFAULT_TS_PREFIX = 'dynamic' + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): if f'peer_{peer}' in ipsec['dhcp_no_address']: continue @@ -724,10 +862,16 @@ def generate(ipsec): passthrough = None for local_prefix in local_prefixes: + if local_prefix == DEFAULT_TS_PREFIX: + continue + for remote_prefix in remote_prefixes: + if remote_prefix == DEFAULT_TS_PREFIX: + continue + local_net = ipaddress.ip_network(local_prefix) remote_net = ipaddress.ip_network(remote_prefix) - if local_net.overlaps(remote_net): + if local_net.subnet_of(remote_net): if passthrough is None: passthrough = [] passthrough.append(local_prefix) @@ -747,6 +891,7 @@ def generate(ipsec): render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.j2', ipsec) render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.j2', ipsec) render(charon_systemd_conf, 'ipsec/charon_systemd.conf.j2', ipsec) + render(charon_logging_conf, 'ipsec/charon_logging.conf.j2', ipsec) render(interface_conf, 'ipsec/interfaces_use.conf.j2', ipsec) render(swanctl_conf, 'ipsec/swanctl.conf.j2', ipsec) diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 04ccbcec3..d6f5e4c28 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index 42785134f..61c566bf5 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-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,6 +21,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate +from vyos.defaults import systemd_services from vyos.pki import find_chain from vyos.pki import encode_certificate from vyos.pki import load_certificate @@ -37,19 +38,22 @@ from passlib.hash import sha512_crypt from time import sleep from vyos import airbag + airbag.enable() -cfg_dir = '/run/ocserv' -ocserv_conf = cfg_dir + '/ocserv.conf' -ocserv_passwd = cfg_dir + '/ocpasswd' +cfg_dir = '/run/ocserv' +ocserv_conf = cfg_dir + '/ocserv.conf' +ocserv_passwd = cfg_dir + '/ocpasswd' ocserv_otp_usr = cfg_dir + '/users.oath' -radius_cfg = cfg_dir + '/radiusclient.conf' +radius_cfg = cfg_dir + '/radiusclient.conf' radius_servers = cfg_dir + '/radius_servers' + # Generate hash from user cleartext password def get_hash(password): return sha512_crypt.hash(password) + def get_config(config=None): if config: conf = config @@ -59,78 +63,139 @@ def get_config(config=None): if not conf.exists(base): return None - ocserv = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True, - with_pki=True) + ocserv = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + with_pki=True, + ) return ocserv + def verify(ocserv): if ocserv is None: return None # Check if listen-ports not binded other services # It can be only listen by 'ocserv-main' for proto, port in ocserv.get('listen_ports').items(): - if check_port_availability(ocserv['listen_address'], int(port), proto) is not True and \ - not is_listen_port_bind_service(int(port), 'ocserv-main'): + if check_port_availability( + ocserv['listen_address'], int(port), proto + ) is not True and not is_listen_port_bind_service(int(port), 'ocserv-main'): raise ConfigError(f'"{proto}" port "{port}" is used by another service') # Check 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') + if 'accounting' in ocserv: + if 'mode' in ocserv['accounting'] and 'radius' in ocserv['accounting']['mode']: + if not ocserv['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"] 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.get("authentication", {}).get("local_users"): - raise ConfigError('OpenConnect mode local required at least one user') - if not ocserv["authentication"]["local_users"]["username"]: - raise ConfigError('OpenConnect mode local required at least one user') + if 'authentication' in ocserv: + if 'mode' in ocserv['authentication']: + if ( + ('local' in ocserv['authentication']['mode'] + and 'radius' in ocserv['authentication']['mode']) + or + ('local' in ocserv['authentication']['mode'] + and 'certificate' in ocserv['authentication']['mode']) + or + ('radius' in ocserv['authentication']['mode'] + and 'certificate' in ocserv['authentication']['mode']) + ): + raise ConfigError( + 'OpenConnect authentication modes are mutually-exclusive. Use only one of local, radius, or certificate.' + ) + if 'radius' in ocserv['authentication']['mode']: + if 'server' not in ocserv['authentication']['radius']: + raise ConfigError( + 'OpenConnect authentication mode radius requires at least one RADIUS server' + ) + if 'local' in ocserv['authentication']['mode']: + if not ocserv.get('authentication', {}).get('local_users'): + raise ConfigError( + 'OpenConnect mode local required at least one user' + ) + if not ocserv['authentication']['local_users']['username']: + raise ConfigError( + 'OpenConnect mode local required at least one user' + ) else: # For OTP mode: verify that each local user has an OTP key - if "otp" in ocserv["authentication"]["mode"]["local"]: + if 'otp' in ocserv['authentication']['mode']['local']: users_wo_key = [] - for user, user_config in ocserv["authentication"]["local_users"]["username"].items(): + for user, user_config in ocserv['authentication'][ + 'local_users' + ]['username'].items(): # User has no OTP key defined - if dict_search('otp.key', user_config) == None: + if dict_search('otp.key', user_config) is None: users_wo_key.append(user) if users_wo_key: - raise ConfigError(f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}') + raise ConfigError( + f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}' + ) # For password (and default) mode: verify that each local user has password - if "password" in ocserv["authentication"]["mode"]["local"] or "otp" not in ocserv["authentication"]["mode"]["local"]: + if ( + 'password' in ocserv['authentication']['mode']['local'] + or 'otp' not in ocserv['authentication']['mode']['local'] + ): users_wo_pswd = [] - for user in ocserv["authentication"]["local_users"]["username"]: - if not "password" in ocserv["authentication"]["local_users"]["username"][user]: + for user in ocserv['authentication']['local_users']['username']: + if ( + 'password' + not in ocserv['authentication']['local_users'][ + 'username' + ][user] + ): users_wo_pswd.append(user) if users_wo_pswd: - raise ConfigError(f'password required for users:\n{users_wo_pswd}') + 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') + 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: @@ -144,99 +209,170 @@ def verify(ocserv): raise ConfigError('SSL certificate missing on OpenConnect config!') verify_pki_certificate(ocserv, ocserv['ssl']['certificate']) + if 'ca_certificate' not in ocserv['ssl'] and 'certificate' in ocserv['authentication']['mode']: + raise ConfigError('CA certificate must be provided in certificate authentication mode!') + if 'ca_certificate' in ocserv['ssl']: for ca_cert in ocserv['ssl']['ca_certificate']: verify_pki_ca_certificate(ocserv, ca_cert) # Check network settings - if "network_settings" in ocserv: - if "push_route" in ocserv["network_settings"]: + if 'network_settings' in ocserv: + if 'push_route' in ocserv['network_settings']: # Replace default route - if "0.0.0.0/0" in ocserv["network_settings"]["push_route"]: - ocserv["network_settings"]["push_route"].remove("0.0.0.0/0") - ocserv["network_settings"]["push_route"].append("default") + if '0.0.0.0/0' in ocserv['network_settings']['push_route']: + ocserv['network_settings']['push_route'].remove('0.0.0.0/0') + ocserv['network_settings']['push_route'].append('default') else: - ocserv["network_settings"]["push_route"] = ["default"] + ocserv['network_settings']['push_route'] = ['default'] else: raise ConfigError('OpenConnect network settings required!') + def generate(ocserv): if not ocserv: return None - if "radius" in ocserv["authentication"]["mode"]: + if 'radius' in ocserv['authentication']['mode']: 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"] + 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}) + 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"]: + render( + radius_servers, + 'ocserv/radius_servers.j2', + ocserv['authentication']['radius'], + ) + elif 'local' in ocserv['authentication']['mode']: # if mode "OTP", generate OTP users file parameters - if "otp" in ocserv["authentication"]["mode"]["local"]: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: + if 'otp' in ocserv['authentication']['mode']['local']: + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: # OTP token type from CLI parameters: - otp_interval = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("interval")) - token_type = ocserv["authentication"]["local_users"]["username"][user]["otp"].get("token_type") - otp_length = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("otp_length")) - if token_type == "hotp-time": - otp_type = "HOTP/T" + otp_interval - elif token_type == "hotp-event": - otp_type = "HOTP/E" + otp_interval = str( + ocserv['authentication']['local_users']['username'][user][ + 'otp' + ].get('interval') + ) + token_type = ocserv['authentication']['local_users']['username'][ + user + ]['otp'].get('token_type') + otp_length = str( + ocserv['authentication']['local_users']['username'][user][ + 'otp' + ].get('otp_length') + ) + if token_type == 'hotp-time': + otp_type = 'HOTP/T' + otp_interval + elif token_type == 'hotp-event': + otp_type = 'HOTP/E' else: - otp_type = "HOTP/T" + otp_interval - ocserv["authentication"]["local_users"]["username"][user]["otp"]["token_tmpl"] = otp_type + "/" + otp_length + otp_type = 'HOTP/T' + otp_interval + ocserv['authentication']['local_users']['username'][user]['otp'][ + 'token_tmpl' + ] = otp_type + '/' + otp_length # if there is a password, generate hash - if "password" in ocserv["authentication"]["mode"]["local"] or not "otp" in ocserv["authentication"]["mode"]["local"]: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: - ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) - - if "password-otp" in ocserv["authentication"]["mode"]["local"]: + if ( + 'password' in ocserv['authentication']['mode']['local'] + or 'otp' not in ocserv['authentication']['mode']['local'] + ): + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: + ocserv['authentication']['local_users']['username'][user][ + 'hash' + ] = get_hash( + ocserv['authentication']['local_users']['username'][user][ + 'password' + ] + ) + + if 'password-otp' in ocserv['authentication']['mode']['local']: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) # Render local users OTP keys - render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) - elif "password" in ocserv["authentication"]["mode"]["local"]: + render( + ocserv_otp_usr, + 'ocserv/ocserv_otp_usr.j2', + ocserv['authentication']['local_users'], + ) + elif 'password' in ocserv['authentication']['mode']['local']: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) - elif "otp" in ocserv["authentication"]["mode"]["local"]: + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) + elif 'otp' in ocserv['authentication']['mode']['local']: # Render local users OTP keys - render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_otp_usr, + 'ocserv/ocserv_otp_usr.j2', + ocserv['authentication']['local_users'], + ) else: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) else: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: - ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: + ocserv['authentication']['local_users']['username'][user]['hash'] = ( + get_hash( + ocserv['authentication']['local_users']['username'][user][ + 'password' + ] + ) + ) # Render local users - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) - if "ssl" in ocserv: + if 'ssl' in ocserv: cert_file_path = os.path.join(cfg_dir, 'cert.pem') cert_key_path = os.path.join(cfg_dir, 'cert.key') - if 'certificate' in ocserv['ssl']: cert_name = ocserv['ssl']['certificate'] pki_cert = ocserv['pki']['certificate'][cert_name] loaded_pki_cert = load_certificate(pki_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in ocserv['pki']['ca'].values()} if 'ca' in ocserv['pki'] else {} + loaded_ca_certs = ( + { + load_certificate(c['certificate']) + for c in ocserv['pki']['ca'].values() + } + if 'ca' in ocserv['pki'] + else {} + ) cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) - write_file(cert_file_path, - '\n'.join(encode_certificate(c) for c in cert_full_chain)) + write_file( + cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain), + ) if 'private' in pki_cert and 'key' in pki_cert['private']: write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) @@ -250,7 +386,8 @@ def generate(ocserv): 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)) + '\n'.join(encode_certificate(c) for c in ca_full_chain) + ) write_file(ca_cert_file_path, '\n'.join(ca_chains)) @@ -259,21 +396,24 @@ def generate(ocserv): def apply(ocserv): + service_name = systemd_services['openconnect'] if not ocserv: - call('systemctl stop ocserv.service') + call(f'systemctl stop {service_name}') for file in [ocserv_conf, ocserv_passwd, ocserv_otp_usr]: if os.path.exists(file): os.unlink(file) else: - call('systemctl reload-or-restart ocserv.service') + call(f'systemctl reload-or-restart {service_name}') counter = 0 while True: # exit early when service runs - if is_systemd_service_running("ocserv.service"): + if is_systemd_service_running(service_name): break sleep(0.250) if counter > 5: - raise ConfigError('OpenConnect failed to start, check the logs for details') + raise ConfigError( + 'OpenConnect failed to start, check the logs for details' + ) break counter += 1 diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index c0d8330bd..c11619779 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-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 7490fd0e0..5382fc711 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py new file mode 100755 index 000000000..342d58fca --- /dev/null +++ b/src/conf_mode/vpp.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from pathlib import Path + +from pyroute2.iproute import IPRoute + +try: + from vpp_papi import VPPIOError, VPPValueError +except ImportError: # pylint: disable=import-error + VPPIOError = VPPValueError = None + +from vyos import ConfigError +from vyos import airbag +from vyos.base import Warning +from vyos.config import Config, config_dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos.configdict import node_changed, is_member +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_virtual_interface_exists +from vyos.ifconfig import Section +from vyos.logger import getLogger +from vyos.template import render +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.convert import range_str_to_list +from vyos.utils.convert import list_to_range_str +from vyos.utils.dict import dict_search +from vyos.utils.file import read_file +from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import unload_kmod +from vyos.utils.kernel import list_loaded_modules +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active + +from vyos.vpp import VPPControl +from vyos.vpp import control_host +from vyos.vpp import VppNotRunningError +from vyos.vpp.config_verify import ( + verify_vpp_remove_interface, + verify_vpp_minimum_cpus, + verify_vpp_minimum_memory, + verify_vpp_cpu_cores, + verify_vpp_memory, + verify_vpp_statseg_size, + verify_vpp_interfaces_dpdk_num_queues, + verify_routes_count, + verify_vpp_main_heap_size, + verify_vpp_buffers, +) +from vyos.vpp.config_resource_checks import memory +from vyos.vpp.config_filter import iface_filter_eth +from vyos.vpp.utils import EthtoolGDrvinfo +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces +from vyos.vpp.configdb import JSONStorage + +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' + +vpp_log = getLogger( + service_name, format='%(filename)s[%(process)d]: %(message)s', address='/dev/log' +) + +dependency_interface_type_map = { + 'vpp_interfaces_bonding': 'bonding', + 'vpp_interfaces_bridge': 'bridge', + 'vpp_interfaces_gre': 'gre', + 'vpp_interfaces_ipip': 'ipip', + 'vpp_interfaces_loopback': 'loopback', + 'vpp_interfaces_vxlan': 'vxlan', + 'vpp_interfaces_xconnect': 'xconnect', +} + +# dict of drivers that needs to be overridden +override_drivers: dict[str, str] = { + 'hv_netvsc': 'uio_hv_generic', +} + +# drivers that does not use PCIe addresses +not_pci_drv: list[str] = ['hv_netvsc'] + +# drivers that support interrupt RX mode for DPDK and XDP +drivers_support_interrupt: dict[str, list] = { + 'atlantic': ['dpdk', 'xdp'], + 'bnx2x': ['dpdk'], + 'e1000': ['dpdk'], + 'ena': ['dpdk', 'xdp'], + 'i40e': ['dpdk', 'xdp'], + 'ice': ['dpdk', 'xdp'], + 'igb': ['xdp'], + 'igc': ['dpdk', 'xdp'], + 'ixgbe': ['dpdk', 'xdp'], + 'qede': ['dpdk', 'xdp'], + 'vmxnet3': ['xdp'], + 'virtio_net': ['xdp'], +} + +# drivers that require changing channels (half the maximum number of RX/TX queues) +ethtool_channels_change_drv: list[str] = ['ena', 'gve'] + +# List of NICs where VPP activation is supported +SUPPORTED_PCI_IDS = ( + '15b3:1019', # Mellanox Technologies MT28800 Family [ConnectX-5 Ex] + '15b3:101d', # Mellanox Technologies MT2892 Family [ConnectX-6 Dx] + '15b3:101e', # Mellanox Technologies ConnectX Family mlx5Gen Virtual Function + '8086:1592', # Intel Corporation Ethernet Controller E810-C for QSFP + '1ae0:0042', # Google, Inc. Compute Engine Virtual Ethernet [gVNIC] + '1af4:1000', # Red Hat, Inc. Virtio network device (legacy ID) + '1af4:1041', # Red Hat, Inc. Virtio network device (modern ID) + '1d0f:ec20', # Amazon.com, Inc. Elastic Network Adapter (ENA) +) +SUPPORTED_DRIVERS = ( + 'hv_netvsc', # Microsoft Hyper-V network interface card +) + + +def _load_module(module_name: str): + """ + Load a kernel module + + Args: + module_name (str): Name of the module to load. + """ + if module_name in list_loaded_modules(): + vpp_log.info(f"Module '{module_name}' is already loaded") + return + try: + check_kmod(module_name) + vpp_log.info(f"Module '{module_name}' loaded successfully") + except Exception as e: + vpp_log.error(f"Failed to load module '{module_name}': {e}") + raise + + +def _unload_module(module_name: str): + """ + Unload a kernel module + + Args: + module_name (str): Name of the module to unload. + """ + if module_name not in list_loaded_modules(): + vpp_log.info(f"Module '{module_name}' is not loaded") + return + try: + unload_kmod(module_name) + vpp_log.info(f"Module '{module_name}' unloaded successfully") + except Exception as e: + vpp_log.error(f"Failed to unload module '{module_name}': {e}") + raise + + +def _configure_vpp_cpu_settings(config: dict): + """Configure VPP CPU settings: main-core and corelist-workers based on 'cpu-cores'. + + Reads the actually-isolated CPUs from the running kernel + (/sys/devices/system/cpu/isolated) and assigns: + - main_core: the first isolated CPU (index 0) + - corelist_workers: the next (cpu_cores - 1) isolated CPUs + """ + cpu_cores = int(config['settings']['resource_allocation']['cpu_cores']) + # Use the system's actual isolated CPUs, not config values which may + # require a reboot to take effect + isolated = read_file('/sys/devices/system/cpu/isolated') + cpus_isolated = range_str_to_list(isolated) + + if cpu_cores <= len(cpus_isolated): + # First isolated CPU is the VPP main thread; remaining are workers + config['settings']['cpu'] = {'main_core': str(cpus_isolated[0])} + + if cpu_cores > 1: + config['settings']['cpu']['corelist_workers'] = list_to_range_str( + cpus_isolated[1:cpu_cores] + ) + + +def _normalize_buffers(config: dict): + """Replace 'auto' buffers_per_numa with calculated value""" + if ( + config['settings']['resource_allocation']['buffers']['buffers_per_numa'] + == 'auto' + ): + buffers = memory.buffers_required(config['settings']) + config['settings']['resource_allocation']['buffers']['buffers_per_numa'] = str( + buffers + ) + + +def _get_max_xdp_rx_queues(config: dict): + """ + Count max number of RX queues for XDP driver + - If the interface driver is in `ethtool_channels_change_drv` + only half of the available queues are used (to avoid NIC issues) + - For other interface drivers the full number of queues is returned. + - If neither `rx` nor `combined` is set, return 1. + """ + for key in ('rx', 'combined'): + value = config['channels'].get(key) + if value: + if config['original_driver'] in ethtool_channels_change_drv: + return max(1, int(value) // 2) + else: + return int(value) + + return 1 + + +def _is_device_allowed(config: dict, iface: str): + """ + Determines if a network interface device is allowed to be used + with VPP based on its PCI ID or driver. + """ + if 'allow_unsupported_nics' in config['settings']: + return True + + persist_config = dict_search(f'persist_config.{iface}', config, default={}) + + pci_id = persist_config.get('pci_id') + # PCI ID is sufficient by itself, if presented + if pci_id is not None and pci_id in SUPPORTED_PCI_IDS: + return True + + # If the PCI ID did not match or does not exist, fall back to a driver + original_driver = persist_config.get('original_driver') + if original_driver is not None and original_driver in SUPPORTED_DRIVERS: + return True + + return False + + +def get_config(config=None): + # use persistent config to store interfaces data between executions + # this is required because some interfaces after they are connected + # to VPP is really hard or impossible to restore without knowing + # their original parameters (like IDs) + with JSONStorage('vpp_conf') as persist_config: + eth_ifaces_persist: dict[str, dict[str, str]] = persist_config.read( + 'eth_ifaces', {} + ) + + if config: + conf = config + else: + conf = Config() + + base = ['vpp'] + base_settings = ['vpp', 'settings'] + + # find interfaces removed from VPP + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + removed_ifaces = [] + tmp = node_changed(conf, base_settings + ['interface']) + if tmp: + for removed_iface in tmp: + to_append = { + 'iface_name': removed_iface, + 'driver': 'dpdk', + } + removed_ifaces.append(to_append) + # add an interface to a list of interfaces that need + # to be reinitialized after the commit + set_dependents('ethernet', conf, removed_iface) + + # Get interfaces that should be used in PPPoE for control-plane integration + pppoe_ifaces = conf.get_config_dict( + ['service', 'pppoe-server', 'interface'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + changed_pppoe_ifaces = [ + iface for iface in pppoe_ifaces if iface.split('.')[0] in tmp + ] + + interfaces_config = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + if not conf.exists(base): + if changed_pppoe_ifaces: + set_dependents('pppoe_server', conf) + return { + 'removed_ifaces': removed_ifaces, + 'persist_config': eth_ifaces_persist, + 'interfaces_vpp': interfaces_config, + 'pppoe_ifaces': pppoe_ifaces, + 'remove': {}, + } + + config = conf.get_config_dict( + base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + ) + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + + # Since XDP is no longer configurable via the CLI (T8202), + # this code is kept commented out to simplify reintroducing XDP in the future. + # + # # delete driver-incompatible defaults + # for iface, iface_config in config.get('settings', {}).get('interface', {}).items(): + # if iface_config.get('driver') == 'dpdk': + # del default_values['settings']['interface'][iface]['xdp_options'] + # elif iface_config.get('driver') == 'xdp': + # del default_values['settings']['interface'][iface]['dpdk_options'] + + config = config_dict_merge(default_values, config) + + # add running config + if effective_config: + default_values_effective = conf.get_config_defaults( + **effective_config.kwargs, recursive=True + ) + effective_config = config_dict_merge(default_values_effective, effective_config) + # Buffer normalization (auto → computed) + _normalize_buffers(effective_config) + for iface_config in effective_config['settings']['interface'].values(): + iface_config['driver'] = 'dpdk' + config['effective'] = effective_config + + # Save important info about all interfaces that cannot be retrieved later + # Add new interfaces (only if they are first time seen in a config) + for iface, iface_config in config.get('settings', {}).get('interface', {}).items(): + if iface not in effective_config.get('settings', {}).get('interface', {}): + eth_ifaces_persist[iface] = { + 'original_driver': EthtoolGDrvinfo(iface).driver, + } + eth_ifaces_persist[iface]['bus_id'] = control_host.get_bus_name(iface) + eth_ifaces_persist[iface]['dev_id'] = control_host.get_dev_id(iface) + eth_ifaces_persist[iface]['pci_id'] = control_host.get_pci_id(iface) + eth_ifaces_persist[iface]['channels'] = control_host.get_eth_channels(iface) + + # Return to config dictionary + config['persist_config'] = eth_ifaces_persist + + # list of all Ethernet interfaces with vifs + ifaces_with_vifs = cli_ethernet_with_vifs_ifaces(conf, include_nested_vifs=True) + + if 'settings' in config: + if 'interface' in config['settings']: + interface_rx_mode = config['settings'].get('interface_rx_mode') + + for iface, iface_config in config['settings']['interface'].items(): + iface_config['driver'] = 'dpdk' + + # old_driver = leaf_node_changed( + # conf, base_settings + ['interface', iface, 'driver'] + # ) + # + # if old_driver: + # config['settings']['interface'][iface]['driver_changed'] = {} + + # Get current kernel module, required for extra verification and + # logic for VMBus interfaces + config['settings']['interface'][iface]['kernel_module'] = ( + EthtoolGDrvinfo(iface).driver + ) + + # filter unsupported config nodes + iface_filter_eth(conf, iface) + set_dependents('ethernet', conf, iface) + # Interfaces with changed driver should be removed/readded + # if old_driver and old_driver[0] == 'dpdk': + # removed_ifaces.append( + # { + # 'iface_name': iface, + # 'driver': 'dpdk', + # } + # ) + + # Collect memberships as sets for uniqueness + bond_member = is_member(conf, iface, 'bonding') + bridge_member = is_member(conf, iface, 'bridge') + + # Look for VLAN interfaces of this parent + vlans = [ + vlan_iface + for vlan_iface in ifaces_with_vifs + if vlan_iface.startswith(f'{iface}.') + ] + for vlan_iface in vlans: + bond_member.update(is_member(conf, vlan_iface, 'bonding')) + bridge_member.update(is_member(conf, vlan_iface, 'bridge')) + + # Store as lists + if bond_member: + iface_config['bond_member'] = list(bond_member) + if bridge_member: + iface_config['bridge_member'] = list(bridge_member) + + # Get PCI address or device ID + if iface_config['driver'] == 'dpdk': + if 'dpdk_options' not in iface_config: + iface_config['dpdk_options'] = {} + # Check in a persistent config first + id_from_persistent_conf = eth_ifaces_persist.get(iface, {}).get( + 'dev_id' + ) + if id_from_persistent_conf: + iface_config['dpdk_options']['dev_id'] = id_from_persistent_conf + else: + try: + iface_to_search = iface + # if old_driver and old_driver[0] == 'xdp': + # iface_to_search = f'defunct_{iface}' + iface_config['dpdk_options']['dev_id'] = ( + control_host.get_dev_id(iface_to_search) + ) + except Exception: + # Return empty address if all attempts failed + # We will catch this in verify() + iface_config['dpdk_options']['dev_id'] = '' + # prepare XDP interface parameters + if iface_config['driver'] == 'xdp': + xdp_api_params = { + 'rxq_size': int(iface_config['xdp_options']['rx_queue_size']), + 'txq_size': int(iface_config['xdp_options']['tx_queue_size']), + } + if iface_config['xdp_options']['num_rx_queues'] == 'all': + # 65535 is used as special value to request all available queues + xdp_api_params['rxq_num'] = 65535 + else: + xdp_api_params['rxq_num'] = int( + iface_config['xdp_options']['num_rx_queues'] + ) + if 'zero-copy' in iface_config['xdp_options']: + xdp_api_params['mode'] = 'zero-copy' + if ( + interface_rx_mode in ('interrupt', 'adaptive') + and int(config['settings']['resource_allocation']['cpu_cores']) + > 1 + ): + xdp_api_params['flags'] = 'no_syscall_lock' + iface_config['xdp_api_params'] = xdp_api_params + + # Buffer normalization (auto → computed) + _normalize_buffers(config) + # Configure VPP main-core and workers 'cpu-cores' settings + _configure_vpp_cpu_settings(config) + + if removed_ifaces: + config['removed_ifaces'] = removed_ifaces + + config['interfaces_vpp'] = interfaces_config + + # Dependencies + for dependency, interface_type in dependency_interface_type_map.items(): + if conf.exists(['interfaces', 'vpp', interface_type]): + for iface, iface_config in interfaces_config.get( + interface_type, {} + ).items(): + set_dependents(dependency, conf, iface) + + config['ipoe_conf'] = conf.get_config_dict( + ['service', 'ipoe-server'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # sFlow dependency + if conf.exists(['vpp', 'sflow']): + set_dependents('vpp_sflow', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + # IPFIX dependency + if conf.exists(['vpp', 'ipfix']): + set_dependents('vpp_ipfix', conf) + + # PPPoE dependency + added_pppoe_ifaces = [ + iface + for iface in pppoe_ifaces + if iface.split('.')[0] in config.get('settings', {}).get('interface', {}) + ] + changed_pppoe_ifaces.extend(added_pppoe_ifaces) + if changed_pppoe_ifaces: + set_dependents('pppoe_server', conf) + config['changed_pppoe_ifaces'] = changed_pppoe_ifaces + + return config + + +def verify(config): + if config.get('interfaces_vpp') and 'remove' in config: + raise ConfigError( + 'VPP cannot be removed while VPP interfaces exist. Remove all "interfaces vpp" first!' + ) + + # Find PPPoE ifaces where the base matches any VPP interface (base or VLAN) + pppoe_vpp_ifaces = [ + iface for iface in config.get('pppoe_ifaces', {}) if iface.startswith('vpp') + ] + if 'remove' in config and pppoe_vpp_ifaces: + raise ConfigError( + f'Cannot remove VPP: PPPoE server still uses VPP interface(s): {", ".join(pppoe_vpp_ifaces)}' + ) + + # bail out early - looks like removal from running config + if not config or 'remove' in config: + return None + + # Check removed interfaces (and their VLANs) against all VPP features + for removed_iface in config.get('removed_ifaces', []): + verify_vpp_remove_interface( + removed_iface['iface_name'], config, match_vlans=True + ) + + if 'settings' not in config: + raise ConfigError('"settings interface" is required but not set!') + + if 'interface' not in config['settings']: + raise ConfigError('"settings interface" is required but not set!') + + ipoe_ifaces = list( + { + iface + for iface in config['settings']['interface'] + for ipoe_iface in config.get('ipoe_conf', {}).get('interface', {}) + if iface == ipoe_iface or ipoe_iface.startswith(f'{iface}.') + } + ) + if ipoe_ifaces: + raise ConfigError( + f'Interface(s) {", ".join(ipoe_ifaces)} cannot be added to VPP because ' + 'IPoE is already configured. An interface cannot be used by both VPP and IPoE!' + ) + + # check if the system meets minimal requirements + verify_vpp_minimum_memory() + + # check if Ethernet interfaces exist + ethernet_ifaces = Section.interfaces('ethernet') + for iface in config['settings']['interface'].keys(): + if iface not in ethernet_ifaces: + raise ConfigError(f'Interface {iface} does not exist or is not Ethernet!') + + # Resource usage checks + cpu_cores = int(config['settings']['resource_allocation']['cpu_cores']) + verify_vpp_minimum_cpus() + verify_vpp_cpu_cores(cpu_cores) + + verify_vpp_main_heap_size(config['settings']) + verify_vpp_statseg_size(config['settings']) + + # Check buffers + verify_vpp_buffers(config['settings']) + + # Check if available memory is enough for current VPP config + verify_vpp_memory(config) + + interface_rx_mode = config['settings'].get('interface_rx_mode') + + # ensure DPDK/XDP settings are properly configured + for iface, iface_config in config['settings']['interface'].items(): + if not _is_device_allowed(config, iface): + raise ConfigError( + f'NIC used by "{iface}" is not validated for VPP on VyOS. ' + 'Using it is unsafe and unsupported and will void support for the entire system. ' + 'To proceed at your own risk, enable: "set vpp settings allow-unsupported-nics".' + ) + + err_message = f'Cannot add {iface} to VPP - ' + if 'bond_member' in iface_config: + raise ConfigError( + err_message + + f'interface (or its VLAN) is a member of bond(s): {", ".join(iface_config["bond_member"])}' + ) + if 'bridge_member' in iface_config: + raise ConfigError( + err_message + + f'interface (or its VLAN) is a member of bridge(s): {", ".join(iface_config["bridge_member"])}' + ) + + if iface_config['driver'] == 'xdp' and 'xdp_options' in iface_config: + if iface_config['xdp_options']['num_rx_queues'] != 'all': + rx_queues = iface_config['xdp_api_params']['rxq_num'] + max_rx_queues = _get_max_xdp_rx_queues(config['persist_config'][iface]) + if rx_queues > max_rx_queues: + raise ConfigError( + f'Maximum supported number of RX queues for interface {iface} is {max_rx_queues}. ' + f'Please set "xdp-options num-rx-queues" to {max_rx_queues} or fewer' + ) + + Warning(f'Not all RX queues will be connected to VPP for {iface}!') + + if iface_config['driver'] == 'dpdk': + if 'num_rx_queues' in iface_config: + rx_queues = int(iface_config['num_rx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='receive', num_queues=rx_queues, workers=cpu_cores + ) + + if 'num_tx_queues' in iface_config: + tx_queues = int(iface_config['num_tx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='transmit', num_queues=tx_queues, workers=cpu_cores + ) + + # RX-mode verification + rx_mode = interface_rx_mode + if rx_mode and rx_mode != 'polling': + # By default drivers operate in polling mode. Not all NIC drivers support + # RX mode interrupt and adaptive + driver = config.get('persist_config').get(iface).get('original_driver') + if ( + driver not in drivers_support_interrupt + or iface_config['driver'] not in drivers_support_interrupt[driver] + ): + raise ConfigError( + f'RX mode {rx_mode} is not supported for interface {iface}' + ) + + verify_routes_count(config['settings']) + + for pppoe_iface in config.get('changed_pppoe_ifaces', []): + if '.' in pppoe_iface: + verify_virtual_interface_exists(config, pppoe_iface) + else: + verify_interface_exists(config, pppoe_iface) + + +def generate(config): + if not config or 'remove' in config: + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + render(service_conf, 'vpp/startup.conf.j2', config['settings']) + render(systemd_override, 'vpp/override.conf.j2', config) + + return None + + +def initialize_interface(iface, driver, iface_config) -> None: + # DPDK - rescan PCI to use a proper driver + if driver == 'dpdk' and iface_config['original_driver'] not in not_pci_drv: + # 'gve' devices require a specific unbind/bind process instead of a standard PCI rescan. + if iface_config['original_driver'] == 'gve': + control_host.rebind_gve_driver( + iface, iface_config['bus_id'], iface_config['dev_id'] + ) + else: + control_host.pci_rescan(iface_config['dev_id']) + # rename to the proper name + iface_new_name: str = control_host.get_eth_name(iface_config['dev_id']) + control_host.rename_iface(iface_new_name, iface) + + # XDP - rename an interface, disable promisc and XDP, set original channels + if driver == 'xdp': + control_host.set_promisc(f'defunct_{iface}', 'off') + control_host.rename_iface(f'defunct_{iface}', iface) + control_host.xdp_remove(iface) + if iface_config['original_driver'] in ethtool_channels_change_drv: + control_host.set_eth_channels(iface, iface_config['channels']) + + # Rename Mellanox NIC to a normal name + try: + if control_host.get_eth_driver(f'defunct_{iface}') == 'mlx5_core': + control_host.rename_iface(f'defunct_{iface}', iface) + except Exception: + pass + + # Replace a driver with original for VMBus interfaces and rename it + if driver == 'dpdk' and iface_config['original_driver'] in override_drivers: + control_host.override_driver(iface_config['bus_id'], iface_config['dev_id']) + iface_new_name: str = control_host.get_eth_name(iface_config['dev_id']) + control_host.rename_iface(iface_new_name, iface) + + +def apply(config): + # modrpobe modules + modules = ('vfio_iommu_type1', 'vfio_pci', 'vfio_pci_core', 'vfio') + # Open persistent config + # It is required for operations with interfaces + if not config or 'remove' in config: + # Cleanup persistent config + with JSONStorage('vpp_conf') as persist_config: + persist_config.delete() + # And stop the service + call(f'systemctl stop {service_name}.service') + # Unlod modules (modprobe -r) + for module in modules: + _unload_module(module) + else: + # Some interfaces required extra preparation before VPP can be started + if 'settings' in config and 'interface' in config.get('settings'): + # modprobe vfio + if any( + iface_config.get('driver') == 'dpdk' + for iface_config in config['settings']['interface'].values() + ): + for module in modules: + _load_module(module) + + for iface, iface_config in config['settings']['interface'].items(): + if iface_config['driver'] == 'dpdk': + # ena interfaces require noiommu mode + if iface_config['kernel_module'] == 'ena': + control_host.unsafe_noiommu_mode(True) + + original_driver = config['persist_config'][iface]['original_driver'] + effective_ifaces = ( + config.get('effective', {}) + .get('settings', {}) + .get('interface', {}) + ) + # Check if the driver needs to be overridden: + # either the kernel module requires it, or the interface is being switched + # from XDP (hv_netvsc) to DPDK (T7797) + override_xdp_to_dpdk = ( + effective_ifaces.get(iface, {}).get('driver') == 'xdp' + and original_driver == 'hv_netvsc' + ) + k_module = ( + original_driver + if override_xdp_to_dpdk + else iface_config['kernel_module'] + ) + if ( + iface_config['kernel_module'] in override_drivers + or override_xdp_to_dpdk + ): + control_host.override_driver( + config['persist_config'][iface]['bus_id'], + config['persist_config'][iface]['dev_id'], + override_drivers[k_module], + ) + + call('systemctl daemon-reload') + call(f'systemctl restart {service_name}.service') + + # Initialize interfaces removed from VPP + for iface in config.get('removed_ifaces', []): + initialize_interface( + iface['iface_name'], + iface['driver'], + config['persist_config'][iface['iface_name']], + ) + + # Remove what is not in the config anymore + if iface['iface_name'] not in config.get('settings', {}).get('interface', {}): + del config['persist_config'][iface['iface_name']] + + if 'settings' in config and 'interface' in config.get('settings'): + interface_rx_mode = config['settings'].get('interface_rx_mode') + + # connect to VPP + try: + # Bail out early if VPP service is not running + if not is_systemd_service_active(f'{service_name}.service'): + raise VppNotRunningError( + 'VPP service is not running or failed to start' + ) + + vpp_control = VPPControl() + + # preconfigure LCP plugin + if 'ignore_kernel_routes' in config['settings']: + vpp_control.cli_cmd('lcp param route-no-paths off') + else: + vpp_control.cli_cmd('lcp param route-no-paths on') + # add interfaces + iproute = IPRoute() + for iface, iface_config in config['settings']['interface'].items(): + # add XDP interfaces + if iface_config['driver'] == 'xdp': + control_host.rename_iface(iface, f'defunct_{iface}') + + # Some cloud NICs fail to load XDP if all RX queues are configured. To avoid this, + # we limit the number of queues to half of the maximum supported by the driver. + if ( + config['persist_config'][iface]['original_driver'] + in ethtool_channels_change_drv + ): + max_rx_queues = _get_max_xdp_rx_queues( + config['persist_config'][iface] + ) + channels_orig = config['persist_config'][iface]['channels'] + channels = {} + if channels_orig.get('rx'): + channels = {'rx': max_rx_queues, 'tx': max_rx_queues} + if channels_orig.get('combined'): + channels['combined'] = max_rx_queues + if channels: + control_host.set_eth_channels(f'defunct_{iface}', channels) + + vpp_control.xdp_iface_create( + host_if=f'defunct_{iface}', + name=iface, + **iface_config['xdp_api_params'], + ) + # replicate MAC address of a real interface + real_mac = control_host.get_eth_mac(f'defunct_{iface}') + vpp_control.set_iface_mac(iface, real_mac) + if 'promisc' in iface_config['xdp_options']: + control_host.set_promisc(f'defunct_{iface}', 'on') + control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') + # Rename Mellanox interfaces to hide them and create LCP properly + if ( + iface in Section.interfaces() + and control_host.get_eth_driver(iface) == 'mlx5_core' + ): + control_host.rename_iface(iface, f'defunct_{iface}') + control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') + # Create lcp + if iface not in Section.interfaces(): + vpp_control.lcp_pair_add(iface, iface) + + # For unknown reasons, if multiple interfaces later try to be + # initialized by configuration scripts, some of them may stuck + # in an endless UP/DOWN loop + # We found two workarounds - pause initialization (requires + # main code modifications). + # And this one + dev_index = iproute.link_lookup(ifname=iface)[0] + iproute.link('set', index=dev_index, state='up') + + # Set rx-mode. Should be configured after interface state set to UP + rx_mode = interface_rx_mode + if rx_mode: + # to hardware side + vpp_control.iface_rxmode(iface, rx_mode) + # to kernel side + lcp_name = vpp_control.lcp_pair_find(vpp_name_hw=iface).get( + 'vpp_name_kernel' + ) + vpp_control.iface_rxmode(lcp_name, rx_mode) + + # Synchronize routes via LCP + vpp_control.lcp_resync() + + except (VPPIOError, VPPValueError, VppNotRunningError) as e: + # if cannot connect to VPP or an error occurred then + # we need to stop vpp service and initialize interfaces + call(f'systemctl stop {service_name}.service') + for iface, iface_config in config['settings']['interface'].items(): + initialize_interface( + iface, iface_config['driver'], config['persist_config'][iface] + ) + + raise ConfigError( + f'An error occurred: {e}. ' + 'VPP service will be restarted with the previous configuration' + ) + + # Save persistent config + if 'persist_config' in config and config['persist_config']: + with JSONStorage('vpp_conf') as persist_config: + persist_config.write('eth_ifaces', config['persist_config']) + + # 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/vpp_acl.py b/src/conf_mode/vpp_acl.py new file mode 100644 index 000000000..5b282dfdc --- /dev/null +++ b/src/conf_mode/vpp_acl.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config +from vyos.utils.network import get_protocol_by_name + +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.acl import Acl +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + +# ACL action flags +action_map = { + 'deny': 0, + 'permit': 1, + 'permit-reflect': 2, +} + + +def get_tcp_mask_value(set_flags, unset_flags): + mask = 0 + value = 0 + + for flag in set_flags + unset_flags: + bit = TCP_FLAGS.get(flag.upper()) + mask |= bit + if flag in set_flags: + value |= bit + + return mask, value + + +def get_port_first_last(port_range, protocol): + first_port = 0 + last_port = 65535 + if not port_range: + if protocol in ['icmp', 'ipv6-icmp']: + last_port = 255 + elif '-' not in port_range: + first_port = last_port = port_range + else: + first_port, last_port = port_range.split('-') + return int(first_port), int(last_port) + + +def create_ip_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('source', {}).get('prefix', ''), + 'dst_prefix': rule.get('destination', {}).get('prefix', ''), + 'proto': ( + int(get_protocol_by_name(rule.get('protocol'))) + if rule.get('protocol') != 'all' + else 0 + ), + } + + tcp_flags = rule.get('tcp_flags', {}) + set_flags = tcp_flags.get('is_set', []) + unet_flags = tcp_flags.get('is_not_set', []) + tcp_mask, tcp_value = get_tcp_mask_value(set_flags, unet_flags) + r['tcp_flags_mask'] = tcp_mask + r['tcp_flags_value'] = tcp_value + + src_ports = rule.get('source', {}).get('port') + src_first_port, src_last_port = get_port_first_last( + src_ports, rule.get('protocol') + ) + r['srcport_or_icmptype_first'] = src_first_port + r['srcport_or_icmptype_last'] = src_last_port + + dst_ports = rule.get('destination', {}).get('port') + dst_first_port, dst_last_port = get_port_first_last( + dst_ports, rule.get('protocol') + ) + r['dstport_or_icmpcode_first'] = dst_first_port + r['dstport_or_icmpcode_last'] = dst_last_port + + rules_list.append(r) + + return rules_list + + +def create_mac_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('prefix', ''), + 'src_mac': rule.get('mac_address', ''), + 'src_mac_mask': rule.get('mac_mask', ''), + } + rules_list.append(r) + + return rules_list + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'acl'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + changed_ip_ifaces = node_changed( + conf, + base + ['ip', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_mac_ifaces = node_changed( + conf, + base + ['mac', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + config.update( + { + 'changed_ip_ifaces': changed_ip_ifaces, + 'changed_mac_ifaces': changed_mac_ifaces, + 'vpp_ifaces': list( + dict.fromkeys( + cli_ifaces_list(conf) + cli_ethernet_with_vifs_ifaces(conf) + ) + ), + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + for acl_type in ['ip', 'mac']: + if acl_type in config: + acl = config.get(acl_type) + if 'tag_name' not in acl: + raise ConfigError(f'"tag-name" is required for "acl {acl_type}"') + + for acl_name, acl_config in acl.get('tag_name').items(): + if 'rule' not in acl_config: + raise ConfigError(f'Rules must be configured for ACL {acl_name}') + + for rule, rule_config in acl_config.get('rule').items(): + err_msg = f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + if 'action' not in rule_config: + raise ConfigError(f'{err_msg} action must be defined') + + for iface, iface_config in acl.get('interface', {}).items(): + if iface not in config.get('vpp_ifaces'): + raise ConfigError( + f'{iface} must be a VPP interface for ACL interface' + ) + verify_vpp_interface_not_a_member(iface, config) + + if 'ip' in config: + acl = config.get('ip') + for acl_name, acl_config in acl.get('tag_name').items(): + for rule, rule_config in acl_config.get('rule').items(): + err_msg = ( + f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + ) + + # verify IPv4 and IPv6 address family + src_prefix = rule_config.get('source', {}).get('prefix') + dst_prefix = rule_config.get('destination', {}).get('prefix') + src = ipaddress.ip_network(src_prefix) if src_prefix else None + dst = ipaddress.ip_network(dst_prefix) if dst_prefix else None + + if src and dst: + if src.version != dst.version: + raise ConfigError( + f'{err_msg} source and destination prefixes must be from the same IP family' + ) + elif src or dst: + family = src.version if src else dst.version + if family == 6: + raise ConfigError( + f'{err_msg} both source and destination prefixes must be defined for IPv6' + ) + + # verify protocol + protocol = rule_config.get('protocol') + if protocol != 'all': + proto = get_protocol_by_name(protocol) + if not isinstance(proto, int) and ( + not proto.isdigit() or int(proto) > 147 + ): + raise ConfigError( + f'{err_msg} protocol name {protocol} is not valid' + ) + + # verify TCP flags + if 'tcp_flags' in rule_config: + if rule_config.get('protocol') != 'tcp': + raise ConfigError( + f'{err_msg} protocol must be tcp when specifying tcp flags' + ) + + tcp_flags = rule_config.get('tcp_flags', {}) + flags_set = tcp_flags.get('is_set', []) + flags_not_set = tcp_flags.get('is_not_set', []) + + # same flag cannot be both set and not set + conflict = [flag for flag in flags_set if flag in flags_not_set] + if conflict: + raise ConfigError( + f'{err_msg} cannot match a TCP flag as both set and not set: ' + f'{", ".join(sorted(conflict))}' + ) + + for iface, iface_config in acl.get('interface', {}).items(): + if not any(key in iface_config for key in ('input', 'output')): + raise ConfigError( + f'Please specify direction input/output for interface {iface}' + ) + + for direction in ['input', 'output']: + if direction in iface_config: + iface_acl = iface_config.get(direction) + if 'acl_tag' not in iface_acl: + raise ConfigError( + f'"acl-tag" is required for {direction} interface {iface}' + ) + + used_names = [] + for tag, tag_conf in iface_acl.get('acl_tag').items(): + if 'tag_name' not in tag_conf: + raise ConfigError( + f'"tag-name" is required for {direction} interface {iface} with acl-tag {tag}' + ) + name = tag_conf.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. ' + f'Cannot use it for {direction} interface {iface}' + ) + if name in used_names: + raise ConfigError( + f'ACL with tag-name {name} is already used for {direction} interface {iface}' + ) + used_names.append(name) + + if 'mac' in config: + acl = config.get('mac') + for iface, iface_config in acl.get('interface', {}).items(): + if 'tag_name' not in iface_config: + raise ConfigError(f'"tag-name" is required for interface {iface}') + name = iface_config.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. Cannot use it for interface {iface}' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + acl = Acl() + + if 'effective' in config: + # Delete ACL ip + if 'ip' in config.get('effective'): + remove_config_ip = config.get('effective').get('ip') + + # Delete ACL interfaces + for interface in config.get('changed_ip_ifaces'): + acl.delete_acl_interface(interface) + + # Delete ACLs + for acl_name in remove_config_ip.get('tag_name'): + if acl_name not in config.get('ip', {}).get('tag_name', {}): + acl.delete_acl(acl_name) + + # Delete ACL mac + if 'mac' in config.get('effective'): + remove_config_mac = config.get('effective').get('mac') + + # Delete ACL interfaces + for interface in config.get('changed_mac_ifaces'): + acl.delete_acl_mac_interface(interface) + + # Delete ACL mac + for acl_name in remove_config_mac.get('tag_name'): + if acl_name not in config.get('mac', {}).get('tag_name', {}): + acl.delete_acl_mac(acl_name) + + if 'remove' in config: + return None + + # Add or replace ACL ip + config_ip = config.get('ip', {}) + for acl_name in config_ip.get('tag_name', {}): + rules = create_ip_rules_list( + config_ip.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl(acl_name, rules) + + for iface, iface_config in config_ip.get('interface', {}).items(): + input_tags = [ + v['tag_name'] + for v in iface_config.get('input', {}).get('acl_tag', {}).values() + ] + output_tags = [ + v['tag_name'] + for v in iface_config.get('output', {}).get('acl_tag', {}).values() + ] + acl.add_acl_interface(iface, input_tags, output_tags) + + # Add or replace ACL mac + config_mac = config.get('mac', {}) + for acl_name in config_mac.get('tag_name', {}): + rules = create_mac_rules_list( + config_mac.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl_mac(acl_name, rules) + + for iface, iface_config in config_mac.get('interface', {}).items(): + acl.add_acl_mac_interface(iface, iface_config.get('tag_name')) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_bonding.py b/src/conf_mode/vpp_interfaces_bonding.py new file mode 100644 index 000000000..c7fd43bb4 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_bonding.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos import ConfigError +from vyos.utils.assertion import assert_mac +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig import Interface +from vyos.ifconfig.vpp import VPPBondInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_remove_interface +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature +from vyos.vpp.utils import cli_ifaces_list + + +def _get_bond_mode(mode_name: str) -> int: + """Convert VyOS CLI name bonding mode to VPP compatible""" + mode_mapping = { + 'round-robin': 1, + 'active-backup': 2, + 'xor-hash': 3, + 'broadcast': 4, + '802.3ad': 5, + } + + return mode_mapping.get(mode_name, 5) + + +def _get_bond_lb(lb_name: str) -> int: + """Convert VyOS CLI name bonding load balance to VPP compatible""" + lb_mapping = { + 'layer2': 0, + 'layer2+3': 2, + 'layer3+4': 1, + } + + return lb_mapping.get(lb_name, 0) + + +def get_config(config=None) -> dict: + """Get Bonding interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bonding interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'bonding'] + + ifname, config = get_interface_dict(conf, base) + + # Get pppoe-server interfaces + config['pppoe_ifaces'] = conf.list_nodes(['service', 'pppoe-server', 'interface']) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + config['vpp_ifaces'] = cli_ifaces_list(conf, 'candidate') + + # convert values to VPP compatible + if 'mode' in config: + config['mode'] = _get_bond_mode(config['mode']) + if 'hash_policy' in config: + config['hash_policy'] = _get_bond_lb(config['hash_policy']) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + config['bond_members'] = deps_bond_dict(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # PPPoE dependency + if any(i == ifname or i.startswith(f'{ifname}.') for i in config['pppoe_ifaces']): + set_dependents('pppoe_server', conf) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + # IPFIX dependency + if conf.exists(['vpp', 'ipfix']): + set_dependents('vpp_ipfix', conf) + + return config + + +def verify(config): + ifname = config['ifname'] + if 'deleted' in config and any( + i == ifname or i.startswith(f'{ifname}.') + for i in config.get('pppoe_ifaces', []) + ): + raise ConfigError( + 'Cannot remove interface: it is still in use by the PPPoE server' + ) + + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + if 'deleted' in config: + verify_vpp_remove_interface(ifname, config.get('vpp'), match_vlans=True) + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP bonding interface: vpp.service is not running' + ) + + # Member must belong to VPP + for iface in config.get('member', {}).get('interface', []): + if iface not in config['vpp_ifaces']: + raise ConfigError(f'{iface} must be a VPP interface for bonding') + + # Each interface can belong only to one bond + bond_members = config['bond_members'][iface] + if len(bond_members) > 1: + raise ConfigError( + f'Interface {iface} cannot be a member of multiple bonding interfaces: {", ".join(bond_members)}' + ) + + verify_member_conflicts(iface, config, 'bond') + verify_vpp_interface_not_in_feature(iface, config.get('vpp')) + + if mtu := config.get('mtu'): + mtu = int(mtu) + max_mtu = Interface(iface).get_max_mtu() + min_mtu = Interface(iface).get_min_mtu() + if mtu > max_mtu: + raise ConfigError( + f'Configured MTU is greater than member interface "{iface}" maximum of {max_mtu}!' + ) + if mtu < min_mtu: + raise ConfigError( + f'Configured MTU is less than member interface "{iface}" minimum of {min_mtu}!' + ) + + if 'mac' in config: + mac = config['mac'] + try: + assert_mac(mac, test_all_zero=False) + except Exception: + raise ConfigError( + f'Cannot use {mac}: it is a multicast MAC address. Please provide a unicast MAC address.' + ) + + for vif_remove in config.get('vif_remove', []): + vif_iface = f'{ifname}.{vif_remove}' + if vif_iface in config.get('pppoe_ifaces', []): + raise ConfigError( + f'Cannot remove interface {vif_iface}: it is still in use by the PPPoE server' + ) + verify_vpp_remove_interface(vif_iface, config.get('vpp')) + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + bond = VPPBondInterface(ifname, config) + bond.remove() + + if 'deleted' in config: + return + + bond.update(config) + + call_dependents() + + 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/vpp_interfaces_bridge.py b/src/conf_mode/vpp_interfaces_bridge.py new file mode 100644 index 000000000..4d65690ce --- /dev/null +++ b/src/conf_mode/vpp_interfaces_bridge.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos import ConfigError +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPBridgeInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature + + +def get_config(config=None) -> dict: + """Get Bridge interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bridge interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'bridge'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get global vpp interfaces for verify + config['vpp_interfaces'] = conf.get_config_dict( + ['vpp', 'settings', 'interface'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get all gre interfaces config + config['gre_interfaces'] = conf.get_config_dict( + ['interfaces', 'vpp', 'gre'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + config['bond_members'] = deps_bond_dict(conf) + config['bridge_members'] = deps_bridge_dict(conf) + config['xconn_members'] = deps_xconnect_dict(conf) + + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'deleted' in config or 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP bridge interface: vpp.service is not running' + ) + + # Check if interface exists in vpp before adding to bridge-domain + allowed_prefixes = ('vppbond', 'vppgre', 'vpplo', 'vppvxlan') + + if 'member' in config: + bvi_exists = False + for member, member_config in ( + config.get('member', {}).get('interface', {}).items() + ): + # Check if the interface exists in VPP settings or starts with allowed prefixes + if not ( + member in config.get('vpp_interfaces', {}) + or member.startswith(allowed_prefixes) + ): + raise ConfigError( + f"Interface '{member}' not found in 'vpp settings interface' or does not start with allowed prefixes {allowed_prefixes}" + ) + + # Each interface can belong only to one bridge + bridge_members = config['bridge_members'][member] + if len(bridge_members) > 1: + raise ConfigError( + f'Interface {member} is added to more than one bridge: {", ".join(bridge_members)}' + ) + + verify_member_conflicts(member, config, 'bridge') + verify_vpp_interface_not_in_feature(member, config.get('vpp')) + + # Check if BVI is already defined, only one BVI per bridge domain is allowed + if 'bvi' in member_config: + if bvi_exists: + raise ConfigError("Only one BVI per bridge domain is allowed") + if not member.startswith('vpplo'): + raise ConfigError("BVI can only be defined on loopback interface") + bvi_exists = True + + # check GRE tunnels as part of the bridge, only tunnel-type "teb" is allowed + # set interfaces vpp bridge vppbr1 member interface vppgre1 + # set interfaces vpp gre vppgre1 tunnel-type teb + if member.startswith('vppgre'): + if member in config.get('gre_interfaces'): + gre_config = config.get('gre_interfaces').get(member) + if gre_config.get('tunnel_type') != 'teb': + raise ConfigError( + f'GRE interface "{member}" in bridge must have tunnel-type "teb". ' + f'Current tunnel-type is "{gre_config.get("tunnel_type")}".' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + bridge = VPPBridgeInterface(ifname) + bridge.remove() + + if 'deleted' in config: + return + + bridge.update(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/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py new file mode 100644 index 000000000..3756449a2 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPGREInterface +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_tunnel_source_address +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get GRE interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: GRE interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'gre'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # Get 'vpp settings' config + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get all gre interfaces config + config['gre_interfaces'] = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + # config removed + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP GRE interface: vpp.service is not running' + ) + + # source-address and remote are mandatory options + required_keys = {'source_address', 'remote', 'tunnel_type'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + verify_mtu_ipv6(config) + + # Disable checks as point-to-multipoint mode does not work without 'teib' feature that is not implemented yet + # # check multipoint mode + # if config.get('mode') == 'point-to-multipoint': + # # For multipoint mode, remote IP must be 0.0.0.0 + # if config.get('remote') != '0.0.0.0': + # raise ConfigError('For point-to-multipoint mode, remote must be 0.0.0.0') + # + # # Only one multipoint GRE tunnel is allowed from the same source address + # # set interfaces vpp gre vppgre0 mode 'point-to-multipoint' + # # set interfaces vpp gre vppgre0 remote '0.0.0.0' + # # set interfaces vpp gre vppgre0 source-address '192.0.2.1' + # # set interfaces vpp gre vppgre1 mode 'point-to-multipoint' + # # set interfaces vpp gre vppgre1 remote '0.0.0.0' + # # set interfaces vpp gre vppgre1 source-address '192.0.2.1' + # for other_iface, other_iface_config in config['gre_interfaces'].items(): + # if other_iface == config['ifname']: + # continue + # if other_iface_config['mode'] == 'point-to-multipoint': + # if config['source_address'] == other_iface_config.get('source_address'): + # raise ConfigError( + # 'Only one multipoint GRE tunnel is allowed from the same source address' + # ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + gre = VPPGREInterface(ifname, config) + gre.remove() + + if 'deleted' in config: + return + + gre.update(config) + + call_dependents() + + 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/vpp_interfaces_ipip.py b/src/conf_mode/vpp_interfaces_ipip.py new file mode 100644 index 000000000..dd4a19e36 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_ipip.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPIPIPInterface +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import ( + verify_vpp_remove_xconnect_interface, + verify_vpp_tunnel_source_address, +) +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get IPIP interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: IPIP interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'ipip'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + + # config removed + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP ipip interface: vpp.service is not running' + ) + + # source-address and remote are mandatory options + required_keys = {'source_address', 'remote'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + # Delete interface + ipip = VPPIPIPInterface(ifname, config) + ipip.remove() + + if 'deleted' in config: + return None + + ipip.update(config) + + call_dependents() + + 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/vpp_interfaces_loopback.py b/src/conf_mode/vpp_interfaces_loopback.py new file mode 100644 index 000000000..2f7b59354 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_loopback.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPLoopbackInterface + + +def get_config(config=None) -> dict: + """Get Loopback interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Loopback interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'loopback'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get 'vpp settings' config + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP loopback interface: vpp.service is not running' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + loopback = VPPLoopbackInterface(ifname, config) + loopback.remove() + + if 'deleted' in config: + return + + loopback.update(config) + + call_dependents() + + 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/vpp_interfaces_vxlan.py b/src/conf_mode/vpp_interfaces_vxlan.py new file mode 100644 index 000000000..ac9a9517b --- /dev/null +++ b/src/conf_mode/vpp_interfaces_vxlan.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.ifconfig import Interface +from vyos.template import is_ipv6 +from vyos.utils.network import get_interfaces_by_ip +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPVXLANInterface +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_tunnel_source_address +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get VXLAN interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: VXLAN interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'vxlan'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP vxlan interface: vpp.service is not running' + ) + + required_keys = {'source_address', 'remote', 'vni'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + # VXLAN adds at least an overhead of 50 bytes - we need to check the + # underlying device if our VXLAN package is not going to be fragmented! + source_address = config['source_address'] + vxlan_overhead = 50 + if is_ipv6(source_address): + # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 + # bytes larger than the IPv4 header - assuming no extra options are + # in use. + vxlan_overhead += 20 + + ifaces_with_ip = get_interfaces_by_ip(source_address) + vpp_ifaces = config['vpp_ether_vif_ifaces'] + matching_iface = next((iface for iface in ifaces_with_ip if iface in vpp_ifaces)) + + lower_mtu = Interface(matching_iface).get_mtu() + if lower_mtu < (int(config['mtu']) + vxlan_overhead): + Warning( + f'Underlying device MTU is too small ({lower_mtu} bytes) ' + f'for VXLAN overhead ({vxlan_overhead} bytes!)' + ) + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + # Delete interface + vxlan = VPPVXLANInterface(ifname, config) + vxlan.remove() + + if 'deleted' in config: + return None + + vxlan.update(config) + + call_dependents() + + 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/vpp_interfaces_xconnect.py b/src/conf_mode/vpp_interfaces_xconnect.py new file mode 100644 index 000000000..29f2da520 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_xconnect.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos import ConfigError +from vyos.configdict import get_interface_dict +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPXconnectInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature +from vyos.vpp.utils import cli_ifaces_list + + +def get_config(config=None) -> dict: + """Get Xconnect interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bridge interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'xconnect'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary per interface delete + effective_config = conf.get_config_dict( + base + [ifname], + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + config['bond_members'] = deps_bond_dict(conf) + config['bridge_members'] = deps_bridge_dict(conf) + config['xconn_members'] = deps_xconnect_dict(conf) + config['vpp_ifaces'] = cli_ifaces_list(conf, 'candidate') + + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'deleted' in config or 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure layer 2 cross-connect: vpp.service is not running' + ) + + # Xconnect requires 2 members + if len(config.get('member', {}).get('interface')) != 2: + raise ConfigError('Cross connect requires 2 members') + + not_allowed_prefixes = ('vppbond', 'vppbr', 'vpplo') + for iface in config.get('member', {}).get('interface', []): + # Ensure the interface is allowed as xconnect member + if iface.startswith(not_allowed_prefixes): + raise ConfigError(f'{iface} cannot be configured as xconnect member') + # Member must belong to VPP + if iface not in config['vpp_ifaces']: + raise ConfigError(f'{iface} must be a VPP interface for xconnect') + + # Each interface can belong only to one xconnect + xconn_members = config['xconn_members'][iface] + if len(xconn_members) > 1: + raise ConfigError( + f'Interface {iface} added to more than one xconnect: {", ".join(xconn_members)}' + ) + + verify_member_conflicts(iface, config, 'xconn') + verify_vpp_interface_not_in_feature(iface, config.get('vpp')) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + xconnect = VPPXconnectInterface(ifname) + + # Delete xconnect + if 'effective' in config: + remove_config = config.get('effective') + members = remove_config['member']['interface'] + xconnect.remove(members) + + if 'deleted' in config: + return None + + # Add xconnect + xconnect.update(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/vpp_ipfix.py b/src/conf_mode/vpp_ipfix.py new file mode 100644 index 000000000..8a7633389 --- /dev/null +++ b/src/conf_mode/vpp_ipfix.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from vyos import ConfigError +from vyos.config import Config +from vyos.vpp.ipfix import IPFIX +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'ipfix'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + if not conf.exists(base): + config['remove'] = True + return config + + # Add list of VPP interfaces to the config + config.update({'vpp_ifaces': cli_ifaces_list(conf)}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config: + return None + + # Verify that at least one interface is configured + if 'interface' not in config or not config['interface']: + raise ConfigError( + 'At least one interface must be configured for IPFIX monitoring' + ) + + # Verify that all interfaces specified exist in VPP + vpp = VPPControl() + for interface in config['interface']: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for IPFIX monitoring' + ) + verify_vpp_interface_not_a_member(interface, config) + + # Verify that at least one collector is configured + if 'collector' not in config: + raise ConfigError('At least one IPFIX collector must be configured') + + # Enforce that only one collector is configured (VPP limitation) + if len(config['collector']) > 1: + raise ConfigError('Only one IPFIX collector can be configured') + + # Verify that source_address is specified + for c, c_conf in config.get('collector', {}).items(): + if 'source_address' not in c_conf: + raise ConfigError(f'Source address must be specified for collector {c}') + + # Verify active timeout is not greater than inactive timeout + if 'active_timeout' in config and 'inactive_timeout' in config: + active_timeout = int(config['active_timeout']) + inactive_timeout = int(config['inactive_timeout']) + + if active_timeout > inactive_timeout: + raise ConfigError( + f'Active timeout ({active_timeout}) cannot be greater than inactive timeout ({inactive_timeout})' + ) + + +def generate(config): + # No templates to render for IPFIX + pass + + +def apply(config): + i = IPFIX() + + # Remove collectors + for c, c_conf in config.get('effective', {}).get('collector', {}).items(): + i.ipfix_exporter_delete() + + # Remove interfaces + for iface, iface_conf in config.get('effective', {}).get('interface', {}).items(): + iface = vpp_iface_name_transform(iface) + direction = iface_conf.get('direction') + which = iface_conf.get('flow_variant') + i.flowprobe_interface_delete(iface, direction=direction, which=which) + + if 'remove' in config: + return None + + active_timeout = config.get('active_timeout') + inactive_timeout = config.get('inactive_timeout') + flowprobe_record = config.get('flowprobe_record') + + # Flowprobe params + i.flowprobe_set_params( + active_timer=int(active_timeout), + passive_timer=int(inactive_timeout), + record_flags=list(flowprobe_record), + ) + + # Collectors + for c, c_conf in config.get('collector', {}).items(): + collector_address = c + collector_port = c_conf.get('port') + src_address = c_conf.get('source_address') + template_interval = c_conf.get('template_interval') + path_mtu = c_conf.get('path_mtu') + udp_checksum = 'udp_checksum' in c_conf + + i.collector_address = collector_address + i.src_address = src_address + i.collector_port = int(collector_port) + i.template_interval = int(template_interval) + i.path_mtu = int(path_mtu) + i.udp_checksum = udp_checksum + # VRF support is not currently implemented; exporter is always configured in the default VRF (0). + # Consider adding VRF support in the future if needed. + i.vrf_id = 0 + + i.set_ipfix_exporter() + + # Interfaces + if 'interface' in config: + for iface, iface_config in config.get('interface', {}).items(): + iface = vpp_iface_name_transform(iface) + direction = iface_config.get('direction') + which = iface_config.get('flow_variant') + + i.flowprobe_interface_add(iface, direction=direction, which=which) + + +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/vpp_nat_cgnat.py b/src/conf_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..838d13239 --- /dev/null +++ b/src/conf_mode/vpp_nat_cgnat.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError +from vyos.config import Config, config_dict_merge +from vyos.configdict import node_changed +from vyos.configdiff import Diff +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform + +from vyos.vpp.nat.det44 import Det44 +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_nat_interfaces +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat', 'cgnat'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary to delete + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + config = config_dict_merge(default_values, config) + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_rules = node_changed( + conf, + base + ['rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_exclude_rules = node_changed( + conf, + base + ['exclude', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_rules = list(config.get('rule', {}).keys()) + changed_exclude_rules = list(config.get('exclude', {}).get('rule', {}).keys()) + + config.update( + { + 'changed_rules': changed_rules, + 'changed_exclude_rules': changed_exclude_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + config['nat44_config'] = conf.get_config_dict( + ['vpp', 'nat', 'nat44'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for CGNAT') + if 'rule' not in config: + raise ConfigError('Rules must be configured for CGNAT') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. ' + f'Please add: {", ".join(missing_keys)}' + ) + + conflict_ifaces = set(config['interface']['inside']).intersection( + set(config['interface']['outside']) + ) + if conflict_ifaces: + raise ConfigError( + f'Interface cannot be both inside and outside. ' + f'Please choose a side for: {", ".join(conflict_ifaces)} ' + ) + + verify_nat_interfaces(config, 'nat44') + + vpp = VPPControl() + for direction in ['inside', 'outside']: + for interface in config['interface'][direction]: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for {direction} CGNAT interface' + ) + verify_vpp_interface_not_a_member(interface, config) + + required_keys = {'outside_prefix', 'inside_prefix'} + for rule in config['rule']: + missing_keys = required_keys - set(config['rule'][rule].keys()) + if missing_keys: + raise ConfigError( + f'Both inside-prefix and outside-prefix must be configured in rule {rule}. ' + f'Please add: {", ".join(missing_keys).replace("_", "-")}' + ) + + # Verify exclude rules (identity mappings) + if 'exclude' in config: + # Track identity mappings to detect duplicates + seen_mappings = {} + + for rule, rule_config in config['exclude'].get('rule', {}).items(): + error_msg = f'Exclude rule {rule}:' + + if 'local_address' not in rule_config: + raise ConfigError(f'{error_msg} local-address must be specified') + + has_protocol = ( + 'protocol' in rule_config and rule_config.get('protocol') != 'all' + ) + has_port = 'local_port' in rule_config + + # Either both protocol and local-port are set, or neither + if has_protocol != has_port: + raise ConfigError( + f'{error_msg} protocol and local-port must either both be specified or both omitted' + ) + + # Check for duplicate identity mappings + # VPP identifies identity mappings by (address, protocol, port) tuple + local_addr = rule_config['local_address'] + protocol = rule_config.get('protocol', 'all') + port = rule_config.get('local_port', 0) + + mapping_key = (local_addr, protocol, port) + + if mapping_key in seen_mappings: + duplicate_rule = seen_mappings[mapping_key] + raise ConfigError( + f'{error_msg} duplicate identity mapping - ' + f'address {local_addr}, protocol {protocol}, port {port} ' + f'already configured in exclude rule {duplicate_rule}' + ) + + seen_mappings[mapping_key] = rule + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + cgnat = Det44() + + if 'remove' in config: + cgnat.disable_det44_plugin() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in cgnat.get_det44_interfaces_inside(): + cgnat.delete_det44_interface_inside(interface) + # Delete outside interfaces + for interface in cgnat.get_det44_interfaces_outside(): + cgnat.delete_det44_interface_outside(interface) + # Delete CGNAT rules + for rule in config['changed_rules']: + if rule in remove_config.get('rule', {}): + rule_config = remove_config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.delete_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + # Delete CGNAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in remove_config.get('exclude', {}).get('rule', {}): + rule_config = remove_config['exclude']['rule'][rule] + cgnat.delete_det44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + tag=rule_config.get('description', ''), + ) + + # Add DET44 + cgnat.enable_det44_plugin() + # Add inside interfaces + for interface in config['interface']['inside']: + vpp_iface_name = vpp_iface_name_transform(interface) + cgnat.add_det44_interface_inside(vpp_iface_name) + # Add outside interfaces + for interface in config['interface']['outside']: + vpp_iface_name = vpp_iface_name_transform(interface) + cgnat.add_det44_interface_outside(vpp_iface_name) + # Add CGNAT rules + for rule in config['changed_rules']: + if rule in config.get('rule', {}): + rule_config = config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.add_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + # Add CGNAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in config.get('exclude', {}).get('rule', {}): + rule_config = config['exclude']['rule'][rule] + cgnat.add_det44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + tag=rule_config.get('description', ''), + ) + # Set CGNAT timeouts + cgnat.set_det44_timeouts( + icmp=int(config['timeout']['icmp']), + udp=int(config['timeout']['udp']), + tcp_established=int(config['timeout']['tcp_established']), + tcp_transitory=int(config['timeout']['tcp_transitory']), + ) + + +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/vpp_nat_nat44.py b/src/conf_mode/vpp_nat_nat44.py new file mode 100644 index 000000000..8d69ee786 --- /dev/null +++ b/src/conf_mode/vpp_nat_nat44.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config, config_dict_merge +from vyos.utils.network import get_interface_address + +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.vpp.nat.nat44 import Nat44 +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_nat_interfaces +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat', 'nat44'] + + # Get config_dict + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + return config + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + config = config_dict_merge(default_values, config) + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_static_rules = node_changed( + conf, + base + ['static', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_exclude_rules = node_changed( + conf, + base + ['exclude', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_static_rules = list(config.get('static', {}).get('rule', {}).keys()) + changed_exclude_rules = list(config.get('exclude', {}).get('rule', {}).keys()) + + config.update( + { + 'changed_static_rules': changed_static_rules, + 'changed_exclude_rules': changed_exclude_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + config['cgnat_config'] = conf.get_config_dict( + ['vpp', 'nat', 'cgnat'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def convert_range_to_list_ips(address_range) -> list: + """Converts IP range to a list of IPs . + + Example: + % ip = IPOperations('192.0.0.1-192.0.2.5') + % ip.convert_prefix_to_list_ips() + ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4', '192.0.2.5'] + """ + if '-' in address_range: + start_ip, end_ip = address_range.split('-') + start_ip = ipaddress.ip_address(start_ip) + end_ip = ipaddress.ip_address(end_ip) + return [ + str(ipaddress.ip_address(ip)) + for ip in range(int(start_ip), int(end_ip) + 1) + ] + else: + return [address_range] + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for NAT44') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. Please add: {", ".join(missing_keys)}' + ) + + verify_nat_interfaces(config, 'cgnat') + + vpp = VPPControl() + for direction in ['inside', 'outside']: + for interface in config['interface'][direction]: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for {direction} NAT interface' + ) + verify_vpp_interface_not_a_member(interface, config) + + if not config.get('address_pool', {}).get('translation') and not config.get( + 'static', {} + ).get('rule'): + raise ConfigError('"address-pool translation" or "static rule" is required') + + addresses_translation = [] + addresses_twice_nat = [] + if 'address_pool' in config: + address_pool = config.get('address_pool') + if 'translation' in address_pool: + if not address_pool['translation'].get('address') and not address_pool[ + 'translation' + ].get('interface'): + raise ConfigError( + '"address-pool translation" requires address or interface' + ) + + for address_range in address_pool['translation'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_translation: + raise ConfigError( + f'Address {address} is already in use in "address-pool translation address"' + ) + addresses_translation.append(address) + + for interface in address_pool['translation'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool translation interface"' + ) + address_info = get_interface_address(interface).get('addr_info') + if not address_info: + raise ConfigError( + f'{interface} should have an address to be used for "address-pool translation interface"' + ) + iface_address = address_info[0].get('local') + addresses_translation.append(iface_address) + + if 'twice_nat' in address_pool: + if not address_pool['twice_nat'].get('address') and not address_pool[ + 'twice_nat' + ].get('interface'): + raise ConfigError( + '"address-pool twice-nat" requires address or interface' + ) + + for address_range in address_pool['twice_nat'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_twice_nat: + raise ConfigError( + f'Address {address} is already in use in "address-pool twice-nat address"' + ) + addresses_twice_nat.append(address) + + for interface in address_pool['twice_nat'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool twice-nat interface"' + ) + address_info = get_interface_address(interface).get('addr_info') + if not address_info: + raise ConfigError( + f'{interface} should have an address to be used for "address-pool twice-nat interface"' + ) + iface_address = address_info[0].get('local') + addresses_twice_nat.append(iface_address) + + if 'static' in config: + addresses_with_ports = set() + addresses_without_ports = set() + local_addresses = set() + + for rule, rule_config in config['static'].get('rule', {}).items(): + error_msg = f'Configuration error in static rule {rule}:' + + if not rule_config.get('local', {}).get('address'): + raise ConfigError(f'{error_msg} local settings require address') + + if not rule_config.get('external', {}).get('address'): + raise ConfigError(f'{error_msg} external settings require address') + + has_local_port = 'port' in rule_config.get('local', {}) + has_external_port = 'port' in rule_config.get('external', {}) + + if not has_external_port == has_local_port: + raise ConfigError( + f'{error_msg} source and destination ports must either ' + 'both be specified, or neither must be specified' + ) + + # Either both protocol and ports are set, or both no protocol and no ports + if (rule_config['protocol'] != 'all') != has_local_port: + raise ConfigError( + f'{error_msg} protocol and ports must either both be specified or both omitted' + ) + + ext_address = rule_config['external']['address'] + port = rule_config['external'].get('port') + local_address = rule_config['local']['address'] + + if port: + pair = (ext_address, port) + if ( + pair in addresses_with_ports + or ext_address in addresses_without_ports + ): + raise ConfigError( + f'{error_msg} external address/port is already in use!' + ) + addresses_with_ports.add(pair) + + else: + if ext_address in addresses_without_ports or any( + addr == ext_address for addr, _ in addresses_with_ports + ): + raise ConfigError( + f'{error_msg} external address is already in use!' + ) + addresses_without_ports.add(ext_address) + + if local_address in local_addresses: + raise ConfigError( + f'{error_msg} local address {local_address} is already in use' + ) + local_addresses.add(local_address) + + options = rule_config.get('options', {}) + + if 'self_twice_nat' in options and ext_address not in addresses_translation: + raise ConfigError( + f'{error_msg} external address {ext_address} must be part of ' + '"address-pool translation" when using self-twice-nat' + ) + + if all(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} cannot set both options "twice-nat" and "self-twice-nat"' + ) + if any(key in options for key in ('twice_nat', 'self_twice_nat')): + if not has_local_port or rule_config['protocol'] == 'all': + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require port and protocol to be set' + ) + if not config.get('address_pool', {}).get('twice_nat'): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require "address-pool twice-nat" to be set' + ) + if 'twice_nat_address' in options: + if not any(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat option required when twice-nat-address is set' + ) + tn_address = options['twice_nat_address'] + if tn_address not in addresses_twice_nat: + raise ConfigError( + f'{error_msg} twice-nat-address {tn_address} is not in "address-pool twice-nat"' + ) + + if 'exclude' in config: + for rule, rule_config in config['exclude'].get('rule', {}).items(): + keys = {'local_address', 'external_interface'} + if not any(key in rule_config for key in keys): + raise ConfigError( + f'Local-address or external-interface must be specified for exclude rule {rule}' + ) + if all(key in rule_config for key in keys): + raise ConfigError( + f'Cannot set both address and interface for exclude rule {rule}' + ) + if ( + 'external_interface' in rule_config + and rule_config.get('external_interface') not in config['vpp_ifaces'] + ): + raise ConfigError( + f'{rule_config["external_interface"]} must be a VPP interface for exclude rule {rule}' + ) + + # Either both protocol and local-port are set, or both no protocol and no port + if (rule_config['protocol'] != 'all') != ('local_port' in rule_config): + raise ConfigError( + f'Protocol and local-port must either both be specified or both omitted for exclude rule {rule}' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + n = Nat44() + + if 'remove' in config: + n.disable_nat44_ed() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in remove_config['interface']['inside']: + if interface not in config.get('interface', {}).get('inside', []): + vpp_iface_name = vpp_iface_name_transform(interface) + n.delete_nat44_interface_inside(vpp_iface_name) + # Delete outside interfaces + for interface in remove_config['interface']['outside']: + if interface not in config.get('interface', {}).get('outside', []): + vpp_iface_name = vpp_iface_name_transform(interface) + n.delete_nat44_interface_outside(vpp_iface_name) + # Delete address pool + address_pool = config.get('address_pool', {}) + for address in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('address', []) + ): + if address not in address_pool.get('translation', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=False) + for interface in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('interface', []) + ): + if interface not in address_pool.get('translation', {}).get( + 'interface', [] + ): + n.delete_nat44_interface_address(interface, twice_nat=False) + for address in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('address', []) + ): + if address not in address_pool.get('twice_nat', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=True) + for interface in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('interface', []) + ): + if interface not in address_pool.get('twice_nat', {}).get('interface', []): + n.delete_nat44_interface_address(interface, twice_nat=True) + # Delete NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in remove_config.get('static', {}).get('rule', {}): + rule_config = remove_config['static']['rule'][rule] + n.delete_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Delete NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in remove_config.get('exclude', {}).get('rule', {}): + rule_config = remove_config['exclude']['rule'][rule] + n.delete_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + + # Add NAT44 + n.enable_nat44_ed() + + # Dynamic rules always require `address-pool translation` in CLI - we can use this for an easy validation + # Forwarding must be disabled when dynamic rules are present + # Without dynamic rules, forwarding remains enabled + enable_forwarding = not bool(config.get('address_pool', {}).get('translation')) + n.enable_disable_nat44_forwarding(enable_forwarding) + + # Add inside interfaces + for interface in config['interface']['inside']: + vpp_iface_name = vpp_iface_name_transform(interface) + n.add_nat44_interface_inside(vpp_iface_name) + # Add outside interfaces + for interface in config['interface']['outside']: + vpp_iface_name = vpp_iface_name_transform(interface) + n.add_nat44_interface_outside(vpp_iface_name) + # Add translation pool + for address in ( + config.get('address_pool', {}).get('translation', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=False) + for interface in ( + config.get('address_pool', {}).get('translation', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=False) + for address in ( + config.get('address_pool', {}).get('twice_nat', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=True) + for interface in ( + config.get('address_pool', {}).get('twice_nat', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=True) + # Add NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in config.get('static', {}).get('rule', {}): + rule_config = config['static']['rule'][rule] + n.add_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Add NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in config.get('exclude', {}).get('rule', {}): + rule_config = config['exclude']['rule'][rule] + n.add_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + if 'timeout' in config: + n.set_nat_timeouts( + icmp=int(config.get('timeout').get('icmp')), + udp=int(config.get('timeout').get('udp')), + tcp_established=int(config.get('timeout').get('tcp_established')), + tcp_transitory=int(config.get('timeout').get('tcp_transitory')), + ) + if 'session_limit' in config: + n.set_nat44_session_limit(int(config['session_limit'])) + + +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/vpp_sflow.py b/src/conf_mode/vpp_sflow.py new file mode 100644 index 000000000..de592f514 --- /dev/null +++ b/src/conf_mode/vpp_sflow.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from vyos import ConfigError +from vyos.config import Config +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.sflow import SFlow +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'sflow'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get system sflow configuration to check for server + system_sflow = conf.get_config_dict( + ['system', 'sflow'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + if system_sflow: + config['system_sflow'] = system_sflow + + if effective_config: + config.update({'effective': effective_config}) + + if not conf.exists(base): + config['remove'] = True + return config + + # Add list of VPP interfaces to the config + config.update({'vpp_ifaces': cli_ifaces_list(conf)}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config: + return None + + # Check if interface section exists + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for sFlow') + + # Verify that all interfaces specified exist in VPP + for interface in config['interface']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for sFlow monitoring' + ) + verify_vpp_interface_not_a_member(interface, config) + + # Verify that system sflow has enable-vpp defined + if 'system_sflow' not in config or 'vpp' not in config.get('system_sflow', {}): + raise ConfigError( + '"sflow vpp" must be defined under system sflow configuration' + ) + + +def generate(config): + # No templates to render for sFlow + pass + + +def apply(config): + s = SFlow() + + # Disable sFlow on deleted interface + for interface in config.get('effective', {}).get('interface', []): + if interface not in config.get('interface', []): + s.disable_sflow(interface) + + if 'remove' in config: + return None + + # Configure sample rate + if 'sampling_rate' in config.get('system_sflow', {}): + s.set_sampling_rate(int(config['system_sflow']['sampling_rate'])) + + # Configure polling interval + if 'polling' in config.get('system_sflow', {}): + s.set_polling_interval(int(config['system_sflow']['polling'])) + + # Configure header bytes + if 'header_bytes' in config: + s.set_header_bytes(int(config['header_bytes'])) + + # Configure interfaces + for interface in config.get('interface', []): + s.enable_sflow(interface) + + +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 8baf55857..c307ae27e 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,6 +18,8 @@ from sys import exit from jmespath import search from json import loads +import vyos.defaults + from vyos.config import Config from vyos.configdict import node_changed from vyos.configverify import verify_route_map @@ -27,6 +29,8 @@ from vyos.frrender import get_frrender_dict from vyos.ifconfig import Interface from vyos.template import render from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_set_nested +from vyos.utils.dict import dict_search_recursive from vyos.utils.network import get_vrf_tableid from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists @@ -116,6 +120,17 @@ def get_config(config=None): vrf = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True) + # Policy based routing supports referencing VRFs in it's rules - we need to + # prevent VRF deletion if VRF is used in a PBR rule + for policy_type in ['local-route', 'local-route6', 'route', 'route6']: + tmp = conf.get_config_dict(['policy', policy_type], + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + if tmp: + policy_type = policy_type.replace('-', '_') + dict_set_nested(f'policy.{policy_type}', tmp, vrf) + # determine which VRF has been removed for name in node_changed(conf, base + ['name']): if 'vrf_remove' not in vrf: @@ -128,6 +143,10 @@ def get_config(config=None): # get VRF bound routing instances routes = vrf_routing(conf, name) if routes: vrf['vrf_remove'][name]['route'] = routes + # get VRF bound policy routes + if 'policy' in vrf: + for key, _ in dict_search_recursive(vrf['policy'], 'vrf'): + if key == name: vrf['vrf_remove'][name]['policy'] = {} if 'name' in vrf: vrf['conntrack'] = conntrack_required(conf) @@ -141,12 +160,13 @@ def verify(vrf): # ensure VRF is not assigned to any interface if 'vrf_remove' in vrf: for name, config in vrf['vrf_remove'].items(): + err = f'Can not remove VRF "{name}",' if 'interface' in config: - raise ConfigError(f'Can not remove VRF "{name}", it still has '\ - f'member interfaces!') + raise ConfigError(f'{err} it still has member interfaces!') if 'route' in config: - raise ConfigError(f'Can not remove VRF "{name}", it still has '\ - f'static routes installed!') + raise ConfigError(f'{err} it still has static routes installed!') + if 'policy' in config: + raise ConfigError(f'{err} it still has policy routes!') if 'name' in vrf: reserved_names = ['add', 'all', 'broadcast', 'default', 'delete', 'dev', @@ -157,12 +177,17 @@ def verify(vrf): 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!') + raise ConfigError(f'VRF name "{name}" is reserved and cannot be used!') # table id is mandatory if 'table' not in vrf_config: raise ConfigError(f'VRF "{name}" table id is mandatory!') + if int(vrf_config['table']) == vyos.defaults.rt_global_vrf: + raise ConfigError( + f'VRF "{name}" table id {vrf_config["table"]} cannot be used!' + ) + # routing table id can't be changed - OS restriction if interface_exists(name): tmp = get_vrf_tableid(name) @@ -218,13 +243,13 @@ def apply(vrf): bind_all = '0' if 'bind_to_all' in vrf: bind_all = '1' - sysctl_write('net.ipv4.tcp_l3mdev_accept', bind_all) - sysctl_write('net.ipv4.udp_l3mdev_accept', bind_all) + sysctl_write(['net', 'ipv4', 'tcp_l3mdev_accept'], bind_all) + sysctl_write(['net', 'ipv4', 'udp_l3mdev_accept'], bind_all) for tmp in (dict_search('vrf_remove', vrf) or []): if 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. + # (e.g. dhclient) as there is a dependency 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): @@ -233,13 +258,19 @@ def apply(vrf): vrf_iface.set_dhcpv6(False) # Remove nftables conntrack zone map item - nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' + nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ \'"{tmp}"\' }}' # Check if deleting is possible first to avoid raising errors _, err = popen(f'nft --check {nft_del_element}') if not err: # Remove map element cmd(f'nft {nft_del_element}') + # Remove all ip rules pointing to this VRF table + table_id = get_vrf_tableid(tmp) + for afi in ['-4', '-6']: + while call(f'ip {afi} rule del table {table_id}') == 0: + pass + # Delete the VRF Kernel interface call(f'ip link delete dev {tmp}') @@ -313,11 +344,11 @@ def apply(vrf): state = 'down' if 'disable' in config else 'up' vrf_if.set_admin_state(state) # Add nftables conntrack zone map item - nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' + nft_add_element = f'add element inet vrf_zones ct_iface_map {{ \'"{name}"\' : {table} }}' cmd(f'nft {nft_add_element}') # Only call into nftables as long as there is nothing setup to avoid wasting - # CPU time and thus lenghten the commit process + # CPU time and thus lengthen the commit process if not nft_vrf_zone_rule_setup: nft_vrf_zone_rule_setup = is_nft_vrf_zone_rule_setup() # Install nftables conntrack rules only once |
