diff options
Diffstat (limited to 'src/conf_mode')
73 files changed, 3890 insertions, 2225 deletions
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 14387cbbf..3636b0871 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -22,6 +22,7 @@ from ipaddress import ip_address from ipaddress import ip_network from json import dumps as json_write +import psutil from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge @@ -148,6 +149,9 @@ def verify(container): if network_name not in container.get('network', {}): raise ConfigError(f'Container network "{network_name}" does not exist!') + 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!') + if 'address' in container_config['network'][network_name]: cnt_ipv4 = 0 cnt_ipv6 = 0 @@ -220,6 +224,21 @@ def verify(container): if not os.path.exists(source): raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') + if 'tmpfs' in container_config: + for tmpfs, tmpfs_config in container_config['tmpfs'].items(): + if 'destination' not in tmpfs_config: + raise ConfigError(f'tmpfs "{tmpfs}" has no destination path configured!') + if 'size' in tmpfs_config: + free_mem_mb: int = psutil.virtual_memory().available / 1024 / 1024 + if int(tmpfs_config['size']) > free_mem_mb: + Warning(f'tmpfs "{tmpfs}" size is greater than the current free memory!') + + total_mem_mb: int = (psutil.virtual_memory().total / 1024 / 1024) / 2 + if int(tmpfs_config['size']) > total_mem_mb: + raise ConfigError(f'tmpfs "{tmpfs}" size should not be more than 50% of total system memory!') + else: + raise ConfigError(f'tmpfs "{tmpfs}" has no size configured!') + if 'port' in container_config: for tmp in container_config['port']: if not {'source', 'destination'} <= set(container_config['port'][tmp]): @@ -359,13 +378,26 @@ def generate_run_arguments(name, container_config): prop = vol_config['propagation'] volume += f' --volume {svol}:{dvol}:{mode},{prop}' + # Mount tmpfs + tmpfs = '' + if 'tmpfs' in container_config: + for tmpfs_config in container_config['tmpfs'].values(): + dest = tmpfs_config['destination'] + size = tmpfs_config['size'] + tmpfs += f' --mount=type=tmpfs,tmpfs-size={size}M,destination={dest}' + host_pid = '' if 'allow_host_pid' in container_config: host_pid = '--pid host' + name_server = '' + if 'name_server' in container_config: + for ns in container_config['name_server']: + name_server += f'--dns {ns}' + 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} ' \ - f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid} {host_pid}' + f'--name {name} {hostname} {device} {port} {name_server} {volume} {tmpfs} {env_opt} {label} {uid} {host_pid}' entrypoint = '' if 'entrypoint' in container_config: @@ -419,12 +451,18 @@ def generate(container): 'dns_enabled': True, 'ipam_options': { 'driver': 'host-local' + }, + 'options': { + 'mtu': '1500' } } if 'no_name_server' in network_config: tmp['dns_enabled'] = False + if 'mtu' in network_config: + tmp['options']['mtu'] = network_config['mtu'] + for prefix in network_config['prefix']: net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)} tmp['subnets'].append(net) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 9e95d7794..768bb127d 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -17,9 +17,7 @@ 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 @@ -33,34 +31,33 @@ 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 call +from vyos.utils.process import cmd from vyos.utils.process import rc_cmd +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() nftables_conf = '/run/nftables.conf' +domain_resolver_usage = '/run/use-vyos-domain-resolver-firewall' -sysfs_config = { - 'all_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_all', 'enable': '0', 'disable': '1'}, - 'broadcast_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_broadcasts', 'enable': '0', 'disable': '1'}, - 'directed_broadcast' : {'sysfs': '/proc/sys/net/ipv4/conf/all/bc_forwarding', 'enable': '1', 'disable': '0'}, - 'ip_src_route': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_source_route'}, - 'ipv6_receive_redirects': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_redirects'}, - 'ipv6_src_route': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_source_route', 'enable': '0', 'disable': '-1'}, - 'log_martians': {'sysfs': '/proc/sys/net/ipv4/conf/all/log_martians'}, - 'receive_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_redirects'}, - 'send_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/send_redirects'}, - 'syn_cookies': {'sysfs': '/proc/sys/net/ipv4/tcp_syncookies'}, - 'twa_hazards_protection': {'sysfs': '/proc/sys/net/ipv4/tcp_rfc1337'} -} +sysctl_file = r'/run/sysctl/10-vyos-firewall.conf' valid_groups = [ 'address_group', 'domain_group', 'network_group', 'port_group', - 'interface_group' + 'interface_group', + ## Added for group ussage in bridge firewall + 'ipv4_address_group', + 'ipv6_address_group', + 'ipv4_network_group', + 'ipv6_network_group' ] nested_group_types = [ @@ -135,13 +132,67 @@ def get_config(config=None): firewall['geoip_updated'] = geoip_updated(conf, firewall) - fqdn_config_parse(firewall) + fqdn_config_parse(firewall, 'firewall') + + if not os.path.exists(nftables_conf): + firewall['first_install'] = True + + if 'zone' in firewall: + for local_zone, local_zone_conf in firewall['zone'].items(): + if 'local_zone' not in local_zone_conf: + # Get physical interfaces assigned to the zone if vrf is used: + if 'vrf' in local_zone_conf['member']: + local_zone_conf['vrf_interfaces'] = {} + for vrf_name in local_zone_conf['member']['vrf']: + local_zone_conf['vrf_interfaces'][vrf_name] = ','.join(get_vrf_members(vrf_name)) + continue + + local_zone_conf['from_local'] = {} + + for zone, zone_conf in firewall['zone'].items(): + if zone == local_zone or 'from' not in zone_conf: + continue + if local_zone in zone_conf['from']: + local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] set_dependents('conntrack', conf) return firewall -def verify_rule(firewall, rule_conf, ipv6): +def verify_jump_target(firewall, hook, jump_target, family, recursive=False): + targets_seen = [] + targets_pending = [jump_target] + + while targets_pending: + target = targets_pending.pop() + + if 'name' not in firewall[family]: + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') + elif target not in dict_search_args(firewall, family, 'name'): + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') + + target_rules = dict_search_args(firewall, family, 'name', target, 'rule') + no_ipsec_in = hook in ('output', ) + + if target_rules: + for target_rule_conf in target_rules.values(): + # Output hook types will not tolerate 'meta ipsec exists' matches even in jump targets: + if no_ipsec_in and (dict_search_args(target_rule_conf, 'ipsec', 'match_ipsec_in') is not None \ + or dict_search_args(target_rule_conf, 'ipsec', 'match_none_in') is not None): + raise ConfigError(f'Invalid jump-target for {hook}. Firewall {family} name {target} rules contain incompatible ipsec inbound matches') + # Make sure we're not looping back on ourselves somewhere: + if recursive and 'jump_target' in target_rule_conf: + child_target = target_rule_conf['jump_target'] + if child_target in targets_seen: + raise ConfigError(f'Loop detected in jump-targets, firewall {family} name {target} refers to previously traversed {family} name {child_target}') + targets_pending.append(child_target) + if len(targets_seen) == 7: + path_txt = ' -> '.join(targets_seen) + Warning(f'Deep nesting of jump targets has reached 8 levels deep, following the path {path_txt} -> {child_target}!') + + targets_seen.append(target) + +def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -152,12 +203,10 @@ def verify_rule(firewall, rule_conf, ipv6): if 'jump' not in rule_conf['action']: raise ConfigError('jump-target defined, but action jump needed and it is not defined') target = rule_conf['jump_target'] - if not ipv6: - if target not in dict_search_args(firewall, 'ipv4', 'name'): - raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. + verify_jump_target(firewall, hook, target, family, recursive=True) else: - if target not in dict_search_args(firewall, 'ipv6', 'name'): - raise ConfigError(f'Invalid jump-target. Firewall ipv6 name {target} does not exist on the system') + verify_jump_target(firewall, hook, target, family, recursive=False) if rule_conf['action'] == 'offload': if 'offload_target' not in rule_conf: @@ -198,13 +247,45 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError('Limit rate integer cannot be less than 1') if 'ipsec' in rule_conf: - if {'match_ipsec', 'match_non_ipsec'} <= set(rule_conf['ipsec']): - raise ConfigError('Cannot specify both "match-ipsec" and "match-non-ipsec"') + if {'match_ipsec_in', 'match_none_in'} <= set(rule_conf['ipsec']): + raise ConfigError('Cannot specify both "match-ipsec" and "match-none"') + if {'match_ipsec_out', 'match_none_out'} <= set(rule_conf['ipsec']): + raise ConfigError('Cannot specify both "match-ipsec" and "match-none"') if 'recent' in rule_conf: if not {'count', 'time'} <= set(rule_conf['recent']): raise ConfigError('Recent "count" and "time" values must be defined') + if 'gre' in rule_conf: + if dict_search_args(rule_conf, 'protocol') != 'gre': + raise ConfigError('Protocol must be gre when matching GRE flags and fields') + + if dict_search_args(rule_conf, 'gre', 'key'): + if dict_search_args(rule_conf, 'gre', 'version') == 'pptp': + raise ConfigError('GRE tunnel keys are not present in PPTP') + + 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 + # (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. + 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: + raise ConfigError('Matching GRE tunnel key implies "flags key", cannot specify "flags key unset"') + + gre_inner_proto = dict_search_args(rule_conf, 'gre', 'inner_proto') + if gre_inner_proto is not None: + try: + gre_inner_value = int(gre_inner_proto, 0) + 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. + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -217,9 +298,9 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError(f'Cannot match a tcp flag as set and not set') if 'protocol' in rule_conf: - if rule_conf['protocol'] == 'icmp' and ipv6: + if rule_conf['protocol'] == 'icmp' and family == 'ipv6': raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') - if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: + if rule_conf['protocol'] == 'ipv6-icmp' and family == 'ipv4': raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') for side in ['destination', 'source']: @@ -237,7 +318,18 @@ def verify_rule(firewall, rule_conf, ipv6): if group in side_conf['group']: group_name = side_conf['group'][group] - fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + if family == 'ipv6' and group in ['address_group', 'network_group']: + fw_group = f'ipv6_{group}' + elif family == 'bridge': + if group =='ipv4_address_group': + fw_group = 'address_group' + elif group == 'ipv4_network_group': + fw_group = 'network_group' + else: + fw_group = group + else: + fw_group = group + error_group = fw_group.replace("_", "-") if group in ['address_group', 'network_group', 'domain_group']: @@ -273,7 +365,7 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError(f'Dynamic address group must be defined.') else: target = rule_conf['add_address_to_group'][type]['address_group'] - fwall_group = 'ipv6_address_group' if ipv6 else 'address_group' + fwall_group = 'ipv6_address_group' if family == 'ipv6' else 'address_group' group_obj = dict_search_args(firewall, 'group', 'dynamic_group', fwall_group, target) if group_obj is None: raise ConfigError(f'Invalid dynamic address group on firewall rule') @@ -350,71 +442,66 @@ def verify(firewall): for group_name, group in groups.items(): verify_nested_group(group_name, group, groups, []) - if 'ipv4' in firewall: - for name in ['name','forward','input','output']: - if name in firewall['ipv4']: - for name_id, name_conf in firewall['ipv4'][name].items(): - if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: - raise ConfigError('default-action set to jump, but no default-jump-target specified') - if 'default_jump_target' in name_conf: - target = name_conf['default_jump_target'] - if 'jump' not in name_conf['default_action']: - raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') - if name_conf['default_jump_target'] == name_id: - raise ConfigError(f'Loop detected on default-jump-target.') - ## Now need to check that default-jump-target exists (other firewall chain/name) - if target not in dict_search_args(firewall['ipv4'], 'name'): - raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') - - if 'rule' in name_conf: - for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, rule_conf, False) - - if 'ipv6' in firewall: - for name in ['name','forward','input','output']: - if name in firewall['ipv6']: - for name_id, name_conf in firewall['ipv6'][name].items(): - if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: - raise ConfigError('default-action set to jump, but no default-jump-target specified') - if 'default_jump_target' in name_conf: - target = name_conf['default_jump_target'] - if 'jump' not in name_conf['default_action']: - raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') - if name_conf['default_jump_target'] == name_id: - raise ConfigError(f'Loop detected on default-jump-target.') - ## Now need to check that default-jump-target exists (other firewall chain/name) - if target not in dict_search_args(firewall['ipv6'], 'name'): - raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') - - if 'rule' in name_conf: - for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, rule_conf, True) - - #### ZONESSSS + for family in ['ipv4', 'ipv6', 'bridge']: + if family in firewall: + for chain in ['name','forward','input','output', 'prerouting']: + if chain in firewall[family]: + for priority, priority_conf in firewall[family][chain].items(): + if 'jump' in priority_conf['default_action'] and 'default_jump_target' not in priority_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in priority_conf: + target = priority_conf['default_jump_target'] + if 'jump' not in priority_conf['default_action']: + raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') + if priority_conf['default_jump_target'] == priority: + raise ConfigError(f'Loop detected on default-jump-target.') + if target not in dict_search_args(firewall[family], 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + if 'rule' in priority_conf: + for rule_id, rule_conf in priority_conf['rule'].items(): + verify_rule(firewall, family, chain, priority, rule_id, rule_conf) + local_zone = False zone_interfaces = [] + zone_vrf = [] if 'zone' in firewall: for zone, zone_conf in firewall['zone'].items(): - if 'local_zone' not in zone_conf and 'interface' not in zone_conf: + if 'local_zone' not in zone_conf and 'member' not in zone_conf: raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') if 'local_zone' in zone_conf: if local_zone: raise ConfigError('There cannot be multiple local zones') - if 'interface' in zone_conf: + if 'member' in zone_conf: raise ConfigError('Local zone cannot have interfaces assigned') if 'intra_zone_filtering' in zone_conf: raise ConfigError('Local zone cannot use intra-zone-filtering') local_zone = True - if 'interface' in zone_conf: - found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] + if 'member' in zone_conf: + if 'interface' in zone_conf['member']: + for iface in zone_conf['member']['interface']: - if found_duplicates: - raise ConfigError(f'Interfaces cannot be assigned to multiple zones') + if iface in zone_interfaces: + raise ConfigError(f'Interfaces cannot be assigned to multiple zones') - zone_interfaces += zone_conf['interface'] + iface_vrf = get_interface_vrf(iface) + if iface_vrf != 'default': + Warning(f"Interface {iface} assigned to zone {zone} is in VRF {iface_vrf}. This might not work as expected.") + zone_interfaces.append(iface) + + if 'vrf' in zone_conf['member']: + for vrf in zone_conf['member']['vrf']: + if vrf in zone_vrf: + raise ConfigError(f'VRF cannot be assigned to multiple zones') + zone_vrf.append(vrf) + + if 'vrf_interfaces' in zone_conf: + for vrf_name, vrf_interfaces in zone_conf['vrf_interfaces'].items(): + if not vrf_interfaces: + raise ConfigError( + f'VRF "{vrf_name}" cannot be a member of any zone. It does not contain any interfaces.') if 'intra_zone_filtering' in zone_conf: intra_zone = zone_conf['intra_zone_filtering'] @@ -450,59 +537,74 @@ def verify(firewall): return None def generate(firewall): - if not os.path.exists(nftables_conf): - firewall['first_install'] = True - - if 'zone' in firewall: - for local_zone, local_zone_conf in firewall['zone'].items(): - if 'local_zone' not in local_zone_conf: - continue - - local_zone_conf['from_local'] = {} - - for zone, zone_conf in firewall['zone'].items(): - if zone == local_zone or 'from' not in zone_conf: - continue - if local_zone in zone_conf['from']: - local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] - render(nftables_conf, 'firewall/nftables.j2', firewall) + render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall) return None -def apply_sysfs(firewall): - for name, conf in sysfs_config.items(): - paths = glob(conf['sysfs']) - value = None - - if name in firewall['global_options']: - conf_value = firewall['global_options'][name] - if conf_value in conf: - value = conf[conf_value] - elif conf_value == 'enable': - value = '1' - elif conf_value == 'disable': - value = '0' - - if value: - for path in paths: - with open(path, 'w') as f: - f.write(value) +def parse_firewall_error(output): + # Define the regex patterns to extract the error message and the comment + error_pattern = re.compile(r'Error:\s*(.*?)\n') + comment_pattern = re.compile(r'comment\s+"([^"]+)"') + error_output = [] + + # Find all error messages in the output + error_matches = error_pattern.findall(output) + # Find all comment matches in the output + comment_matches = comment_pattern.findall(output) + + if not error_matches or not comment_matches: + raise ConfigError(f'Unknown firewall error detected: {output}') + + error_output.append('Fail to apply firewall') + # Loop over the matches and process them + for error_message, comment in zip(error_matches, comment_matches): + # Parse the comment + parsed_entries = comment.split('-') + family = 'bridge' if parsed_entries[0] == 'bri' else parsed_entries[0] + if parsed_entries[1] == 'NAM': + chain = 'name' + elif parsed_entries[1] == 'FWD': + chain = 'forward' + elif parsed_entries[1] == 'INP': + chain = 'input' + elif parsed_entries[1] == 'OUT': + chain = 'output' + elif parsed_entries[1] == 'PRE': + chain = 'prerouting' + error_output.append(f'Error found on: firewall {family} {chain} {parsed_entries[2]} rule {parsed_entries[3]}') + error_output.append(f'\tError message: {error_message.strip()}') + + raise ConfigError('\n'.join(error_output)) def apply(firewall): + # Use nft -c option to check current configuration file + completed_process = subp_run(['nft', '-c', '--file', nftables_conf], capture_output=True) + install_result = completed_process.returncode + if install_result == 1: + # We need to handle firewall error + output = completed_process.stderr + parse_firewall_error(output.decode()) + + # No error detected during check, we can apply the new configuration install_result, output = rc_cmd(f'nft --file {nftables_conf}') + # Double check just in case if install_result == 1: raise ConfigError(f'Failed to apply firewall: {output}') - apply_sysfs(firewall) + # Apply firewall global-options sysctl settings + cmd(f'sysctl -f {sysctl_file}') call_dependents() - # T970 Enable a resolver (systemd daemon) that checks - # domain-group/fqdn addresses and update entries for domains by timeout - # If router loaded without internet connection or for synchronization - domain_action = 'stop' - if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'] or firewall['ip6_fqdn']: - domain_action = 'restart' + ## DOMAIN RESOLVER + domain_action = 'restart' + if 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) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') if firewall['geoip_updated']: diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py index 5e5d5fba1..84316c16e 100755 --- a/src/conf_mode/interfaces_bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -25,23 +25,26 @@ from vyos.configdict import is_source_interface from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_eapol 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.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.template import render_to_string from vyos.utils.assertion import assert_mac from vyos.utils.dict import dict_search from vyos.utils.dict import dict_to_paths_values from vyos.utils.network import interface_exists +from vyos.utils.process import is_systemd_service_running from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured -from vyos.configdep import set_dependents, call_dependents +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -73,7 +76,7 @@ def get_config(config=None): else: conf = Config() base = ['interfaces', 'bonding'] - ifname, bond = get_interface_dict(conf, base) + ifname, bond = get_interface_dict(conf, base, with_pki=True) # To make our own life easier transfor the list of member interfaces # into a dictionary - we will use this to add additional information @@ -86,10 +89,13 @@ def get_config(config=None): bond['mode'] = get_bond_mode(bond['mode']) tmp = is_node_changed(conf, base + [ifname, 'mode']) - if tmp: bond['shutdown_required'] = {} + if tmp: bond.update({'shutdown_required' : {}}) tmp = is_node_changed(conf, base + [ifname, 'lacp-rate']) - if tmp: bond['shutdown_required'] = {} + if tmp: bond.update({'shutdown_required' : {}}) + + tmp = is_node_changed(conf, base + [ifname, 'evpn']) + if tmp: bond.update({'frr_dict' : get_frrender_dict(conf)}) # determine which members have been removed interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) @@ -120,9 +126,8 @@ def get_config(config=None): # Restore existing config level conf.set_level(old_level) - if dict_search('member.interface', bond): - for interface, interface_config in bond['member']['interface'].items(): - + if dict_search('member.interface', bond) is not None: + for interface in bond['member']['interface']: interface_ethernet_config = conf.get_config_dict( ['interfaces', 'ethernet', interface], key_mangling=('-', '_'), @@ -131,44 +136,45 @@ def get_config(config=None): with_defaults=False, with_recursive_defaults=False) - interface_config['config_paths'] = dict_to_paths_values(interface_ethernet_config) + bond['member']['interface'][interface].update({'config_paths' : + dict_to_paths_values(interface_ethernet_config)}) # Check if member interface is a new member if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]): bond['shutdown_required'] = {} - interface_config['new_added'] = {} + 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']): - interface_config['disable'] = '' + if tmp: bond['member']['interface'][interface].update({'disable': ''}) conf.set_level(old_level) # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') - if tmp: interface_config['is_bridge_member'] = tmp + if tmp: bond['member']['interface'][interface].update({'is_bridge_member' : tmp}) # Check if member interface is already member of a bond tmp = is_member(conf, interface, 'bonding') - for tmp in is_member(conf, interface, 'bonding'): - if bond['ifname'] == tmp: - continue - interface_config['is_bond_member'] = tmp + if ifname in tmp: + del tmp[ifname] + if tmp: bond['member']['interface'][interface].update({'is_bond_member' : tmp}) # Check if member interface is used as source-interface on another interface tmp = is_source_interface(conf, interface) - if tmp: interface_config['is_source_interface'] = tmp + if tmp: bond['member']['interface'][interface].update({'is_source_interface' : tmp}) # bond members must not have an assigned address tmp = has_address_configured(conf, interface) - if tmp: interface_config['has_address'] = {} + if tmp: bond['member']['interface'][interface].update({'has_address' : ''}) # bond members must not have a VRF attached tmp = has_vrf_configured(conf, interface) - if tmp: interface_config['has_vrf'] = {} + if tmp: bond['member']['interface'][interface].update({'has_vrf' : ''}) + return bond @@ -196,6 +202,7 @@ def verify(bond): verify_dhcpv6(bond) verify_vrf(bond) verify_mirror_redirect(bond) + verify_eapol(bond) # use common function to verify VLAN configuration verify_vlan_config(bond) @@ -258,16 +265,16 @@ def verify(bond): return None def generate(bond): - bond['frr_zebra_config'] = '' - if 'deleted' not in bond: - bond['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', bond) + if 'frr_dict' in bond and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(bond['frr_dict']) return None def apply(bond): - ifname = bond['ifname'] - b = BondIf(ifname) + if 'frr_dict' in bond and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + + b = BondIf(bond['ifname']) if 'deleted' in bond: - # delete interface b.remove() else: b.update(bond) @@ -279,17 +286,6 @@ def apply(bond): raise ConfigError('Error in updating ethernet interface ' 'after deleting it from bond') - zebra_daemon = 'zebra' - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True) - if 'frr_zebra_config' in bond: - frr_cfg.add_before(frr.default_add_before, bond['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) - return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 7b2c1ee0b..aff93af2a 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -53,27 +53,30 @@ def get_config(config=None): tmp = node_changed(conf, base + [ifname, 'member', 'interface']) if tmp: if 'member' in bridge: - bridge['member'].update({'interface_remove' : tmp }) + bridge['member'].update({'interface_remove': {t: {} for t in tmp}}) else: - bridge.update({'member' : {'interface_remove' : tmp }}) - for interface in tmp: - # When using VXLAN member interfaces that are configured for Single - # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to - # re-create VLAN to VNI mappings if required, but only if the interface - # is already live on the system - this must not be done on first commit - if interface.startswith('vxlan') and interface_exists(interface): - set_dependents('vxlan', conf, interface) - # When using Wireless member interfaces we need to inform hostapd - # to properly set-up the bridge - elif interface.startswith('wlan') and interface_exists(interface): - set_dependents('wlan', conf, interface) + bridge.update({'member': {'interface_remove': {t: {} for t in tmp}}}) + for interface in tmp: + # When using VXLAN member interfaces that are configured for Single + # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to + # re-create VLAN to VNI mappings if required, but only if the interface + # is already live on the system - this must not be done on first commit + if interface.startswith('vxlan') and interface_exists(interface): + set_dependents('vxlan', conf, interface) + _, vxlan = get_interface_dict(conf, ['interfaces', 'vxlan'], ifname=interface) + bridge['member']['interface_remove'].update({interface: vxlan}) + # When using Wireless member interfaces we need to inform hostapd + # to properly set-up the bridge + elif interface.startswith('wlan') and interface_exists(interface): + set_dependents('wlan', conf, interface) if dict_search('member.interface', bridge) is not None: for interface in list(bridge['member']['interface']): # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') - if tmp and bridge['ifname'] not in tmp: - bridge['member']['interface'][interface].update({'is_bridge_member' : tmp}) + if ifname in tmp: + del tmp[ifname] + if tmp: bridge['member']['interface'][interface].update({'is_bridge_member' : tmp}) # Check if member interface is already member of a bond tmp = is_member(conf, interface, 'bonding') @@ -118,6 +121,16 @@ def get_config(config=None): return bridge def verify(bridge): + # to delete interface or remove a member interface VXLAN first need to check if + # VXLAN does not require to be a member of a bridge interface + if dict_search('member.interface_remove', bridge): + for iface, iface_config in bridge['member']['interface_remove'].items(): + if iface.startswith('vxlan') and dict_search('parameters.neighbor_suppress', iface_config) != None: + raise ConfigError( + f'To detach interface {iface} from bridge you must first ' + f'disable "neighbor-suppress" parameter in the VXLAN interface {iface}' + ) + if 'deleted' in bridge: return None @@ -192,7 +205,7 @@ def apply(bridge): try: call_dependents() except ConfigError: - raise ConfigError('Error updating member interface configuration after changing bridge!') + raise ConfigError(f'Error updating member interface {interface} configuration after changing bridge!') return None diff --git a/src/conf_mode/interfaces_dummy.py b/src/conf_mode/interfaces_dummy.py index e771581e1..db768b94d 100755 --- a/src/conf_mode/interfaces_dummy.py +++ b/src/conf_mode/interfaces_dummy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -55,7 +55,7 @@ def generate(dummy): return None def apply(dummy): - d = DummyIf(dummy['ifname']) + d = DummyIf(**dummy) # Remove dummy interface if 'deleted' in dummy: diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py index afc48ead8..41c89fdf8 100755 --- a/src/conf_mode/interfaces_ethernet.py +++ b/src/conf_mode/interfaces_ethernet.py @@ -31,32 +31,21 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.configverify import verify_bond_bridge_member -from vyos.configverify import verify_pki_certificate -from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_eapol from vyos.ethtool import Ethtool +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.ifconfig import EthernetIf from vyos.ifconfig import BondIf -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.template import render_to_string -from vyos.utils.process import call from vyos.utils.dict import dict_search 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.file import write_file +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() -# XXX: wpa_supplicant works on the source interface -cfg_dir = '/run/wpa_supplicant' -wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' - def update_bond_options(conf: Config, eth_conf: dict) -> list: """ Return list of blocked options if interface is a bond member @@ -176,6 +165,9 @@ def get_config(config=None): tmp = is_node_changed(conf, base + [ifname, 'duplex']) if tmp: ethernet.update({'speed_duplex_changed': {}}) + tmp = is_node_changed(conf, base + [ifname, 'evpn']) + if tmp: ethernet.update({'frr_dict' : get_frrender_dict(conf)}) + return ethernet def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): @@ -277,23 +269,6 @@ def verify_allowedbond_changes(ethernet: dict): f' on interface "{ethernet["ifname"]}".' \ f' Interface is a bond member') -def verify_eapol(ethernet: dict): - """ - Common helper function used by interface implementations to perform - recurring validation of EAPoL configuration. - """ - if 'eapol' not in ethernet: - return - - if 'certificate' not in ethernet['eapol']: - raise ConfigError('Certificate must be specified when using EAPoL!') - - verify_pki_certificate(ethernet, ethernet['eapol']['certificate'], no_password_protected=True) - - if 'ca_certificate' in ethernet['eapol']: - for ca_cert in ethernet['eapol']['ca_certificate']: - verify_pki_ca_certificate(ethernet, ca_cert) - def verify(ethernet): if 'deleted' in ethernet: return None @@ -346,90 +321,26 @@ def verify_ethernet(ethernet): verify_vlan_config(ethernet) return None - def generate(ethernet): - # render real configuration file once - wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) - - if 'deleted' in ethernet: - # delete configuration on interface removal - if os.path.isfile(wpa_supplicant_conf): - os.unlink(wpa_supplicant_conf) - return None - - if 'eapol' in ethernet: - ifname = ethernet['ifname'] - - render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) - - cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') - cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') - - cert_name = ethernet['eapol']['certificate'] - pki_cert = ethernet['pki']['certificate'][cert_name] - - loaded_pki_cert = load_certificate(pki_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['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_key_path, wrap_private_key(pki_cert['private']['key'])) - - if 'ca_certificate' in ethernet['eapol']: - ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') - ca_chains = [] - - for ca_cert_name in ethernet['eapol']['ca_certificate']: - pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - ca_chains.append( - '\n'.join(encode_certificate(c) for c in ca_full_chain)) - - write_file(ca_cert_file_path, '\n'.join(ca_chains)) - - ethernet['frr_zebra_config'] = '' - if 'deleted' not in ethernet: - ethernet['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', ethernet) - + if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(ethernet['frr_dict']) return None def apply(ethernet): - ifname = ethernet['ifname'] - # take care about EAPoL supplicant daemon - eapol_action='stop' - - e = EthernetIf(ifname) + if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + e = EthernetIf(ethernet['ifname']) if 'deleted' in ethernet: - # delete interface e.remove() else: e.update(ethernet) - if 'eapol' in ethernet: - eapol_action='reload-or-restart' - - call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') - - zebra_daemon = 'zebra' - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True) - if 'frr_zebra_config' in ethernet: - frr_cfg.add_before(frr.default_add_before, ethernet['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) + return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) - apply(c) except ConfigError as e: print(e) diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 007708d4a..1c5b4d0e7 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -47,7 +47,7 @@ def get_config(config=None): # GENEVE interfaces are picky and require recreation if certain parameters # change. But a GENEVE interface should - of course - not be re-created if # it's description or IP address is adjusted. Feels somehow logic doesn't it? - for cli_option in ['remote', 'vni', 'parameters']: + for cli_option in ['remote', 'vni', 'parameters', 'port']: if is_node_changed(conf, base + [ifname, cli_option]): geneve.update({'rebuild_required': {}}) diff --git a/src/conf_mode/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index b9f827bee..f0a70436e 100755 --- a/src/conf_mode/interfaces_l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -86,6 +86,8 @@ def generate(l2tpv3): return None def apply(l2tpv3): + check_kmod(k_mod) + # Check if L2TPv3 interface already exists if interface_exists(l2tpv3['ifname']): # L2TPv3 is picky when changing tunnels/sessions, thus we can simply @@ -102,7 +104,6 @@ def apply(l2tpv3): if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 467b6f6af..a9b4e570d 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -32,6 +32,7 @@ from vyos.base import DeprecationWarning from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdiff import get_config_diff from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mirror_redirect @@ -94,6 +95,23 @@ def get_config(config=None): if 'deleted' in openvpn: return openvpn + if not is_node_changed(conf, base) and dict_search_args(openvpn, 'tls'): + diff = get_config_diff(conf) + if diff.get_child_nodes_diff(['pki'], recursive=True).get('add') == ['ca', 'certificate']: + crl_path = os.path.join(cfg_dir, f'{ifname}_crl.pem') + if os.path.exists(crl_path): + # do not restart service when changed only CRL and crl file already exist + openvpn.update({'no_restart_crl': True}) + for rec in diff.get_child_nodes_diff(['pki', 'ca'], recursive=True).get('add'): + if diff.get_child_nodes_diff(['pki', 'ca', rec], recursive=True).get('add') != ['crl']: + openvpn.update({'no_restart_crl': False}) + break + if openvpn.get('no_restart_crl'): + for rec in diff.get_child_nodes_diff(['pki', 'certificate'], recursive=True).get('add'): + if diff.get_child_nodes_diff(['pki', 'certificate', rec], recursive=True).get('add') != ['revoke']: + openvpn.update({'no_restart_crl': False}) + break + if is_node_changed(conf, base + [ifname, 'openvpn-option']): openvpn.update({'restart_required': {}}) if is_node_changed(conf, base + [ifname, 'enable-dco']): @@ -123,6 +141,18 @@ def get_config(config=None): openvpn['module_load_dco'] = {} break + # Calculate the protocol modifier. This is concatenated to the protocol string to direct + # OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which + # type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces + # binding the socket in IPv6 mode, which can also receive IPv4 traffic (when using the + # default "ipv6" mode, we specify "bind ipv6only" to disable kernel dual-stack behaviors). + if openvpn['ip_version'] == 'ipv4': + openvpn['protocol_modifier'] = '4' + elif openvpn['ip_version'] in ['ipv6', 'dual-stack']: + openvpn['protocol_modifier'] = '6' + else: + openvpn['protocol_modifier'] = '' + return openvpn def is_ec_private_key(pki, cert_name): @@ -257,6 +287,9 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Protocol "tcp-passive" is not valid in client mode') + if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': + raise ConfigError('"ip-version dual-stack" is not supported in client mode') + if dict_search('tls.dh_params', openvpn): raise ConfigError('Cannot specify "tls dh-params" in client mode') @@ -264,6 +297,9 @@ def verify(openvpn): # OpenVPN site-to-site - VERIFY # elif openvpn['mode'] == 'site-to-site': + if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': + raise ConfigError('"ip-version dual-stack" is not supported in site-to-site mode') + if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn: raise ConfigError('Must specify "local-address" or add interface to bridge') @@ -322,8 +358,8 @@ def verify(openvpn): if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]: raise ConfigError('Must specify IPv4 "subnet-mask" for local-address') - if dict_search('encryption.ncp_ciphers', openvpn): - raise ConfigError('NCP ciphers can only be used in client or server mode') + if dict_search('encryption.data_ciphers', openvpn): + raise ConfigError('Cipher negotiation can only be used in client or server mode') else: # checks for client-server or site-to-site bridged @@ -444,6 +480,13 @@ def verify(openvpn): if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet: print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.') + if 'topology' in openvpn['server']: + if openvpn['server']['topology'] == 'net30': + DeprecationWarning('Topology net30 is deprecated '\ + 'and will be removed in future VyOS versions. '\ + 'Switch to "subnet" or "p2p"' + ) + # add mfa users to the file the mfa plugin uses if dict_search('server.mfa.totp', openvpn): user_data = '' @@ -480,6 +523,25 @@ def verify(openvpn): # not depending on any operation mode # + # verify that local_host/remote_host match with any ip_version override + # specified (if a dns name is specified for remote_host, no attempt is made + # to verify that record resolves to an address of the configured family) + if 'local_host' in openvpn: + if openvpn['ip_version'] == 'ipv4' and is_ipv6(openvpn['local_host']): + raise ConfigError('Cannot use an IPv6 "local-host" with "ip-version ipv4"') + elif openvpn['ip_version'] == 'ipv6' and is_ipv4(openvpn['local_host']): + raise ConfigError('Cannot use an IPv4 "local-host" with "ip-version ipv6"') + elif openvpn['ip_version'] == 'dual-stack': + raise ConfigError('Cannot use "local-host" with "ip-version dual-stack". "dual-stack" is only supported when OpenVPN binds to all available interfaces.') + + if 'remote_host' in openvpn: + remote_hosts = dict_search('remote_host', openvpn) + for remote_host in remote_hosts: + if openvpn['ip_version'] == 'ipv4' and is_ipv6(remote_host): + raise ConfigError('Cannot use an IPv6 "remote-host" with "ip-version ipv4"') + elif openvpn['ip_version'] == 'ipv6' and is_ipv4(remote_host): + raise ConfigError('Cannot use an IPv4 "remote-host" with "ip-version ipv6"') + # verify specified IP address is present on any interface on this system if 'local_host' in openvpn: if not is_addr_assigned(openvpn['local_host']): @@ -527,6 +589,10 @@ def verify(openvpn): print('Warning: using dh-params and EC keys simultaneously will ' \ 'lead to DH ciphers being used instead of ECDH') + if dict_search('encryption.cipher', openvpn): + raise ConfigError('"encryption cipher" option is deprecated for TLS mode. ' + 'Use "encryption data-ciphers" instead') + if dict_search('encryption.cipher', openvpn) == 'none': print('Warning: "encryption none" was specified!') print('No encryption will be performed and data is transmitted in ' \ @@ -738,10 +804,12 @@ def apply(openvpn): # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - action = 'reload-or-restart' - if 'restart_required' in openvpn: - action = 'restart' - call(f'systemctl {action} openvpn@{interface}.service') + + if not openvpn.get('no_restart_crl'): + action = 'reload-or-restart' + if 'restart_required' in openvpn: + action = 'restart' + call(f'systemctl {action} openvpn@{interface}.service') o = VTunIf(**openvpn) o.update(openvpn) diff --git a/src/conf_mode/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py index 43ba72857..ee1436e49 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-2024 yOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,9 +13,8 @@ # # 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 - +import ipaddress from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed @@ -89,6 +88,13 @@ def verify(tunnel): raise ConfigError('Tunnel used for NHRP, it can not be deleted!') return None + if 'nhrp' in tunnel: + if 'address' in tunnel: + address_list = dict_search('address', tunnel) + for tunip in address_list: + if ipaddress.ip_network(tunip, strict=False).prefixlen != 32: + raise ConfigError( + 'Tunnel is used for NHRP, Netmask should be /32!') verify_tunnel(tunnel) @@ -145,11 +151,20 @@ def verify(tunnel): # If no IP GRE key is defined we can not have more then one GRE tunnel # bound to any one interface/IP address and the same remote. This will # result in a OS PermissionError: add tunnel "gre0" failed: File exists - if (their_address == our_address or our_source_if == their_source_if) and \ - our_remote == their_remote: - raise ConfigError(f'Missing required "ip key" parameter when '\ - 'running more then one GRE based tunnel on the '\ - 'same source-interface/source-address') + if our_remote == their_remote: + if our_address is not None and their_address == our_address: + # If set to the same values, this is always a fail + raise ConfigError(f'Missing required "ip key" parameter when '\ + 'running more then one GRE based tunnel on the '\ + 'same source-address') + + if their_source_if == our_source_if and their_address == our_address: + # Note that lack of None check on these is deliberate. + # source-if and source-ip matching while unset (all None) is a fail + # source-ifs set and matching with unset source-ips is a fail + raise ConfigError(f'Missing required "ip key" parameter when '\ + 'running more then one GRE based tunnel on the '\ + 'same source-interface') # Keys are not allowed with ipip and sit tunnels if tunnel['encapsulation'] in ['ipip', 'sit']: diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 0e0b77877..877d013cf 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -29,11 +29,12 @@ 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 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 @@ -54,6 +55,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'peer']): wireguard.update({'rebuild_required': {}}) + wireguard['peers_need_resolve'] = [] + if 'peer' in wireguard: + for peer, peer_config in wireguard['peer'].items(): + if 'disable' not in peer_config and 'host_name' in peer_config: + wireguard['peers_need_resolve'].append(peer) + return wireguard def verify(wireguard): @@ -70,9 +77,6 @@ def verify(wireguard): if 'private_key' not in wireguard: raise ConfigError('Wireguard private-key not defined') - if 'peer' not in wireguard: - raise ConfigError('At least one Wireguard peer is required!') - 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: @@ -80,30 +84,47 @@ def verify(wireguard): 'cannot be used for the interface!') # run checks on individual configured WireGuard peer - public_keys = [] - for tmp in wireguard['peer']: - peer = wireguard['peer'][tmp] + if 'peer' in wireguard: + public_keys = [] + for tmp in wireguard['peer']: + peer = wireguard['peer'][tmp] + + base_error = f'WireGuard peer "{tmp}":' + + if 'host_name' in peer and 'address' in peer: + raise ConfigError(f'{base_error} address/host-name are mutually exclusive!') + + if 'allowed_ips' not in peer: + raise ConfigError(f'{base_error} missing mandatory allowed-ips!') - if 'allowed_ips' not in peer: - raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') + if 'public_key' not in peer: + raise ConfigError(f'{base_error} missing mandatory public-key!') - if 'public_key' not in peer: - raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!') + if peer['public_key'] in public_keys: + raise ConfigError(f'{base_error} duplicate public-key!') - if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer): - raise ConfigError('Both Wireguard port and address must be defined ' - f'for peer "{tmp}" if either one of them is set!') + if 'disable' not in peer: + if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): + tmp = wireguard["ifname"] + raise ConfigError(f'{base_error} identical public key as interface "{tmp}"!') - if peer['public_key'] in public_keys: - raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + port_addr_error = f'{base_error} both port and address/host-name must '\ + 'be defined if either one of them is set!' + if 'port' not in peer: + if 'host_name' in peer or 'address' in peer: + raise ConfigError(port_addr_error) + else: + if 'host_name' not in peer and 'address' not in peer: + raise ConfigError(port_addr_error) - if 'disable' not in peer: - if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): - raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"') + public_keys.append(peer['public_key']) - public_keys.append(peer['public_key']) +def generate(wireguard): + return None 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, @@ -119,11 +140,27 @@ def apply(wireguard): wg = WireGuardIf(**wireguard) wg.update(wireguard) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] + + ## DOMAIN RESOLVER + domain_action = 'restart' + if 'peers_need_resolve' in wireguard and len(wireguard['peers_need_resolve']) > 0 and 'disable' not in 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) + write_file(domain_resolver_usage, text) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': try: - check_kmod('wireguard') c = get_config() verify(c) apply(c) diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py index ff38c979c..d24675ee6 100755 --- a/src/conf_mode/interfaces_wireless.py +++ b/src/conf_mode/interfaces_wireless.py @@ -48,6 +48,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' +country_code_path = ['system', 'wireless', 'country-code'] + def find_other_stations(conf, base, ifname): """ Only one wireless interface per phy can be in station mode - @@ -82,7 +84,11 @@ def get_config(config=None): conf = Config() base = ['interfaces', 'wireless'] - ifname, wifi = get_interface_dict(conf, base) + _, wifi = get_interface_dict(conf, base) + + # retrieve global Wireless regulatory domain setting + if conf.exists(country_code_path): + wifi['country_code'] = conf.return_value(country_code_path) if 'deleted' not in wifi: # then get_interface_dict provides default keys @@ -149,7 +155,8 @@ def verify(wifi): if wifi['type'] == 'access-point': if 'country_code' not in wifi: - raise ConfigError('Wireless country-code is mandatory') + raise ConfigError(f'Wireless country-code is mandatory, use: '\ + f'"set {" ".join(country_code_path)}"!') if 'channel' not in wifi: raise ConfigError('Wireless channel must be configured!') @@ -186,11 +193,18 @@ def verify(wifi): if not any(i in ['passphrase', 'radius'] for i in wpa): raise ConfigError('Misssing WPA key or RADIUS server') + if 'username' in wpa: + if 'passphrase' not in wpa: + raise ConfigError('WPA-Enterprise configured - missing passphrase!') + elif 'passphrase' in wpa: + # check if passphrase meets the regex .{8,63} + if len(wpa['passphrase']) < 8 or len(wpa['passphrase']) > 63: + raise ConfigError('WPA passphrase must be between 8 and 63 characters long') if 'radius' in wpa: if 'server' in wpa['radius']: for server in wpa['radius']['server']: if 'key' not in wpa['radius']['server'][server]: - raise ConfigError(f'Misssing RADIUS shared secret key for server: {server}') + raise ConfigError(f'Missing RADIUS shared secret key for server: {server}') if 'capabilities' in wifi: capabilities = wifi['capabilities'] @@ -225,6 +239,8 @@ def verify(wifi): return None def generate(wifi): + check_kmod('mac80211') + interface = wifi['ifname'] # Delete config files if interface is removed @@ -319,7 +335,6 @@ def apply(wifi): if __name__ == '__main__': try: - check_kmod('mac80211') c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/load-balancing_reverse-proxy.py b/src/conf_mode/load-balancing_haproxy.py index 17226efe9..5fd1beec9 100755..100644 --- a/src/conf_mode/load-balancing_reverse-proxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -48,7 +48,7 @@ def get_config(config=None): else: conf = Config() - base = ['load-balancing', 'reverse-proxy'] + base = ['load-balancing', 'haproxy'] if not conf.exists(base): return None lb = conf.get_config_dict(base, @@ -78,6 +78,13 @@ def verify(lb): not is_listen_port_bind_service(int(tmp_port), 'haproxy'): raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') + if 'http_compression' in front_config: + if front_config['mode'] != 'http': + raise ConfigError(f'service {front} must be set to http mode to use http-compression!') + if len(front_config['http_compression']['mime_type']) == 0: + raise ConfigError(f'service {front} must have at least one mime-type configured to use' + f'http_compression!') + for back, back_config in lb['backend'].items(): if 'http_check' in back_config: http_check = back_config['http_check'] diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 5da0b906b..b3dd80a9a 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 VyOS maintainers and contributors +# Copyright (C) 2023-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,24 +14,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os - from sys import exit -from shutil import rmtree -from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.utils.process import cmd -from vyos.template import render from vyos import ConfigError from vyos import airbag airbag.enable() -load_balancing_dir = '/run/load-balance' -load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf' -systemd_service = 'vyos-wan-load-balance.service' - +service = 'vyos-wan-load-balance.service' def get_config(config=None): if config: @@ -40,6 +32,7 @@ def get_config(config=None): conf = Config() base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, @@ -59,87 +52,61 @@ def verify(lb): if not lb: return None - if 'interface_health' not in lb: - raise ConfigError( - 'A valid WAN load-balance configuration requires an interface with a nexthop!' - ) - - for interface, interface_config in lb['interface_health'].items(): - if 'nexthop' not in interface_config: - raise ConfigError( - f'interface-health {interface} nexthop must be specified!') - - if 'test' in interface_config: - for test_rule, test_config in interface_config['test'].items(): - if 'type' in test_config: - if test_config['type'] == 'user-defined' and 'test_script' not in test_config: - raise ConfigError( - f'test {test_rule} script must be defined for test-script!' - ) - - if 'rule' not in lb: - Warning( - 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!' - ) + if 'interface_health' in lb: + for ifname, health_conf in lb['interface_health'].items(): + if 'nexthop' not in health_conf: + raise ConfigError(f'Nexthop must be configured for interface {ifname}') + + if 'test' not in health_conf: + continue + + for test_id, test_conf in health_conf['test'].items(): + if 'type' not in test_conf: + raise ConfigError(f'No type configured for health test on interface {ifname}') + + if test_conf['type'] == 'user-defined' and 'test_script' not in test_conf: + raise ConfigError(f'Missing user-defined script for health test on interface {ifname}') else: - for rule, rule_config in lb['rule'].items(): - if 'inbound_interface' not in rule_config: - raise ConfigError(f'rule {rule} inbound-interface must be specified!') - if {'failover', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} failover cannot be configured with exclude!') - if {'limit', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} limit cannot be used with exclude!') - if 'interface' not in rule_config: - if 'exclude' not in rule_config: - Warning( - f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule' - ) - for direction in {'source', 'destination'}: - if direction in rule_config: - if 'protocol' in rule_config and 'port' in rule_config[ - direction]: - if rule_config['protocol'] not in {'tcp', 'udp'}: - raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"') + raise ConfigError('Interface health tests must be configured') + if 'rule' in lb: + for rule_id, rule_conf in lb['rule'].items(): + if 'interface' not in rule_conf: + raise ConfigError(f'Interface not specified on load-balancing wan rule {rule_id}') -def generate(lb): - if not lb: - # Delete /run/load-balance/wlb.conf - if os.path.isfile(load_balancing_conf_file): - os.unlink(load_balancing_conf_file) - # Delete old directories - if os.path.isdir(load_balancing_dir): - rmtree(load_balancing_dir, ignore_errors=True) - if os.path.exists('/var/run/load-balance/wlb.out'): - os.unlink('/var/run/load-balance/wlb.out') + if 'failover' in rule_conf and 'exclude' in rule_conf: + raise ConfigError(f'Failover cannot be configured with exclude on load-balancing wan rule {rule_id}') - return None + if 'limit' in rule_conf: + if 'exclude' in rule_conf: + raise ConfigError(f'Limit cannot be configured with exclude on load-balancing wan rule {rule_id}') - # Create load-balance dir - if not os.path.isdir(load_balancing_dir): - os.mkdir(load_balancing_dir) + if 'rate' in rule_conf['limit'] and 'period' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit period" on load-balancing wan rule {rule_id}') - render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb) + if 'period' in rule_conf['limit'] and 'rate' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit rate" on load-balancing wan rule {rule_id}') - return None + for direction in ['source', 'destination']: + if direction in rule_conf: + 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}') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError(f'Protocol must be tcp, udp or tcp_udp to specify port on load-balancing wan rule {rule_id}') +def generate(lb): + return None def apply(lb): if not lb: - try: - cmd(f'systemctl stop {systemd_service}') - except Exception as e: - print(f"Error message: {e}") - + cmd(f'sudo systemctl stop {service}') else: - cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') - cmd(f'systemctl restart {systemd_service}') + cmd(f'sudo systemctl restart {service}') call_dependents() - return None - - if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index f74bb217e..504b3e82a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -17,6 +17,7 @@ import os from sys import exit +from pathlib import Path from vyos.base import Warning from vyos.config import Config @@ -26,10 +27,13 @@ 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 +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 from vyos import airbag @@ -39,6 +43,7 @@ k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' +domain_resolver_usage = '/run/use-vyos-domain-resolver-nat' valid_groups = [ 'address_group', @@ -71,6 +76,8 @@ def get_config(config=None): if 'dynamic_group' in nat['firewall_group']: del nat['firewall_group']['dynamic_group'] + fqdn_config_parse(nat, 'nat') + return nat def verify_rule(config, err_msg, groups_dict): @@ -240,6 +247,8 @@ def generate(nat): return None def apply(nat): + check_kmod(k_mod) + cmd(f'nft --file {nftables_nat_config}') cmd(f'nft --file {nftables_static_nat_conf}') @@ -249,11 +258,23 @@ def apply(nat): call_dependents() + # DOMAIN RESOLVER + if nat and 'deleted' not in nat: + domain_action = 'restart' + if nat['ip_fqdn'].items(): + 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) + + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py index c1e7ebf85..df501ce7f 100755 --- a/src/conf_mode/nat64.py +++ b/src/conf_mode/nat64.py @@ -20,7 +20,7 @@ import csv import os import re -from ipaddress import IPv6Network +from ipaddress import IPv6Network, IPv6Address from json import dumps as json_write from vyos import ConfigError @@ -46,7 +46,12 @@ def get_config(config: Config | None = None) -> None: base = ["nat64"] nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True) - base_src = base + ["source", "rule"] + return nat64 + + +def verify(nat64) -> None: + check_kmod(["jool"]) + base_src = ["nat64", "source", "rule"] # Load in existing instances so we can destroy any unknown lines = cmd("jool instance display --csv").splitlines() @@ -76,12 +81,8 @@ def get_config(config: Config | None = None) -> None: ): rules[num]["recreate"] = True - return nat64 - - -def verify(nat64) -> None: if not nat64: - # no need to verify the CLI as nat64 is going to be deactivated + # nothing left to do return if dict_search("source.rule", nat64): @@ -103,8 +104,14 @@ def verify(nat64) -> None: # 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") - if IPv6Network(instance["source"]["prefix"]).prefixlen != 96: + src_prefix = IPv6Network(instance["source"]["prefix"]) + if src_prefix.prefixlen != 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) if pools: @@ -122,6 +129,9 @@ def verify(nat64) -> None: def generate(nat64) -> None: + if not nat64: + return + os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) if dict_search("source.rule", nat64): @@ -178,6 +188,7 @@ def generate(nat64) -> None: def apply(nat64) -> None: if not nat64: + unload_kmod(['jool']) return if dict_search("source.rule", nat64): @@ -205,7 +216,6 @@ def apply(nat64) -> None: if __name__ == "__main__": try: - check_kmod(["jool"]) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 075738dad..95dfae3a5 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -26,6 +26,7 @@ from vyos.utils.dict import dict_search from vyos.utils.kernel import check_kmod from vyos.utils.network import interface_exists from vyos.utils.process import cmd +from vyos.utils.process import run from vyos.template import is_ipv6 from vyos import ConfigError from vyos import airbag @@ -48,6 +49,14 @@ def get_config(config=None): if not conf.exists(base): nat['deleted'] = '' + return nat + + nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + # Remove dynamic firewall groups if present: + if 'dynamic_group' in nat['firewall_group']: + del nat['firewall_group']['dynamic_group'] return nat @@ -99,27 +108,39 @@ def verify(nat): if not interface_exists(interface_name): Warning(f'Interface "{interface_name}" for destination NAT66 rule "{rule}" does not exist!') + 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') + return None def generate(nat): if not os.path.exists(nftables_nat66_config): nat['first_install'] = True - render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755) + render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat) + + # dry-run newly generated configuration + tmp = run(f'nft --check --file {nftables_nat66_config}') + if tmp > 0: + raise ConfigError('Configuration file errors encountered!') + return None def apply(nat): - if not nat: - return None + check_kmod(k_mod) cmd(f'nft --file {nftables_nat66_config}') + + if not nat or 'deleted' in nat: + os.unlink(nftables_nat66_config) + call_dependents() return None if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py new file mode 100755 index 000000000..3484e5873 --- /dev/null +++ b/src/conf_mode/nat_cgnat.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 ipaddress +import jmespath +import logging +import os + +from sys import exit +from logging.handlers import SysLogHandler + +from vyos.config import Config +from vyos.configdict import is_node_changed +from vyos.template import render +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +nftables_cgnat_config = '/run/nftables-cgnat.nft' + +# Logging +logger = logging.getLogger('cgnat') +logger.setLevel(logging.DEBUG) + +syslog_handler = SysLogHandler(address="/dev/log") +syslog_handler.setLevel(logging.INFO) + +formatter = logging.Formatter('%(name)s: %(message)s') +syslog_handler.setFormatter(formatter) + +logger.addHandler(syslog_handler) + + +class IPOperations: + def __init__(self, ip_prefix: str): + self.ip_prefix = ip_prefix + self.ip_network = ipaddress.ip_network(ip_prefix) if '/' in ip_prefix else None + + def get_ips_count(self) -> int: + """Returns the number of IPs in a prefix or range. + + Example: + % ip = IPOperations('192.0.2.0/30') + % ip.get_ips_count() + 4 + % ip = IPOperations('192.0.2.0-192.0.2.2') + % ip.get_ips_count() + 3 + """ + if '-' in self.ip_prefix: + start_ip, end_ip = self.ip_prefix.split('-') + start_ip = ipaddress.ip_address(start_ip) + end_ip = ipaddress.ip_address(end_ip) + return int(end_ip) - int(start_ip) + 1 + elif '/31' in self.ip_prefix: + return 2 + elif '/32' in self.ip_prefix: + return 1 + else: + return sum( + 1 + for _ in [self.ip_network.network_address] + + list(self.ip_network.hosts()) + + [self.ip_network.broadcast_address] + ) + + def convert_prefix_to_list_ips(self) -> list: + """Converts a prefix or IP range to a list of IPs including the network and broadcast addresses. + + Example: + % ip = IPOperations('192.0.2.0/30') + % ip.convert_prefix_to_list_ips() + ['192.0.2.0', '192.0.2.1', '192.0.2.2', '192.0.2.3'] + % + % 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 self.ip_prefix: + start_ip, end_ip = self.ip_prefix.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) + ] + elif '/31' in self.ip_prefix: + return [ + str(ip) + for ip in [ + self.ip_network.network_address, + self.ip_network.broadcast_address, + ] + ] + elif '/32' in self.ip_prefix: + return [str(self.ip_network.network_address)] + else: + return [ + str(ip) + for ip in [self.ip_network.network_address] + + list(self.ip_network.hosts()) + + [self.ip_network.broadcast_address] + ] + + def get_prefix_by_ip_range(self) -> list[ipaddress.IPv4Network]: + """Return the common prefix for the address range + + Example: + % ip = IPOperations('100.64.0.1-100.64.0.5') + % ip.get_prefix_by_ip_range() + [IPv4Network('100.64.0.1/32'), IPv4Network('100.64.0.2/31'), IPv4Network('100.64.0.4/31')] + """ + # We do not need to convert the IP range to network + # if it is already in network format + if self.ip_network: + return [self.ip_network] + + # Raise an error if the IP range is not in the correct format + if '-' not in self.ip_prefix: + raise ValueError( + 'Invalid IP range format. Please provide the IP range in CIDR format or with "-" separator.' + ) + # Split the IP range and convert it to IP address objects + range_start, range_end = self.ip_prefix.split('-') + range_start = ipaddress.IPv4Address(range_start) + range_end = ipaddress.IPv4Address(range_end) + + # Return the summarized IP networks list + return list(ipaddress.summarize_address_range(range_start, range_end)) + + +def _delete_conntrack_entries(source_prefixes: list[ipaddress.IPv4Network]) -> None: + """Delete all conntrack entries for the list of prefixes""" + for source_prefix in source_prefixes: + run(f'conntrack -D -s {source_prefix}') + + +def generate_port_rules( + external_hosts: list, + internal_hosts: list, + port_count: int, + global_port_range: str = '1024-65535', +) -> list: + """Generates a list of nftables option rules for the batch file. + + Args: + external_hosts (list): A list of external host IPs. + internal_hosts (list): A list of internal host IPs. + port_count (int): The number of ports required per host. + global_port_range (str): The global port range to be used. Default is '1024-65535'. + + Returns: + list: A list containing two elements: + - proto_map_elements (list): A list of proto map elements. + - other_map_elements (list): A list of other map elements. + """ + rules = [] + proto_map_elements = [] + other_map_elements = [] + start_port, end_port = map(int, global_port_range.split('-')) + total_possible_ports = (end_port - start_port) + 1 + + # Calculate the required number of ports per host + required_ports_per_host = port_count + current_port = start_port + current_external_index = 0 + + for internal_host in internal_hosts: + external_host = external_hosts[current_external_index] + next_end_port = current_port + required_ports_per_host - 1 + + # If the port range exceeds the end_port, move to the next external host + while next_end_port > end_port: + current_external_index = (current_external_index + 1) % len(external_hosts) + external_host = external_hosts[current_external_index] + current_port = start_port + next_end_port = current_port + required_ports_per_host - 1 + + proto_map_elements.append( + f'{internal_host} : {external_host} . {current_port}-{next_end_port}' + ) + other_map_elements.append(f'{internal_host} : {external_host}') + + current_port = next_end_port + 1 + if current_port > end_port: + current_port = start_port + current_external_index += 1 # Move to the next external host + + return [proto_map_elements, other_map_elements] + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['nat', 'cgnat'] + config = conf.get_config_dict( + base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + effective_config = conf.get_config_dict( + base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + effective=True, + ) + + # Check if the pool configuration has changed + if not conf.exists(base) or is_node_changed(conf, base + ['pool']): + config['delete_conntrack_entries'] = {} + + # add running config + if effective_config: + config['effective'] = effective_config + + if not conf.exists(base): + config['deleted'] = {} + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if 'deleted' in config: + return None + + if 'pool' not in config: + raise ConfigError(f'Pool must be defined!') + if 'rule' not in config: + raise ConfigError(f'Rule must be defined!') + + for pool in ('external', 'internal'): + if pool not in config['pool']: + raise ConfigError(f'{pool} pool must be defined!') + for pool_name, pool_config in config['pool'][pool].items(): + if 'range' not in pool_config: + raise ConfigError( + f'Range for "{pool} pool {pool_name}" must be defined!' + ) + + external_pools_query = "keys(pool.external)" + external_pools: list = jmespath.search(external_pools_query, config) + internal_pools_query = "keys(pool.internal)" + internal_pools: list = jmespath.search(internal_pools_query, config) + + used_external_pools = {} + used_internal_pools = {} + for rule, rule_config in config['rule'].items(): + if 'source' not in rule_config: + raise ConfigError(f'Rule "{rule}" source pool must be defined!') + if 'pool' not in rule_config['source']: + raise ConfigError(f'Rule "{rule}" source pool must be defined!') + + if 'translation' not in rule_config: + raise ConfigError(f'Rule "{rule}" translation pool must be defined!') + + # Check if pool exists + internal_pool = rule_config['source']['pool'] + if internal_pool not in internal_pools: + raise ConfigError(f'Internal pool "{internal_pool}" does not exist!') + external_pool = rule_config['translation']['pool'] + if external_pool not in external_pools: + raise ConfigError(f'External pool "{external_pool}" does not exist!') + + # Check pool duplication in different rules + if external_pool in used_external_pools: + raise ConfigError( + f'External pool "{external_pool}" is already used in rule ' + f'{used_external_pools[external_pool]} and cannot be used in ' + f'rule {rule}!' + ) + + if internal_pool in used_internal_pools: + raise ConfigError( + f'Internal pool "{internal_pool}" is already used in rule ' + f'{used_internal_pools[internal_pool]} and cannot be used in ' + f'rule {rule}!' + ) + + used_external_pools[external_pool] = rule + used_internal_pools[internal_pool] = rule + + # Check calculation for allocation + external_port_range: str = config['pool']['external'][external_pool]['external_port_range'] + + external_ip_ranges: list = list( + config['pool']['external'][external_pool]['range'] + ) + internal_ip_ranges: list = config['pool']['internal'][internal_pool]['range'] + start_port, end_port = map(int, external_port_range.split('-')) + ports_per_range_count: int = (end_port - start_port) + 1 + + external_list_hosts_count = [] + external_list_hosts = [] + internal_list_hosts_count = [] + internal_list_hosts = [] + for ext_range in external_ip_ranges: + # External hosts count + e_count = IPOperations(ext_range).get_ips_count() + external_list_hosts_count.append(e_count) + # External hosts list + e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips() + external_list_hosts.extend(e_hosts) + for int_range in internal_ip_ranges: + # Internal hosts count + i_count = IPOperations(int_range).get_ips_count() + internal_list_hosts_count.append(i_count) + # Internal hosts list + i_hosts = IPOperations(int_range).convert_prefix_to_list_ips() + internal_list_hosts.extend(i_hosts) + + external_host_count = sum(external_list_hosts_count) + internal_host_count = sum(internal_list_hosts_count) + ports_per_user: int = int( + config['pool']['external'][external_pool]['per_user_limit']['port'] + ) + users_per_extip = ports_per_range_count // ports_per_user + max_users = users_per_extip * external_host_count + + if internal_host_count > max_users: + raise ConfigError( + f'Rule "{rule}" does not have enough ports available for the ' + f'specified parameters' + ) + + +def generate(config): + if 'deleted' in config: + return None + + proto_maps = [] + other_maps = [] + + for rule, rule_config in config['rule'].items(): + ext_pool_name: str = rule_config['translation']['pool'] + int_pool_name: str = rule_config['source']['pool'] + + # Sort the external ranges by sequence + external_ranges: list = sorted( + config['pool']['external'][ext_pool_name]['range'], + key=lambda r: int(config['pool']['external'][ext_pool_name]['range'][r].get('seq', 999999)) + ) + internal_ranges: list = [range for range in config['pool']['internal'][int_pool_name]['range']] + external_list_hosts_count = [] + external_list_hosts = [] + internal_list_hosts_count = [] + internal_list_hosts = [] + + for ext_range in external_ranges: + # External hosts count + e_count = IPOperations(ext_range).get_ips_count() + external_list_hosts_count.append(e_count) + # External hosts list + e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips() + external_list_hosts.extend(e_hosts) + + for int_range in internal_ranges: + # Internal hosts count + i_count = IPOperations(int_range).get_ips_count() + internal_list_hosts_count.append(i_count) + # Internal hosts list + i_hosts = IPOperations(int_range).convert_prefix_to_list_ips() + internal_list_hosts.extend(i_hosts) + + external_host_count = sum(external_list_hosts_count) + internal_host_count = sum(internal_list_hosts_count) + ports_per_user = int( + jmespath.search(f'pool.external."{ext_pool_name}".per_user_limit.port', config) + ) + external_port_range: str = jmespath.search( + f'pool.external."{ext_pool_name}".external_port_range', config + ) + + rule_proto_maps, rule_other_maps = generate_port_rules( + external_list_hosts, internal_list_hosts, ports_per_user, external_port_range + ) + + proto_maps.extend(rule_proto_maps) + other_maps.extend(rule_other_maps) + + config['proto_map_elements'] = ', '.join(proto_maps) + config['other_map_elements'] = ', '.join(other_maps) + + render(nftables_cgnat_config, 'firewall/nftables-cgnat.j2', config) + + # dry-run newly generated configuration + tmp = run(f'nft --check --file {nftables_cgnat_config}') + if tmp > 0: + raise ConfigError('Configuration file errors encountered!') + + +def apply(config): + if 'deleted' in config: + # Cleanup cgnat + cmd('nft delete table ip cgnat') + if os.path.isfile(nftables_cgnat_config): + os.unlink(nftables_cgnat_config) + else: + cmd(f'nft --file {nftables_cgnat_config}') + + # Delete conntrack entries + # if the pool configuration has changed + if 'delete_conntrack_entries' in config and 'effective' in config: + # Prepare the list of internal pool prefixes + internal_pool_prefix_list: list[ipaddress.IPv4Network] = [] + + # Get effective rules configurations + for rule_config in config['effective'].get('rule', {}).values(): + # Get effective internal pool configuration + internal_pool = rule_config['source']['pool'] + # Find the internal IP ranges for the internal pool + internal_ip_ranges: list[str] = config['effective']['pool']['internal'][ + internal_pool + ]['range'] + # Get the IP prefixes for the internal IP range + for internal_range in internal_ip_ranges: + ip_prefix: list[ipaddress.IPv4Network] = IPOperations( + internal_range + ).get_prefix_by_ip_range() + # Add the IP prefixes to the list of all internal pool prefixes + internal_pool_prefix_list += ip_prefix + + # Delete required sources for conntrack + _delete_conntrack_entries(internal_pool_prefix_list) + + # Logging allocations + if 'log_allocation' in config: + allocations = config['proto_map_elements'] + allocations = allocations.split(',') + for allocation in allocations: + try: + # Split based on the delimiters used in the nft data format + internal_host, rest = allocation.split(' : ') + external_host, port_range = rest.split(' . ') + # Log the parsed data + logger.info( + f'Internal host: {internal_host.lstrip()}, external host: {external_host}, Port range: {port_range}') + except ValueError as e: + # Log error message + logger.error(f"Error processing line '{allocation}': {e}") + + +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/netns.py b/src/conf_mode/netns.py new file mode 100755 index 000000000..b57e46a0d --- /dev/null +++ b/src/conf_mode/netns.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +def netns_interfaces(c, match): + """ + get NETNS bound interfaces + """ + matched = [] + old_level = c.get_level() + c.set_level(['interfaces']) + section = c.get_config_dict([], get_first_key=True) + for type in section: + interfaces = section[type] + for name in interfaces: + interface = interfaces[name] + if 'netns' in interface: + v = interface.get('netns', '') + if v == match: + matched.append(name) + + c.set_level(old_level) + return matched + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['netns'] + netns = conf.get_config_dict(base, get_first_key=True, + no_tag_node_value_mangle=True) + + # determine which NETNS has been removed + for name in node_changed(conf, base + ['name']): + if 'netns_remove' not in netns: + netns.update({'netns_remove' : {}}) + + netns['netns_remove'][name] = {} + # get NETNS bound interfaces + interfaces = netns_interfaces(conf, name) + if interfaces: netns['netns_remove'][name]['interface'] = interfaces + + return netns + +def verify(netns): + # ensure NETNS is not assigned to any interface + if 'netns_remove' in netns: + for name, config in netns['netns_remove'].items(): + if 'interface' in config: + raise ConfigError(f'Can not remove network namespace "{name}", it '\ + f'still has member interfaces!') + + if 'name' in netns: + for name, config in netns['name'].items(): + # no tests (yet) + pass + + return None + +def generate(netns): + if not netns: + return None + + return None + + +def apply(netns): + + for tmp in (dict_search('netns_remove', netns) or []): + if os.path.isfile(f'/run/netns/{tmp}'): + call(f'ip netns del {tmp}') + + if 'name' in netns: + for name, config in netns['name'].items(): + if not os.path.isfile(f'/run/netns/{name}'): + call(f'ip netns add {name}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 4a0e86f32..acea2c9be 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -27,6 +27,7 @@ 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.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate from vyos.pki import load_public_key @@ -36,9 +37,11 @@ from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters from vyos.utils.boot import boot_configuration_complete +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.file import read_file from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active @@ -47,6 +50,7 @@ from vyos import airbag airbag.enable() vyos_certbot_dir = directories['certbot'] +vyos_ca_certificates_dir = directories['ca_certificates'] # keys to recursively search for under specified path sync_search = [ @@ -68,7 +72,7 @@ sync_search = [ }, { 'keys': ['certificate', 'ca_certificate'], - 'path': ['load_balancing', 'reverse_proxy'], + 'path': ['load_balancing', 'haproxy'], }, { 'keys': ['key'], @@ -85,6 +89,10 @@ sync_search = [ { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'sstp'], + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['service', 'stunnel'], } ] @@ -142,35 +150,15 @@ def get_config(config=None): if len(argv) > 1 and argv[1] == 'certbot_renew': pki['certbot_renew'] = {} - tmp = node_changed(conf, base + ['ca'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'ca' : tmp}) + changed_keys = ['ca', 'certificate', 'dh', 'key-pair', 'openssh', 'openvpn'] - tmp = node_changed(conf, base + ['certificate'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'certificate' : tmp}) + for key in changed_keys: + tmp = node_changed(conf, base + [key], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - tmp = node_changed(conf, base + ['dh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'dh' : tmp}) + if 'changed' not in pki: + pki.update({'changed':{}}) - tmp = node_changed(conf, base + ['key-pair'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'key_pair' : tmp}) - - tmp = node_changed(conf, base + ['openssh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'openssh' : tmp}) - - tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) - if tmp: - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'openvpn' : tmp}) + pki['changed'].update({key.replace('-', '_') : tmp}) # We only merge on the defaults of there is a configuration at all if conf.exists(base): @@ -410,10 +398,33 @@ def verify(pki): return None +def cleanup_system_ca(): + if not os.path.exists(vyos_ca_certificates_dir): + os.mkdir(vyos_ca_certificates_dir) + else: + for filename in os.listdir(vyos_ca_certificates_dir): + full_path = os.path.join(vyos_ca_certificates_dir, filename) + if os.path.isfile(full_path): + os.unlink(full_path) + def generate(pki): if not pki: + cleanup_system_ca() return None + # Create or cleanup CA install directory + if 'changed' in pki and 'ca' in pki['changed']: + cleanup_system_ca() + + if 'ca' in pki: + for ca, ca_conf in pki['ca'].items(): + if 'system_install' in ca_conf: + ca_obj = load_certificate(ca_conf['certificate']) + ca_path = os.path.join(vyos_ca_certificates_dir, f'{ca}.crt') + + with open(ca_path, 'w') as f: + f.write(encode_certificate(ca_obj)) + # Certbot renewal only needs to re-trigger the services to load up the # new PEM file if 'certbot_renew' in pki: @@ -442,9 +453,37 @@ def generate(pki): # Get foldernames under vyos_certbot_dir which each represent a certbot cert if os.path.exists(f'{vyos_certbot_dir}/live'): for cert in certbot_list_on_disk: + # ACME certificate is no longer in use by CLI remove it if cert not in certbot_list: - # certificate is no longer active on the CLI - remove it certbot_delete(cert) + continue + # ACME not enabled for individual certificate - bail out early + if 'acme' not in pki['certificate'][cert]: + continue + + # Read in ACME certificate chain information + tmp = read_file(f'{vyos_certbot_dir}/live/{cert}/chain.pem') + tmp = load_certificate(tmp, wrap_tags=False) + cert_chain_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1]) + + # Check if CA chain certificate is already present on CLI to avoid adding + # a duplicate. This only checks for manual added CA certificates and not + # auto added ones with the AUTOCHAIN_ prefix + autochain_prefix = 'AUTOCHAIN_' + ca_cert_present = False + if 'ca' in pki: + for ca_base64, cli_path in dict_search_recursive(pki['ca'], 'certificate'): + # Ignore automatic added CA certificates + if any(item.startswith(autochain_prefix) for item in cli_path): + continue + if cert_chain_base64 == ca_base64: + ca_cert_present = True + + 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}" ...') + add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64) return None @@ -452,6 +491,7 @@ def apply(pki): systemd_certbot_name = 'certbot.timer' if not pki: call(f'systemctl stop {systemd_certbot_name}') + call('update-ca-certificates') return None has_certbot = False @@ -469,6 +509,10 @@ def apply(pki): if 'changed' in pki: call_dependents() + # Rebuild ca-certificates bundle + if 'ca' in pki['changed']: + call('update-ca-certificates') + return None if __name__ == '__main__': diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index a5963e72c..a90e33e81 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,16 +17,16 @@ from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.template import render_to_string +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import frr_protocols +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 import frr from vyos import airbag - airbag.enable() - def community_action_compatibility(actions: dict) -> bool: """ Check compatibility of values in community and large community sections @@ -87,31 +87,27 @@ def get_config(config=None): else: conf = Config() - base = ['policy'] - policy = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['protocols'], key_mangling=('-', '_'), - no_tag_node_value_mangle=True) - # Merge policy dict into "regular" config dict - policy = dict_merge(tmp, policy) - return policy - - -def verify(policy): - if not policy: + return get_frrender_dict(conf) + + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'policy'): return None - for policy_type in ['access_list', 'access_list6', 'as_path_list', - 'community_list', 'extcommunity_list', - 'large_community_list', - 'prefix_list', 'prefix_list6', 'route_map']: + policy_types = ['access_list', 'access_list6', 'as_path_list', + 'community_list', 'extcommunity_list', + 'large_community_list', 'prefix_list', + 'prefix_list6', 'route_map'] + + policy = config_dict['policy'] + for protocol in frr_protocols: + if protocol not in config_dict: + continue + if 'protocol' not in policy: + policy.update({'protocol': {}}) + policy['protocol'].update({protocol : config_dict[protocol]}) + + for policy_type in policy_types: # Bail out early and continue with next policy type if policy_type not in policy: continue @@ -246,72 +242,36 @@ def verify(policy): # When the "routing policy" changes and policies, route-maps etc. are deleted, # it is our responsibility to verify that the policy can not be deleted if it # is used by any routing protocol - if 'protocols' in policy: - for policy_type in ['access_list', 'access_list6', 'as_path_list', - 'community_list', - 'extcommunity_list', 'large_community_list', - 'prefix_list', 'route_map']: - if policy_type in policy: - for policy_name in list(set(routing_policy_find(policy_type, - policy[ - 'protocols']))): - found = False - if policy_name in policy[policy_type]: - found = True - # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related - # list - we need to go the extra mile here and check both prefix-lists - if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \ - policy['prefix_list6']: - found = True - if not found: - tmp = policy_type.replace('_', '-') - raise ConfigError( - f'Can not delete {tmp} "{policy_name}", still in use!') + # Check if any routing protocol is activated + if 'protocol' in policy: + for policy_type in policy_types: + for policy_name in list(set(routing_policy_find(policy_type, policy['protocol']))): + found = False + if policy_type in policy and policy_name in policy[policy_type]: + found = True + # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related + # list - we need to go the extra mile here and check both prefix-lists + if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \ + policy['prefix_list6']: + found = True + if not found: + tmp = policy_type.replace('_', '-') + raise ConfigError( + f'Can not delete {tmp} "{policy_name}", still in use!') return None -def generate(policy): - if not policy: - return None - policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None - -def apply(policy): - bgp_daemon = 'bgpd' - zebra_daemon = 'zebra' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(bgp_daemon) - frr_cfg.modify_section(r'^bgp as-path access-list .*') - frr_cfg.modify_section(r'^bgp community-list .*') - frr_cfg.modify_section(r'^bgp extcommunity-list .*') - frr_cfg.modify_section(r'^bgp large-community-list .*') - frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', - remove_stop_mark=True) - if 'new_frr_config' in policy: - frr_cfg.add_before(frr.default_add_before, policy['new_frr_config']) - frr_cfg.commit_configuration(bgp_daemon) - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(r'^access-list .*') - frr_cfg.modify_section(r'^ipv6 access-list .*') - frr_cfg.modify_section(r'^ip prefix-list .*') - frr_cfg.modify_section(r'^ipv6 prefix-list .*') - frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', - remove_stop_mark=True) - if 'new_frr_config' in policy: - frr_cfg.add_before(frr.default_add_before, policy['new_frr_config']) - frr_cfg.commit_configuration(zebra_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None - if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/policy_local-route.py b/src/conf_mode/policy_local-route.py index 331fd972d..9be2bc227 100755 --- a/src/conf_mode/policy_local-route.py +++ b/src/conf_mode/policy_local-route.py @@ -54,6 +54,7 @@ def get_config(config=None): dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) + vrf = leaf_node_changed(conf, base_rule + [rule, 'set', 'vrf']) proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) rule_def = {} if src: @@ -70,6 +71,8 @@ def get_config(config=None): rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) if table: rule_def = dict_merge({'table' : table}, rule_def) + if vrf: + rule_def = dict_merge({'vrf' : vrf}, rule_def) if proto: rule_def = dict_merge({'protocol' : proto}, rule_def) dict = dict_merge({dict_id : {rule : rule_def}}, dict) @@ -90,6 +93,7 @@ def get_config(config=None): dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) + vrf = leaf_node_changed(conf, base_rule + [rule, 'set', 'vrf']) proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) # keep track of changes in configuration # otherwise we might remove an existing node although nothing else has changed @@ -179,6 +183,15 @@ def get_config(config=None): if len(table) > 0: rule_def = dict_merge({'table' : table}, rule_def) + # vrf + if vrf is None: + if 'set' in rule_config and 'vrf' in rule_config['set']: + rule_def = dict_merge({'vrf': [rule_config['set']['vrf']]}, rule_def) + else: + changed = True + if len(vrf) > 0: + rule_def = dict_merge({'vrf' : vrf}, rule_def) + # protocol if proto is None: if 'protocol' in rule_config: @@ -218,8 +231,15 @@ def verify(pbr): ): raise ConfigError('Source or destination address or fwmark or inbound-interface or protocol is required!') - if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']: - raise ConfigError('Table set is required!') + if 'set' not in pbr_route['rule'][rule]: + raise ConfigError('Either set table or set vrf is required!') + + set_tgts = pbr_route['rule'][rule]['set'] + if 'table' not in set_tgts and 'vrf' not in set_tgts: + raise ConfigError('Either set table or set vrf is required!') + + if 'table' in set_tgts and 'vrf' in set_tgts: + raise ConfigError('set table and set vrf cannot both be set!') if 'inbound_interface' in pbr_route['rule'][rule]: interface = pbr_route['rule'][rule]['inbound_interface'] @@ -250,11 +270,14 @@ def apply(pbr): fwmark = rule_config.get('fwmark', ['']) inbound_interface = rule_config.get('inbound_interface', ['']) protocol = rule_config.get('protocol', ['']) - table = rule_config.get('table', ['']) + # VRF 'default' is actually table 'main' for RIB rules + vrf = [ 'main' if x == 'default' else x for x in rule_config.get('vrf', ['']) ] + # See generate section below for table/vrf overlap explanation + table_or_vrf = rule_config.get('table', vrf) - for src, dst, src_port, dst_port, fwmk, iif, proto, table in product( + for src, dst, src_port, dst_port, fwmk, iif, proto, table_or_vrf in product( source, destination, source_port, destination_port, - fwmark, inbound_interface, protocol, table): + fwmark, inbound_interface, protocol, table_or_vrf): f_src = '' if src == '' else f' from {src} ' f_src_port = '' if src_port == '' else f' sport {src_port} ' f_dst = '' if dst == '' else f' to {dst} ' @@ -262,7 +285,7 @@ def apply(pbr): f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' f_iif = '' if iif == '' else f' iif {iif} ' f_proto = '' if proto == '' else f' ipproto {proto} ' - f_table = '' if table == '' else f' lookup {table} ' + f_table = '' if table_or_vrf == '' else f' lookup {table_or_vrf} ' call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif}{f_table}') @@ -276,7 +299,13 @@ def apply(pbr): if 'rule' in pbr_route: for rule, rule_config in pbr_route['rule'].items(): - table = rule_config['set'].get('table', '') + # VRFs get configred 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' + else: + table_or_vrf = rule_config['set'].get('table', vrf) source = rule_config.get('source', {}).get('address', ['all']) source_port = rule_config.get('source', {}).get('port', '') destination = rule_config.get('destination', {}).get('address', ['all']) @@ -295,7 +324,7 @@ def apply(pbr): f_iif = f' iif {inbound_interface} ' if inbound_interface else '' f_proto = f' ipproto {protocol} ' if protocol else '' - call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table}') + call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table_or_vrf}') return None diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index c58fe1bce..223175b8a 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -25,6 +25,9 @@ from vyos.template import render from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import get_vrf_tableid +from vyos.defaults import rt_global_table +from vyos.defaults import rt_global_vrf from vyos import ConfigError from vyos import airbag airbag.enable() @@ -83,6 +86,9 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id): if not tcp_flags or 'syn' not in tcp_flags: raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + 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') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -152,15 +158,26 @@ def apply_table_marks(policy): for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: for rule_id, rule_conf in pol_conf['rule'].items(): + vrf_table_id = None set_table = dict_search_args(rule_conf, 'set', 'table') - if set_table: + set_vrf = dict_search_args(rule_conf, 'set', 'vrf') + if set_vrf: + if set_vrf == 'default': + vrf_table_id = rt_global_vrf + else: + vrf_table_id = get_vrf_tableid(set_vrf) + elif set_table: if set_table == 'main': - set_table = '254' - if set_table in tables: + vrf_table_id = rt_global_table + else: + vrf_table_id = set_table + if vrf_table_id is not None: + vrf_table_id = int(vrf_table_id) + if vrf_table_id in tables: continue - tables.append(set_table) - table_mark = mark_offset - int(set_table) - cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') + tables.append(vrf_table_id) + table_mark = mark_offset - vrf_table_id + cmd(f'{cmd_str} rule add pref {vrf_table_id} fwmark {table_mark} table {vrf_table_id}') def cleanup_table_marks(): for cmd_str in ['ip', 'ip -6']: diff --git a/src/conf_mode/protocols_babel.py b/src/conf_mode/protocols_babel.py index 90b6e4a31..80a847af8 100755 --- a/src/conf_mode/protocols_babel.py +++ b/src/conf_mode/protocols_babel.py @@ -17,15 +17,14 @@ from sys import exit from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configdict import dict_merge -from vyos.configdict import node_changed +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_access_list from vyos.configverify import verify_prefix_list +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search -from vyos.template import render_to_string +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -34,46 +33,16 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'babel'] - babel = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - babel['interface_removed'] = list(interfaces_removed) + return get_frrender_dict(conf) - # Bail out early if configuration tree does not exist - if not conf.exists(base): - babel.update({'deleted' : ''}) - return babel - - # We have gathered the dict representation of the CLI, but there are default - # values which we need to update into the dictionary retrieved. - default_values = conf.get_config_defaults(base, key_mangling=('-', '_'), - get_first_key=True, - recursive=True) - - # merge in default values - babel = config_dict_merge(default_values, babel) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - babel = dict_merge(tmp, babel) - return babel - -def verify(babel): - if not babel: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'babel'): return None + babel = config_dict['babel'] + babel['policy'] = config_dict['policy'] + # verify distribute_list if "distribute_list" in babel: acl_keys = { @@ -120,32 +89,14 @@ def verify(babel): verify_prefix_list(prefix_list, babel, version='6' if address_family == 'ipv6' else '') -def generate(babel): - if not babel or 'deleted' in babel: - return None - - babel['new_frr_config'] = render_to_string('frr/babeld.frr.j2', babel) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(babel): - babel_daemon = 'babeld' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - frr_cfg.load_configuration(babel_daemon) - frr_cfg.modify_section('^router babel', stop_pattern='^exit', remove_stop_mark=True) - - for key in ['interface', 'interface_removed']: - if key not in babel: - continue - for interface in babel[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'new_frr_config' in babel: - frr_cfg.add_before(frr.default_add_before, babel['new_frr_config']) - frr_cfg.commit_configuration(babel_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index 1361bb1a9..d3bc3e961 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -16,11 +16,13 @@ from vyos.config import Config from vyos.configverify import verify_vrf +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.template import is_ipv6 -from vyos.template import render_to_string from vyos.utils.network import is_ipv6_link_local +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -29,22 +31,14 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'bfd'] - bfd = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - # Bail out early if configuration tree does not exist - if not conf.exists(base): - return bfd - bfd = conf.merge_defaults(bfd, recursive=True) + return get_frrender_dict(conf) - return bfd - -def verify(bfd): - if not bfd: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'bfd'): return None + bfd = config_dict['bfd'] if 'peer' in bfd: for peer, peer_config in bfd['peer'].items(): # IPv6 link local peers require an explicit local address/interface @@ -83,22 +77,13 @@ def verify(bfd): return None -def generate(bfd): - if not bfd: - return None - bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.j2', bfd) - -def apply(bfd): - bfd_daemon = 'bfdd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(bfd_daemon) - frr_cfg.modify_section('^bfd', stop_pattern='^exit', remove_stop_mark=True) - if 'new_frr_config' in bfd: - frr_cfg.add_before(frr.default_add_before, bfd['new_frr_config']) - frr_cfg.commit_configuration(bfd_daemon) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 22f020099..c4af717af 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -19,21 +19,20 @@ from sys import argv from vyos.base import Warning from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import node_changed +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_prefix_list from vyos.configverify import verify_route_map from vyos.configverify import verify_vrf +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.template import is_ip from vyos.template import is_interface -from vyos.template import render_to_string from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_vrf from vyos.utils.network import is_addr_assigned +from vyos.utils.process import is_systemd_service_running from vyos.utils.process import process_named_running -from vyos.utils.process import call from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -43,68 +42,7 @@ def get_config(config=None): else: conf = Config() - vrf = None - if len(argv) > 1: - vrf = argv[1] - - base_path = ['protocols', 'bgp'] - - # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'bgp'] or base_path - bgp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - # Remove per interface MPLS configuration - get a list if changed - # nodes under the interface tagNode - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - bgp['interface_removed'] = list(interfaces_removed) - - # Assign the name of our VRF context. This MUST be done before the return - # statement below, else on deletion we will delete the default instance - # instead of the VRF instance. - if vrf: - bgp.update({'vrf' : vrf}) - # We can not delete the BGP VRF instance if there is a L3VNI configured - # FRR L3VNI must be deleted first otherwise we will see error: - # "FRR error: Please unconfigure l3vni 3000" - tmp = ['vrf', 'name', vrf, 'vni'] - if conf.exists_effective(tmp): - bgp.update({'vni' : conf.return_effective_value(tmp)}) - # We can safely delete ourself from the dependent vrf list - if vrf in bgp['dependent_vrfs']: - del bgp['dependent_vrfs'][vrf] - - bgp['dependent_vrfs'].update({'default': {'protocols': { - 'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True)}}}) - - if not conf.exists(base): - # If bgp instance is deleted then mark it - bgp.update({'deleted' : ''}) - return bgp - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - bgp = conf.merge_defaults(bgp, recursive=True) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - bgp = dict_merge(tmp, bgp) - - return bgp - + return get_frrender_dict(conf, argv) def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) -> bool: """ @@ -237,13 +175,24 @@ def verify_afi(peer_config, bgp_config): if tmp: return True return False -def verify(bgp): +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'bgp'): + return None + + vrf = None + 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'] + bgp['policy'] = config_dict['policy'] + if 'deleted' in bgp: - if 'vrf' in bgp: + if vrf: # Cannot delete vrf if it exists in import vrf list in other vrfs for tmp_afi in ['ipv4_unicast', 'ipv6_unicast']: - if verify_vrf_as_import(bgp['vrf'], tmp_afi, bgp['dependent_vrfs']): - raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \ + if verify_vrf_as_import(vrf, tmp_afi, bgp['dependent_vrfs']): + raise ConfigError(f'Cannot delete VRF instance "{vrf}", ' \ 'unconfigure "import vrf" commands!') else: # We are running in the default VRF context, thus we can not delete @@ -252,8 +201,9 @@ def verify(bgp): for vrf, vrf_options in bgp['dependent_vrfs'].items(): if vrf != 'default': if dict_search('protocols.bgp', vrf_options): - raise ConfigError('Cannot delete default BGP instance, ' \ - 'dependent VRF instance(s) exist(s)!') + dependent_vrfs = ', '.join(bgp['dependent_vrfs'].keys()) + raise ConfigError(f'Cannot delete default BGP instance, ' \ + f'dependent VRF instance(s): {dependent_vrfs}') if 'vni' in vrf_options: raise ConfigError('Cannot delete default BGP instance, ' \ 'dependent L3VNI exists!') @@ -281,9 +231,8 @@ def verify(bgp): for interface in bgp['interface']: error_msg = f'Interface "{interface}" belongs to different VRF instance' tmp = get_interface_vrf(interface) - if 'vrf' in bgp: - if bgp['vrf'] != tmp: - vrf = bgp['vrf'] + if vrf: + if vrf != tmp: raise ConfigError(f'{error_msg} "{vrf}"!') elif tmp != 'default': raise ConfigError(f'{error_msg} "{tmp}"!') @@ -384,10 +333,8 @@ def verify(bgp): # Only checks for ipv4 and ipv6 neighbors # Check if neighbor address is assigned as system interface address - vrf = None vrf_error_msg = f' in default VRF!' - if 'vrf' in bgp: - vrf = bgp['vrf'] + if vrf: vrf_error_msg = f' in VRF "{vrf}"!' if is_ip(peer) and is_addr_assigned(peer, vrf): @@ -529,7 +476,7 @@ def verify(bgp): f'{afi} administrative distance {key}!') if afi in ['ipv4_unicast', 'ipv6_unicast']: - vrf_name = bgp['vrf'] if dict_search('vrf', bgp) else 'default' + vrf_name = vrf if vrf else 'default' # Verify if currant VRF contains rd and route-target options # and does not exist in import list in other VRFs if dict_search(f'rd.vpn.export', afi_config): @@ -602,46 +549,14 @@ def verify(bgp): return None -def generate(bgp): - if not bgp or 'deleted' in bgp: - return None - - bgp['frr_bgpd_config'] = render_to_string('frr/bgpd.frr.j2', bgp) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(bgp): - if 'deleted' in bgp: - # We need to ensure that the L3VNI is deleted first. - # This is not possible with old config backend - # priority bug - if {'vrf', 'vni'} <= set(bgp): - call('vtysh -c "conf t" -c "vrf {vrf}" -c "no vni {vni}"'.format(**bgp)) - - bgp_daemon = 'bgpd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # Generate empty helper string which can be ammended to FRR commands, it - # will be either empty (default VRF) or contain the "vrf <name" statement - vrf = '' - if 'vrf' in bgp: - vrf = ' vrf ' + bgp['vrf'] - - frr_cfg.load_configuration(bgp_daemon) - - # Remove interface specific config - for key in ['interface', 'interface_removed']: - if key not in bgp: - continue - for interface in bgp[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - frr_cfg.modify_section(f'^router bgp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True) - if 'frr_bgpd_config' in bgp: - frr_cfg.add_before(frr.default_add_before, bgp['frr_bgpd_config']) - frr_cfg.commit_configuration(bgp_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py index 609b39065..324ff883f 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 VyOS maintainers and contributors +# Copyright (C) 2022-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,86 +18,49 @@ from sys import exit from sys import argv from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.template import render_to_string +from vyos.configverify import has_frr_protocol_in_dict +from vyos.configverify import verify_vrf +from vyos.utils.process import is_systemd_service_running +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() - def get_config(config=None): if config: conf = config else: conf = Config() - vrf = None - if len(argv) > 1: - vrf = argv[1] - - base_path = ['protocols', 'eigrp'] - - # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'eigrp'] or base_path - eigrp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - # Assign the name of our VRF context. This MUST be done before the return - # statement below, else on deletion we will delete the default instance - # instead of the VRF instance. - if vrf: eigrp.update({'vrf' : vrf}) - - if not conf.exists(base): - eigrp.update({'deleted' : ''}) - if not vrf: - # We are running in the default VRF context, thus we can not delete - # our main EIGRP instance if there are dependent EIGRP VRF instances. - eigrp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + return get_frrender_dict(conf, argv) - return eigrp - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - eigrp = dict_merge(tmp, eigrp) - - return eigrp - -def verify(eigrp): - pass - -def generate(eigrp): - if not eigrp or 'deleted' in eigrp: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'eigrp'): return None - eigrp['frr_eigrpd_config'] = render_to_string('frr/eigrpd.frr.j2', eigrp) + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] -def apply(eigrp): - eigrp_daemon = 'eigrpd' + # eqivalent of the C foo ? 'a' : 'b' statement + eigrp = vrf and config_dict['vrf']['name'][vrf]['protocols']['eigrp'] or config_dict['eigrp'] + eigrp['policy'] = config_dict['policy'] - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() + if 'system_as' not in eigrp: + raise ConfigError('EIGRP system-as must be defined!') - # Generate empty helper string which can be ammended to FRR commands, it - # will be either empty (default VRF) or contain the "vrf <name" statement - vrf = '' - if 'vrf' in eigrp: - vrf = ' vrf ' + eigrp['vrf'] + if vrf: + verify_vrf({'vrf': vrf}) - frr_cfg.load_configuration(eigrp_daemon) - frr_cfg.modify_section(f'^router eigrp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True) - if 'frr_eigrpd_config' in eigrp: - frr_cfg.add_before(frr.default_add_before, eigrp['frr_eigrpd_config']) - frr_cfg.commit_configuration(eigrp_daemon) +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 config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index ba2f3cf0d..1c994492e 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -18,16 +18,16 @@ from sys import exit from sys import argv from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import node_changed +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_interface_exists +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.ifconfig import Interface from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config -from vyos.template import render_to_string +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -37,54 +37,21 @@ def get_config(config=None): else: conf = Config() - vrf = None - if len(argv) > 1: - vrf = argv[1] + return get_frrender_dict(conf, argv) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'isis'): + return None - base_path = ['protocols', 'isis'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'isis'] or base_path - isis = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - # Assign the name of our VRF context. This MUST be done before the return - # statement below, else on deletion we will delete the default instance - # instead of the VRF instance. - if vrf: isis['vrf'] = vrf - - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - isis['interface_removed'] = list(interfaces_removed) - - # Bail out early if configuration tree does no longer exist. this must - # be done after retrieving the list of interfaces to be removed. - if not conf.exists(base): - isis.update({'deleted' : ''}) - return isis - - # merge in default values - isis = conf.merge_defaults(isis, recursive=True) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - isis = dict_merge(tmp, isis) - - return isis - -def verify(isis): - # bail out early - looks like removal from running config - if not isis or 'deleted' in isis: + isis = vrf and config_dict['vrf']['name'][vrf]['protocols']['isis'] or config_dict['isis'] + isis['policy'] = config_dict['policy'] + + if 'deleted' in isis: return None if 'net' not in isis: @@ -114,12 +81,11 @@ def verify(isis): f'Recommended area lsp-mtu {recom_area_mtu} or less ' \ '(calculated on MTU size).') - if 'vrf' in isis: + if vrf: # If interface specific options are set, we must ensure that the # interface is bound to our requesting VRF. Due to the VyOS # priorities the interface is bound to the VRF after creation of # the VRF itself, and before any routing protocol is configured. - vrf = isis['vrf'] tmp = get_interface_config(interface) if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') @@ -266,39 +232,14 @@ def verify(isis): return None -def generate(isis): - if not isis or 'deleted' in isis: - return None - - isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.j2', isis) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(isis): - isis_daemon = 'isisd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # Generate empty helper string which can be ammended to FRR commands, it - # will be either empty (default VRF) or contain the "vrf <name" statement - vrf = '' - if 'vrf' in isis: - vrf = ' vrf ' + isis['vrf'] - - frr_cfg.load_configuration(isis_daemon) - frr_cfg.modify_section(f'^router isis VyOS{vrf}', stop_pattern='^exit', remove_stop_mark=True) - - for key in ['interface', 'interface_removed']: - if key not in isis: - continue - for interface in isis[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'frr_isisd_config' in isis: - frr_cfg.add_before(frr.default_add_before, isis['frr_isisd_config']) - - frr_cfg.commit_configuration(isis_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index ad164db9f..33d9a6dae 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-2022 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,33 +20,32 @@ from sys import exit from glob import glob from vyos.config import Config -from vyos.template import render_to_string +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search from vyos.utils.file import read_file +from vyos.utils.process import is_systemd_service_running from vyos.utils.system import sysctl_write from vyos.configverify import verify_interface_exists from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() -config_file = r'/tmp/ldpd.frr' - def get_config(config=None): if config: conf = config else: conf = Config() - base = ['protocols', 'mpls'] - mpls = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - return mpls + return get_frrender_dict(conf) -def verify(mpls): - # If no config, then just bail out early. - if not mpls: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'mpls'): return None + mpls = config_dict['mpls'] + if 'interface' in mpls: for interface in mpls['interface']: verify_interface_exists(mpls, interface) @@ -68,26 +67,19 @@ def verify(mpls): return None -def generate(mpls): - # If there's no MPLS config generated, create dictionary key with no value. - if not mpls or 'deleted' in mpls: - return None - - mpls['frr_ldpd_config'] = render_to_string('frr/ldpd.frr.j2', mpls) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(mpls): - ldpd_damon = 'ldpd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() - frr_cfg.load_configuration(ldpd_damon) - frr_cfg.modify_section(f'^mpls ldp', stop_pattern='^exit', remove_stop_mark=True) + if not has_frr_protocol_in_dict(config_dict, 'mpls'): + return None - if 'frr_ldpd_config' in mpls: - frr_cfg.add_before(frr.default_add_before, mpls['frr_ldpd_config']) - frr_cfg.commit_configuration(ldpd_damon) + mpls = config_dict['mpls'] # Set number of entries in the platform label tables labels = '0' diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 0bd68b7d8..ac92c9d99 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-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,95 +14,112 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os +from sys import exit +from sys import argv +import ipaddress from vyos.config import Config -from vyos.configdict import node_changed from vyos.template import render +from vyos.configverify import has_frr_protocol_in_dict from vyos.utils.process import run +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.utils.process import is_systemd_service_running + airbag.enable() -opennhrp_conf = '/run/opennhrp/opennhrp.conf' +nflog_redirect = 1 +nflog_multicast = 2 nhrp_nftables_conf = '/run/nftables_nhrp.conf' + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['protocols', 'nhrp'] - - nhrp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel']) - - if not conf.exists(base): - return nhrp - nhrp['if_tunnel'] = conf.get_config_dict(['interfaces', 'tunnel'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + return get_frrender_dict(conf, argv) - nhrp['profile_map'] = {} - profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - for name, profile_conf in profile.items(): - if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: - interfaces = profile_conf['bind']['tunnel'] - if isinstance(interfaces, str): - interfaces = [interfaces] - for interface in interfaces: - nhrp['profile_map'][interface] = name - - return nhrp - -def verify(nhrp): - if 'tunnel' in nhrp: - for name, nhrp_conf in nhrp['tunnel'].items(): - if not nhrp['if_tunnel'] or name not in nhrp['if_tunnel']: +def verify(config_dict): + if not config_dict or 'deleted' in config_dict: + return None + if 'tunnel' in config_dict: + for name, nhrp_conf in config_dict['tunnel'].items(): + if not config_dict['if_tunnel'] or name not in config_dict['if_tunnel']: raise ConfigError(f'Tunnel interface "{name}" does not exist') - tunnel_conf = nhrp['if_tunnel'][name] + tunnel_conf = config_dict['if_tunnel'][name] + if 'address' in tunnel_conf: + address_list = dict_search('address', tunnel_conf) + for tunip in address_list: + if ipaddress.ip_network(tunip, + strict=False).prefixlen != 32: + raise ConfigError( + f'Tunnel {name} is used for NHRP, Netmask should be /32!') if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre': raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel') + if 'network_id' not in nhrp_conf: + raise ConfigError(f'network-id is not specified in tunnel "{name}"') + if 'remote' in tunnel_conf: raise ConfigError(f'Tunnel "{name}" cannot have a remote address defined') - if 'map' in nhrp_conf: - for map_name, map_conf in nhrp_conf['map'].items(): - if 'nbma_address' not in map_conf: + map_tunnelip = dict_search('map.tunnel_ip', nhrp_conf) + if map_tunnelip: + for map_name, map_conf in map_tunnelip.items(): + if 'nbma' not in map_conf: raise ConfigError(f'nbma-address missing on map {map_name} on tunnel {name}') - if 'dynamic_map' in nhrp_conf: - for map_name, map_conf in nhrp_conf['dynamic_map'].items(): - if 'nbma_domain_name' not in map_conf: - raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') + nhs_tunnelip = dict_search('nhs.tunnel_ip', nhrp_conf) + nbma_list = [] + if nhs_tunnelip: + for nhs_name, nhs_conf in nhs_tunnelip.items(): + if 'nbma' not in nhs_conf: + raise ConfigError(f'nbma-address missing on map nhs {nhs_name} on tunnel {name}') + if nhs_name != 'dynamic': + if len(list(dict_search('nbma', nhs_conf))) > 1: + raise ConfigError( + f'Static nhs tunnel-ip {nhs_name} cannot contain multiple nbma-addresses') + for nbma_ip in dict_search('nbma', nhs_conf): + if nbma_ip not in nbma_list: + nbma_list.append(nbma_ip) + else: + raise ConfigError( + f'Nbma address {nbma_ip} cannot be maped to several tunnel-ip') return None -def generate(nhrp): - if not os.path.exists(nhrp_nftables_conf): - nhrp['first_install'] = True - render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp) - render(nhrp_nftables_conf, 'nhrp/nftables.conf.j2', nhrp) +def generate(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'nhrp'): + return None + + if 'deleted' in config_dict['nhrp']: + return None + render(nhrp_nftables_conf, 'frr/nhrpd_nftables.conf.j2', config_dict['nhrp']) + + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(nhrp): + +def apply(config_dict): + nft_rc = run(f'nft --file {nhrp_nftables_conf}') if nft_rc != 0: raise ConfigError('Failed to apply NHRP tunnel firewall rules') - action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' - service_rc = run(f'systemctl {action} opennhrp.service') - if service_rc != 0: - raise ConfigError(f'Failed to {action} the NHRP service') - + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None + if __name__ == '__main__': try: c = get_config() @@ -112,3 +129,4 @@ if __name__ == '__main__': except ConfigError as e: print(e) exit(1) + diff --git a/src/conf_mode/protocols_openfabric.py b/src/conf_mode/protocols_openfabric.py new file mode 100644 index 000000000..7df11fb20 --- /dev/null +++ b/src/conf_mode/protocols_openfabric.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.configverify import has_frr_protocol_in_dict +from vyos.utils.process import is_systemd_service_running +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +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, 'openfabric'): + return None + + openfabric = config_dict['openfabric'] + if 'deleted' in openfabric: + return None + + if 'net' not in openfabric: + raise ConfigError('Network entity is mandatory!') + + # last byte in OpenFabric area address must be 0 + tmp = openfabric['net'].split('.') + if int(tmp[-1]) != 0: + raise ConfigError('Last byte of OpenFabric network entity title must always be 0!') + + if 'domain' not in openfabric: + raise ConfigError('OpenFabric domain name is mandatory!') + + interfaces_used = [] + + for domain, domain_config in openfabric['domain'].items(): + # If interface not set + if 'interface' not in domain_config: + raise ConfigError(f'Interface used for routing updates in OpenFabric "{domain}" is mandatory!') + + for iface, iface_config in domain_config['interface'].items(): + verify_interface_exists(openfabric, iface) + + # interface can be activated only on one OpenFabric instance + if iface in interfaces_used: + raise ConfigError(f'Interface {iface} is already used in different OpenFabric instance!') + + if 'address_family' not in iface_config or len(iface_config['address_family']) < 1: + raise ConfigError(f'Need to specify address family for the interface "{iface}"!') + + # If md5 and plaintext-password set at the same time + if 'password' in iface_config: + if {'md5', 'plaintext_password'} <= set(iface_config['password']): + raise ConfigError(f'Can use either md5 or plaintext-password for password for the interface!') + + if iface == 'lo' and 'passive' not in iface_config: + Warning('For loopback interface passive mode is implied!') + + interfaces_used.append(iface) + + # If md5 and plaintext-password set at the same time + password = 'domain_password' + if password in domain_config: + if {'md5', 'plaintext_password'} <= set(domain_config[password]): + raise ConfigError(f'Can use either md5 or plaintext-password for domain-password!') + + 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 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/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 7347c4faa..c06c0aafc 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -18,18 +18,17 @@ from sys import exit from sys import argv from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configdict import dict_merge -from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_route_map from vyos.configverify import verify_interface_exists from vyos.configverify import verify_access_list -from vyos.template import render_to_string +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -39,85 +38,19 @@ def get_config(config=None): else: conf = Config() - vrf = None - if len(argv) > 1: - vrf = argv[1] - - base_path = ['protocols', 'ospf'] - - # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospf'] or base_path - ospf = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) - - # Assign the name of our VRF context. This MUST be done before the return - # statement below, else on deletion we will delete the default instance - # instead of the VRF instance. - if vrf: ospf['vrf'] = vrf - - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - ospf['interface_removed'] = list(interfaces_removed) - - # Bail out early if configuration tree does no longer exist. this must - # be done after retrieving the list of interfaces to be removed. - if not conf.exists(base): - ospf.update({'deleted' : ''}) - return ospf + return get_frrender_dict(conf, argv) - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - default_values = conf.get_config_defaults(**ospf.kwargs, recursive=True) - - # We have to cleanup the default dict, as default values could enable features - # which are not explicitly enabled on the CLI. Example: default-information - # originate comes with a default metric-type of 2, which will enable the - # entire default-information originate tree, even when not set via CLI so we - # need to check this first and probably drop that key. - if dict_search('default_information.originate', ospf) is None: - del default_values['default_information'] - if 'mpls_te' not in ospf: - del default_values['mpls_te'] - if 'graceful_restart' not in ospf: - del default_values['graceful_restart'] - for area_num in default_values.get('area', []): - if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None: - del default_values['area'][area_num]['area_type']['nssa'] - - for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']: - if dict_search(f'redistribute.{protocol}', ospf) is None: - del default_values['redistribute'][protocol] - if not bool(default_values['redistribute']): - del default_values['redistribute'] - - for interface in ospf.get('interface', []): - # We need to reload the defaults on every pass b/c of - # hello-multiplier dependency on dead-interval - # If hello-multiplier is set, we need to remove the default from - # dead-interval. - if 'hello_multiplier' in ospf['interface'][interface]: - del default_values['interface'][interface]['dead_interval'] - - ospf = config_dict_merge(default_values, ospf) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - ospf = dict_merge(tmp, ospf) +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'ospf'): + return None - return ospf + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] -def verify(ospf): - if not ospf: - return None + # eqivalent of the C foo ? 'a' : 'b' statement + ospf = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospf'] or config_dict['ospf'] + ospf['policy'] = config_dict['policy'] verify_common_route_maps(ospf) @@ -164,8 +97,7 @@ def verify(ospf): # interface is bound to our requesting VRF. Due to the VyOS # priorities the interface is bound to the VRF after creation of # the VRF itself, and before any routing protocol is configured. - if 'vrf' in ospf: - vrf = ospf['vrf'] + if vrf: tmp = get_interface_config(interface) if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') @@ -244,39 +176,14 @@ def verify(ospf): return None -def generate(ospf): - if not ospf or 'deleted' in ospf: - return None - - ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.j2', ospf) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(ospf): - ospf_daemon = 'ospfd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # Generate empty helper string which can be ammended to FRR commands, it - # will be either empty (default VRF) or contain the "vrf <name" statement - vrf = '' - if 'vrf' in ospf: - vrf = ' vrf ' + ospf['vrf'] - - frr_cfg.load_configuration(ospf_daemon) - frr_cfg.modify_section(f'^router ospf{vrf}', stop_pattern='^exit', remove_stop_mark=True) - - for key in ['interface', 'interface_removed']: - if key not in ospf: - continue - for interface in ospf[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'frr_ospfd_config' in ospf: - frr_cfg.add_before(frr.default_add_before, ospf['frr_ospfd_config']) - - frr_cfg.commit_configuration(ospf_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index 60c2a9b16..2563eb7d5 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -18,18 +18,17 @@ from sys import exit from sys import argv from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configdict import dict_merge -from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_route_map from vyos.configverify import verify_interface_exists -from vyos.template import render_to_string +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.ifconfig import Interface from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -39,75 +38,19 @@ def get_config(config=None): else: conf = Config() - vrf = None - if len(argv) > 1: - vrf = argv[1] + return get_frrender_dict(conf, argv) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'ospfv3'): + return None - base_path = ['protocols', 'ospfv3'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospfv3'] or base_path - ospfv3 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - # Assign the name of our VRF context. This MUST be done before the return - # statement below, else on deletion we will delete the default instance - # instead of the VRF instance. - if vrf: ospfv3['vrf'] = vrf - - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - ospfv3['interface_removed'] = list(interfaces_removed) - - # Bail out early if configuration tree does no longer exist. this must - # be done after retrieving the list of interfaces to be removed. - if not conf.exists(base): - ospfv3.update({'deleted' : ''}) - return ospfv3 - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - default_values = conf.get_config_defaults(**ospfv3.kwargs, - recursive=True) - - # We have to cleanup the default dict, as default values could enable features - # which are not explicitly enabled on the CLI. Example: default-information - # originate comes with a default metric-type of 2, which will enable the - # entire default-information originate tree, even when not set via CLI so we - # need to check this first and probably drop that key. - if dict_search('default_information.originate', ospfv3) is None: - del default_values['default_information'] - if 'graceful_restart' not in ospfv3: - del default_values['graceful_restart'] - - for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'ripng', 'static']: - if dict_search(f'redistribute.{protocol}', ospfv3) is None: - del default_values['redistribute'][protocol] - if not bool(default_values['redistribute']): - del default_values['redistribute'] - - default_values.pop('interface', {}) - - # merge in remaining default values - ospfv3 = config_dict_merge(default_values, ospfv3) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - ospfv3 = dict_merge(tmp, ospfv3) - - return ospfv3 - -def verify(ospfv3): - if not ospfv3: - return None + ospfv3 = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospfv3'] or config_dict['ospfv3'] + ospfv3['policy'] = config_dict['policy'] verify_common_route_maps(ospfv3) @@ -137,47 +80,21 @@ def verify(ospfv3): # interface is bound to our requesting VRF. Due to the VyOS # priorities the interface is bound to the VRF after creation of # the VRF itself, and before any routing protocol is configured. - if 'vrf' in ospfv3: - vrf = ospfv3['vrf'] + if vrf: tmp = get_interface_config(interface) if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') return None -def generate(ospfv3): - if not ospfv3 or 'deleted' in ospfv3: - return None - - ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.j2', ospfv3) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(ospfv3): - ospf6_daemon = 'ospf6d' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # Generate empty helper string which can be ammended to FRR commands, it - # will be either empty (default VRF) or contain the "vrf <name" statement - vrf = '' - if 'vrf' in ospfv3: - vrf = ' vrf ' + ospfv3['vrf'] - - frr_cfg.load_configuration(ospf6_daemon) - frr_cfg.modify_section(f'^router ospf6{vrf}', stop_pattern='^exit', remove_stop_mark=True) - - for key in ['interface', 'interface_removed']: - if key not in ospfv3: - continue - for interface in ospfv3[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'new_frr_config' in ospfv3: - frr_cfg.add_before(frr.default_add_before, ospfv3['new_frr_config']) - - frr_cfg.commit_configuration(ospf6_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 79294a1f0..632099964 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -22,72 +22,33 @@ from signal import SIGTERM from sys import exit from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configdict import node_changed from vyos.configverify import verify_interface_exists +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.frrender import pim_daemon +from vyos.utils.process import is_systemd_service_running from vyos.utils.process import process_named_running from vyos.utils.process import call -from vyos.template import render_to_string from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() -RESERVED_MC_NET = '224.0.0.0/24' - - def get_config(config=None): if config: conf = config else: conf = Config() - base = ['protocols', 'pim'] - - pim = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - # We can not run both IGMP proxy and PIM at the same time - get IGMP - # proxy status - if conf.exists(['protocols', 'igmp-proxy']): - pim.update({'igmp_proxy_enabled' : {}}) - - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - pim['interface_removed'] = list(interfaces_removed) - - # Bail out early if configuration tree does no longer exist. this must - # be done after retrieving the list of interfaces to be removed. - if not conf.exists(base): - pim.update({'deleted' : ''}) - return pim - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - default_values = conf.get_config_defaults(**pim.kwargs, recursive=True) - - # We have to cleanup the default dict, as default values could enable features - # which are not explicitly enabled on the CLI. Example: default-information - # originate comes with a default metric-type of 2, which will enable the - # entire default-information originate tree, even when not set via CLI so we - # need to check this first and probably drop that key. - for interface in pim.get('interface', []): - # We need to reload the defaults on every pass b/c of - # hello-multiplier dependency on dead-interval - # If hello-multiplier is set, we need to remove the default from - # dead-interval. - if 'igmp' not in pim['interface'][interface]: - del default_values['interface'][interface]['igmp'] - - pim = config_dict_merge(default_values, pim) - return pim - -def verify(pim): - if not pim or 'deleted' in pim: + return get_frrender_dict(conf) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'pim'): + return None + + pim = config_dict['pim'] + + if 'deleted' in pim: return None if 'igmp_proxy_enabled' in pim: @@ -96,6 +57,7 @@ def verify(pim): if 'interface' not in pim: raise ConfigError('PIM require defined interfaces!') + RESERVED_MC_NET = '224.0.0.0/24' for interface, interface_config in pim['interface'].items(): verify_interface_exists(pim, interface) @@ -124,41 +86,26 @@ def verify(pim): raise ConfigError(f'{pim_base_error} must be unique!') unique.append(gr_addr) -def generate(pim): - if not pim or 'deleted' in pim: - return None - pim['frr_pimd_config'] = render_to_string('frr/pimd.frr.j2', pim) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(pim): - pim_daemon = 'pimd' - pim_pid = process_named_running(pim_daemon) - - if not pim or 'deleted' in pim: - if 'deleted' in pim: - os.kill(int(pim_pid), SIGTERM) +def apply(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'pim'): + return None + pim_pid = process_named_running(pim_daemon) + pim = config_dict['pim'] + if 'deleted' in pim: + os.kill(int(pim_pid), SIGTERM) return None if not pim_pid: call('/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1') - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - frr_cfg.load_configuration(pim_daemon) - frr_cfg.modify_section(f'^ip pim') - frr_cfg.modify_section(f'^ip igmp') - - for key in ['interface', 'interface_removed']: - if key not in pim: - continue - for interface in pim[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'frr_pimd_config' in pim: - frr_cfg.add_before(frr.default_add_before, pim['frr_pimd_config']) - frr_cfg.commit_configuration(pim_daemon) + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py index 581ffe238..03a79139a 100755 --- a/src/conf_mode/protocols_pim6.py +++ b/src/conf_mode/protocols_pim6.py @@ -19,12 +19,12 @@ from ipaddress import IPv6Network from sys import exit from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configdict import node_changed +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_interface_exists -from vyos.template import render_to_string +from vyos.utils.process import is_systemd_service_running +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -33,34 +33,15 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'pim6'] - pim6 = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, with_recursive_defaults=True) + return get_frrender_dict(conf) - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - pim6['interface_removed'] = list(interfaces_removed) +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'pim6'): + return None - # Bail out early if configuration tree does no longer exist. this must - # be done after retrieving the list of interfaces to be removed. - if not conf.exists(base): - pim6.update({'deleted' : ''}) - return pim6 - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - default_values = conf.get_config_defaults(**pim6.kwargs, recursive=True) - - pim6 = config_dict_merge(default_values, pim6) - return pim6 - -def verify(pim6): - if not pim6 or 'deleted' in pim6: - return + pim6 = config_dict['pim6'] + if 'deleted' in pim6: + return None for interface, interface_config in pim6.get('interface', {}).items(): verify_interface_exists(pim6, interface) @@ -94,32 +75,14 @@ def verify(pim6): raise ConfigError(f'{pim_base_error} must be unique!') unique.append(gr_addr) -def generate(pim6): - if not pim6 or 'deleted' in pim6: - return - pim6['new_frr_config'] = render_to_string('frr/pim6d.frr.j2', pim6) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(pim6): - if pim6 is None: - return - - pim6_daemon = 'pim6d' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - frr_cfg.load_configuration(pim6_daemon) - - for key in ['interface', 'interface_removed']: - if key not in pim6: - continue - for interface in pim6[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'new_frr_config' in pim6: - frr_cfg.add_before(frr.default_add_before, pim6['new_frr_config']) - frr_cfg.commit_configuration(pim6_daemon) +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index 9afac544d..ec9dfbb8b 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -17,15 +17,15 @@ from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import node_changed +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_access_list from vyos.configverify import verify_prefix_list +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search -from vyos.template import render_to_string +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -34,41 +34,16 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'rip'] - rip = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - rip['interface_removed'] = list(interfaces_removed) + return get_frrender_dict(conf) - # Bail out early if configuration tree does not exist - if not conf.exists(base): - rip.update({'deleted' : ''}) - return rip - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - rip = conf.merge_defaults(rip, recursive=True) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - rip = dict_merge(tmp, rip) - - return rip - -def verify(rip): - if not rip: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'rip'): return None + rip = config_dict['rip'] + rip['policy'] = config_dict['policy'] + verify_common_route_maps(rip) acl_in = dict_search('distribute_list.access_list.in', rip) @@ -93,39 +68,14 @@ def verify(rip): raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \ f'with "split-horizon disable" for "{interface}"!') -def generate(rip): - if not rip or 'deleted' in rip: - return None - - rip['new_frr_config'] = render_to_string('frr/ripd.frr.j2', rip) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(rip): - rip_daemon = 'ripd' - zebra_daemon = 'zebra' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section('^ip protocol rip route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') - frr_cfg.commit_configuration(zebra_daemon) - - frr_cfg.load_configuration(rip_daemon) - frr_cfg.modify_section('^key chain \S+', stop_pattern='^exit', remove_stop_mark=True) - frr_cfg.modify_section('^router rip', stop_pattern='^exit', remove_stop_mark=True) - - for key in ['interface', 'interface_removed']: - if key not in rip: - continue - for interface in rip[key]: - frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) - - if 'new_frr_config' in rip: - frr_cfg.add_before(frr.default_add_before, rip['new_frr_config']) - frr_cfg.commit_configuration(rip_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index 23416ff96..9a9ac8ec8 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -17,14 +17,15 @@ from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_access_list from vyos.configverify import verify_prefix_list +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search -from vyos.template import render_to_string +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -33,32 +34,16 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'ripng'] - ripng = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # Bail out early if configuration tree does not exist - if not conf.exists(base): - return ripng + return get_frrender_dict(conf) - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - ripng = conf.merge_defaults(ripng, recursive=True) - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - ripng = dict_merge(tmp, ripng) - - return ripng - -def verify(ripng): - if not ripng: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'ripng'): return None + ripng = config_dict['ripng'] + ripng['policy'] = config_dict['policy'] + verify_common_route_maps(ripng) acl_in = dict_search('distribute_list.access_list.in', ripng) @@ -83,34 +68,14 @@ def verify(ripng): raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \ f'with "split-horizon disable" for "{interface}"!') -def generate(ripng): - if not ripng: - ripng['new_frr_config'] = '' - return None - - ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.j2', ripng) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(ripng): - ripng_daemon = 'ripngd' - zebra_daemon = 'zebra' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section('^ipv6 protocol ripng route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') - frr_cfg.commit_configuration(zebra_daemon) - - frr_cfg.load_configuration(ripng_daemon) - frr_cfg.modify_section('key chain \S+', stop_pattern='^exit', remove_stop_mark=True) - frr_cfg.modify_section('interface \S+', stop_pattern='^exit', remove_stop_mark=True) - frr_cfg.modify_section('^router ripng', stop_pattern='^exit', remove_stop_mark=True) - if 'new_frr_config' in ripng: - frr_cfg.add_before(frr.default_add_before, ripng['new_frr_config']) - frr_cfg.commit_configuration(ripng_daemon) - +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index a59ecf3e4..ef0250e3d 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -20,13 +20,15 @@ from glob import glob 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.pki import wrap_openssh_public_key from vyos.pki import wrap_openssh_private_key -from vyos.template import render_to_string from vyos.utils.dict import dict_search_args from vyos.utils.file import write_file +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -37,25 +39,14 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['protocols', 'rpki'] + return get_frrender_dict(conf) - rpki = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, with_pki=True) - # Bail out early if configuration tree does not exist - if not conf.exists(base): - rpki.update({'deleted' : ''}) - return rpki - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - rpki = conf.merge_defaults(rpki, recursive=True) - - return rpki - -def verify(rpki): - if not rpki: +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None + rpki = config_dict['rpki'] + if 'cache' in rpki: preferences = [] for peer, peer_config in rpki['cache'].items(): @@ -81,12 +72,14 @@ def verify(rpki): return None -def generate(rpki): +def generate(config_dict): for key in glob(f'{rpki_ssh_key_base}*'): os.unlink(key) - if not rpki: - return + if not has_frr_protocol_in_dict(config_dict, 'rpki'): + return None + + rpki = config_dict['rpki'] if 'cache' in rpki: for cache, cache_config in rpki['cache'].items(): @@ -102,21 +95,13 @@ def generate(rpki): write_file(cache_config['ssh']['public_key_file'], wrap_openssh_public_key(public_key_data, public_key_type)) write_file(cache_config['ssh']['private_key_file'], wrap_openssh_private_key(private_key_data)) - rpki['new_frr_config'] = render_to_string('frr/rpki.frr.j2', rpki) - + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(rpki): - bgp_daemon = 'bgpd' - - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(bgp_daemon) - frr_cfg.modify_section('^rpki', stop_pattern='^exit', remove_stop_mark=True) - if 'new_frr_config' in rpki: - frr_cfg.add_before(frr.default_add_before, rpki['new_frr_config']) - - frr_cfg.commit_configuration(bgp_daemon) +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py index b36c2ca11..f2bd42a79 100755 --- a/src/conf_mode/protocols_segment-routing.py +++ b/src/conf_mode/protocols_segment-routing.py @@ -17,12 +17,15 @@ from sys import exit from vyos.config import Config -from vyos.configdict import node_changed -from vyos.template import render_to_string +from vyos.configdict import list_diff +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.ifconfig import Section from vyos.utils.dict import dict_search +from vyos.utils.process import is_systemd_service_running from vyos.utils.system import sysctl_write from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -32,25 +35,14 @@ def get_config(config=None): else: conf = Config() - base = ['protocols', 'segment-routing'] - sr = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_recursive_defaults=True) + return get_frrender_dict(conf) - # FRR has VRF support for different routing daemons. As interfaces belong - # to VRFs - or the global VRF, we need to check for changed interfaces so - # that they will be properly rendered for the FRR config. Also this eases - # removal of interfaces from the running configuration. - interfaces_removed = node_changed(conf, base + ['interface']) - if interfaces_removed: - sr['interface_removed'] = list(interfaces_removed) +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'segment_routing'): + return None - import pprint - pprint.pprint(sr) - return sr + sr = config_dict['segment_routing'] -def verify(sr): if 'srv6' in sr: srv6_enable = False if 'interface' in sr: @@ -62,47 +54,43 @@ def verify(sr): raise ConfigError('SRv6 should be enabled on at least one interface!') return None -def generate(sr): - if not sr: - return None - - sr['new_frr_config'] = render_to_string('frr/zebra.segment_routing.frr.j2', sr) +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(sr): - zebra_daemon = 'zebra' +def apply(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'segment_routing'): + return None - if 'interface_removed' in sr: - for interface in sr['interface_removed']: - # Disable processing of IPv6-SR packets - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + sr = config_dict['segment_routing'] + + current_interfaces = Section.interfaces() + sr_interfaces = list(sr.get('interface', {}).keys()) - if 'interface' in sr: - for interface, interface_config in sr['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') - # 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') - elif tmp == 'drop': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1') - elif tmp == 'ignore': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1') - else: - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + 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') - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(r'^segment-routing') - if 'new_frr_config' in sr: - frr_cfg.add_before(frr.default_add_before, sr['new_frr_config']) - frr_cfg.commit_configuration(zebra_daemon) + 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') + # 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') + elif tmp == 'drop': + sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1') + elif tmp == 'ignore': + sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1') + else: + sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index a2373218a..1b9e51167 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -14,19 +14,19 @@ # 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 ipaddress import IPv4Network from sys import exit from sys import argv from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import get_dhcp_interfaces -from vyos.configdict import get_pppoe_interfaces +from vyos.configverify import has_frr_protocol_in_dict 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.process import is_systemd_service_running from vyos.template import render -from vyos.template import render_to_string from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -38,36 +38,20 @@ def get_config(config=None): else: conf = Config() + return get_frrender_dict(conf, argv) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'static'): + return None + vrf = None - if len(argv) > 1: - vrf = argv[1] + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] - base_path = ['protocols', 'static'] # eqivalent of the C foo ? 'a' : 'b' statement - base = vrf and ['vrf', 'name', vrf, 'protocols', 'static'] or base_path - static = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) - - # Assign the name of our VRF context - if vrf: static['vrf'] = vrf - - # We also need some additional information from the config, prefix-lists - # and route-maps for instance. They will be used in verify(). - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = conf.get_config_dict(['policy']) - # Merge policy dict into "regular" config dict - static = dict_merge(tmp, static) - - # T3680 - get a list of all interfaces currently configured to use DHCP - tmp = get_dhcp_interfaces(conf, vrf) - if tmp: static.update({'dhcp' : tmp}) - tmp = get_pppoe_interfaces(conf, vrf) - if tmp: static.update({'pppoe' : tmp}) - - return static - -def verify(static): + static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] + static['policy'] = config_dict['policy'] + verify_common_route_maps(static) for route in ['route', 'route6']: @@ -88,37 +72,36 @@ def verify(static): if {'blackhole', 'reject'} <= set(prefix_options): raise ConfigError(f'Can not use both blackhole and reject for '\ - 'prefix "{prefix}"!') + f'prefix "{prefix}"!') + + if 'multicast' in static and 'route' in static['multicast']: + for prefix, prefix_options in static['multicast']['route'].items(): + if not IPv4Network(prefix).is_multicast: + raise ConfigError(f'{prefix} is not a multicast network!') return None -def generate(static): - if not static: +def generate(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'static'): return None - # Put routing table names in /etc/iproute2/rt_tables - render(config_file, 'iproute2/static.conf.j2', static) - static['new_frr_config'] = render_to_string('frr/staticd.frr.j2', static) - return None - -def apply(static): - static_daemon = 'staticd' + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(static_daemon) + # eqivalent of the C foo ? 'a' : 'b' statement + static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] - if 'vrf' in static: - vrf = static['vrf'] - frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit-vrf', remove_stop_mark=True) - else: - frr_cfg.modify_section(r'^ip route .*') - frr_cfg.modify_section(r'^ipv6 route .*') + # Put routing table names in /etc/iproute2/rt_tables + render(config_file, 'iproute2/static.conf.j2', static) - if 'new_frr_config' in static: - frr_cfg.add_before(frr.default_add_before, static['new_frr_config']) - frr_cfg.commit_configuration(static_daemon) + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) + return None +def apply(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py deleted file mode 100755 index d323ceb4f..000000000 --- a/src/conf_mode/protocols_static_multicast.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2024 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from ipaddress import IPv4Address -from sys import exit - -from vyos import ConfigError -from vyos import frr -from vyos.config import Config -from vyos.template import render_to_string - -from vyos import airbag -airbag.enable() - -config_file = r'/tmp/static_mcast.frr' - -# Get configuration for static multicast route -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - mroute = { - 'old_mroute' : {}, - 'mroute' : {} - } - - base_path = "protocols static multicast" - - if not (conf.exists(base_path) or conf.exists_effective(base_path)): - return None - - conf.set_level(base_path) - - # Get multicast effective routes - for route in conf.list_effective_nodes('route'): - mroute['old_mroute'][route] = {} - for next_hop in conf.list_effective_nodes('route {0} next-hop'.format(route)): - mroute['old_mroute'][route].update({ - next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop)) - }) - - # Get multicast effective interface-routes - for route in conf.list_effective_nodes('interface-route'): - if not route in mroute['old_mroute']: - mroute['old_mroute'][route] = {} - for next_hop in conf.list_effective_nodes('interface-route {0} next-hop-interface'.format(route)): - mroute['old_mroute'][route].update({ - next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop)) - }) - - # Get multicast routes - for route in conf.list_nodes('route'): - mroute['mroute'][route] = {} - for next_hop in conf.list_nodes('route {0} next-hop'.format(route)): - mroute['mroute'][route].update({ - next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop)) - }) - - # Get multicast interface-routes - for route in conf.list_nodes('interface-route'): - if not route in mroute['mroute']: - mroute['mroute'][route] = {} - for next_hop in conf.list_nodes('interface-route {0} next-hop-interface'.format(route)): - mroute['mroute'][route].update({ - next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop)) - }) - - return mroute - -def verify(mroute): - if mroute is None: - return None - - for route in mroute['mroute']: - route = route.split('/') - if IPv4Address(route[0]) < IPv4Address('224.0.0.0'): - raise ConfigError(route + " not a multicast network") - - -def generate(mroute): - if mroute is None: - return None - - mroute['new_frr_config'] = render_to_string('frr/static_mcast.frr.j2', mroute) - return None - - -def apply(mroute): - if mroute is None: - return None - static_daemon = 'staticd' - - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(static_daemon) - - if 'old_mroute' in mroute: - for route_gr in mroute['old_mroute']: - for nh in mroute['old_mroute'][route_gr]: - if mroute['old_mroute'][route_gr][nh]: - frr_cfg.modify_section(f'^ip mroute {route_gr} {nh} {mroute["old_mroute"][route_gr][nh]}') - else: - frr_cfg.modify_section(f'^ip mroute {route_gr} {nh}') - - if 'new_frr_config' in mroute: - frr_cfg.add_before(frr.default_add_before, mroute['new_frr_config']) - - frr_cfg.commit_configuration(static_daemon) - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index 2445b8515..59e307a39 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -17,6 +17,7 @@ from sys import exit from netifaces import interfaces +from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents from vyos.configdep import call_dependents @@ -89,6 +90,36 @@ def _clean_conf_dict(conf): return conf +def _get_group_filters(config: dict, group_name: str, visited=None) -> dict: + filters = dict() + if not visited: + visited = [group_name, ] + else: + if group_name in visited: + return filters + visited.append(group_name) + + for filter, filter_config in config.get(group_name, {}).items(): + if filter == 'match': + for match, match_config in filter_config.items(): + filters[f'{group_name}-{match}'] = match_config + elif filter == 'match_group': + for group in filter_config: + filters.update(_get_group_filters(config, group, visited)) + + return filters + + +def _get_group_match(config:dict, group_name:str) -> dict: + match = dict() + for key, val in _get_group_filters(config, group_name).items(): + # delete duplicate matches + if val not in match.values(): + match[key] = val + + return match + + def get_config(config=None): if config: conf = config @@ -135,11 +166,27 @@ def get_config(config=None): qos = conf.merge_defaults(qos, recursive=True) + if 'traffic_match_group' in qos: + for group, group_config in qos['traffic_match_group'].items(): + if 'match_group' in group_config: + qos['traffic_match_group'][group]['match'] = _get_group_match(qos['traffic_match_group'], group) + for policy in qos.get('policy', []): for p_name, p_config in qos['policy'][policy].items(): # cleanup empty match config if 'class' in p_config: for cls, cls_config in p_config['class'].items(): + if 'match_group' in cls_config: + # merge group match to match + for group in cls_config['match_group']: + for match, match_conf in qos['traffic_match_group'].get(group, {'match': {}})['match'].items(): + if 'match' not in cls_config: + cls_config['match'] = dict() + if match in cls_config['match']: + cls_config['match'][f'{group}-{match}'] = match_conf + else: + cls_config['match'][match] = match_conf + if 'match' in cls_config: cls_config['match'] = _clean_conf_dict(cls_config['match']) if cls_config['match'] == {}: @@ -147,6 +194,68 @@ def get_config(config=None): return qos + +def _verify_match(cls_config: dict) -> None: + if 'match' in cls_config: + for match, match_config in cls_config['match'].items(): + filters = set(match_config) + if {'ip', 'ipv6'} <= filters: + raise ConfigError( + f'Can not use both IPv6 and IPv4 in one match ({match})!') + + if {'interface', 'vif'} & filters: + if {'ip', 'ipv6', 'ether'} & filters: + raise ConfigError( + f'Can not combine protocol and interface or vlan tag match ({match})!') + + +def _verify_match_group_exist(cls_config, qos): + if 'match_group' in cls_config: + for group in cls_config['match_group']: + if 'traffic_match_group' not in qos or group not in qos['traffic_match_group']: + Warning(f'Match group "{group}" does not exist!') + + +def _verify_default_policy_exist(policy, policy_config): + if 'default' not in policy_config: + raise ConfigError(f'Policy {policy} misses "default" class!') + + +def _check_shaper_hfsc_rate(cls, cls_conf): + is_m2_exist = False + for crit in TrafficShaperHFSC.criteria: + if cls_conf.get(crit, {}).get('m2') is not None: + is_m2_exist = True + + if cls_conf.get(crit, {}).get('m1') is not None: + for crit_val in ['m2', 'd']: + if cls_conf.get(crit, {}).get(crit_val) is None: + raise ConfigError( + f'{cls} {crit} m1 value is set, but no {crit_val} was found!' + ) + + if not is_m2_exist: + raise ConfigError(f'At least one m2 value needs to be set for class: {cls}') + + if ( + cls_conf.get('upperlimit', {}).get('m2') is not None + and cls_conf.get('linkshare', {}).get('m2') is None + ): + raise ConfigError( + f'Linkshare m2 needs to be defined to use upperlimit m2 for class: {cls}' + ) + + +def _verify_shaper_hfsc(policy, policy_config): + _verify_default_policy_exist(policy, policy_config) + + _check_shaper_hfsc_rate('default', policy_config.get('default')) + + if 'class' in policy_config: + for cls, cls_conf in policy_config['class'].items(): + _check_shaper_hfsc_rate(cls, cls_conf) + + def verify(qos): if not qos or 'interface' not in qos: return None @@ -174,11 +283,8 @@ def verify(qos): # bandwidth is not mandatory for priority-queue - that is why this is on the exception list if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError(f'Bandwidth must be defined for policy "{policy}" class "{cls}"!') - if 'match' in cls_config: - for match, match_config in cls_config['match'].items(): - if {'ip', 'ipv6'} <= set(match_config): - raise ConfigError(f'Can not use both IPv6 and IPv4 in one match ({match})!') - + _verify_match(cls_config) + _verify_match_group_exist(cls_config, qos) if policy_type in ['random_detect']: if 'precedence' in policy_config: for precedence, precedence_config in policy_config['precedence'].items(): @@ -193,8 +299,13 @@ def verify(qos): if queue_lim < max_tr: raise ConfigError(f'Policy "{policy}" uses queue-limit "{queue_lim}" < max-threshold "{max_tr}"!') if policy_type in ['priority_queue']: - if 'default' not in policy_config: - raise ConfigError(f'Policy {policy} misses "default" class!') + _verify_default_policy_exist(policy, policy_config) + if policy_type in ['rate_control']: + if 'bandwidth' not in policy_config: + raise ConfigError('Bandwidth not defined') + if policy_type in ['shaper_hfsc']: + _verify_shaper_hfsc(policy, policy_config) + if 'default' in policy_config: if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError('Bandwidth not defined for default traffic!') @@ -216,14 +327,21 @@ def verify(qos): if direction not in tmp: raise ConfigError(f'Selected QoS policy on interface "{interface}" only supports "{tmp}"!') + if 'traffic_match_group' in qos: + for group, group_config in qos['traffic_match_group'].items(): + _verify_match(group_config) + _verify_match_group_exist(group_config, qos) + return None + def generate(qos): if not qos or 'interface' not in qos: return None return None + def apply(qos): # Always delete "old" shapers first for interface in interfaces(): @@ -254,6 +372,7 @@ def apply(qos): return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_aws_glb.py b/src/conf_mode/service_aws_glb.py new file mode 100755 index 000000000..d1ed5a07b --- /dev/null +++ b/src/conf_mode/service_aws_glb.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +systemd_service = 'aws-gwlbtun.service' +systemd_override = '/run/systemd/system/aws-gwlbtun.service.d/10-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'aws', 'glb'] + if not conf.exists(base): + return None + + glb = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + return glb + + +def verify(glb): + # bail out early - looks like removal from running config + if not glb: + return None + + +def generate(glb): + if not glb: + return None + + render(systemd_override, 'aws/override_aws_gwlbtun.conf.j2', glb) + + +def apply(glb): + call('systemctl daemon-reload') + if not glb: + call(f'systemctl stop {systemd_service}') + else: + call(f'systemctl 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_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 544b759ac..5a729af74 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-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,25 +16,42 @@ import os +from glob import glob from ipaddress import ip_address from ipaddress import ip_network from netaddr import IPRange from sys import exit -from vyos.base import DeprecationWarning from vyos.config import 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.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.process import call -from vyos.utils.process import run +from vyos.utils.network import interface_exists from vyos.utils.network import is_subnet_connected from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag + airbag.enable() -config_file = '/run/dhcp-server/dhcpd.conf' -systemd_override = r'/run/systemd/system/isc-dhcp-server.service.d/10-override.conf' +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*' +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 dhcp_slice_range(exclude_list, range_dict): """ @@ -58,19 +75,21 @@ def dhcp_slice_range(exclude_list, range_dict): range_last_exclude = '' for e in exclude_list: - if (ip_address(e) >= ip_address(range_start)) and \ - (ip_address(e) <= ip_address(range_stop)): + if (ip_address(e) >= ip_address(range_start)) and ( + ip_address(e) <= ip_address(range_stop) + ): range_last_exclude = e for e in exclude_list: - if (ip_address(e) >= ip_address(range_start)) and \ - (ip_address(e) <= ip_address(range_stop)): - + if (ip_address(e) >= ip_address(range_start)) and ( + ip_address(e) <= ip_address(range_stop) + ): # Build new address range ending one address before exclude address - r = { - 'start' : range_start, - 'stop' : str(ip_address(e) -1) - } + r = {'start': range_start, 'stop': str(ip_address(e) - 1)} + + if 'option' in range_dict: + r['option'] = range_dict['option'] + # On the next run our address range will start one address after # the exclude address range_start = str(ip_address(e) + 1) @@ -84,21 +103,23 @@ def dhcp_slice_range(exclude_list, range_dict): # Take care of last IP address range spanning from the last exclude # address (+1) to the end of the initial configured range if ip_address(e) == ip_address(range_last_exclude): - r = { - 'start': str(ip_address(e) + 1), - 'stop': str(range_stop) - } + r = {'start': str(ip_address(e) + 1), 'stop': str(range_stop)} + + if 'option' in range_dict: + r['option'] = range_dict['option'] + if not (ip_address(r['start']) > ip_address(r['stop'])): output.append(r) else: - # if the excluded address was not part of the range, we simply return - # the entire ranga again - if not range_last_exclude: - if range_dict not in output: - output.append(range_dict) + # if the excluded address was not part of the range, we simply return + # the entire ranga again + if not range_last_exclude: + if range_dict not in output: + output.append(range_dict) return output + def get_config(config=None): if config: conf = config @@ -108,10 +129,13 @@ def get_config(config=None): if not conf.exists(base): return None - dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) + dhcp = conf.get_config_dict( + base, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True, + ) if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): @@ -123,19 +147,31 @@ def get_config(config=None): new_range_id = 0 new_range_dict = {} for r, r_config in subnet_config['range'].items(): - for slice in dhcp_slice_range(subnet_config['exclude'], r_config): - new_range_dict.update({new_range_id : slice}) - new_range_id +=1 + for slice in dhcp_slice_range( + subnet_config['exclude'], r_config + ): + new_range_dict.update({new_range_id: slice}) + new_range_id += 1 dhcp['shared_network_name'][network]['subnet'][subnet].update( - {'range' : new_range_dict}) + {'range': new_range_dict} + ) if len(dhcp['high_availability']) == 1: ## only default value for mode is set, need to remove ha node del dhcp['high_availability'] + else: + if dict_search('high_availability.certificate', dhcp): + dhcp['pki'] = conf.get_config_dict( + ['pki'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) return dhcp + def verify(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: @@ -143,73 +179,85 @@ def verify(dhcp): # If DHCP is enabled we need one share-network if 'shared_network_name' not in dhcp: - raise ConfigError('No DHCP shared networks configured.\n' \ - 'At least one DHCP shared network must be configured.') + raise ConfigError( + 'No DHCP shared networks configured.\n' + 'At least one DHCP shared network must be configured.' + ) # Inspect shared-network/subnet listen_ok = False subnets = [] - failover_ok = False - shared_networks = len(dhcp['shared_network_name']) + shared_networks = len(dhcp['shared_network_name']) disabled_shared_networks = 0 - common_deprecation_msg = 'are subject of removal in VyOS 1.5! Please raise a feature request for proper CLI nodes!' - if 'global_parameters' in dhcp: - DeprecationWarning(f'Additional global parameters {common_deprecation_msg}') + subnet_ids = [] # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): - if 'shared_network_parameters' in network_config: - DeprecationWarning(f'Additional shared network parameters in "{network}" {common_deprecation_msg}') - if 'disable' in network_config: disabled_shared_networks += 1 if 'subnet' not in network_config: - raise ConfigError(f'No subnets defined for {network}. At least one\n' \ - 'lease subnet must be configured.') + raise ConfigError( + f'No subnets defined for {network}. At least one\n' + 'lease subnet must be configured.' + ) for subnet, subnet_config in network_config['subnet'].items(): - if 'subnet_parameters' in subnet_config: - DeprecationWarning(f'Additional subnet parameters in "{subnet}" {common_deprecation_msg}') + if 'subnet_id' not in subnet_config: + raise ConfigError( + f'Unique subnet ID not specified for subnet "{subnet}"' + ) + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) # All delivered static routes require a next-hop to be set if 'static_route' in subnet_config: for route, route_option in subnet_config['static_route'].items(): if 'next_hop' not in route_option: - raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!') - - # DHCP failover needs at least one subnet that uses it - if 'enable_failover' in subnet_config: - if 'high_availability' not in dhcp: - raise ConfigError(f'Can not enable high availability for "{subnet}" in "{network}".\n' \ - 'High availability is not configured globally!') - failover_ok = True + raise ConfigError( + f'DHCP static-route "{route}" requires router to be defined!' + ) # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] for range, range_config in subnet_config['range'].items(): if not {'start', 'stop'} <= set(range_config): - raise ConfigError(f'DHCP range "{range}" start and stop address must be defined!') + raise ConfigError( + f'DHCP range "{range}" start and stop address must be defined!' + ) # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): - raise ConfigError(f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!' + ) # Stop address must be greater or equal to start address - if ip_address(range_config['stop']) < ip_address(range_config['start']): - raise ConfigError(f'DHCP range "{range}" stop address must be greater or equal\n' \ - 'to the ranges start address!') + if ip_address(range_config['stop']) < ip_address( + range_config['start'] + ): + raise ConfigError( + f'DHCP range "{range}" stop address must be greater or equal\n' + 'to the ranges start address!' + ) for network in networks: start = range_config['start'] stop = range_config['stop'] if start in network: - raise ConfigError(f'Range "{range}" start address "{start}" already part of another range!') + raise ConfigError( + f'Range "{range}" start address "{start}" already part of another range!' + ) if stop in network: - raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!') + raise ConfigError( + f'Range "{range}" stop address "{stop}" already part of another range!' + ) tmp = IPRange(range_config['start'], range_config['stop']) networks.append(tmp) @@ -218,36 +266,61 @@ def verify(dhcp): if 'exclude' in subnet_config: for exclude in subnet_config['exclude']: if ip_address(exclude) not in ip_network(subnet): - raise ConfigError(f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!' + ) # At least one DHCP address range or static-mapping required if 'range' not in subnet_config and 'static_mapping' not in subnet_config: - raise ConfigError(f'No DHCP address range or active static-mapping configured\n' \ - f'within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'No DHCP address range or active static-mapping configured\n' + f'within shared-network "{network}, {subnet}"!' + ) if 'static_mapping' in subnet_config: # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) used_ips = [] used_mac = [] + used_duid = [] for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ip_address' in mapping_config: - if ip_address(mapping_config['ip_address']) not in ip_network(subnet): - raise ConfigError(f'Configured static lease address for mapping "{mapping}" is\n' \ - f'not within shared-network "{network}, {subnet}"!') - - if 'mac_address' not in mapping_config: - raise ConfigError(f'MAC address required for static mapping "{mapping}"\n' \ - f'within shared-network "{network}, {subnet}"!') + if ip_address(mapping_config['ip_address']) not in ip_network( + subnet + ): + raise ConfigError( + f'Configured static lease address for mapping "{mapping}" is\n' + f'not 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 'disable' not in mapping_config: if mapping_config['ip_address'] in used_ips: - raise ConfigError(f'Configured IP address for static mapping "{mapping}" already exists on another static mapping') + raise ConfigError( + f'Configured IP address for static mapping "{mapping}" already exists on another static mapping' + ) used_ips.append(mapping_config['ip_address']) - if 'mac_address' in mapping_config and 'disable' not in mapping_config: - if mapping_config['mac_address'] in used_mac: - raise ConfigError(f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping') - used_mac.append(mapping_config['mac_address']) + if 'disable' not in mapping_config: + if 'mac' in mapping_config: + if mapping_config['mac'] in used_mac: + raise ConfigError( + f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping' + ) + used_mac.append(mapping_config['mac']) + + if 'duid' in mapping_config: + if mapping_config['duid'] in used_duid: + raise ConfigError( + f'Configured DUID for static mapping "{mapping}" already exists on another static mapping' + ) + used_duid.append(mapping_config['duid']) # There must be one subnet connected to a listen interface. # This only counts if the network itself is not disabled! @@ -257,90 +330,199 @@ def verify(dhcp): # Subnets must be non overlapping if subnet in subnets: - raise ConfigError(f'Configured subnets must be unique! Subnet "{subnet}"\n' - 'defined multiple times!') + raise ConfigError( + f'Configured subnets must be unique! Subnet "{subnet}"\n' + 'defined multiple times!' + ) subnets.append(subnet) # Check for overlapping subnets net = ip_network(subnet) for n in subnets: net2 = ip_network(n) - if (net != net2): + if net != net2: if net.overlaps(net2): - raise ConfigError(f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!') + raise ConfigError( + f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!' + ) # Prevent 'disable' for shared-network if only one network is configured if (shared_networks - disabled_shared_networks) < 1: - raise ConfigError(f'At least one shared network must be active!') + raise ConfigError('At least one shared network must be active!') if 'high_availability' in dhcp: - if not failover_ok: - raise ConfigError('DHCP failover must be enabled for at least one subnet!') - for key in ['name', 'remote', 'source_address', 'status']: if key not in dhcp['high_availability']: tmp = key.replace('_', '-') - raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') - - for address in (dict_search('listen_address', dhcp) or []): + raise ConfigError( + f'DHCP high-availability requires "{tmp}" to be specified!' + ) + + if len({'certificate', 'ca_certificate'} & set(dhcp['high_availability'])) == 1: + raise ConfigError( + 'DHCP secured high-availability requires both certificate and CA certificate' + ) + + if 'certificate' in dhcp['high_availability']: + cert_name = dhcp['high_availability']['certificate'] + + if cert_name not in dhcp['pki']['certificate']: + raise ConfigError( + 'Invalid certificate specified for DHCP high-availability' + ) + + if not dict_search_args( + dhcp['pki']['certificate'], cert_name, 'certificate' + ): + raise ConfigError( + 'Invalid certificate specified for DHCP high-availability' + ) + + if not dict_search_args( + dhcp['pki']['certificate'], cert_name, 'private', 'key' + ): + raise ConfigError( + 'Missing private key on certificate specified for DHCP high-availability' + ) + + if 'ca_certificate' in dhcp['high_availability']: + ca_cert_name = dhcp['high_availability']['ca_certificate'] + if ca_cert_name not in dhcp['pki']['ca']: + raise ConfigError( + 'Invalid CA certificate specified for DHCP high-availability' + ) + + if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): + raise ConfigError( + 'Invalid CA certificate specified for DHCP high-availability' + ) + + for address in dict_search('listen_address', dhcp) or []: if is_addr_assigned(address, include_vrf=True): listen_ok = True # no need to probe further networks, we have one that is valid continue else: - raise ConfigError(f'listen-address "{address}" not configured on any interface') + raise ConfigError( + f'listen-address "{address}" not configured on any interface' + ) if not listen_ok: - raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n' - 'broadcast interface configured, nor was there an explicit listen-address\n' - 'configured for serving DHCP relay packets!') + raise ConfigError( + 'None of the configured subnets have an appropriate primary IP address on any\n' + 'broadcast interface configured, nor was there an explicit listen-address\n' + 'configured for serving DHCP relay packets!' + ) + + if 'listen_address' in dhcp and 'listen_interface' in dhcp: + raise ConfigError( + 'Cannot define listen-address and listen-interface at the same time' + ) + + for interface in dict_search('listen_interface', dhcp) or []: + if not interface_exists(interface): + raise ConfigError(f'listen-interface "{interface}" does not exist') return None + def generate(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: return None - # Please see: https://vyos.dev/T1129 for quoting of the raw - # parameters we can pass to ISC DHCPd - tmp_file = '/tmp/dhcpd.conf' - render(tmp_file, 'dhcp-server/dhcpd.conf.j2', dhcp, - formater=lambda _: _.replace(""", '"')) - # XXX: as we have the ability for a user to pass in "raw" options via VyOS - # CLI (see T3544) we now ask ISC dhcpd to test the newly rendered - # configuration - tmp = run(f'/usr/sbin/dhcpd -4 -q -t -cf {tmp_file}') - if tmp > 0: - if os.path.exists(tmp_file): - os.unlink(tmp_file) - raise ConfigError('Configuration file errors encountered - check your options!') - - # Now that we know that the newly rendered configuration is "good" we can - # render the "real" configuration - render(config_file, 'dhcp-server/dhcpd.conf.j2', dhcp, - formater=lambda _: _.replace(""", '"')) - render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) - - # Clean up configuration test file - if os.path.exists(tmp_file): - os.unlink(tmp_file) + dhcp['lease_file'] = lease_file + dhcp['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Ensure correct permissions on lease files + backups + for file in glob(lease_file_glob): + chown(file, user=user_group, group='vyattacfg') + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + for f in [cert_file, cert_key_file, ca_cert_file]: + if os.path.exists(f): + os.unlink(f) + + if 'high_availability' in dhcp: + if 'certificate' in dhcp['high_availability']: + cert_name = dhcp['high_availability']['certificate'] + cert_data = dhcp['pki']['certificate'][cert_name]['certificate'] + key_data = dhcp['pki']['certificate'][cert_name]['private']['key'] + write_file( + cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600 + ) + write_file( + cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600 + ) + + dhcp['high_availability']['cert_file'] = cert_file + dhcp['high_availability']['cert_key_file'] = cert_key_file + + if 'ca_certificate' in dhcp['high_availability']: + ca_cert_name = dhcp['high_availability']['ca_certificate'] + ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate'] + write_file( + ca_cert_file, + wrap_certificate(ca_cert_data), + user=user_group, + mode=0o600, + ) + + 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, + ) return None + def apply(dhcp): - call('systemctl daemon-reload') - # bail out early - looks like removal from running config + services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] + if not dhcp or 'disable' in dhcp: - call('systemctl stop isc-dhcp-server.service') + for service in services: + call(f'systemctl stop {service}.service') + if os.path.exists(config_file): os.unlink(config_file) return None - call('systemctl restart isc-dhcp-server.service') + 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: + action = 'stop' + + call(f'systemctl {action} {service}.service') + return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 25f19285c..7af88007c 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-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,6 +16,7 @@ import os +from glob import glob from ipaddress import ip_address from ipaddress import ip_network from sys import exit @@ -23,13 +24,21 @@ from sys import exit from vyos.config import 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 import ConfigError from vyos import airbag airbag.enable() -config_file = '/run/dhcp-server/dhcpdv6.conf' +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*' +user_group = '_kea' def get_config(config=None): if config: @@ -57,6 +66,7 @@ def verify(dhcpv6): # Inspect shared-network/subnet subnets = [] + subnet_ids = [] listen_ok = False for network, network_config in dhcpv6['shared_network_name'].items(): # A shared-network requires a subnet definition @@ -66,26 +76,37 @@ def verify(dhcpv6): 'each shared network!') for subnet, subnet_config in network_config['subnet'].items(): - if 'address_range' in subnet_config: - if 'start' in subnet_config['address_range']: - range6_start = [] - range6_stop = [] - for start, start_config in subnet_config['address_range']['start'].items(): - if 'stop' not in start_config: - raise ConfigError(f'address-range stop address for start "{start}" is not defined!') - stop = start_config['stop'] + if 'subnet_id' not in subnet_config: + raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) + + if 'range' in subnet_config: + range6_start = [] + range6_stop = [] + + for num, range_config in subnet_config['range'].items(): + if 'start' in range_config: + start = range_config['start'] + + if 'stop' not in range_config: + raise ConfigError(f'Range stop address for start "{start}" is not defined!') + stop = range_config['stop'] # Start address must be inside network if not ip_address(start) in ip_network(subnet): - raise ConfigError(f'address-range start address "{start}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range start address "{start}" is not in subnet "{subnet}"!') # Stop address must be inside network if not ip_address(stop) in ip_network(subnet): - raise ConfigError(f'address-range stop address "{stop}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!') # Stop address must be greater or equal to start address if not ip_address(stop) >= ip_address(start): - raise ConfigError(f'address-range stop address "{stop}" must be greater than or equal ' \ + raise ConfigError(f'Range stop address "{stop}" must be greater than or equal ' \ f'to the range start address "{start}"!') # DHCPv6 range start address must be unique - two ranges can't @@ -93,6 +114,7 @@ def verify(dhcpv6): if start in range6_start: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool start address "{start}" defined multiple times!') + range6_start.append(start) # DHCPv6 range stop address must be unique - two ranges can't @@ -100,34 +122,47 @@ def verify(dhcpv6): if stop in range6_stop: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool stop address "{stop}" defined multiple times!') + range6_stop.append(stop) - if 'prefix' in subnet_config: - for prefix in subnet_config['prefix']: - if ip_network(prefix) not in ip_network(subnet): - raise ConfigError(f'address-range prefix "{prefix}" is not in subnet "{subnet}"!') + if 'prefix' in range_config: + prefix = range_config['prefix'] + + if not ip_network(prefix).subnet_of(ip_network(subnet)): + raise ConfigError(f'Range prefix "{prefix}" is not in subnet "{subnet}"') # Prefix delegation sanity checks if 'prefix_delegation' in subnet_config: - if 'start' not in subnet_config['prefix_delegation']: - raise ConfigError(f'Start address of delegated IPv6 prefix range "{prefix}" '\ - f'must be configured!') + if 'prefix' not in subnet_config['prefix_delegation']: + raise ConfigError('prefix-delegation prefix not defined!') - for prefix, prefix_config in subnet_config['prefix_delegation']['start'].items(): - if 'stop' not in prefix_config: - raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}" '\ - f'must be configured!') + for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items(): + if 'delegated_length' not in prefix_config: + raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\ + f'must be configured') - start_addr = prefix - stop_addr = prefix_config['stop'] + if 'prefix_length' not in prefix_config: + raise ConfigError('Length of delegated IPv6 prefix must be configured') - if ip_address(stop_addr) <= ip_address(start_addr): - raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}" '\ - f'must be greater than start address!') + if prefix_config['prefix_length'] > prefix_config['delegated_length']: + raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') - if 'prefix_length' not in prefix_config: - raise ConfigError(f'Length of delegated IPv6 prefix '\ - f'must be configured!') + 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}') + + if excluded_len <= prefix_config['delegated_length']: + raise ConfigError('Excluded IPv6 prefix must be smaller than delegated prefix') + + if not excluded_obj.subnet_of(prefix_obj): + raise ConfigError(f'Excluded prefix "{excluded_prefix}" does not exist in the prefix') # Static mappings don't require anything (but check if IP is in subnet if it's set) if 'static_mapping' in subnet_config: @@ -137,13 +172,20 @@ def verify(dhcpv6): if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') - if 'vendor_option' in subnet_config: - if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: - raise ConfigError(f'No more than two Cisco tftp-servers should be defined for subnet "{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 'option' in subnet_config: + if 'vendor_option' in subnet_config['option']: + if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2: + raise ConfigError(f'No more than two Cisco tftp-servers should be defined for subnet "{subnet}"!') # Subnets must be unique if subnet in subnets: raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') + subnets.append(subnet) # DHCPv6 requires at least one configured address range or one static mapping @@ -176,12 +218,29 @@ def generate(dhcpv6): if not dhcpv6 or 'disable' in dhcpv6: return None - render(config_file, 'dhcp-server/dhcpdv6.conf.j2', dhcpv6) + dhcpv6['lease_file'] = lease_file + dhcpv6['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Ensure correct permissions on lease files + backups + for file in glob(lease_file_glob): + chown(file, user=user_group, group='vyattacfg') + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group) return None def apply(dhcpv6): # bail out early - looks like removal from running config - service_name = 'isc-dhcp-server6.service' + 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}') @@ -190,6 +249,7 @@ def apply(dhcpv6): return None call(f'systemctl restart {service_name}') + return None if __name__ == '__main__': diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py index 70686534f..e3bdbc9f8 100755 --- a/src/conf_mode/service_dns_forwarding.py +++ b/src/conf_mode/service_dns_forwarding.py @@ -224,6 +224,18 @@ def get_config(config=None): dns['authoritative_zones'].append(zone) + if 'zone_cache' in dns: + # convert refresh interval to sec: + for _, zone_conf in dns['zone_cache'].items(): + if 'options' in zone_conf \ + and 'refresh' in zone_conf['options']: + + if 'on_reload' in zone_conf['options']['refresh']: + interval = 0 + else: + interval = zone_conf['options']['refresh']['interval'] + zone_conf['options']['refresh']['interval'] = interval + return dns def verify(dns): @@ -259,8 +271,16 @@ def verify(dns): if not 'system_name_server' in dns: print('Warning: No "system name-server" configured') + if 'zone_cache' in dns: + for name, conf in dns['zone_cache'].items(): + if ('source' not in conf) \ + or ('url' in conf['source'] and 'axfr' in conf['source']): + raise ConfigError(f'Invalid configuration for zone "{name}": ' + f'Please select one source type "url" or "axfr".') + return None + def generate(dns): # bail out early - looks like removal from running config if not dns: diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index 16c82e591..a14d4b5b6 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -31,6 +31,7 @@ from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import verify_accel_ppp_authentication from vyos import ConfigError from vyos import airbag + airbag.enable() @@ -52,7 +53,9 @@ def get_config(config=None): if dict_search('client_ip_pool', ipoe): # Multiple named pools require ordered values T5099 - ipoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', ipoe)) + ipoe['ordered_named_pools'] = get_pools_in_order( + dict_search('client_ip_pool', ipoe) + ) ipoe['server_type'] = 'ipoe' return ipoe @@ -68,8 +71,22 @@ def verify(ipoe): for interface, iface_config in ipoe['interface'].items(): verify_interface_exists(ipoe, interface, warning_only=True) if 'client_subnet' in iface_config and 'vlan' in iface_config: - raise ConfigError('Option "client-subnet" and "vlan" are mutually exclusive, ' - 'use "client-ip-pool" instead!') + raise ConfigError( + 'Options "client-subnet" and "vlan" are mutually exclusive, ' + 'use "client-ip-pool" instead!' + ) + if 'vlan_mon' in iface_config and 'vlan' not in iface_config: + raise ConfigError('Option "vlan-mon" requires "vlan" to be set!') + + if 'lua_username' in iface_config: + if 'lua_file' not in ipoe: + raise ConfigError( + 'Option "lua-username" requires "lua-file" to be set!' + ) + if dict_search('authentication.mode', ipoe) != 'radius': + raise ConfigError( + 'Can configure username with Lua script only for RADIUS authentication' + ) verify_accel_ppp_authentication(ipoe, local_users=False) verify_accel_ppp_ip_pool(ipoe) @@ -86,14 +103,15 @@ def generate(ipoe): render(ipoe_conf, 'accel-ppp/ipoe.config.j2', ipoe) if dict_search('authentication.mode', ipoe) == 'local': - render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', - ipoe, permission=0o640) + render( + ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', ipoe, permission=0o640 + ) return None def apply(ipoe): systemd_service = 'accel-ppp@ipoe.service' - if ipoe == None: + if ipoe is None: call(f'systemctl stop {systemd_service}') for file in [ipoe_conf, ipoe_chap_secrets]: if os.path.exists(file): @@ -101,7 +119,10 @@ def apply(ipoe): return None - call(f'systemctl reload-or-restart {systemd_service}') + # Accel-pppd does not do soft-reload correctly. + # Most of the changes require restarting the service + call(f'systemctl restart {systemd_service}') + if __name__ == '__main__': try: diff --git a/src/conf_mode/service_monitoring_network_event.py b/src/conf_mode/service_monitoring_network_event.py new file mode 100644 index 000000000..104e6ce23 --- /dev/null +++ b/src/conf_mode/service_monitoring_network_event.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import json + +from sys import exit + +from vyos.config import Config +from vyos.utils.file import write_file +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +vyos_network_event_logger_config = r'/run/vyos-network-event-logger.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'monitoring', 'network-event'] + if not conf.exists(base): + return None + + monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + monitoring = conf.merge_defaults(monitoring, recursive=True) + + return monitoring + + +def verify(monitoring): + if not monitoring: + return None + + return None + + +def generate(monitoring): + if not monitoring: + # Delete config + if os.path.exists(vyos_network_event_logger_config): + os.unlink(vyos_network_event_logger_config) + + return None + + # Create config + log_conf_json = json.dumps(monitoring, indent=4) + write_file(vyos_network_event_logger_config, log_conf_json) + + return None + + +def apply(monitoring): + # Reload systemd manager configuration + systemd_service = 'vyos-network-event-logger.service' + + if not monitoring: + call(f'systemctl stop {systemd_service}') + return + + call(f'systemctl restart {systemd_service}') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_monitoring_prometheus.py b/src/conf_mode/service_monitoring_prometheus.py new file mode 100755 index 000000000..9a07d8593 --- /dev/null +++ b/src/conf_mode/service_monitoring_prometheus.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +node_exporter_service_file = '/etc/systemd/system/node_exporter.service' +node_exporter_systemd_service = 'node_exporter.service' +node_exporter_collector_path = '/run/node_exporter/collector' + +frr_exporter_service_file = '/etc/systemd/system/frr_exporter.service' +frr_exporter_systemd_service = 'frr_exporter.service' + +blackbox_exporter_service_file = '/etc/systemd/system/blackbox_exporter.service' +blackbox_exporter_systemd_service = 'blackbox_exporter.service' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'monitoring', 'prometheus'] + if not conf.exists(base): + return None + + monitoring = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + tmp = is_node_changed(conf, base + ['node-exporter', 'vrf']) + if tmp: + monitoring.update({'node_exporter_restart_required': {}}) + + tmp = is_node_changed(conf, base + ['frr-exporter', 'vrf']) + if tmp: + monitoring.update({'frr_exporter_restart_required': {}}) + + tmp = False + for node in ['vrf', 'config-file']: + tmp = tmp or is_node_changed(conf, base + ['blackbox-exporter', node]) + if tmp: + monitoring.update({'blackbox_exporter_restart_required': {}}) + + return monitoring + + +def verify(monitoring): + if not monitoring: + return None + + if 'node_exporter' in monitoring: + verify_vrf(monitoring['node_exporter']) + + if 'frr_exporter' in monitoring: + verify_vrf(monitoring['frr_exporter']) + + if 'blackbox_exporter' in monitoring: + verify_vrf(monitoring['blackbox_exporter']) + + if ( + 'modules' in monitoring['blackbox_exporter'] + and 'dns' in monitoring['blackbox_exporter']['modules'] + and 'name' in monitoring['blackbox_exporter']['modules']['dns'] + ): + for mod_name, mod_config in monitoring['blackbox_exporter']['modules'][ + 'dns' + ]['name'].items(): + if 'query_name' not in mod_config: + raise ConfigError( + f'query name not specified in dns module {mod_name}' + ) + + return None + + +def generate(monitoring): + if not monitoring or 'node_exporter' not in monitoring: + # Delete systemd files + if os.path.isfile(node_exporter_service_file): + os.unlink(node_exporter_service_file) + + if not monitoring or 'frr_exporter' not in monitoring: + # Delete systemd files + if os.path.isfile(frr_exporter_service_file): + os.unlink(frr_exporter_service_file) + + if not monitoring or 'blackbox_exporter' not in monitoring: + # Delete systemd files + if os.path.isfile(blackbox_exporter_service_file): + os.unlink(blackbox_exporter_service_file) + + if not monitoring: + return None + + if 'node_exporter' in monitoring: + # Render node_exporter node_exporter_service_file + render( + node_exporter_service_file, + 'prometheus/node_exporter.service.j2', + monitoring['node_exporter'], + ) + if ( + 'collectors' in monitoring['node_exporter'] + and 'textfile' in monitoring['node_exporter']['collectors'] + ): + # Create textcollector folder + if not os.path.isdir(node_exporter_collector_path): + os.makedirs(node_exporter_collector_path) + + if 'frr_exporter' in monitoring: + # Render frr_exporter service_file + render( + frr_exporter_service_file, + 'prometheus/frr_exporter.service.j2', + monitoring['frr_exporter'], + ) + + if 'blackbox_exporter' in monitoring: + # Render blackbox_exporter service_file + render( + blackbox_exporter_service_file, + 'prometheus/blackbox_exporter.service.j2', + monitoring['blackbox_exporter'], + ) + # Render blackbox_exporter config file + render( + '/run/blackbox_exporter/config.yml', + 'prometheus/blackbox_exporter.yml.j2', + monitoring['blackbox_exporter'], + ) + + return None + + +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 not monitoring or 'frr_exporter' not in monitoring: + 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 not monitoring: + return + + if 'node_exporter' in monitoring: + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'node_exporter_restart_required' in monitoring: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {node_exporter_systemd_service}') + + if 'frr_exporter' in monitoring: + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'frr_exporter_restart_required' in monitoring: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {frr_exporter_systemd_service}') + + if 'blackbox_exporter' in monitoring: + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'blackbox_exporter_restart_required' in monitoring: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {blackbox_exporter_systemd_service}') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py index 98d8a32ca..f17146a8d 100755 --- a/src/conf_mode/service_monitoring_zabbix-agent.py +++ b/src/conf_mode/service_monitoring_zabbix-agent.py @@ -18,6 +18,8 @@ import os from vyos.config import Config from vyos.template import render +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file from vyos.utils.process import call from vyos import ConfigError from vyos import airbag @@ -26,6 +28,7 @@ airbag.enable() service_name = 'zabbix-agent2' service_conf = f'/run/zabbix/{service_name}.conf' +service_psk_file = f'/run/zabbix/{service_name}.psk' systemd_override = r'/run/systemd/system/zabbix-agent2.service.d/10-override.conf' @@ -49,6 +52,8 @@ def get_config(config=None): if 'directory' in config and config['directory'].endswith('/'): config['directory'] = config['directory'][:-1] + config['service_psk_file'] = service_psk_file + return config @@ -60,18 +65,34 @@ def verify(config): if 'server' not in config: raise ConfigError('Server is required!') + if 'authentication' in config and dict_search("authentication.mode", + config) == 'pre_shared_secret': + if 'id' not in config['authentication']['psk']: + raise ConfigError( + 'PSK identity is required for pre-shared-secret authentication mode') + + if 'secret' not in config['authentication']['psk']: + raise ConfigError( + 'PSK secret is required for pre-shared-secret authentication mode') + def generate(config): # bail out early - looks like removal from running config if config is None: # Remove old config and return - config_files = [service_conf, systemd_override] + config_files = [service_conf, systemd_override, service_psk_file] for file in config_files: if os.path.isfile(file): os.unlink(file) return None + if not dict_search("authentication.psk.secret", config): + if os.path.isfile(service_psk_file): + os.unlink(service_psk_file) + else: + write_file(service_psk_file, config["authentication"]["psk"]["secret"]) + # Write configuration file render(service_conf, 'zabbix-agent/zabbix-agent.conf.j2', config) render(systemd_override, 'zabbix-agent/10-override.conf.j2', config) diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 83880fd72..32563aa0e 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -17,6 +17,7 @@ import os from vyos.config import Config +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 @@ -42,13 +43,21 @@ def get_config(config=None): if not conf.exists(base): return None - ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_defaults=True) + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) ntp['config_file'] = config_file ntp['user'] = user_group tmp = is_node_changed(conf, base + ['vrf']) if tmp: ntp.update({'restart_required': {}}) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(**ntp.kwargs, recursive=True) + # Only defined PTP default port, if PTP feature is in use + if 'ptp' not in ntp: + del default_values['ptp'] + + ntp = config_dict_merge(default_values, ntp) return ntp def verify(ntp): @@ -87,6 +96,15 @@ def verify(ntp): if ipv6_addresses > 1: raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ') + if 'server' in ntp: + for host, server in ntp['server'].items(): + if 'ptp' in server: + if 'ptp' not in ntp: + raise ConfigError('PTP must be enabled for the NTP service '\ + f'before it can be used for server "{host}"') + else: + break + return None def generate(ntp): diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 566a7b149..ac697c509 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -121,9 +121,12 @@ def verify(pppoe): raise ConfigError('At least one listen interface must be defined!') # Check is interface exists in the system - for interface in pppoe['interface']: + for interface, interface_config in pppoe['interface'].items(): verify_interface_exists(pppoe, interface, warning_only=True) + if 'vlan_mon' in interface_config and not 'vlan' in interface_config: + raise ConfigError('Option "vlan-mon" requires "vlan" to be set!') + return None diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index f65d0d6bc..c64c59af7 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-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,14 +22,17 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_vrf +from vyos.defaults import systemd_services from vyos.snmpv3_hashgen import plaintext_to_md5 from vyos.snmpv3_hashgen import plaintext_to_sha1 from vyos.snmpv3_hashgen import random from vyos.template import render -from vyos.utils.process import call -from vyos.utils.permission import chmod_755 +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.network import is_addr_assigned +from vyos.utils.process import call +from vyos.utils.permission import chmod_755 from vyos.version import get_version_data from vyos import ConfigError from vyos import airbag @@ -39,8 +42,9 @@ config_file_client = r'/etc/snmp/snmp.conf' config_file_daemon = r'/etc/snmp/snmpd.conf' config_file_access = r'/usr/share/snmp/snmpd.conf' config_file_user = r'/var/lib/snmp/snmpd.conf' +default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' -systemd_service = 'snmpd.service' +systemd_service = systemd_services['snmpd'] def get_config(config=None): if config: @@ -83,8 +87,20 @@ def get_config(config=None): tmp = {'::1': {'port': '161'}} snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']: + for key, val in snmp['script_extensions']['extension_name'].items(): + if 'script' not in val: + continue + script_path = val['script'] + # if script has not absolute path, use pre configured path + if not os.path.isabs(script_path): + script_path = os.path.join(default_script_dir, script_path) + + snmp['script_extensions']['extension_name'][key]['script'] = script_path + return snmp + def verify(snmp): if 'deleted' in snmp: return None @@ -131,6 +147,9 @@ def verify(snmp): return None if 'user' in snmp['v3']: + if 'engineid' not in snmp['v3']: + raise ConfigError(f'EngineID must be configured for SNMPv3!') + for user, user_config in snmp['v3']['user'].items(): if 'group' not in user_config: raise ConfigError(f'Group membership required for user "{user}"!') @@ -192,12 +211,8 @@ def generate(snmp): return None if 'v3' in snmp: - # net-snmp is now regenerating the configuration file in the background - # thus we need to re-open and re-read the file as the content changed. - # After that we can no read the encrypted password from the config and - # replace the CLI plaintext password with its encrypted version. - os.environ['vyos_libexec_dir'] = '/usr/libexec/vyos' - + # SNMPv3 uses a hashed password. If CLI defines a plaintext password, + # we will hash it in the background and replace the CLI node! if 'user' in snmp['v3']: for user, user_config in snmp['v3']['user'].items(): if dict_search('auth.type', user_config) == 'sha': @@ -212,8 +227,9 @@ def generate(snmp): snmp['v3']['user'][user]['auth']['encrypted_password'] = tmp del snmp['v3']['user'][user]['auth']['plaintext_password'] - call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" auth encrypted-password "{tmp}" > /dev/null') - call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" auth plaintext-password > /dev/null') + cli_base = ['service', 'snmp', 'v3', 'user', user, 'auth'] + delete_cli_node(cli_base + ['plaintext-password']) + add_cli_node(cli_base + ['encrypted-password'], value=tmp) if dict_search('privacy.plaintext_password', user_config) is not None: tmp = hash(dict_search('privacy.plaintext_password', user_config), @@ -222,8 +238,9 @@ def generate(snmp): snmp['v3']['user'][user]['privacy']['encrypted_password'] = tmp del snmp['v3']['user'][user]['privacy']['plaintext_password'] - call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" privacy encrypted-password "{tmp}" > /dev/null') - call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" privacy plaintext-password > /dev/null') + cli_base = ['service', 'snmp', 'v3', 'user', user, 'privacy'] + delete_cli_node(cli_base + ['plaintext-password']) + add_cli_node(cli_base + ['encrypted-password'], value=tmp) # Write client config file render(config_file_client, 'snmp/etc.snmp.conf.j2', snmp) @@ -246,17 +263,7 @@ def apply(snmp): return None # start SNMP daemon - call(f'systemctl restart {systemd_service}') - - # Enable AgentX in FRR - # This should be done for each daemon individually because common command - # works only if all the daemons started with SNMP support - frr_daemons_list = [ - 'bgpd', 'ospf6d', 'ospfd', 'ripd', 'ripngd', 'isisd', 'ldpd', 'zebra' - ] - for frr_daemon in frr_daemons_list: - call(f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null') - + call(f'systemctl reload-or-restart {systemd_service}') return None if __name__ == '__main__': diff --git a/src/conf_mode/service_ssh.py b/src/conf_mode/service_ssh.py index 9abdd33dc..759f87bb2 100755 --- a/src/conf_mode/service_ssh.py +++ b/src/conf_mode/service_ssh.py @@ -23,10 +23,16 @@ from syslog import LOG_INFO 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.utils.process import call 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.utils.file import write_file + airbag.enable() config_file = r'/run/sshd/sshd_config' @@ -38,6 +44,9 @@ 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' + + def get_config(config=None): if config: conf = config @@ -47,10 +56,13 @@ def get_config(config=None): if not conf.exists(base): return None - ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=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': {}}) + 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. @@ -62,20 +74,32 @@ def get_config(config=None): # Ignore default XML values if config doesn't exists # Delete key from dict if not conf.exists(base + ['dynamic-protection']): - del ssh['dynamic_protection'] + del ssh['dynamic_protection'] return ssh + def verify(ssh): if not ssh: return None if 'rekey' in ssh and 'data' not in ssh['rekey']: - raise ConfigError(f'Rekey data is required!') + 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') + + 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") verify_vrf(ssh) return None + def generate(ssh): if not ssh: if os.path.isfile(config_file): @@ -95,6 +119,24 @@ 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) + render(config_file, 'ssh/sshd_config.j2', ssh) if 'dynamic_protection' in ssh: @@ -103,12 +145,12 @@ def generate(ssh): return None + def apply(ssh): - systemd_service_ssh = 'ssh.service' systemd_service_sshguard = 'sshguard.service' if not ssh: # SSH access is removed in the commit - call(f'systemctl stop ssh@*.service') + call('systemctl stop ssh@*.service') call(f'systemctl stop {systemd_service_sshguard}') return None @@ -122,13 +164,14 @@ def apply(ssh): if 'restart_required' in ssh: # this is only true if something for the VRFs changed, thus we # stop all VRF services and only restart then new ones - call(f'systemctl stop ssh@*.service') + call('systemctl stop ssh@*.service') systemd_action = 'restart' for vrf in ssh['vrf']: call(f'systemctl {systemd_action} ssh@{vrf}.service') return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_stunnel.py b/src/conf_mode/service_stunnel.py new file mode 100644 index 000000000..8ec762548 --- /dev/null +++ b/src/conf_mode/service_stunnel.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 shutil import rmtree + +from sys import exit + +from netifaces import AF_INET +from psutil import net_if_addrs + +from vyos.config import Config +from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_pki_certificate +from vyos.pki import encode_certificate +from vyos.pki import encode_private_key +from vyos.pki import find_chain +from vyos.pki import load_certificate +from vyos.pki import load_private_key +from vyos.utils.dict import dict_search +from vyos.utils.file import makedir +from vyos.utils.file import write_file +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.process import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +stunnel_dir = '/run/stunnel' +config_file = f'{stunnel_dir}/stunnel.conf' +stunnel_ca_dir = f'{stunnel_dir}/ca' +stunnel_psk_dir = f'{stunnel_dir}/psk' + +# config based on +# http://man.he.net/man8/stunnel4 + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'stunnel'] + if not conf.exists(base): + return None + + stunnel = conf.get_config_dict(base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + with_pki=True) + stunnel['config_file'] = config_file + return stunnel + + +def verify(stunnel): + if not stunnel: + return None + + stunnel_listen_addresses = list() + for mode, conf in stunnel.items(): + if mode not in ['server', 'client']: + continue + + for app, app_conf in conf.items(): + # connect, listen, exec and some protocols e.g. socks on server mode are endpoints. + endpoints = 0 + if 'socks' == app_conf.get('protocol') and mode == 'server': + if 'connect' in app_conf: + raise ConfigError("The 'connect' option cannot be used with the 'socks' protocol in server mode.") + endpoints += 1 + + for item in ['connect', 'listen']: + if item in app_conf: + endpoints += 1 + if 'port' not in app_conf[item]: + raise ConfigError(f'{mode} [{app}]: {item} port number is required!') + elif item == 'listen': + raise ConfigError(f'{mode} [{app}]: {item} port number is required!') + + if endpoints != 2: + raise ConfigError(f'{mode} [{app}]: connect port number is required!') + + if 'address' in app_conf['listen']: + laddresses = [dict_search('listen.address', app_conf)] + else: + laddresses = list() + ifaces = net_if_addrs() + for iface_name, iface_addresses in ifaces.items(): + for iface_addr in iface_addresses: + if iface_addr.family == AF_INET: + laddresses.append(iface_addr.address) + + lport = int(dict_search('listen.port', app_conf)) + + for address in laddresses: + if f'{address}:{lport}' in stunnel_listen_addresses: + raise ConfigError( + f'{mode} [{app}]: Address {address}:{lport} already ' + f'in use by other stunnel service') + + stunnel_listen_addresses.append(f'{address}:{lport}') + if not check_port_availability(address, lport, 'tcp') and \ + not is_listen_port_bind_service(lport, 'stunnel'): + raise ConfigError( + f'{mode} [{app}]: Address {address}:{lport} already in use') + + if 'options' in app_conf: + protocol = app_conf.get('protocol') + if protocol not in ['connect', 'smtp']: + raise ConfigError("Additional option is only supported in the 'connect' and 'smtp' protocols.") + if protocol == 'smtp' and ('domain' in app_conf['options'] or 'host' in app_conf['options']): + raise ConfigError("Protocol 'smtp' does not support options 'domain' and 'host'.") + + # set default authentication option + if 'authentication' not in app_conf['options']: + app_conf['options']['authentication'] = 'basic' if protocol == 'connect' else 'plain' + + for option, option_config in app_conf['options'].items(): + if option == 'authentication': + if protocol == 'connect' and option_config not in ['basic', 'ntlm']: + raise ConfigError("Supported authentication types for the 'connect' protocol are 'basic' or 'ntlm'") + elif protocol == 'smtp' and option_config not in ['plain', 'login']: + raise ConfigError("Supported authentication types for the 'smtp' protocol are 'plain' or 'login'") + if option == 'host': + if 'address' not in option_config: + raise ConfigError('Address is required for option host.') + if 'port' not in option_config: + raise ConfigError('Port is required for option host.') + + # check pki certs + for key in ['ca_certificate', 'certificate']: + tmp = dict_search(f'ssl.{key}', app_conf) + if mode == 'server' and key != 'ca_certificate' and not tmp and 'psk' not in app_conf: + raise ConfigError(f'{mode} [{app}]: TLS server needs a certificate or PSK') + if tmp: + if key == 'ca_certificate': + for ca_cert in tmp: + verify_pki_ca_certificate(stunnel, ca_cert) + else: + verify_pki_certificate(stunnel, tmp) + + #check psk + if 'psk' in app_conf: + for psk, psk_conf in app_conf['psk'].items(): + if 'id' not in psk_conf or 'secret' not in psk_conf: + raise ConfigError( + f'Authentication psk "{psk}" missing "id" or "secret"') + + +def generate(stunnel): + if not stunnel or ('client' not in stunnel and 'server' not in stunnel): + if os.path.isdir(stunnel_dir): + rmtree(stunnel_dir, ignore_errors=True) + + return None + makedir(stunnel_dir) + + exist_files = list() + current_files = [config_file, config_file.replace('.conf', 'pid')] + for root, dirs, files in os.walk(stunnel_dir): + for file in files: + exist_files.append(os.path.join(root, file)) + + loaded_ca_certs = {load_certificate(c['certificate']) + for c in stunnel['pki']['ca'].values()} if 'pki' in stunnel and 'ca' in stunnel['pki'] else {} + + for mode, conf in stunnel.items(): + if mode not in ['server', 'client']: + continue + + for app, app_conf in conf.items(): + if 'ssl' in app_conf: + if 'certificate' in app_conf['ssl']: + cert_name = app_conf['ssl']['certificate'] + + pki_cert = stunnel['pki']['certificate'][cert_name] + cert_file_path = os.path.join(stunnel_dir, + f'{mode}-{app}-{cert_name}.pem') + cert_key_path = os.path.join(stunnel_dir, + f'{mode}-{app}-{cert_name}.pem.key') + app_conf['ssl']['cert'] = cert_file_path + + loaded_pki_cert = load_certificate(pki_cert['certificate']) + 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)) + current_files.append(cert_file_path) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + app_conf['ssl']['cert_key'] = cert_key_path + loaded_key = load_private_key(pki_cert['private']['key'], + passphrase=None, wrap_tags=True) + key_pem = encode_private_key(loaded_key, passphrase=None) + write_file(cert_key_path, key_pem, mode=0o600) + current_files.append(cert_key_path) + + if 'ca_certificate' in app_conf['ssl']: + app_conf['ssl']['ca_path'] = stunnel_ca_dir + app_conf['ssl']['ca_file'] = f'{mode}-{app}-ca.pem' + ca_cert_file_path = os.path.join(stunnel_ca_dir, app_conf['ssl']['ca_file']) + ca_chains = [] + + for ca_name in app_conf['ssl']['ca_certificate']: + pki_ca_cert = stunnel['pki']['ca'][ca_name] + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + ca_chains.append( + '\n'.join(encode_certificate(c) for c in ca_full_chain)) + + write_file(ca_cert_file_path, '\n'.join(ca_chains)) + current_files.append(ca_cert_file_path) + + if 'psk' in app_conf: + psk_data = list() + psk_file_path = os.path.join(stunnel_psk_dir, f'{mode}_{app}.txt') + + for _, psk_conf in app_conf['psk'].items(): + psk_data.append(f'{psk_conf["id"]}:{psk_conf["secret"]}') + + write_file(psk_file_path, '\n'.join(psk_data)) + app_conf['psk']['file'] = psk_file_path + current_files.append(psk_file_path) + + for file in exist_files: + if file not in current_files: + os.unlink(file) + + render(config_file, 'stunnel/stunnel_config.j2', stunnel) + + +def apply(stunnel): + if not stunnel or ('client' not in stunnel and 'server' not in stunnel): + call('systemctl stop stunnel.service') + else: + call('systemctl restart stunnel.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_suricata.py b/src/conf_mode/service_suricata.py new file mode 100755 index 000000000..1ce170145 --- /dev/null +++ b/src/conf_mode/service_suricata.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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.base import Warning +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 = '/run/suricata/suricata.yaml' +rotate_file = '/etc/logrotate.d/suricata' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'suricata'] + + if not conf.exists(base): + return None + + suricata = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_recursive_defaults=True) + + return suricata + +# https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search +def topological_sort(source): + sorted_nodes = [] + permanent_marks = set() + temporary_marks = set() + + def visit(n, v): + if n in permanent_marks: + return + if n in temporary_marks: + raise ConfigError('At least one cycle exists in the referenced groups') + + temporary_marks.add(n) + + for m in v.get('group', []): + m = m.lstrip('!').replace('-', '_') + if m not in source: + raise ConfigError(f'Undefined referenced group "{m}"') + visit(m, source[m]) + + temporary_marks.remove(n) + permanent_marks.add(n) + sorted_nodes.append((n, v)) + + while len(permanent_marks) < len(source): + n = next(n for n in source.keys() if n not in permanent_marks) + visit(n, source[n]) + + return sorted_nodes + +def verify(suricata): + if not suricata: + return None + + if 'interface' not in suricata: + raise ConfigError('No interfaces configured!') + + if 'address_group' not in suricata: + raise ConfigError('No address-group configured!') + + if 'port_group' not in suricata: + raise ConfigError('No port-group configured!') + + try: + topological_sort(suricata['address_group']) + except (ConfigError,StopIteration) as e: + raise ConfigError(f'Invalid address-group: {e}') + + try: + topological_sort(suricata['port_group']) + except (ConfigError,StopIteration) as e: + raise ConfigError(f'Invalid port-group: {e}') + +def generate(suricata): + if not suricata: + for file in [config_file, rotate_file]: + if os.path.isfile(file): + os.unlink(file) + + return None + + # Config-related formatters + def to_var(s:str): + return s.replace('-','_').upper() + + def to_val(s:str): + return s.replace('-',':') + + def to_ref(s:str): + if s[0] == '!': + return '!$' + to_var(s[1:]) + return '$' + to_var(s) + + def to_config(kind:str): + def format_group(group): + (name, value) = group + property = [to_val(property) for property in value.get(kind,[])] + group = [to_ref(group) for group in value.get('group',[])] + return (to_var(name), property + group) + return format_group + + # Format the address group + suricata['address_group'] = map(to_config('address'), + topological_sort(suricata['address_group'])) + + # Format the port group + suricata['port_group'] = map(to_config('port'), + topological_sort(suricata['port_group'])) + + render(config_file, 'ids/suricata.j2', {'suricata': suricata}) + render(rotate_file, 'ids/suricata_logrotate.j2', suricata) + return None + +def apply(suricata): + systemd_service = 'suricata.service' + if not suricata or 'interface' not in suricata: + # Stop suricata service if removed + call(f'systemctl stop {systemd_service}') + else: + Warning('To fetch the latest rules, use "update suricata"; ' + 'To periodically fetch the latest rules, ' + 'use the task scheduler!') + call(f'systemctl 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_webproxy.py b/src/conf_mode/service_webproxy.py index c40961bd5..12ae4135e 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -27,7 +27,7 @@ from vyos.utils.permission import chmod_755 from vyos.utils.dict import dict_search from vyos.utils.file import write_file from vyos.utils.network import is_addr_assigned -from vyos.base import Warning, DeprecationWarning +from vyos.base import Warning from vyos import ConfigError from vyos import airbag @@ -220,9 +220,6 @@ def generate(proxy): generate_sg_rule_localdb(category, list_type, rule, proxy) check_blacklist_categorydb(rule_config_section) - DeprecationWarning('URL filtering with SquidGuard is deprecated and ' - 'will be removed in the future VyOS versions.') - return None diff --git a/src/conf_mode/system_acceleration.py b/src/conf_mode/system_acceleration.py index e4b248675..d2cf44ff0 100755 --- a/src/conf_mode/system_acceleration.py +++ b/src/conf_mode/system_acceleration.py @@ -79,6 +79,9 @@ def verify(qat): if not data: raise ConfigError('No QAT acceleration device found') +def generate(qat): + return + def apply(qat): # Shutdown VPN service which can use QAT if 'ipsec' in qat: diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py index c681a8405..a3ce66512 100755 --- a/src/conf_mode/system_config-management.py +++ b/src/conf_mode/system_config-management.py @@ -22,6 +22,7 @@ from vyos.config import Config from vyos.config_mgmt import ConfigMgmt from vyos.config_mgmt import commit_post_hook_dir, commit_hooks + def get_config(config=None): if config: conf = config @@ -36,22 +37,32 @@ def get_config(config=None): return mgmt -def verify(_mgmt): + +def verify(mgmt): + if mgmt is None: + return + + d = mgmt.config_dict + confirm = d.get('commit_confirm', {}) + if confirm.get('action', '') == 'reload' and 'commit_revisions' not in d: + raise ConfigError('commit-confirm reload requires non-zero commit-revisions') + return + def generate(mgmt): if mgmt is None: return mgmt.initialize_revision() + def apply(mgmt): if mgmt is None: return locations = mgmt.locations - archive_target = os.path.join(commit_post_hook_dir, - commit_hooks['commit_archive']) + archive_target = os.path.join(commit_post_hook_dir, commit_hooks['commit_archive']) if locations: try: os.symlink('/usr/bin/config-mgmt', archive_target) @@ -68,8 +79,9 @@ def apply(mgmt): raise ConfigError from exc revisions = mgmt.max_revisions - revision_target = os.path.join(commit_post_hook_dir, - commit_hooks['commit_revision']) + revision_target = os.path.join( + commit_post_hook_dir, commit_hooks['commit_revision'] + ) if revisions > 0: try: os.symlink('/usr/bin/config-mgmt', revision_target) @@ -85,6 +97,7 @@ def apply(mgmt): except OSError as exc: raise ConfigError from exc + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py index 56d798e43..f25ed8d10 100755 --- a/src/conf_mode/system_conntrack.py +++ b/src/conf_mode/system_conntrack.py @@ -13,17 +13,19 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. - +import json import os from sys import exit +from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents 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.process import cmd +from vyos.utils.file import write_file +from vyos.utils.process import cmd, call from vyos.utils.process import rc_cmd from vyos.template import render from vyos import ConfigError @@ -33,6 +35,7 @@ airbag.enable() conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' nftables_ct_file = r'/run/nftables-ct.conf' +vyos_conntrack_logger_config = r'/run/vyos-conntrack-logger.conf' # Every ALG (Application Layer Gateway) consists of either a Kernel Object # also called a Kernel Module/Driver or some rules present in iptables @@ -55,6 +58,11 @@ module_map = { 'nftables': ['tcp dport {1723} ct helper set "pptp_tcp" return'], 'ipv4': True }, + 'rtsp': { + 'ko': ['nf_nat_rtsp', 'nf_conntrack_rtsp'], + 'nftables': ['tcp dport {554} ct helper set "rtsp_tcp" return'], + 'ipv4': True + }, 'sip': { 'ko': ['nf_nat_sip', 'nf_conntrack_sip'], 'nftables': ['tcp dport {5060,5061} ct helper set "sip_tcp" return', @@ -107,6 +115,7 @@ def get_config(config=None): return conntrack + def verify(conntrack): for inet in ['ipv4', 'ipv6']: if dict_search_args(conntrack, 'ignore', inet, 'rule') != None: @@ -160,6 +169,8 @@ 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') + if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): if 'protocol' not in rule_config: @@ -173,6 +184,11 @@ def generate(conntrack): if not os.path.exists(nftables_ct_file): conntrack['first_install'] = True + if 'log' not in conntrack: + # Remove old conntrack-logger config and return + if os.path.exists(vyos_conntrack_logger_config): + os.unlink(vyos_conntrack_logger_config) + # Determine if conntrack is needed conntrack['ipv4_firewall_action'] = 'return' conntrack['ipv6_firewall_action'] = 'return' @@ -191,12 +207,17 @@ def generate(conntrack): render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) + + if 'log' in conntrack: + log_conf_json = json.dumps(conntrack['log'], indent=4) + write_file(vyos_conntrack_logger_config, log_conf_json) + return None def apply(conntrack): # Depending on the enable/disable state of the ALG (Application Layer Gateway) # modules we need to either insmod or rmmod the helpers. - + add_modules = [] rm_modules = [] @@ -235,8 +256,14 @@ def apply(conntrack): # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080 cmd(f'sysctl -f {sysctl_file}') + if 'log' in conntrack: + call(f'systemctl restart vyos-conntrack-logger.service') + else: + call(f'systemctl stop vyos-conntrack-logger.service') + return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py index a12ee363d..925c4a562 100755 --- a/src/conf_mode/system_flow-accounting.py +++ b/src/conf_mode/system_flow-accounting.py @@ -18,7 +18,6 @@ import os import re from sys import exit -from ipaddress import ip_address from vyos.config import Config from vyos.config import config_dict_merge @@ -159,9 +158,9 @@ def get_config(config=None): # delete individual flow type defaults - should only be added if user # sets this feature - for flow_type in ['sflow', 'netflow']: - if flow_type not in flow_accounting and flow_type in default_values: - del default_values[flow_type] + flow_type = 'netflow' + if flow_type not in flow_accounting and flow_type in default_values: + del default_values[flow_type] flow_accounting = config_dict_merge(default_values, flow_accounting) @@ -171,9 +170,9 @@ def verify(flow_config): if not flow_config: return None - # check if at least one collector is enabled - if 'sflow' not in flow_config and 'netflow' not in flow_config and 'disable_imt' in flow_config: - raise ConfigError('You need to configure at least sFlow or NetFlow, ' \ + # 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 @@ -185,45 +184,7 @@ def verify(flow_config): for interface in flow_config['interface']: verify_interface_exists(flow_config, interface, warning_only=True) - # check sFlow configuration - if 'sflow' in flow_config: - # check if at least one sFlow collector is configured - if 'server' not in flow_config['sflow']: - raise ConfigError('You need to configure at least one sFlow server!') - - # check that all sFlow collectors use the same IP protocol version - sflow_collector_ipver = None - for server in flow_config['sflow']['server']: - if sflow_collector_ipver: - if sflow_collector_ipver != ip_address(server).version: - raise ConfigError("All sFlow servers must use the same IP protocol") - else: - sflow_collector_ipver = ip_address(server).version - - # check if vrf is defined for Sflow - verify_vrf(flow_config) - sflow_vrf = None - if 'vrf' in flow_config: - sflow_vrf = flow_config['vrf'] - - # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa - for server in flow_config['sflow']['server']: - if 'agent_address' in flow_config['sflow']: - if ip_address(server).version != ip_address(flow_config['sflow']['agent_address']).version: - raise ConfigError('IPv4 and IPv6 addresses can not be mixed in "sflow agent-address" and "sflow '\ - 'server". You need to set the same IP version for both "agent-address" and '\ - 'all sFlow servers') - - if 'agent_address' in flow_config['sflow']: - tmp = flow_config['sflow']['agent_address'] - if not is_addr_assigned(tmp, sflow_vrf): - raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') - - # Check if configured sflow source-address exist in the system - if 'source_address' in flow_config['sflow']: - if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf): - tmp = flow_config['sflow']['source_address'] - raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') + verify_vrf(flow_config) # check NetFlow configuration if 'netflow' in flow_config: diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py index 6173530ff..d9ac543d0 100755 --- a/src/conf_mode/system_frr.py +++ b/src/conf_mode/system_frr.py @@ -17,7 +17,7 @@ from sys import exit from vyos import ConfigError -from vyos import airbag +from vyos.base import Warning from vyos.config import Config from vyos.logger import syslog from vyos.template import render_to_string @@ -25,6 +25,8 @@ 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 import airbag airbag.enable() # path to daemons config and config status files @@ -63,10 +65,8 @@ def apply(frr_config): if boot_configuration_complete() and frr_config.get('config_file_changed'): # Since FRR restart is not safe thing, better to give # control over this to users - print(''' - You need to reboot a router (preferred) or restart FRR - to apply changes in modules settings - ''') + Warning('You need to reboot the router (preferred) or restart '\ + 'FRR to apply changes in modules settings') # restart FRR automatically # During initial boot this should be safe in most cases diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index 3f245f166..fef034d1c 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-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,6 +23,7 @@ import vyos.hostsd_client from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed +from vyos.defaults import systemd_services from vyos.ifconfig import Section from vyos.template import is_ip from vyos.utils.process import cmd @@ -174,11 +175,13 @@ def apply(config): # Restart services that use the hostname if hostname_new != hostname_old: - call("systemctl restart rsyslog.service") + tmp = systemd_services['rsyslog'] + call(f'systemctl restart {tmp}') # If SNMP is running, restart it too if process_named_running('snmpd') and config['snmpd_restart_reqired']: - call('systemctl restart snmpd.service') + tmp = systemd_services['snmpd'] + call(f'systemctl restart {tmp}') return None diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py index c8a91fd2f..7f3796168 100755 --- a/src/conf_mode/system_ip.py +++ b/src/conf_mode/system_ip.py @@ -17,17 +17,17 @@ from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_route_map -from vyos.template import render_to_string +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_active +from vyos.utils.process import is_systemd_service_running 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 frr from vyos import airbag airbag.enable() @@ -36,42 +36,36 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['system', 'ip'] - - opt = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - # When working with FRR we need to know the corresponding address-family - opt['afi'] = 'ip' - - # We also need the route-map information from the config - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], - get_first_key=True)}} - # Merge policy dict into "regular" config dict - opt = dict_merge(tmp, opt) # If IPv4 ARP table size is set here and also manually in sysctl, the more # fine grained value from sysctl must win set_dependents('sysctl', conf) + return get_frrender_dict(conf) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'ip'): + return None - return opt + opt = config_dict['ip'] + opt['policy'] = config_dict['policy'] -def verify(opt): if 'protocol' in opt: for protocol, protocol_options in opt['protocol'].items(): if 'route_map' in protocol_options: verify_route_map(protocol_options['route_map'], opt) return -def generate(opt): - opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt) - return +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, 'ip'): + + return None + opt = config_dict['ip'] -def apply(opt): # Apply ARP threshold values # table_size has a default value - thus the key always exists size = int(dict_search('arp.table_size', opt)) @@ -82,11 +76,6 @@ def apply(opt): # Minimum number of stored records is indicated which is not cleared sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8) - # enable/disable IPv4 forwarding - tmp = dict_search('disable_forwarding', opt) - value = '0' if (tmp != None) else '1' - write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) - # configure multipath tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) value = '1' if (tmp != None) else '0' @@ -121,19 +110,11 @@ def apply(opt): # running when this script is called first. Skip this part and wait for initial # commit of the configuration to trigger this statement if is_systemd_service_active('frr.service'): - zebra_daemon = 'zebra' - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(r'no ip nht resolve-via-default') - frr_cfg.modify_section(r'ip protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') - if 'frr_zebra_config' in opt: - frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() call_dependents() + return None if __name__ == '__main__': try: diff --git a/src/conf_mode/system_ipv6.py b/src/conf_mode/system_ipv6.py index a2442d009..309869b2f 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-2023 VyOS maintainers and contributors +# Copyright (C) 2019-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,17 +18,18 @@ import os from sys import exit from vyos.config import Config -from vyos.configdict import dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_route_map -from vyos.template import render_to_string +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_active +from vyos.utils.process import is_systemd_service_running 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 frr from vyos import airbag airbag.enable() @@ -37,42 +38,35 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['system', 'ipv6'] - - opt = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - # When working with FRR we need to know the corresponding address-family - opt['afi'] = 'ipv6' - - # We also need the route-map information from the config - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], - get_first_key=True)}} - # Merge policy dict into "regular" config dict - opt = dict_merge(tmp, opt) # If IPv6 neighbor table size is set here and also manually in sysctl, the more # fine grained value from sysctl must win set_dependents('sysctl', conf) + return get_frrender_dict(conf) + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'ipv6'): + return None - return opt + opt = config_dict['ipv6'] + opt['policy'] = config_dict['policy'] -def verify(opt): if 'protocol' in opt: for protocol, protocol_options in opt['protocol'].items(): if 'route_map' in protocol_options: verify_route_map(protocol_options['route_map'], opt) return -def generate(opt): - opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt) - return +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, 'ipv6'): + return None + opt = config_dict['ipv6'] -def apply(opt): # configure multipath tmp = dict_search('multipath.layer4_hashing', opt) value = '1' if (tmp != None) else '0' @@ -88,11 +82,6 @@ def apply(opt): # Minimum number of stored records is indicated which is not cleared sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) - # enable/disable IPv6 forwarding - tmp = dict_search('disable_forwarding', opt) - value = '0' if (tmp != None) else '1' - write_file('/proc/sys/net/ipv6/conf/all/forwarding', value) - # configure IPv6 strict-dad tmp = dict_search('strict_dad', opt) value = '2' if (tmp != None) else '1' @@ -105,19 +94,11 @@ def apply(opt): # running when this script is called first. Skip this part and wait for initial # commit of the configuration to trigger this statement if is_systemd_service_active('frr.service'): - zebra_daemon = 'zebra' - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(r'no ipv6 nht resolve-via-default') - frr_cfg.modify_section(r'ipv6 protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') - if 'frr_zebra_config' in opt: - frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() call_dependents() + return None if __name__ == '__main__': try: diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index de02a64c6..d3a969d9b 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-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,14 +26,15 @@ from time import sleep from vyos.config import Config from vyos.configverify import verify_vrf -from vyos.defaults import directories from vyos.template import render from vyos.template import is_ipv4 +from vyos.utils.auth import get_current_user +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.process import cmd from vyos.utils.process import call -from vyos.utils.process import rc_cmd from vyos.utils.process import run from vyos.utils.process import DEVNULL from vyos import ConfigError @@ -57,20 +58,21 @@ MAX_RADIUS_TIMEOUT: int = 50 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(): +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_USER_UID: + if getpwnam(s_user.pw_name).pw_uid < min_uid: continue - if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID: + if getpwnam(s_user.pw_name).pw_uid > max_uid: continue if s_user.pw_name in SYSTEM_USER_SKIP_LIST: continue @@ -118,6 +120,12 @@ def get_config(config=None): rm_users = [tmp for tmp in all_users if tmp not in cli_users] if rm_users: login.update({'rm_users' : rm_users}) + # Build TACACS user mapping + if 'tacacs' in login: + login['exclude_users'] = get_local_users(min_uid=0, + max_uid=MIN_TACACS_UID) + cli_users + login['tacacs_min_uid'] = MIN_TACACS_UID + return login def verify(login): @@ -125,10 +133,9 @@ def verify(login): # This check is required as the script is also executed from vyos-router # init script and there is no SUDO_USER environment variable available # during system boot. - if 'SUDO_USER' in os.environ: - cur_user = os.environ['SUDO_USER'] - if cur_user in login['rm_users']: - raise ConfigError(f'Attempting to delete current user: {cur_user}') + tmp = get_current_user() + if tmp in login['rm_users']: + raise ConfigError(f'Attempting to delete current user: {tmp}') if 'user' in login: system_users = getpwall() @@ -221,35 +228,13 @@ def generate(login): login['user'][user]['authentication']['encrypted_password'] = encrypted_password del login['user'][user]['authentication']['plaintext_password'] - # remove old plaintext password and set new encrypted password - env = os.environ.copy() - env['vyos_libexec_dir'] = directories['base'] - # Set default commands for re-adding user with encrypted password - del_user_plain = f"system login user {user} authentication plaintext-password" - add_user_encrypt = f"system login user {user} authentication encrypted-password '{encrypted_password}'" - - lvl = env['VYATTA_EDIT_LEVEL'] - # We're in config edit level, for example "edit system login" - # Change default commands for re-adding user with encrypted password - if lvl != '/': - # Replace '/system/login' to 'system login' - lvl = lvl.strip('/').split('/') - # Convert command str to list - del_user_plain = del_user_plain.split() - # New command exclude level, for example "edit system login" - del_user_plain = del_user_plain[len(lvl):] - # Convert string to list - del_user_plain = " ".join(del_user_plain) - - add_user_encrypt = add_user_encrypt.split() - add_user_encrypt = add_user_encrypt[len(lvl):] - add_user_encrypt = " ".join(add_user_encrypt) - - ret, out = rc_cmd(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) - if ret: raise ConfigError(out) - ret, out = rc_cmd(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) - if ret: raise ConfigError(out) + del_user_plain = ['system', 'login', 'user', user, 'authentication', 'plaintext-password'] + add_user_encrypt = ['system', 'login', 'user', user, 'authentication', 'encrypted-password'] + + delete_cli_node(del_user_plain) + add_cli_node(add_user_encrypt, value=encrypted_password) + else: try: if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): @@ -283,8 +268,6 @@ def generate(login): if os.path.isfile(tacacs_nss_config_file): os.unlink(tacacs_nss_config_file) - - # NSS must always be present on the system render(nss_config_file, 'login/nsswitch.conf.j2', login, permission=0o644, user='root', group='root') @@ -333,7 +316,7 @@ def apply(login): if tmp: command += f" --home '{tmp}'" else: command += f" --home '/home/{user}'" - command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {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 diff --git a/src/conf_mode/system_login_banner.py b/src/conf_mode/system_login_banner.py index 923e1bf57..5826d8042 100755 --- a/src/conf_mode/system_login_banner.py +++ b/src/conf_mode/system_login_banner.py @@ -28,6 +28,7 @@ airbag.enable() PRELOGIN_FILE = r'/etc/issue' PRELOGIN_NET_FILE = r'/etc/issue.net' POSTLOGIN_FILE = r'/etc/motd' +POSTLOGIN_VYOS_FILE = r'/run/motd.d/01-vyos-nonproduction' default_config_data = { 'issue': 'Welcome to VyOS - \\n \\l\n\n', @@ -94,6 +95,9 @@ def apply(banner): render(POSTLOGIN_FILE, 'login/default_motd.j2', banner, permission=0o644, user='root', group='root') + render(POSTLOGIN_VYOS_FILE, 'login/motd_vyos_nonproduction.j2', banner, + permission=0o644, user='root', group='root') + return None if __name__ == '__main__': diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index 52d0b7cda..064a1aa91 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -19,11 +19,13 @@ import os from sys import exit from time import sleep + 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.cpu import get_cpus from vyos.utils.dict import dict_search from vyos.utils.file import write_file from vyos.utils.kernel import check_kmod @@ -35,6 +37,7 @@ from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos import ConfigError from vyos import airbag + airbag.enable() curlrc_config = r'/etc/curlrc' @@ -42,20 +45,25 @@ ssh_config = r'/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf' systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target' usb_autosuspend = r'/etc/udev/rules.d/40-usb-autosuspend.rules' kernel_dynamic_debug = r'/sys/kernel/debug/dynamic_debug/control' -time_format_to_locale = { - '12-hour': 'en_US.UTF-8', - '24-hour': 'en_GB.UTF-8' +time_format_to_locale = {'12-hour': 'en_US.UTF-8', '24-hour': 'en_GB.UTF-8'} +tuned_profiles = { + 'power-save': 'powersave', + 'network-latency': 'network-latency', + 'network-throughput': 'network-throughput', + 'virtual-guest': 'virtual-guest', + 'virtual-host': 'virtual-host', } + def get_config(config=None): if config: conf = config else: conf = Config() base = ['system', 'option'] - options = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) + options = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) if 'performance' in options: # Update IPv4/IPv6 and sysctl options after tuned applied it's settings @@ -64,6 +72,7 @@ def get_config(config=None): return options + def verify(options): if 'http_client' in options: config = options['http_client'] @@ -71,11 +80,13 @@ def verify(options): verify_interface_exists(options, config['source_interface']) if {'source_address', 'source_interface'} <= set(config): - raise ConfigError('Can not define both HTTP source-interface and source-address') + raise ConfigError( + 'Can not define both HTTP source-interface and source-address' + ) if 'source_address' in config: if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with give address specified!') + raise ConfigError('No interface with given address specified!') if 'ssh_client' in options: config = options['ssh_client'] @@ -92,10 +103,20 @@ def verify(options): address = config['source_address'] interface = config['source_interface'] if not is_intf_addr_assigned(interface, address): - raise ConfigError(f'Address "{address}" not assigned on interface "{interface}"!') + raise ConfigError( + f'Address "{address}" not assigned on interface "{interface}"!' + ) + + if 'kernel' in options: + cpu_vendor = get_cpus()[0]['vendor_id'] + if 'amd_pstate_driver' in options['kernel'] and cpu_vendor != 'AuthenticAMD': + raise ConfigError( + f'AMD pstate driver cannot be used with "{cpu_vendor}" CPU!' + ) return None + def generate(options): render(curlrc_config, 'system/curlrc.j2', options) render(ssh_config, 'system/ssh_config.j2', options) @@ -107,10 +128,16 @@ def generate(options): cmdline_options.append('mitigations=off') if 'disable_power_saving' in options['kernel']: cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1') + if 'amd_pstate_driver' in options['kernel']: + mode = options['kernel']['amd_pstate_driver'] + cmdline_options.append( + f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}' + ) grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) return None + def apply(options): # System bootup beep beep_service = 'vyos-beep.service' @@ -149,9 +176,12 @@ def apply(options): if 'performance' in options: cmd('systemctl restart tuned.service') # wait until daemon has started before sending configuration - while (not is_systemd_service_running('tuned.service')): + while not is_systemd_service_running('tuned.service'): sleep(0.250) - cmd('tuned-adm profile network-{performance}'.format(**options)) + performance = ' '.join( + list(tuned_profiles[profile] for profile in options['performance']) + ) + cmd(f'tuned-adm profile {performance}') else: cmd('systemctl stop tuned.service') @@ -164,9 +194,9 @@ def apply(options): # Enable/diable root-partition-auto-resize SystemD service if 'root_partition_auto_resize' in options: - cmd('systemctl enable root-partition-auto-resize.service') + cmd('systemctl enable root-partition-auto-resize.service') else: - cmd('systemctl disable root-partition-auto-resize.service') + cmd('systemctl disable root-partition-auto-resize.service') # Time format 12|24-hour if 'time_format' in options: @@ -186,6 +216,7 @@ def apply(options): else: write_file(kernel_dynamic_debug, f'module {module} -p') + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index 41119b494..a22dac36f 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -54,7 +54,7 @@ def verify(sflow): # Check if configured sflow agent-address exist in the system if 'agent_address' in sflow: tmp = sflow['agent_address'] - if not is_addr_assigned(tmp): + if not is_addr_assigned(tmp, include_vrf=True): raise ConfigError( f'Configured "sflow agent-address {tmp}" does not exist in the system!' ) diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py index eb2f02eb3..414bd4b6b 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-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,17 +20,22 @@ from sys import exit from vyos.base import Warning from vyos.config import Config -from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf +from vyos.defaults import systemd_services +from vyos.utils.network import is_addr_assigned from vyos.utils.process import call from vyos.template import render +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 from vyos import ConfigError from vyos import airbag airbag.enable() -rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf' +rsyslog_conf = '/run/rsyslog/rsyslog.conf' logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' -systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf' + +systemd_socket = 'syslog.socket' +systemd_service = systemd_services['rsyslog'] def get_config(config=None): if config: @@ -46,23 +51,17 @@ def get_config(config=None): syslog.update({ 'logrotate' : logrotate_conf }) - tmp = is_node_changed(conf, base + ['vrf']) - if tmp: syslog.update({'restart_required': {}}) - syslog = conf.merge_defaults(syslog, recursive=True) - if syslog.from_defaults(['global']): - del syslog['global'] - - if ( - 'global' in syslog - and 'preserve_fqdn' in syslog['global'] - and conf.exists(['system', 'host-name']) - and conf.exists(['system', 'domain-name']) - ): - hostname = conf.return_value(['system', 'host-name']) - domain = conf.return_value(['system', 'domain-name']) - fqdn = f'{hostname}.{domain}' - syslog['global']['local_host_name'] = fqdn + if syslog.from_defaults(['local']): + del syslog['local'] + + if 'preserve_fqdn' in syslog: + if conf.exists(['system', 'host-name']): + tmp = conf.return_value(['system', 'host-name']) + syslog['preserve_fqdn']['host_name'] = tmp + if conf.exists(['system', 'domain-name']): + tmp = conf.return_value(['system', 'domain-name']) + syslog['preserve_fqdn']['domain_name'] = tmp return syslog @@ -70,13 +69,33 @@ def verify(syslog): if not syslog: return None - if 'host' in syslog: - for host, host_options in syslog['host'].items(): - if 'protocol' in host_options and host_options['protocol'] == 'udp': - if 'format' in host_options and 'octet_counted' in host_options['format']: - Warning(f'Syslog UDP transport for "{host}" should not use octet-counted format!') - - verify_vrf(syslog) + if 'preserve_fqdn' in syslog: + if 'host_name' not in syslog['preserve_fqdn']: + Warning('No "system host-name" defined - cannot set syslog FQDN!') + if 'domain_name' not in syslog['preserve_fqdn']: + Warning('No "system domain-name" defined - cannot set syslog FQDN!') + + if 'remote' in syslog: + for remote, remote_options in syslog['remote'].items(): + if 'protocol' in remote_options and remote_options['protocol'] == 'udp': + if 'format' in remote_options and 'octet_counted' in remote_options['format']: + Warning(f'Syslog UDP transport for "{remote}" should not use octet-counted format!') + + if 'vrf' in remote_options: + verify_vrf(remote_options) + + if 'source_address' in remote_options: + vrf = None + if 'vrf' in remote_options: + vrf = remote_options['vrf'] + if not is_addr_assigned(remote_options['source_address'], vrf): + raise ConfigError('No interface with given address specified!') + + source_address = remote_options['source_address'] + if ((is_ipv4(remote) and is_ipv6(source_address)) or + (is_ipv6(remote) and is_ipv4(source_address))): + raise ConfigError(f'Source-address "{source_address}" does not match '\ + f'address-family of remote "{remote}"!') def generate(syslog): if not syslog: @@ -88,26 +107,15 @@ def generate(syslog): return None render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog) - render(systemd_override, 'rsyslog/override.conf.j2', syslog) render(logrotate_conf, 'rsyslog/logrotate.j2', syslog) - - # Reload systemd manager configuration - call('systemctl daemon-reload') return None def apply(syslog): - systemd_socket = 'syslog.socket' - systemd_service = 'syslog.service' if not syslog: call(f'systemctl stop {systemd_service} {systemd_socket}') return None - # we need to restart the service if e.g. the VRF name changed - systemd_action = 'reload-or-restart' - if 'restart_required' in syslog: - systemd_action = 'restart' - - call(f'systemctl {systemd_action} {systemd_service}') + call(f'systemctl reload-or-restart {systemd_service}') return None if __name__ == '__main__': diff --git a/src/conf_mode/system_wireless.py b/src/conf_mode/system_wireless.py new file mode 100644 index 000000000..e0ca0ab8e --- /dev/null +++ b/src/conf_mode/system_wireless.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'wireless'] + interface_base = ['interfaces', 'wireless'] + + wireless = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) + + + if conf.exists(interface_base): + wireless['interfaces'] = conf.list_nodes(interface_base) + for interface in wireless['interfaces']: + set_dependents('wireless', conf, interface) + + return wireless + +def verify(wireless): + pass + +def generate(wireless): + pass + +def apply(wireless): + if 'interfaces' in wireless: + call_dependents() + pass + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 65dd458ec..25604d2a2 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-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,12 +21,15 @@ import jmespath from sys import exit from time import sleep +from ipaddress import ip_address +from netaddr import IPNetwork +from netaddr import IPRange -from vyos.base import Warning from vyos.config import Config from vyos.config import config_dict_merge 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_interface_exists from vyos.configverify import dynamic_interface_pattern @@ -47,6 +50,9 @@ from vyos.utils.network import interface_exists from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import call +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 import airbag airbag.enable() @@ -80,8 +86,6 @@ def get_config(config=None): conf = Config() base = ['vpn', 'ipsec'] l2tp_base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings'] - if not conf.exists(base): - return None # retrieve common dictionary keys ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), @@ -89,6 +93,14 @@ def get_config(config=None): get_first_key=True, with_pki=True) + ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) + if ipsec['nhrp_exists']: + set_dependents('nhrp', conf) + + if not conf.exists(base): + ipsec.update({'deleted' : ''}) + return ipsec + # We have to cleanup the default dict, as default values could # enable features which are not explicitly enabled on the # CLI. E.g. dead-peer-detection defaults should not be injected @@ -104,10 +116,11 @@ def get_config(config=None): ipsec = config_dict_merge(default_values, ipsec) ipsec['dhcp_interfaces'] = set() + ipsec['enabled_vti_interfaces'] = set() + ipsec['persistent_vti_interfaces'] = set() ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) - ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) if ipsec['nhrp_exists']: set_dependents('nhrp', conf) @@ -121,6 +134,28 @@ 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 + # case we need to bring the interface up + ipsec['vti_interface_dicts'] = {} + + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + if 'vti' in peer_conf: + if 'bind' in peer_conf['vti']: + vti_interface = peer_conf['vti']['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + + if 'remote_access' in ipsec: + if 'connection' in ipsec['remote_access']: + for name, ra_conf in ipsec['remote_access']['connection'].items(): + if 'bind' in ra_conf: + vti_interface = ra_conf['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + return ipsec def get_dhcp_address(iface): @@ -166,8 +201,8 @@ def verify_pki_rsa(pki, rsa_conf): return True def verify(ipsec): - if not ipsec: - return None + if not ipsec or 'deleted' in ipsec: + return if 'authentication' in ipsec: if 'psk' in ipsec['authentication']: @@ -184,6 +219,19 @@ def verify(ipsec): else: verify_interface_exists(ipsec, interface) + # need to use a pseudo-random function (PRF) with an authenticated encryption algorithm. + # If a hash algorithm is defined then it will be mapped to an equivalent PRF + if 'ike_group' in ipsec: + for _, ike_config in ipsec['ike_group'].items(): + for proposal, proposal_config in ike_config.get('proposal', {}).items(): + if 'encryption' in proposal_config and 'prf' not in proposal_config: + # list of hash algorithms that cannot be mapped to an equivalent PRF + algs = ['aes128gmac', 'aes192gmac', 'aes256gmac', 'sha256_96'] + if 'hash' in proposal_config and proposal_config['hash'] in algs: + raise ConfigError( + f"A PRF algorithm is mandatory in IKE proposal {proposal}" + ) + if 'l2tp' in ipsec: if 'esp_group' in ipsec['l2tp']: if 'esp_group' not in ipsec or ipsec['l2tp']['esp_group'] not in ipsec['esp_group']: @@ -249,7 +297,8 @@ def verify(ipsec): if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on remote-access connection {name}") - ipsec['dhcp_interfaces'].add(dhcp_interface) + if 'disable' not in ra_conf: + ipsec['dhcp_interfaces'].add(dhcp_interface) address = get_dhcp_address(dhcp_interface) count = 0 @@ -304,6 +353,16 @@ def verify(ipsec): if dict_search('remote_access.radius.server', ipsec) == None: raise ConfigError('RADIUS authentication requires at least one server') + if 'bind' in ra_conf: + vti_interface = ra_conf['bind'] + if not interface_exists(vti_interface): + raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!') + + if 'disable' not in ra_conf: + ipsec['enabled_vti_interfaces'].add(vti_interface) + # remote access VPN interfaces are always up regardless of whether clients are connected + ipsec['persistent_vti_interfaces'].add(vti_interface) + if 'pool' in ra_conf: if {'dhcp', 'radius'} <= set(ra_conf['pool']): raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\ @@ -330,26 +389,73 @@ def verify(ipsec): raise ConfigError(f'Requested pool "{pool}" does not exist!') if 'pool' in ipsec['remote_access']: + pool_networks = [] for pool, pool_config in ipsec['remote_access']['pool'].items(): - if 'prefix' not in pool_config: - raise ConfigError(f'Missing madatory prefix option for pool "{pool}"!') + if 'prefix' not in pool_config and 'range' not in pool_config: + raise ConfigError(f'Mandatory prefix or range must be specified for pool "{pool}"!') + + if 'prefix' in pool_config and 'range' in pool_config: + raise ConfigError(f'Only one of prefix or range can be specified for pool "{pool}"!') + + if 'prefix' in pool_config: + range_is_ipv4 = is_ipv4(pool_config['prefix']) + range_is_ipv6 = is_ipv6(pool_config['prefix']) + + net = IPNetwork(pool_config['prefix']) + start = net.first + stop = net.last + for network in pool_networks: + if start in network or stop in network: + raise ConfigError(f'Prefix for pool "{pool}" is already part of another pool\'s range!') + + tmp = IPRange(start, stop) + pool_networks.append(tmp) + + if 'range' in pool_config: + range_config = pool_config['range'] + if not {'start', 'stop'} <= set(range_config.keys()): + raise ConfigError(f'Range start and stop address must be defined for pool "{pool}"!') + + range_both_ipv4 = is_ipv4(range_config['start']) and is_ipv4(range_config['stop']) + range_both_ipv6 = is_ipv6(range_config['start']) and is_ipv6(range_config['stop']) + + if not (range_both_ipv4 or range_both_ipv6): + raise ConfigError(f'Range start and stop must be of the same address family for pool "{pool}"!') + + if ip_address(range_config['stop']) < ip_address(range_config['start']): + raise ConfigError(f'Range stop address must be greater or equal\n' \ + 'to the range\'s start address for pool "{pool}"!') + + range_is_ipv4 = is_ipv4(range_config['start']) + range_is_ipv6 = is_ipv6(range_config['start']) + + start = range_config['start'] + stop = range_config['stop'] + for network in pool_networks: + if start in network: + raise ConfigError(f'Range "{range}" start address "{start}" already part of another pool\'s range!') + if stop in network: + raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another pool\'s range!') + + tmp = IPRange(start, stop) + pool_networks.append(tmp) if 'name_server' in pool_config: if len(pool_config['name_server']) > 2: raise ConfigError(f'Only two name-servers are supported for remote-access pool "{pool}"!') for ns in pool_config['name_server']: - v4_addr_and_ns = is_ipv4(ns) and not is_ipv4(pool_config['prefix']) - v6_addr_and_ns = is_ipv6(ns) and not is_ipv6(pool_config['prefix']) + v4_addr_and_ns = is_ipv4(ns) and not range_is_ipv4 + v6_addr_and_ns = is_ipv6(ns) and not range_is_ipv6 if v4_addr_and_ns or v6_addr_and_ns: - raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and name-server adresses!') + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and name-server addresses!') if 'exclude' in pool_config: for exclude in pool_config['exclude']: - v4_addr_and_exclude = is_ipv4(exclude) and not is_ipv4(pool_config['prefix']) - v6_addr_and_exclude = is_ipv6(exclude) and not is_ipv6(pool_config['prefix']) + v4_addr_and_exclude = is_ipv4(exclude) and not range_is_ipv4 + v6_addr_and_exclude = is_ipv6(exclude) and not range_is_ipv6 if v4_addr_and_exclude or v6_addr_and_exclude: - raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and exclude prefixes!') + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and exclude prefixes!') if 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: for server, server_config in ipsec['remote_access']['radius']['server'].items(): @@ -420,7 +526,8 @@ def verify(ipsec): if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}") - ipsec['dhcp_interfaces'].add(dhcp_interface) + if 'disable' not in peer_conf: + ipsec['dhcp_interfaces'].add(dhcp_interface) address = get_dhcp_address(dhcp_interface) count = 0 @@ -438,14 +545,12 @@ def verify(ipsec): if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") - if dict_search('options.disable_route_autoinstall', - ipsec) == None: - Warning('It\'s recommended to use ipsec vti with the next command\n[set vpn ipsec option disable-route-autoinstall]') - if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') + if 'disable' not in peer_conf: + ipsec['enabled_vti_interfaces'].add(vti_interface) if 'vti' not in peer_conf and 'tunnel' not in peer_conf: raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}") @@ -524,7 +629,7 @@ def generate_pki_files_rsa(pki, rsa_conf): def generate(ipsec): cleanup_pki_files() - if not ipsec: + if not ipsec or 'deleted' in ipsec: for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]: if os.path.isfile(config_file): os.unlink(config_file) @@ -621,11 +726,20 @@ def generate(ipsec): def apply(ipsec): systemd_service = 'strongswan.service' - if not ipsec: + if not ipsec or 'deleted' in ipsec: call(f'systemctl stop {systemd_service}') + if vti_updown_db_exists(): + remove_vti_updown_db() else: call(f'systemctl reload-or-restart {systemd_service}') - + if ipsec['enabled_vti_interfaces']: + with open_vti_updown_db_for_create_or_update() as db: + db.removeAllOtherInterfaces(ipsec['enabled_vti_interfaces']) + db.setPersistentInterfaces(ipsec['persistent_vti_interfaces']) + db.commit(lambda interface: ipsec['vti_interface_dicts'][interface]) + elif vti_updown_db_exists(): + remove_vti_updown_db() + if ipsec: if ipsec.get('nhrp_exists', False): try: call_dependents() @@ -634,7 +748,6 @@ def apply(ipsec): # ConfigError("ConfigError('Interface ethN requires an IP address!')") pass - if __name__ == '__main__': try: ipsec = get_config() diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 72b178c89..8baf55857 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -19,23 +19,23 @@ from jmespath import search from json import loads from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configverify import verify_route_map from vyos.firewall import conntrack_required +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.ifconfig import Interface from vyos.template import render -from vyos.template import render_to_string from vyos.utils.dict import dict_search from vyos.utils.network import get_vrf_tableid from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists from vyos.utils.process import call from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_running from vyos.utils.process import popen from vyos.utils.system import sysctl_write from vyos import ConfigError -from vyos import frr from vyos import airbag airbag.enable() @@ -132,15 +132,9 @@ def get_config(config=None): if 'name' in vrf: vrf['conntrack'] = conntrack_required(conf) - # We also need the route-map information from the config - # - # XXX: one MUST always call this without the key_mangling() option! See - # vyos.configverify.verify_common_route_maps() for more information. - tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'], - get_first_key=True)}} - - # Merge policy dict into "regular" config dict - vrf = dict_merge(tmp, vrf) + # We need to merge the FRR rendering dict into the VRF dict + # this is required to get the route-map information to FRR + vrf.update({'frr_dict' : get_frrender_dict(conf)}) return vrf def verify(vrf): @@ -155,9 +149,11 @@ def verify(vrf): f'static routes installed!') if 'name' in vrf: - reserved_names = ["add", "all", "broadcast", "default", "delete", "dev", - "get", "inet", "mtu", "link", "type", "vrf"] + reserved_names = ['add', 'all', 'broadcast', 'default', 'delete', 'dev', + 'down', 'get', 'inet', 'link', 'mtu', 'type', 'up', 'vrf'] + table_ids = [] + vnis = [] for name, vrf_config in vrf['name'].items(): # Reserved VRF names if name in reserved_names: @@ -178,17 +174,24 @@ def verify(vrf): raise ConfigError(f'VRF "{name}" table id is not unique!') table_ids.append(vrf_config['table']) + # VRF VNIs must be unique on the system + if 'vni' in vrf_config: + vni = vrf_config['vni'] + if vni in vnis: + raise ConfigError(f'VRF "{name}" VNI "{vni}" is not unique!') + vnis.append(vni) + tmp = dict_search('ip.protocol', vrf_config) if tmp != None: for protocol, protocol_options in tmp.items(): if 'route_map' in protocol_options: - verify_route_map(protocol_options['route_map'], vrf) + verify_route_map(protocol_options['route_map'], vrf['frr_dict']) tmp = dict_search('ipv6.protocol', vrf_config) if tmp != None: for protocol, protocol_options in tmp.items(): if 'route_map' in protocol_options: - verify_route_map(protocol_options['route_map'], vrf) + verify_route_map(protocol_options['route_map'], vrf['frr_dict']) return None @@ -196,8 +199,9 @@ def verify(vrf): def generate(vrf): # Render iproute2 VR helper names render(config_file, 'iproute2/vrf.conf.j2', vrf) - # Render VRF Kernel/Zebra route-map filters - vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) + + if 'frr_dict' in vrf and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(vrf['frr_dict']) return None @@ -339,17 +343,8 @@ def apply(vrf): if has_rule(afi, 2000, 'l3mdev'): call(f'ip {afi} rule del pref 2000 l3mdev unreachable') - # Apply FRR filters - zebra_daemon = 'zebra' - # Save original configuration prior to starting any commit actions - frr_cfg = frr.FRRConfig() - - # The route-map used for the FIB (zebra) is part of the zebra daemon - frr_cfg.load_configuration(zebra_daemon) - frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True) - if 'frr_zebra_config' in vrf: - frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config']) - frr_cfg.commit_configuration(zebra_daemon) + if 'frr_dict' in vrf and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None |