diff options
author | Georg <georg@lysergic.dev> | 2022-04-08 14:52:37 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-04-08 14:52:37 +0000 |
commit | 630945291c9a389ad62fd32caea3749f4c5e9d72 (patch) | |
tree | a85f72880269bfb43740b7a0bc790dcaca6de1e7 /src/conf_mode | |
parent | 15461be0cd7b51e0e290d66bae0bb112f6b2c3ea (diff) | |
parent | 654dbc9aa3b0d27ec4f3faefff6cbd85fc3e1d1a (diff) | |
download | vyos-1x-630945291c9a389ad62fd32caea3749f4c5e9d72.tar.gz vyos-1x-630945291c9a389ad62fd32caea3749f4c5e9d72.zip |
Merge branch 'current' into dhcpd
Diffstat (limited to 'src/conf_mode')
46 files changed, 1363 insertions, 640 deletions
diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py index c65ef9540..aabf2bdf5 100755 --- a/src/conf_mode/conntrack.py +++ b/src/conf_mode/conntrack.py @@ -35,6 +35,7 @@ airbag.enable() conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' +nftables_ct_file = r'/run/nftables-ct.conf' # Every ALG (Application Layer Gateway) consists of either a Kernel Object # also called a Kernel Module/Driver or some rules present in iptables @@ -81,16 +82,35 @@ def get_config(config=None): # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = defaults(base) + # XXX: T2665: we can not safely rely on the defaults() when there are + # tagNodes in place, it is better to blend in the defaults manually. + if 'timeout' in default_values and 'custom' in default_values['timeout']: + del default_values['timeout']['custom'] conntrack = dict_merge(default_values, conntrack) return conntrack def verify(conntrack): + if dict_search('ignore.rule', conntrack) != None: + for rule, rule_config in conntrack['ignore']['rule'].items(): + if dict_search('destination.port', rule_config) or \ + dict_search('source.port', rule_config): + if 'protocol' not in rule_config or rule_config['protocol'] not in ['tcp', 'udp']: + raise ConfigError(f'Port requires tcp or udp as protocol in rule {rule}') + return None def generate(conntrack): render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.tmpl', conntrack) render(sysctl_file, 'conntrack/sysctl.conf.tmpl', conntrack) + render(nftables_ct_file, 'conntrack/nftables-ct.tmpl', conntrack) + + # dry-run newly generated configuration + tmp = run(f'nft -c -f {nftables_ct_file}') + if tmp > 0: + if os.path.exists(nftables_ct_file): + os.unlink(nftables_ct_file) + raise ConfigError('Configuration file errors encountered!') return None @@ -127,6 +147,9 @@ def apply(conntrack): if not find_nftables_ct_rule(rule): cmd(f'nft insert rule ip raw VYOS_CT_HELPER {rule}') + # Load new nftables ruleset + cmd(f'nft -f {nftables_ct_file}') + if process_named_running('conntrackd'): # Reload conntrack-sync daemon to fetch new sysctl values resync_conntrackd() diff --git a/src/conf_mode/conntrack_sync.py b/src/conf_mode/conntrack_sync.py index 8f9837c2b..34d1f7398 100755 --- a/src/conf_mode/conntrack_sync.py +++ b/src/conf_mode/conntrack_sync.py @@ -93,9 +93,9 @@ def verify(conntrack): raise ConfigError('Can not configure expect-sync "all" with other protocols!') if 'listen_address' in conntrack: - address = conntrack['listen_address'] - if not is_addr_assigned(address): - raise ConfigError(f'Specified listen-address {address} not assigned to any interface!') + for address in conntrack['listen_address']: + if not is_addr_assigned(address): + raise ConfigError(f'Specified listen-address {address} not assigned to any interface!') vrrp_group = dict_search('failover_mechanism.vrrp.sync_group', conntrack) if vrrp_group == None: diff --git a/src/conf_mode/containers.py b/src/conf_mode/containers.py index 2e14e0b25..516671844 100755 --- a/src/conf_mode/containers.py +++ b/src/conf_mode/containers.py @@ -122,6 +122,18 @@ def verify(container): raise ConfigError(f'IP address "{address}" can not be used for a container, '\ 'reserved for the container engine!') + if 'device' in container_config: + for dev, dev_config in container_config['device'].items(): + if 'source' not in dev_config: + raise ConfigError(f'Device "{dev}" has no source path configured!') + + if 'destination' not in dev_config: + raise ConfigError(f'Device "{dev}" has no destination path configured!') + + source = dev_config['source'] + if not os.path.exists(source): + raise ConfigError(f'Device "{dev}" source path "{source}" does not exist!') + if 'environment' in container_config: for var, cfg in container_config['environment'].items(): if 'value' not in cfg: @@ -266,6 +278,14 @@ def apply(container): c = c.replace('-', '_') cap_add += f' --cap-add={c}' + # Add a host device to the container /dev/x:/dev/x + device = '' + if 'device' in container_config: + for dev, dev_config in container_config['device'].items(): + source_dev = dev_config['source'] + dest_dev = dev_config['destination'] + device += f' --device={source_dev}:{dest_dev}' + # Check/set environment options "-e foo=bar" env_opt = '' if 'environment' in container_config: @@ -296,9 +316,9 @@ def apply(container): container_base_cmd = f'podman run --detach --interactive --tty --replace {cap_add} ' \ f'--memory {memory}m --memory-swap 0 --restart {restart} ' \ - f'--name {name} {port} {volume} {env_opt}' + f'--name {name} {device} {port} {volume} {env_opt}' if 'allow_host_networks' in container_config: - _cmd(f'{container_base_cmd} --net host {image}') + run(f'{container_base_cmd} --net host {image}') else: for network in container_config['network']: ipparam = '' @@ -306,19 +326,25 @@ def apply(container): address = container_config['network'][network]['address'] ipparam = f'--ip {address}' - counter = 0 - while True: - if counter >= 10: - break - try: - _cmd(f'{container_base_cmd} --net {network} {ipparam} {image}') - break - except: - counter = counter +1 - sleep(0.5) + run(f'{container_base_cmd} --net {network} {ipparam} {image}') return None +def run(container_cmd): + counter = 0 + while True: + if counter >= 10: + break + try: + _cmd(container_cmd) + break + except: + counter = counter +1 + sleep(0.5) + + return None + + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index 23a16df63..fa9b21f20 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -16,6 +16,7 @@ import os +from netifaces import interfaces from sys import exit from glob import glob @@ -65,10 +66,6 @@ def get_config(config=None): if conf.exists(base_nameservers): dns.update({'system_name_server': conf.return_values(base_nameservers)}) - base_nameservers_dhcp = ['system', 'name-servers-dhcp'] - if conf.exists(base_nameservers_dhcp): - dns.update({'system_name_server_dhcp': conf.return_values(base_nameservers_dhcp)}) - if 'authoritative_domain' in dns: dns['authoritative_zones'] = [] dns['authoritative_zone_errors'] = [] @@ -272,9 +269,8 @@ def verify(dns): raise ConfigError('Invalid authoritative records have been defined') if 'system' in dns: - if not ('system_name_server' in dns or 'system_name_server_dhcp' in dns): - print("Warning: No 'system name-server' or 'system " \ - "name-servers-dhcp' configured") + if not 'system_name_server' in dns: + print('Warning: No "system name-server" configured') return None @@ -339,10 +335,15 @@ def apply(dns): hc.delete_name_server_tags_recursor(['system']) # add dhcp nameserver tags for configured interfaces - if 'system_name_server_dhcp' in dns: - for interface in dns['system_name_server_dhcp']: - hc.add_name_server_tags_recursor(['dhcp-' + interface, - 'dhcpv6-' + interface ]) + if 'system_name_server' in dns: + for interface in dns['system_name_server']: + # system_name_server key contains both IP addresses and interface + # names (DHCP) to use DNS servers. We need to check if the + # value is an interface name - only if this is the case, add the + # interface based DNS forwarder. + if interface in interfaces(): + hc.add_name_server_tags_recursor(['dhcp-' + interface, + 'dhcpv6-' + interface ]) # hostsd will generate the forward-zones file # the list and keys() are required as get returns a dict, not list diff --git a/src/conf_mode/firewall-interface.py b/src/conf_mode/firewall-interface.py index b0df9dff4..9a5d278e9 100755 --- a/src/conf_mode/firewall-interface.py +++ b/src/conf_mode/firewall-interface.py @@ -31,6 +31,9 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +NAME_PREFIX = 'NAME_' +NAME6_PREFIX = 'NAME6_' + NFT_CHAINS = { 'in': 'VYOS_FW_FORWARD', 'out': 'VYOS_FW_FORWARD', @@ -127,7 +130,7 @@ def apply(if_firewall): name = dict_search_args(if_firewall, direction, 'name') if name: - rule_exists = cleanup_rule('ip filter', chain, if_prefix, ifname, name) + rule_exists = cleanup_rule('ip filter', chain, if_prefix, ifname, f'{NAME_PREFIX}{name}') if not rule_exists: rule_action = 'insert' @@ -138,24 +141,24 @@ def apply(if_firewall): rule_action = 'add' rule_prefix = f'position {handle}' - run(f'nft {rule_action} rule ip filter {chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {name}') + run(f'nft {rule_action} rule ip filter {chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {NAME_PREFIX}{name}') else: cleanup_rule('ip filter', chain, if_prefix, ifname) ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') if ipv6_name: - rule_exists = cleanup_rule('ip6 filter', ipv6_chain, if_prefix, ifname, ipv6_name) + rule_exists = cleanup_rule('ip6 filter', ipv6_chain, if_prefix, ifname, f'{NAME6_PREFIX}{ipv6_name}') if not rule_exists: rule_action = 'insert' rule_prefix = '' - handle = state_policy_handle('ip filter', chain) + handle = state_policy_handle('ip6 filter', ipv6_chain) if handle: rule_action = 'add' rule_prefix = f'position {handle}' - run(f'nft {rule_action} rule ip6 filter {ipv6_chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {ipv6_name}') + run(f'nft {rule_action} rule ip6 filter {ipv6_chain} {rule_prefix} {if_prefix}ifname {ifname} counter jump {NAME6_PREFIX}{ipv6_name}') else: cleanup_rule('ip6 filter', ipv6_chain, if_prefix, ifname) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 75382034f..f33198a49 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import re from glob import glob from json import loads @@ -22,6 +23,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import node_changed from vyos.configdiff import get_config_diff, Diff from vyos.template import render from vyos.util import cmd @@ -33,7 +35,10 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +policy_route_conf_script = '/usr/libexec/vyos/conf_mode/policy-route.py' + nftables_conf = '/run/nftables.conf' +nftables_defines_conf = '/run/nftables_defines.conf' sysfs_config = { 'all_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_all', 'enable': '0', 'disable': '1'}, @@ -49,6 +54,9 @@ sysfs_config = { 'twa_hazards_protection': {'sysfs': '/proc/sys/net/ipv4/tcp_rfc1337'} } +NAME_PREFIX = 'NAME_' +NAME6_PREFIX = 'NAME6_' + preserve_chains = [ 'INPUT', 'FORWARD', @@ -65,6 +73,9 @@ preserve_chains = [ 'VYOS_FRAG6_MARK' ] +nft_iface_chains = ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL'] +nft6_iface_chains = ['VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL'] + valid_groups = [ 'address_group', 'network_group', @@ -97,6 +108,35 @@ def get_firewall_interfaces(conf): out.update(find_interfaces(iftype_conf)) return out +def get_firewall_zones(conf): + used_v4 = [] + used_v6 = [] + zone_policy = conf.get_config_dict(['zone-policy'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + if 'zone' in zone_policy: + for zone, zone_conf in zone_policy['zone'].items(): + if 'from' in zone_conf: + for from_zone, from_conf in zone_conf['from'].items(): + name = dict_search_args(from_conf, 'firewall', 'name') + if name: + used_v4.append(name) + + ipv6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') + if ipv6_name: + used_v6.append(ipv6_name) + + if 'intra_zone_filtering' in zone_conf: + name = dict_search_args(zone_conf, 'intra_zone_filtering', 'firewall', 'name') + if name: + used_v4.append(name) + + ipv6_name = dict_search_args(zone_conf, 'intra_zone_filtering', 'firewall', 'ipv6_name') + if ipv6_name: + used_v6.append(ipv6_name) + + return {'name': used_v4, 'ipv6_name': used_v6} + def get_config(config=None): if config: conf = config @@ -104,16 +144,15 @@ def get_config(config=None): conf = Config() base = ['firewall'] - if not conf.exists(base): - return {} - firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) default_values = defaults(base) firewall = dict_merge(default_values, firewall) + firewall['policy_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) firewall['interfaces'] = get_firewall_interfaces(conf) + firewall['zone_policy'] = get_firewall_zones(conf) if 'config_trap' in firewall and firewall['config_trap'] == 'enable': diff = get_config_diff(conf) @@ -121,6 +160,7 @@ def get_config(config=None): firewall['trap_targets'] = conf.get_config_dict(['service', 'snmp', 'trap-target'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + return firewall def verify_rule(firewall, rule_conf, ipv6): @@ -131,6 +171,12 @@ def verify_rule(firewall, rule_conf, ipv6): if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') + if 'limit' in rule_conf: + if 'rate' in rule_conf['limit']: + rate_int = re.sub(r'\D', '', rule_conf['limit']['rate']) + if int(rate_int) < 1: + raise ConfigError('Limit rate integer cannot be less than 1') + if 'ipsec' in rule_conf: if {'match_ipsec', 'match_non_ipsec'} <= set(rule_conf['ipsec']): raise ConfigError('Cannot specify both "match-ipsec" and "match-non-ipsec"') @@ -139,6 +185,23 @@ def verify_rule(firewall, rule_conf, ipv6): if not {'count', 'time'} <= set(rule_conf['recent']): raise ConfigError('Recent "count" and "time" values must be defined') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') + if tcp_flags: + if dict_search_args(rule_conf, 'protocol') != 'tcp': + raise ConfigError('Protocol must be tcp when specifying tcp flags') + + not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') + if not_flags: + duplicates = [flag for flag in tcp_flags if flag in not_flags] + if duplicates: + raise ConfigError(f'Cannot match a tcp flag as set and not set') + + if 'protocol' in rule_conf: + if rule_conf['protocol'] == 'icmp' and ipv6: + raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') + if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: + raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') + for side in ['destination', 'source']: if side in rule_conf: side_conf = rule_conf[side] @@ -151,16 +214,19 @@ 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 group_name and group_name[0] == '!': + group_name = group_name[1:] - if not dict_search_args(firewall, 'group', fw_group): - error_group = fw_group.replace("_", "-") - raise ConfigError(f'Group defined in rule but {error_group} is not configured') + fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + error_group = fw_group.replace("_", "-") + group_obj = dict_search_args(firewall, 'group', fw_group, group_name) - if group_name not in firewall['group'][fw_group]: - error_group = group.replace("_", "-") + if group_obj is None: raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule') + if not group_obj: + print(f'WARNING: {error_group} "{group_name}" has no members') + if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): if 'protocol' not in rule_conf: raise ConfigError('Protocol must be defined if specifying a port or port-group') @@ -169,10 +235,6 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') def verify(firewall): - # bail out early - looks like removal from running config - if not firewall: - return None - if 'config_trap' in firewall and firewall['config_trap'] == 'enable': if not firewall['trap_targets']: raise ConfigError(f'Firewall config-trap enabled but "service snmp trap-target" is not defined') @@ -195,16 +257,34 @@ def verify(firewall): name = dict_search_args(if_firewall, direction, 'name') ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') - if name and not dict_search_args(firewall, 'name', name): + if name and dict_search_args(firewall, 'name', name) == None: raise ConfigError(f'Firewall name "{name}" is still referenced on interface {ifname}') - if ipv6_name and not dict_search_args(firewall, 'ipv6_name', ipv6_name): + if ipv6_name and dict_search_args(firewall, 'ipv6_name', ipv6_name) == None: raise ConfigError(f'Firewall ipv6-name "{ipv6_name}" is still referenced on interface {ifname}') + for fw_name, used_names in firewall['zone_policy'].items(): + for name in used_names: + if dict_search_args(firewall, fw_name, name) == None: + raise ConfigError(f'Firewall {fw_name.replace("_", "-")} "{name}" is still referenced in zone-policy') + return None +def cleanup_rule(table, jump_chain): + commands = [] + chains = nft_iface_chains if table == 'ip filter' else nft6_iface_chains + for chain in chains: + results = cmd(f'nft -a list chain {table} {chain}').split("\n") + for line in results: + if f'jump {jump_chain}' in line: + handle_search = re.search('handle (\d+)', line) + if handle_search: + commands.append(f'delete rule {table} {chain} handle {handle_search[1]}') + return commands + def cleanup_commands(firewall): commands = [] + commands_end = [] for table in ['ip filter', 'ip6 filter']: state_chain = 'VYOS_STATE_POLICY' if table == 'ip filter' else 'VYOS_STATE_POLICY6' json_str = cmd(f'nft -j list table {table}') @@ -220,11 +300,12 @@ def cleanup_commands(firewall): else: commands.append(f'flush chain {table} {chain}') elif chain not in preserve_chains and not chain.startswith("VZONE"): - if table == 'ip filter' and dict_search_args(firewall, 'name', chain): + if table == 'ip filter' and dict_search_args(firewall, 'name', chain.replace(NAME_PREFIX, "", 1)) != None: commands.append(f'flush chain {table} {chain}') - elif table == 'ip6 filter' and dict_search_args(firewall, 'ipv6_name', chain): + elif table == 'ip6 filter' and dict_search_args(firewall, 'ipv6_name', chain.replace(NAME6_PREFIX, "", 1)) != None: commands.append(f'flush chain {table} {chain}') else: + commands += cleanup_rule(table, chain) commands.append(f'delete chain {table} {chain}') elif 'rule' in item: rule = item['rule'] @@ -234,7 +315,10 @@ def cleanup_commands(firewall): chain = rule['chain'] handle = rule['handle'] commands.append(f'delete rule {table} {chain} handle {handle}') - return commands + elif 'set' in item: + set_name = item['set']['name'] + commands_end.append(f'delete set {table} {set_name}') + return commands + commands_end def generate(firewall): if not os.path.exists(nftables_conf): @@ -243,6 +327,7 @@ def generate(firewall): firewall['cleanup_commands'] = cleanup_commands(firewall) render(nftables_conf, 'firewall/nftables.tmpl', firewall) + render(nftables_defines_conf, 'firewall/nftables-defines.tmpl', firewall) return None def apply_sysfs(firewall): @@ -306,6 +391,12 @@ def state_policy_rule_exists(): search_str = cmd(f'nft list chain ip filter VYOS_FW_FORWARD') return 'VYOS_STATE_POLICY' in search_str +def resync_policy_route(): + # Update policy route as firewall groups were updated + tmp = run(policy_route_conf_script) + if tmp > 0: + print('Warning: Failed to re-apply policy route configuration') + def apply(firewall): if 'first_install' in firewall: run('nfct helper add rpc inet tcp') @@ -325,6 +416,9 @@ def apply(firewall): apply_sysfs(firewall) + if firewall['policy_resync']: + resync_policy_route() + post_apply_trap(firewall) return None diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 975f19acf..25bf54790 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -27,6 +27,7 @@ from vyos.configdict import dict_merge from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.template import render +from vyos.util import call from vyos.util import cmd from vyos.validate import is_addr_assigned from vyos.xml import defaults @@ -35,6 +36,8 @@ from vyos import airbag airbag.enable() uacctd_conf_path = '/run/pmacct/uacctd.conf' +systemd_service = 'uacctd.service' +systemd_override = f'/etc/systemd/system/{systemd_service}.d/override.conf' nftables_nflog_table = 'raw' nftables_nflog_chain = 'VYOS_CT_PREROUTING_HOOK' egress_nftables_nflog_table = 'inet mangle' @@ -236,7 +239,10 @@ def generate(flow_config): if not flow_config: return None - render(uacctd_conf_path, 'netflow/uacctd.conf.tmpl', flow_config) + render(uacctd_conf_path, 'pmacct/uacctd.conf.tmpl', flow_config) + render(systemd_override, 'pmacct/override.conf.tmpl', flow_config) + # Reload systemd manager configuration + call('systemctl daemon-reload') def apply(flow_config): action = 'restart' @@ -246,13 +252,13 @@ def apply(flow_config): _nftables_config([], 'egress') # Stop flow-accounting daemon and remove configuration file - cmd('systemctl stop uacctd.service') + call(f'systemctl stop {systemd_service}') if os.path.exists(uacctd_conf_path): os.unlink(uacctd_conf_path) return # Start/reload flow-accounting daemon - cmd(f'systemctl restart uacctd.service') + call(f'systemctl restart {systemd_service}') # configure nftables rules for defined interfaces if 'interface' in flow_config: diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index b5f5e919f..00f3d4f7f 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -66,6 +66,15 @@ def get_config(config=None): if conf.exists('debug'): http_api['debug'] = True + # this node is not available by CLI by default, and is reserved for + # the graphql tools. One can enable it for testing, with the warning + # that this will open an unauthenticated server. To do so + # mkdir /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql + # touch /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql/node.def + # and configure; editing the config alone is insufficient. + if conf.exists('gql'): + http_api['gql'] = True + if conf.exists('socket'): http_api['socket'] = True diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 431d65f1f..ad5a0f499 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -27,8 +27,9 @@ from vyos.configdict import is_source_interface from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_source_interface +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ifconfig import BondIf @@ -132,10 +133,10 @@ def verify(bond): return None if 'arp_monitor' in bond: - if 'target' in bond['arp_monitor'] and len(int(bond['arp_monitor']['target'])) > 16: + if 'target' in bond['arp_monitor'] and len(bond['arp_monitor']['target']) > 16: raise ConfigError('The maximum number of arp-monitor targets is 16') - if 'interval' in bond['arp_monitor'] and len(int(bond['arp_monitor']['interval'])) > 0: + if 'interval' in bond['arp_monitor'] and int(bond['arp_monitor']['interval']) > 0: if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']: raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \ 'transmit-load-balance or adaptive-load-balance') @@ -149,6 +150,7 @@ def verify(bond): verify_address(bond) verify_dhcpv6(bond) verify_vrf(bond) + verify_mirror_redirect(bond) # use common function to verify VLAN configuration verify_vlan_config(bond) diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index 4d3ebc587..b1f7e6d7c 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -22,12 +22,12 @@ from netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import node_changed -from vyos.configdict import leaf_node_changed from vyos.configdict import is_member from vyos.configdict import is_source_interface from vyos.configdict import has_vlan_subinterface_configured from vyos.configdict import dict_merge from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf from vyos.ifconfig import BridgeIf from vyos.validate import has_address_configured @@ -106,6 +106,7 @@ def verify(bridge): verify_dhcpv6(bridge) verify_vrf(bridge) + verify_mirror_redirect(bridge) ifname = bridge['ifname'] diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py index 55c783f38..4a1eb7b93 100755 --- a/src/conf_mode/interfaces-dummy.py +++ b/src/conf_mode/interfaces-dummy.py @@ -21,6 +21,7 @@ from vyos.configdict import get_interface_dict from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import DummyIf from vyos import ConfigError from vyos import airbag @@ -46,6 +47,7 @@ def verify(dummy): verify_vrf(dummy) verify_address(dummy) + verify_mirror_redirect(dummy) return None diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index e7250fb49..6aea7a80e 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -25,14 +25,16 @@ from vyos.configverify import verify_address from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_eapol from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mirror +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_mtu from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ethtool import Ethtool from vyos.ifconfig import EthernetIf -from vyos.pki import wrap_certificate +from vyos.pki import find_chain +from vyos.pki import encode_certificate +from vyos.pki import load_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call @@ -81,7 +83,7 @@ def verify(ethernet): verify_address(ethernet) verify_vrf(ethernet) verify_eapol(ethernet) - verify_mirror(ethernet) + verify_mirror_redirect(ethernet) ethtool = Ethtool(ifname) # No need to check speed and duplex keys as both have default values. @@ -159,16 +161,26 @@ def generate(ethernet): cert_name = ethernet['eapol']['certificate'] pki_cert = ethernet['pki']['certificate'][cert_name] - write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) + loaded_pki_cert = load_certificate(pki_cert['certificate']) + loaded_ca_certs = {load_certificate(c['certificate']) + for c in ethernet['pki']['ca'].values()} + + cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) + + write_file(cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain)) write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) if 'ca_certificate' in ethernet['eapol']: ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') ca_cert_name = ethernet['eapol']['ca_certificate'] - pki_ca_cert = ethernet['pki']['ca'][cert_name] + pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] + + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) write_file(ca_cert_file_path, - wrap_certificate(pki_ca_cert['certificate'])) + '\n'.join(encode_certificate(c) for c in ca_full_chain)) else: # delete configuration on interface removal if os.path.isfile(wpa_suppl_conf.format(**ethernet)): diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index 2a63b60aa..3a668226b 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.py @@ -24,6 +24,7 @@ from vyos.configdict import get_interface_dict from vyos.configverify import verify_address from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import GeneveIf from vyos import ConfigError @@ -50,6 +51,7 @@ def verify(geneve): verify_mtu_ipv6(geneve) verify_address(geneve) + verify_mirror_redirect(geneve) if 'remote' not in geneve: raise ConfigError('Remote side must be configured') diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index 9b6ddd5aa..22256bf4f 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -25,6 +25,7 @@ from vyos.configdict import leaf_node_changed from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import L2TPv3If from vyos.util import check_kmod from vyos.validate import is_addr_assigned @@ -76,6 +77,7 @@ def verify(l2tpv3): verify_mtu_ipv6(l2tpv3) verify_address(l2tpv3) + verify_mirror_redirect(l2tpv3) return None def generate(l2tpv3): diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces-loopback.py index 193334443..e4bc15bb5 100755 --- a/src/conf_mode/interfaces-loopback.py +++ b/src/conf_mode/interfaces-loopback.py @@ -20,6 +20,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import LoopbackIf from vyos import ConfigError from vyos import airbag @@ -39,6 +40,7 @@ def get_config(config=None): return loopback def verify(loopback): + verify_mirror_redirect(loopback) return None def generate(loopback): diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index eab69f36e..96fc1c41c 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -29,6 +29,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_source_interface from vyos import ConfigError from vyos import airbag @@ -66,6 +67,7 @@ def verify(macsec): verify_vrf(macsec) verify_mtu_ipv6(macsec) verify_address(macsec) + verify_mirror_redirect(macsec) if not (('security' in macsec) and ('cipher' in macsec['security'])): diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 3b8fae710..83d1c6d9b 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -32,8 +32,10 @@ from shutil import rmtree from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import VTunIf from vyos.pki import load_dh_parameters from vyos.pki import load_private_key @@ -47,6 +49,7 @@ from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.util import call from vyos.util import chown +from vyos.util import cmd from vyos.util import dict_search from vyos.util import dict_search_args from vyos.util import makedir @@ -87,6 +90,9 @@ def get_config(config=None): if 'deleted' not in openvpn: openvpn['pki'] = tmp_pki + tmp = leaf_node_changed(conf, ['openvpn-option']) + if tmp: openvpn['restart_required'] = '' + # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict' # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there. tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) @@ -225,11 +231,12 @@ def verify(openvpn): if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn: raise ConfigError('Must specify "local-address" or add interface to bridge') - if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: - raise ConfigError('Only one IPv4 local-address can be specified') + if 'local_address' in openvpn: + if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: + raise ConfigError('Only one IPv4 local-address can be specified') - if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: - raise ConfigError('Only one IPv6 local-address can be specified') + if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: + raise ConfigError('Only one IPv6 local-address can be specified') if openvpn['device_type'] == 'tun': if 'remote_address' not in openvpn: @@ -268,7 +275,7 @@ def verify(openvpn): if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn): raise ConfigError('"remote-address" and "remote-host" can not be the same') - if openvpn['device_type'] == 'tap': + if openvpn['device_type'] == 'tap' and 'local_address' in openvpn: # we can only have one local_address, this is ensured above v4addr = None for laddr in openvpn['local_address']: @@ -423,8 +430,8 @@ def verify(openvpn): # verify specified IP address is present on any interface on this system if 'local_host' in openvpn: if not is_addr_assigned(openvpn['local_host']): - raise ConfigError('local-host IP address "{local_host}" not assigned' \ - ' to any interface'.format(**openvpn)) + print('local-host IP address "{local_host}" not assigned' \ + ' to any interface'.format(**openvpn)) # TCP active if openvpn['protocol'] == 'tcp-active': @@ -489,6 +496,7 @@ def verify(openvpn): raise ConfigError('Username for authentication is missing') verify_vrf(openvpn) + verify_mirror_redirect(openvpn) return None @@ -647,9 +655,19 @@ def apply(openvpn): return None + # verify specified IP address is present on any interface on this system + # Allow to bind service to nonlocal address, if it virtaual-vrrp address + # or if address will be assign later + if 'local_host' in openvpn: + if not is_addr_assigned(openvpn['local_host']): + cmd('sysctl -w net.ipv4.ip_nonlocal_bind=1') + # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - call(f'systemctl reload-or-restart openvpn@{interface}.service') + action = 'reload-or-restart' + if 'restart_required' in openvpn: + action = 'restart' + call(f'systemctl {action} openvpn@{interface}.service') o = VTunIf(**openvpn) o.update(openvpn) diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 584adc75e..bfb1fadd5 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -28,6 +28,7 @@ from vyos.configverify import verify_source_interface from vyos.configverify import verify_interface_exists from vyos.configverify import verify_vrf from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import PPPoEIf from vyos.template import render from vyos.util import call @@ -85,6 +86,7 @@ def verify(pppoe): verify_authentication(pppoe) verify_vrf(pppoe) verify_mtu_ipv6(pppoe) + verify_mirror_redirect(pppoe) if {'connect_on_demand', 'vrf'} <= set(pppoe): raise ConfigError('On-demand dialing and VRF can not be used at the same time') diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 945a2ea9c..f2c85554f 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -25,6 +25,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_mtu_parent +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import MACVLANIf from vyos import ConfigError @@ -60,6 +61,7 @@ def verify(peth): verify_vrf(peth) verify_address(peth) verify_mtu_parent(peth, peth['parent']) + verify_mirror_redirect(peth) # use common function to verify VLAN configuration verify_vlan_config(peth) diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index 30f57ec0c..f4668d976 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2022 yOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,24 +18,20 @@ import os from sys import exit from netifaces import interfaces -from ipaddress import IPv4Address from vyos.config import Config -from vyos.configdict import dict_merge from vyos.configdict import get_interface_dict -from vyos.configdict import node_changed from vyos.configdict import leaf_node_changed from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_interface_exists from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf from vyos.configverify import verify_tunnel from vyos.ifconfig import Interface from vyos.ifconfig import Section from vyos.ifconfig import TunnelIf -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 from vyos.util import get_interface_config from vyos.util import dict_search from vyos import ConfigError @@ -54,8 +50,24 @@ def get_config(config=None): base = ['interfaces', 'tunnel'] tunnel = get_interface_dict(conf, base) - tmp = leaf_node_changed(conf, ['encapsulation']) - if tmp: tunnel.update({'encapsulation_changed': {}}) + if 'deleted' not in tunnel: + tmp = leaf_node_changed(conf, ['encapsulation']) + if tmp: tunnel.update({'encapsulation_changed': {}}) + + # We also need to inspect other configured tunnels as there are Kernel + # restrictions where we need to comply. E.g. GRE tunnel key can't be used + # twice, or with multiple GRE tunnels to the same location we must specify + # a GRE key + conf.set_level(base) + tunnel['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + # delete our own instance from this dict + ifname = tunnel['ifname'] + del tunnel['other_tunnels'][ifname] + # if only one tunnel is present on the system, no need to keep this key + if len(tunnel['other_tunnels']) == 0: + del tunnel['other_tunnels'] # We must check if our interface is configured to be a DMVPN member nhrp_base = ['protocols', 'nhrp', 'tunnel'] @@ -96,35 +108,47 @@ def verify(tunnel): if 'direction' not in tunnel['parameters']['erspan']: raise ConfigError('ERSPAN version 2 requires direction to be set!') - # If tunnel source address any and key not set + # If tunnel source is any and gre key is not set + interface = tunnel['ifname'] if tunnel['encapsulation'] in ['gre'] and \ dict_search('source_address', tunnel) == '0.0.0.0' and \ dict_search('parameters.ip.key', tunnel) == None: - raise ConfigError('Tunnel parameters ip key must be set!') - - if tunnel['encapsulation'] in ['gre', 'gretap']: - if dict_search('parameters.ip.key', tunnel) != None: - # Check pairs tunnel source-address/encapsulation/key with exists tunnels. - # Prevent the same key for 2 tunnels with same source-address/encap. T2920 - for tunnel_if in Section.interfaces('tunnel'): - # It makes no sense to run the test for re-used GRE keys on our - # own interface we are currently working on - if tunnel['ifname'] == tunnel_if: - continue - tunnel_cfg = get_interface_config(tunnel_if) - # no match on encapsulation - bail out - if dict_search('linkinfo.info_kind', tunnel_cfg) != tunnel['encapsulation']: - continue - new_source_address = dict_search('source_address', tunnel) - # Convert tunnel key to ip key, format "ip -j link show" - # 1 => 0.0.0.1, 999 => 0.0.3.231 - orig_new_key = dict_search('parameters.ip.key', tunnel) - new_key = IPv4Address(int(orig_new_key)) - new_key = str(new_key) - if dict_search('address', tunnel_cfg) == new_source_address and \ - dict_search('linkinfo.info_data.ikey', tunnel_cfg) == new_key: - raise ConfigError(f'Key "{orig_new_key}" for source-address "{new_source_address}" ' \ + raise ConfigError(f'"parameters ip key" must be set for {interface} when '\ + 'encapsulation is GRE!') + + gre_encapsulations = ['gre', 'gretap'] + if tunnel['encapsulation'] in gre_encapsulations and 'other_tunnels' in tunnel: + # Check pairs tunnel source-address/encapsulation/key with exists tunnels. + # Prevent the same key for 2 tunnels with same source-address/encap. T2920 + for o_tunnel, o_tunnel_conf in tunnel['other_tunnels'].items(): + # no match on encapsulation - bail out + our_encapsulation = tunnel['encapsulation'] + their_encapsulation = o_tunnel_conf['encapsulation'] + if our_encapsulation in gre_encapsulations and their_encapsulation \ + not in gre_encapsulations: + continue + + our_address = dict_search('source_address', tunnel) + our_key = dict_search('parameters.ip.key', tunnel) + their_address = dict_search('source_address', o_tunnel_conf) + their_key = dict_search('parameters.ip.key', o_tunnel_conf) + if our_key != None: + if their_address == our_address and their_key == our_key: + raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" ' \ f'is already used for tunnel "{tunnel_if}"!') + else: + our_source_if = dict_search('source_interface', tunnel) + their_source_if = dict_search('source_interface', o_tunnel_conf) + our_remote = dict_search('remote', tunnel) + their_remote = dict_search('remote', o_tunnel_conf) + # If no IP GRE key is defined we can not have more then one GRE tunnel + # bound to any one interface/IP address and the same remote. This will + # result in a OS PermissionError: add tunnel "gre0" failed: File exists + if (their_address == our_address or our_source_if == their_source_if) and \ + our_remote == their_remote: + raise ConfigError(f'Missing required "ip key" parameter when '\ + 'running more then one GRE based tunnel on the '\ + 'same source-interface/source-address') # Keys are not allowed with ipip and sit tunnels if tunnel['encapsulation'] in ['ipip', 'sit']: @@ -134,6 +158,7 @@ def verify(tunnel): verify_mtu_ipv6(tunnel) verify_address(tunnel) verify_vrf(tunnel) + verify_mirror_redirect(tunnel) if 'source_interface' in tunnel: verify_interface_exists(tunnel['source_interface']) diff --git a/src/conf_mode/interfaces-vti.py b/src/conf_mode/interfaces-vti.py index 57950ffea..f06fdff1b 100755 --- a/src/conf_mode/interfaces-vti.py +++ b/src/conf_mode/interfaces-vti.py @@ -19,6 +19,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import VTIIf from vyos.util import dict_search from vyos import ConfigError @@ -39,6 +40,7 @@ def get_config(config=None): return vti def verify(vti): + verify_mirror_redirect(vti) return None def generate(vti): diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index 1f097c4e3..0a9b51cac 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,9 +21,11 @@ from netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_source_interface from vyos.ifconfig import Interface from vyos.ifconfig import VXLANIf @@ -34,8 +36,8 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag + Retrive CLI config as dictionary. Dictionary can never be empty, as at least + the interface name will be added or a deleted flag """ if config: conf = config @@ -44,6 +46,16 @@ def get_config(config=None): base = ['interfaces', 'vxlan'] vxlan = get_interface_dict(conf, base) + # VXLAN interfaces are picky and require recreation if certain parameters + # change. But a VXLAN interface should - of course - not be re-created if + # it's description or IP address is adjusted. Feels somehow logic doesn't it? + for cli_option in ['external', 'gpe', 'group', 'port', 'remote', + 'source-address', 'source-interface', 'vni', + 'parameters ip dont-fragment', 'parameters ip tos', + 'parameters ip ttl']: + if leaf_node_changed(conf, cli_option.split()): + vxlan.update({'rebuild_required': {}}) + # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation conf.set_level(base) @@ -70,8 +82,7 @@ def verify(vxlan): if 'group' in vxlan: if 'source_interface' not in vxlan: - raise ConfigError('Multicast VXLAN requires an underlaying interface ') - + raise ConfigError('Multicast VXLAN requires an underlaying interface') verify_source_interface(vxlan) if not any(tmp in ['group', 'remote', 'source_address'] for tmp in vxlan): @@ -108,22 +119,42 @@ def verify(vxlan): raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') + # Check for mixed IPv4 and IPv6 addresses + protocol = None + if 'source_address' in vxlan: + if is_ipv6(vxlan['source_address']): + protocol = 'ipv6' + else: + protocol = 'ipv4' + + if 'remote' in vxlan: + error_msg = 'Can not mix both IPv4 and IPv6 for VXLAN underlay' + for remote in vxlan['remote']: + if is_ipv6(remote): + if protocol == 'ipv4': + raise ConfigError(error_msg) + protocol = 'ipv6' + else: + if protocol == 'ipv6': + raise ConfigError(error_msg) + protocol = 'ipv4' + verify_mtu_ipv6(vxlan) verify_address(vxlan) + verify_mirror_redirect(vxlan) return None - def generate(vxlan): return None - def apply(vxlan): # Check if the VXLAN interface already exists - if vxlan['ifname'] in interfaces(): - v = VXLANIf(vxlan['ifname']) - # VXLAN is super picky and the tunnel always needs to be recreated, - # thus we can simply always delete it first. - v.remove() + if 'rebuild_required' in vxlan or 'delete' in vxlan: + if vxlan['ifname'] in interfaces(): + v = VXLANIf(vxlan['ifname']) + # VXLAN is super picky and the tunnel always needs to be recreated, + # thus we can simply always delete it first. + v.remove() if 'deleted' not in vxlan: # Finally create the new interface @@ -132,7 +163,6 @@ def apply(vxlan): return None - if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index da64dd076..b404375d6 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -28,6 +28,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import WireGuardIf from vyos.util import check_kmod from vyos.util import check_port_availability @@ -70,6 +71,7 @@ def verify(wireguard): verify_mtu_ipv6(wireguard) verify_address(wireguard) verify_vrf(wireguard) + verify_mirror_redirect(wireguard) if 'private_key' not in wireguard: raise ConfigError('Wireguard private-key not defined') diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index af35b5f03..500952df1 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -27,6 +27,7 @@ from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_source_interface +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ifconfig import WiFiIf @@ -189,6 +190,7 @@ def verify(wifi): verify_address(wifi) verify_vrf(wifi) + verify_mirror_redirect(wifi) # use common function to verify VLAN configuration verify_vlan_config(wifi) diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index a4b033374..9a33039a3 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,8 +21,10 @@ from time import sleep from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed from vyos.configverify import verify_authentication from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf from vyos.ifconfig import WWANIf from vyos.util import cmd @@ -36,7 +38,7 @@ from vyos import airbag airbag.enable() service_name = 'ModemManager.service' -cron_script = '/etc/cron.d/wwan' +cron_script = '/etc/cron.d/vyos-wwan' def get_config(config=None): """ @@ -50,6 +52,32 @@ def get_config(config=None): base = ['interfaces', 'wwan'] wwan = get_interface_dict(conf, base) + # We should only terminate the WWAN session if critical parameters change. + # All parameters that can be changed on-the-fly (like interface description) + # should not lead to a reconnect! + tmp = leaf_node_changed(conf, ['address']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = leaf_node_changed(conf, ['apn']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = leaf_node_changed(conf, ['disable']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = leaf_node_changed(conf, ['vrf']) + # leaf_node_changed() returns a list, as VRF is a non-multi node, there + # will be only one list element + if tmp: wwan.update({'vrf_old': tmp[0]}) + + tmp = leaf_node_changed(conf, ['authentication', 'user']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = leaf_node_changed(conf, ['authentication', 'password']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = leaf_node_changed(conf, ['ipv6', 'address', 'autoconf']) + if tmp: wwan.update({'shutdown_required': {}}) + # We need to know the amount of other WWAN interfaces as ModemManager needs # to be started or stopped. conf.set_level(base) @@ -57,8 +85,8 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) - # This if-clause is just to be sure - it will always evaluate to true ifname = wwan['ifname'] + # This if-clause is just to be sure - it will always evaluate to true if ifname in wwan['other_interfaces']: del wwan['other_interfaces'][ifname] if len(wwan['other_interfaces']) == 0: @@ -77,18 +105,31 @@ def verify(wwan): verify_interface_exists(ifname) verify_authentication(wwan) verify_vrf(wwan) + verify_mirror_redirect(wwan) return None def generate(wwan): if 'deleted' in wwan: + # We are the last WWAN interface - there are no other ones remaining + # thus the cronjob needs to go away, too + if 'other_interfaces' not in wwan: + if os.path.exists(cron_script): + os.unlink(cron_script) return None + # Install cron triggered helper script to re-dial WWAN interfaces on + # disconnect - e.g. happens during RF signal loss. The script watches every + # WWAN interface - so there is only one instance. if not os.path.exists(cron_script): write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py') + return None def apply(wwan): + # ModemManager is required to dial WWAN connections - one instance is + # required to serve all modems. Activate ModemManager on first invocation + # of any WWAN interface. if not is_systemd_service_active(service_name): cmd(f'systemctl start {service_name}') @@ -101,17 +142,19 @@ def apply(wwan): break sleep(0.250) - # we only need the modem number. wwan0 -> 0, wwan1 -> 1 - modem = wwan['ifname'].lstrip('wwan') - base_cmd = f'mmcli --modem {modem}' - # Number of bearers is limited - always disconnect first - cmd(f'{base_cmd} --simple-disconnect') + if 'shutdown_required' in wwan: + # we only need the modem number. wwan0 -> 0, wwan1 -> 1 + modem = wwan['ifname'].lstrip('wwan') + base_cmd = f'mmcli --modem {modem}' + # Number of bearers is limited - always disconnect first + cmd(f'{base_cmd} --simple-disconnect') w = WWANIf(wwan['ifname']) if 'deleted' in wwan or 'disable' in wwan: w.remove() - # There are no other WWAN interfaces - stop the daemon + # We are the last WWAN interface - there are no other WWAN interfaces + # remaining, thus we can stop ModemManager and free resources. if 'other_interfaces' not in wwan: cmd(f'systemctl stop {service_name}') # Clean CRON helper script which is used for to re-connect when @@ -121,27 +164,25 @@ def apply(wwan): return None - ip_type = 'ipv4' - slaac = dict_search('ipv6.address.autoconf', wwan) != None - if 'address' in wwan: - if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): - ip_type = 'ipv4v6' - elif 'dhcpv6' in wwan['address'] or slaac: - ip_type = 'ipv6' - elif 'dhcp' in wwan['address']: - ip_type = 'ipv4' - - options = f'ip-type={ip_type},apn=' + wwan['apn'] - if 'authentication' in wwan: - options += ',user={user},password={password}'.format(**wwan['authentication']) - - command = f'{base_cmd} --simple-connect="{options}"' - call(command, stdout=DEVNULL) - w.update(wwan) + if 'shutdown_required' in wwan: + ip_type = 'ipv4' + slaac = dict_search('ipv6.address.autoconf', wwan) != None + if 'address' in wwan: + if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): + ip_type = 'ipv4v6' + elif 'dhcpv6' in wwan['address'] or slaac: + ip_type = 'ipv6' + elif 'dhcp' in wwan['address']: + ip_type = 'ipv4' - if 'other_interfaces' not in wwan and 'deleted' in wwan: - cmd(f'systemctl start {service_name}') + options = f'ip-type={ip_type},apn=' + wwan['apn'] + if 'authentication' in wwan: + options += ',user={user},password={password}'.format(**wwan['authentication']) + command = f'{base_cmd} --simple-connect="{options}"' + call(command, stdout=DEVNULL) + + w.update(wwan) return None if __name__ == '__main__': diff --git a/src/conf_mode/lldp.py b/src/conf_mode/lldp.py index 082c3e128..db8328259 100755 --- a/src/conf_mode/lldp.py +++ b/src/conf_mode/lldp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2020 VyOS maintainers and contributors +# Copyright (C) 2017-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,19 +15,19 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re -from copy import deepcopy from sys import exit from vyos.config import Config +from vyos.configdict import dict_merge from vyos.validate import is_addr_assigned from vyos.validate import is_loopback_addr from vyos.version import get_version_data -from vyos import ConfigError from vyos.util import call +from vyos.util import dict_search +from vyos.xml import defaults from vyos.template import render - +from vyos import ConfigError from vyos import airbag airbag.enable() @@ -35,178 +35,73 @@ config_file = "/etc/default/lldpd" vyos_config_file = "/etc/lldpd.d/01-vyos.conf" base = ['service', 'lldp'] -default_config_data = { - "options": '', - "interface_list": '', - "location": '' -} - -def get_options(config): - options = {} - config.set_level(base) - - options['listen_vlan'] = config.exists('listen-vlan') - options['mgmt_addr'] = [] - for addr in config.return_values('management-address'): - if is_addr_assigned(addr) and not is_loopback_addr(addr): - options['mgmt_addr'].append(addr) - else: - message = 'WARNING: LLDP management address {0} invalid - '.format(addr) - if is_loopback_addr(addr): - message += '(loopback address).' - else: - message += 'address not found.' - print(message) - - snmp = config.exists('snmp enable') - options["snmp"] = snmp - if snmp: - config.set_level('') - options["sys_snmp"] = config.exists('service snmp') - config.set_level(base) - - config.set_level(base + ['legacy-protocols']) - options['cdp'] = config.exists('cdp') - options['edp'] = config.exists('edp') - options['fdp'] = config.exists('fdp') - options['sonmp'] = config.exists('sonmp') - - # start with an unknown version information - version_data = get_version_data() - options['description'] = version_data['version'] - options['listen_on'] = [] - - return options - -def get_interface_list(config): - config.set_level(base) - intfs_names = config.list_nodes(['interface']) - if len(intfs_names) < 0: - return 0 - - interface_list = [] - for name in intfs_names: - config.set_level(base + ['interface', name]) - disable = config.exists(['disable']) - intf = { - 'name': name, - 'disable': disable - } - interface_list.append(intf) - return interface_list - - -def get_location_intf(config, name): - path = base + ['interface', name] - config.set_level(path) - - config.set_level(path + ['location']) - elin = '' - coordinate_based = {} - - if config.exists('elin'): - elin = config.return_value('elin') - - if config.exists('coordinate-based'): - config.set_level(path + ['location', 'coordinate-based']) - - coordinate_based['latitude'] = config.return_value(['latitude']) - coordinate_based['longitude'] = config.return_value(['longitude']) - - coordinate_based['altitude'] = '0' - if config.exists(['altitude']): - coordinate_based['altitude'] = config.return_value(['altitude']) - - coordinate_based['datum'] = 'WGS84' - if config.exists(['datum']): - coordinate_based['datum'] = config.return_value(['datum']) - - intf = { - 'name': name, - 'elin': elin, - 'coordinate_based': coordinate_based - - } - return intf - - -def get_location(config): - config.set_level(base) - intfs_names = config.list_nodes(['interface']) - if len(intfs_names) < 0: - return 0 - - if config.exists('disable'): - return 0 - - intfs_location = [] - for name in intfs_names: - intf = get_location_intf(config, name) - intfs_location.append(intf) - - return intfs_location - - def get_config(config=None): - lldp = deepcopy(default_config_data) if config: conf = config else: conf = Config() + if not conf.exists(base): - return None - else: - lldp['options'] = get_options(conf) - lldp['interface_list'] = get_interface_list(conf) - lldp['location'] = get_location(conf) + return {} - return lldp + lldp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + if conf.exists(['service', 'snmp']): + lldp['system_snmp_enabled'] = '' + + version_data = get_version_data() + lldp['version'] = version_data['version'] + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + # location coordinates have a default value + if 'interface' in lldp: + for interface, interface_config in lldp['interface'].items(): + default_values = defaults(base + ['interface']) + if dict_search('location.coordinate_based', interface_config) == None: + # no location specified - no need to add defaults + del default_values['location']['coordinate_based']['datum'] + del default_values['location']['coordinate_based']['altitude'] + + # cleanup default_values dictionary from inner to outer + # this might feel overkill here, but it does support easy extension + # in the future with additional default values + if len(default_values['location']['coordinate_based']) == 0: + del default_values['location']['coordinate_based'] + if len(default_values['location']) == 0: + del default_values['location'] + + lldp['interface'][interface] = dict_merge(default_values, + lldp['interface'][interface]) + + return lldp def verify(lldp): # bail out early - looks like removal from running config if lldp is None: return - # check location - for location in lldp['location']: - # check coordinate-based - if len(location['coordinate_based']) > 0: - # check longitude and latitude - if not location['coordinate_based']['longitude']: - raise ConfigError('Must define longitude for interface {0}'.format(location['name'])) - - if not location['coordinate_based']['latitude']: - raise ConfigError('Must define latitude for interface {0}'.format(location['name'])) - - if not re.match(r'^(\d+)(\.\d+)?[nNsS]$', location['coordinate_based']['latitude']): - raise ConfigError('Invalid location for interface {0}:\n' \ - 'latitude should be a number followed by S or N'.format(location['name'])) - - if not re.match(r'^(\d+)(\.\d+)?[eEwW]$', location['coordinate_based']['longitude']): - raise ConfigError('Invalid location for interface {0}:\n' \ - 'longitude should be a number followed by E or W'.format(location['name'])) - - # check altitude and datum if exist - if location['coordinate_based']['altitude']: - if not re.match(r'^[-+0-9\.]+$', location['coordinate_based']['altitude']): - raise ConfigError('Invalid location for interface {0}:\n' \ - 'altitude should be a positive or negative number'.format(location['name'])) - - if location['coordinate_based']['datum']: - if not re.match(r'^(WGS84|NAD83|MLLW)$', location['coordinate_based']['datum']): - raise ConfigError("Invalid location for interface {0}:\n' \ - 'datum should be WGS84, NAD83, or MLLW".format(location['name'])) - - # check elin - elif location['elin']: - if not re.match(r'^[0-9]{10,25}$', location['elin']): - raise ConfigError('Invalid location for interface {0}:\n' \ - 'ELIN number must be between 10-25 numbers'.format(location['name'])) + if 'management_address' in lldp: + for address in lldp['management_address']: + message = f'WARNING: LLDP management address "{address}" is invalid' + if is_loopback_addr(address): + print(f'{message} - loopback address') + elif not is_addr_assigned(address): + print(f'{message} - not assigned to any interface') + + if 'interface' in lldp: + for interface, interface_config in lldp['interface'].items(): + # bail out early if no location info present in interface config + if 'location' not in interface_config: + continue + if 'coordinate_based' in interface_config['location']: + if not {'latitude', 'latitude'} <= set(interface_config['location']['coordinate_based']): + raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!') # check options - if lldp['options']['snmp']: - if not lldp['options']['sys_snmp']: + if 'snmp' in lldp and 'enable' in lldp['snmp']: + if 'system_snmp_enabled' not in lldp: raise ConfigError('SNMP must be configured to enable LLDP SNMP') @@ -215,29 +110,17 @@ def generate(lldp): if lldp is None: return - # generate listen on interfaces - for intf in lldp['interface_list']: - tmp = '' - # add exclamation mark if interface is disabled - if intf['disable']: - tmp = '!' - - tmp += intf['name'] - lldp['options']['listen_on'].append(tmp) - - # generate /etc/default/lldpd render(config_file, 'lldp/lldpd.tmpl', lldp) - # generate /etc/lldpd.d/01-vyos.conf render(vyos_config_file, 'lldp/vyos.conf.tmpl', lldp) - def apply(lldp): + systemd_service = 'lldpd.service' if lldp: # start/restart lldp service - call('systemctl restart lldpd.service') + call(f'systemctl restart {systemd_service}') else: # LLDP service has been terminated - call('systemctl stop lldpd.service') + call(f'systemctl stop {systemd_service}') if os.path.isfile(config_file): os.unlink(config_file) if os.path.isfile(vyos_config_file): diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 96f8f6fb6..9f319fc8a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -28,6 +28,7 @@ from vyos.configdict import dict_merge from vyos.template import render from vyos.template import is_ip_network from vyos.util import cmd +from vyos.util import run from vyos.util import check_kmod from vyos.util import dict_search from vyos.validate import is_addr_assigned @@ -179,12 +180,19 @@ def verify(nat): return None def generate(nat): - render(nftables_nat_config, 'firewall/nftables-nat.tmpl', nat, - permission=0o755) + render(nftables_nat_config, 'firewall/nftables-nat.tmpl', nat) + + # dry-run newly generated configuration + tmp = run(f'nft -c -f {nftables_nat_config}') + if tmp > 0: + if os.path.exists(nftables_ct_file): + os.unlink(nftables_ct_file) + raise ConfigError('Configuration file errors encountered!') + return None def apply(nat): - cmd(f'{nftables_nat_config}') + cmd(f'nft -f {nftables_nat_config}') if os.path.isfile(nftables_nat_config): os.unlink(nftables_nat_config) diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy-local-route.py index 539189442..3f834f55c 100755 --- a/src/conf_mode/policy-local-route.py +++ b/src/conf_mode/policy-local-route.py @@ -18,6 +18,7 @@ import os from sys import exit +from netifaces import interfaces from vyos.config import Config from vyos.configdict import dict_merge from vyos.configdict import node_changed @@ -35,35 +36,92 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['policy', 'local-route'] + base = ['policy'] + pbr = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # delete policy local-route - dict = {} - tmp = node_changed(conf, ['policy', 'local-route', 'rule'], key_mangling=('-', '_')) - if tmp: - for rule in (tmp or []): - src = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'source']) - fwmk = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'fwmark']) - if src: - dict = dict_merge({'rule_remove' : {rule : {'source' : src}}}, dict) - pbr.update(dict) - if fwmk: - dict = dict_merge({'rule_remove' : {rule : {'fwmark' : fwmk}}}, dict) + for route in ['local_route', 'local_route6']: + dict_id = 'rule_remove' if route == 'local_route' else 'rule6_remove' + route_key = 'local-route' if route == 'local_route' else 'local-route6' + base_rule = base + [route_key, 'rule'] + + # delete policy local-route + dict = {} + tmp = node_changed(conf, base_rule, key_mangling=('-', '_')) + if tmp: + for rule in (tmp or []): + src = leaf_node_changed(conf, base_rule + [rule, 'source']) + fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) + iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) + dst = leaf_node_changed(conf, base_rule + [rule, 'destination']) + rule_def = {} + if src: + rule_def = dict_merge({'source' : src}, rule_def) + if fwmk: + rule_def = dict_merge({'fwmark' : fwmk}, rule_def) + if iif: + rule_def = dict_merge({'inbound_interface' : iif}, rule_def) + if dst: + rule_def = dict_merge({'destination' : dst}, rule_def) + dict = dict_merge({dict_id : {rule : rule_def}}, dict) pbr.update(dict) - # delete policy local-route rule x source x.x.x.x - # delete policy local-route rule x fwmark x - if 'rule' in pbr: - for rule in pbr['rule']: - src = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'source']) - fwmk = leaf_node_changed(conf, ['policy', 'local-route', 'rule', rule, 'fwmark']) - if src: - dict = dict_merge({'rule_remove' : {rule : {'source' : src}}}, dict) - pbr.update(dict) - if fwmk: - dict = dict_merge({'rule_remove' : {rule : {'fwmark' : fwmk}}}, dict) - pbr.update(dict) + if not route in pbr: + continue + + # delete policy local-route rule x source x.x.x.x + # delete policy local-route rule x fwmark x + # delete policy local-route rule x destination x.x.x.x + if 'rule' in pbr[route]: + for rule, rule_config in pbr[route]['rule'].items(): + src = leaf_node_changed(conf, base_rule + [rule, 'source']) + fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) + iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) + dst = leaf_node_changed(conf, base_rule + [rule, 'destination']) + # keep track of changes in configuration + # otherwise we might remove an existing node although nothing else has changed + changed = False + + rule_def = {} + # src is None if there are no changes to src + if src is None: + # if src hasn't changed, include it in the removal selector + # if a new selector is added, we have to remove all previous rules without this selector + # to make sure we remove all previous rules with this source(s), it will be included + if 'source' in rule_config: + rule_def = dict_merge({'source': rule_config['source']}, rule_def) + else: + # if src is not None, it's previous content will be returned + # this can be an empty array if it's just being set, or the previous value + # either way, something has to be changed and we only want to remove previous values + changed = True + # set the old value for removal if it's not empty + if len(src) > 0: + rule_def = dict_merge({'source' : src}, rule_def) + if fwmk is None: + if 'fwmark' in rule_config: + rule_def = dict_merge({'fwmark': rule_config['fwmark']}, rule_def) + else: + changed = True + if len(fwmk) > 0: + rule_def = dict_merge({'fwmark' : fwmk}, rule_def) + if iif is None: + if 'inbound_interface' in rule_config: + rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def) + else: + changed = True + if len(iif) > 0: + rule_def = dict_merge({'inbound_interface' : iif}, rule_def) + if dst is None: + if 'destination' in rule_config: + rule_def = dict_merge({'destination': rule_config['destination']}, rule_def) + else: + changed = True + if len(dst) > 0: + rule_def = dict_merge({'destination' : dst}, rule_def) + if changed: + dict = dict_merge({dict_id : {rule : rule_def}}, dict) + pbr.update(dict) return pbr @@ -72,13 +130,25 @@ def verify(pbr): if not pbr: return None - if 'rule' in pbr: - for rule in pbr['rule']: - if 'source' not in pbr['rule'][rule] and 'fwmark' not in pbr['rule'][rule]: - raise ConfigError('Source address or fwmark is required!') - else: - if 'set' not in pbr['rule'][rule] or 'table' not in pbr['rule'][rule]['set']: - raise ConfigError('Table set is required!') + for route in ['local_route', 'local_route6']: + if not route in pbr: + continue + + pbr_route = pbr[route] + if 'rule' in pbr_route: + for rule in pbr_route['rule']: + if 'source' not in pbr_route['rule'][rule] \ + and 'destination' not in pbr_route['rule'][rule] \ + and 'fwmark' not in pbr_route['rule'][rule] \ + and 'inbound_interface' not in pbr_route['rule'][rule]: + raise ConfigError('Source or destination address or fwmark or inbound-interface is required!') + else: + if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']: + raise ConfigError('Table set is required!') + if 'inbound_interface' in pbr_route['rule'][rule]: + interface = pbr_route['rule'][rule]['inbound_interface'] + if interface not in interfaces(): + raise ConfigError(f'Interface "{interface}" does not exist') return None @@ -93,36 +163,51 @@ def apply(pbr): return None # Delete old rule if needed - if 'rule_remove' in pbr: - for rule in pbr['rule_remove']: - if 'source' in pbr['rule_remove'][rule]: - for src in pbr['rule_remove'][rule]['source']: - call(f'ip rule del prio {rule} from {src}') - if 'fwmark' in pbr['rule_remove'][rule]: - for fwmk in pbr['rule_remove'][rule]['fwmark']: - call(f'ip rule del prio {rule} from all fwmark {fwmk}') + for rule_rm in ['rule_remove', 'rule6_remove']: + if rule_rm in pbr: + v6 = " -6" if rule_rm == 'rule6_remove' else "" + for rule, rule_config in pbr[rule_rm].items(): + rule_config['source'] = rule_config['source'] if 'source' in rule_config else [''] + for src in rule_config['source']: + f_src = '' if src == '' else f' from {src} ' + rule_config['destination'] = rule_config['destination'] if 'destination' in rule_config else [''] + for dst in rule_config['destination']: + f_dst = '' if dst == '' else f' to {dst} ' + rule_config['fwmark'] = rule_config['fwmark'] if 'fwmark' in rule_config else [''] + for fwmk in rule_config['fwmark']: + f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' + rule_config['inbound_interface'] = rule_config['inbound_interface'] if 'inbound_interface' in rule_config else [''] + for iif in rule_config['inbound_interface']: + f_iif = '' if iif == '' else f' iif {iif} ' + call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_fwmk}{f_iif}') # Generate new config - if 'rule' in pbr: - for rule in pbr['rule']: - table = pbr['rule'][rule]['set']['table'] - # Only source in the rule - # set policy local-route rule 100 source '203.0.113.1' - if 'source' in pbr['rule'][rule] and not 'fwmark' in pbr['rule'][rule]: - for src in pbr['rule'][rule]['source']: - call(f'ip rule add prio {rule} from {src} lookup {table}') - # Only fwmark in the rule - # set policy local-route rule 101 fwmark '23' - if 'fwmark' in pbr['rule'][rule] and not 'source' in pbr['rule'][rule]: - fwmk = pbr['rule'][rule]['fwmark'] - call(f'ip rule add prio {rule} from all fwmark {fwmk} lookup {table}') - # Source and fwmark in the rule - # set policy local-route rule 100 source '203.0.113.1' - # set policy local-route rule 100 fwmark '23' - if 'source' in pbr['rule'][rule] and 'fwmark' in pbr['rule'][rule]: - fwmk = pbr['rule'][rule]['fwmark'] - for src in pbr['rule'][rule]['source']: - call(f'ip rule add prio {rule} from {src} fwmark {fwmk} lookup {table}') + for route in ['local_route', 'local_route6']: + if not route in pbr: + continue + + v6 = " -6" if route == 'local_route6' else "" + + pbr_route = pbr[route] + if 'rule' in pbr_route: + for rule, rule_config in pbr_route['rule'].items(): + table = rule_config['set']['table'] + + rule_config['source'] = rule_config['source'] if 'source' in rule_config else ['all'] + for src in rule_config['source'] or ['all']: + f_src = '' if src == '' else f' from {src} ' + rule_config['destination'] = rule_config['destination'] if 'destination' in rule_config else ['all'] + for dst in rule_config['destination']: + f_dst = '' if dst == '' else f' to {dst} ' + f_fwmk = '' + if 'fwmark' in rule_config: + fwmk = rule_config['fwmark'] + f_fwmk = f' fwmark {fwmk} ' + f_iif = '' + if 'inbound_interface' in rule_config: + iif = rule_config['inbound_interface'] + f_iif = f' iif {iif} ' + call(f'ip{v6} rule add prio {rule} {f_src}{f_dst}{f_fwmk}{f_iif} lookup {table}') return None diff --git a/src/conf_mode/policy-route-interface.py b/src/conf_mode/policy-route-interface.py index e81135a74..1108aebe6 100755 --- a/src/conf_mode/policy-route-interface.py +++ b/src/conf_mode/policy-route-interface.py @@ -52,7 +52,7 @@ def verify(if_policy): if not if_policy: return None - for route in ['route', 'ipv6_route']: + for route in ['route', 'route6']: if route in if_policy: if route not in if_policy['policy']: raise ConfigError('Policy route not configured') @@ -71,7 +71,7 @@ def cleanup_rule(table, chain, ifname, new_name=None): results = cmd(f'nft -a list chain {table} {chain}').split("\n") retval = None for line in results: - if f'oifname "{ifname}"' in line: + if f'ifname "{ifname}"' in line: if new_name and f'jump {new_name}' in line: # new_name is used to clear rules for any previously referenced chains # returns true when rule exists and doesn't need to be created @@ -98,8 +98,8 @@ def apply(if_policy): else: cleanup_rule('ip mangle', route_chain, ifname) - if 'ipv6_route' in if_policy: - name = 'VYOS_PBR6_' + if_policy['ipv6_route'] + if 'route6' in if_policy: + name = 'VYOS_PBR6_' + if_policy['route6'] rule_exists = cleanup_rule('ip6 mangle', ipv6_route_chain, ifname, name) if not rule_exists: diff --git a/src/conf_mode/policy-route.py b/src/conf_mode/policy-route.py index d098be68d..3d1d7d8c5 100755 --- a/src/conf_mode/policy-route.py +++ b/src/conf_mode/policy-route.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import re from json import loads from sys import exit @@ -31,6 +32,35 @@ airbag.enable() mark_offset = 0x7FFFFFFF nftables_conf = '/run/nftables_policy.conf' +preserve_chains = [ + 'VYOS_PBR_PREROUTING', + 'VYOS_PBR_POSTROUTING', + 'VYOS_PBR6_PREROUTING', + 'VYOS_PBR6_POSTROUTING' +] + +valid_groups = [ + 'address_group', + 'network_group', + 'port_group' +] + +def get_policy_interfaces(conf): + out = {} + interfaces = conf.get_config_dict(['interfaces'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + def find_interfaces(iftype_conf, output={}, prefix=''): + for ifname, if_conf in iftype_conf.items(): + if 'policy' in if_conf: + output[prefix + ifname] = if_conf['policy'] + for vif in ['vif', 'vif_s', 'vif_c']: + if vif in if_conf: + output.update(find_interfaces(if_conf[vif], output, f'{prefix}{ifname}.')) + return output + for iftype, iftype_conf in interfaces.items(): + out.update(find_interfaces(iftype_conf)) + return out + def get_config(config=None): if config: conf = config @@ -38,68 +68,149 @@ def get_config(config=None): conf = Config() base = ['policy'] - if not conf.exists(base + ['route']) and not conf.exists(base + ['ipv6-route']): - return None - policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + policy['interfaces'] = get_policy_interfaces(conf) + return policy -def verify(policy): - # bail out early - looks like removal from running config - if not policy: - return None +def verify_rule(policy, name, rule_conf, ipv6): + icmp = 'icmp' if not ipv6 else 'icmpv6' + if icmp in rule_conf: + icmp_defined = False + if 'type_name' in rule_conf[icmp]: + icmp_defined = True + if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]: + raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name') + if 'code' in rule_conf[icmp]: + icmp_defined = True + if 'type' not in rule_conf[icmp]: + raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined') + if 'type' in rule_conf[icmp]: + icmp_defined = True + + if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp: + raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP') + + if 'set' in rule_conf: + if 'tcp_mss' in rule_conf['set']: + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') + if not tcp_flags or 'syn' not in tcp_flags: + raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') + if tcp_flags: + if dict_search_args(rule_conf, 'protocol') != 'tcp': + raise ConfigError('Protocol must be tcp when specifying tcp flags') + + not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') + if not_flags: + duplicates = [flag for flag in tcp_flags if flag in not_flags] + if duplicates: + raise ConfigError(f'Cannot match a tcp flag as set and not set') + + for side in ['destination', 'source']: + if side in rule_conf: + side_conf = rule_conf[side] + + if 'group' in side_conf: + if {'address_group', 'network_group'} <= set(side_conf['group']): + raise ConfigError('Only one address-group or network-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + + if group_name.startswith('!'): + group_name = group_name[1:] + + fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + error_group = fw_group.replace("_", "-") + group_obj = dict_search_args(policy['firewall_group'], fw_group, group_name) - for route in ['route', 'ipv6_route']: + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on policy route rule') + + if not group_obj: + print(f'WARNING: {error_group} "{group_name}" has no members') + + if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): + if 'protocol' not in rule_conf: + raise ConfigError('Protocol must be defined if specifying a port or port-group') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') + +def verify(policy): + for route in ['route', 'route6']: + ipv6 = route == 'route6' if route in policy: for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: - for rule_id, rule_conf in pol_conf.items(): - icmp = 'icmp' if route == 'route' else 'icmpv6' - if icmp in rule_conf: - icmp_defined = False - if 'type_name' in rule_conf[icmp]: - icmp_defined = True - if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]: - raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name') - if 'code' in rule_conf[icmp]: - icmp_defined = True - if 'type' not in rule_conf[icmp]: - raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined') - if 'type' in rule_conf[icmp]: - icmp_defined = True - - if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp: - raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP') - if 'set' in rule_conf: - if 'tcp_mss' in rule_conf['set']: - tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') - if not tcp_flags or 'SYN' not in tcp_flags.split(","): - raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') - if 'tcp' in rule_conf: - if 'flags' in rule_conf['tcp']: - if 'protocol' not in rule_conf or rule_conf['protocol'] != 'tcp': - raise ConfigError(f'{name} rule {rule_id}: TCP flags can only be set if protocol is set to TCP') + for rule_id, rule_conf in pol_conf['rule'].items(): + verify_rule(policy, name, rule_conf, ipv6) + + for ifname, if_policy in policy['interfaces'].items(): + name = dict_search_args(if_policy, 'route') + ipv6_name = dict_search_args(if_policy, 'route6') + if name and not dict_search_args(policy, 'route', name): + raise ConfigError(f'Policy route "{name}" is still referenced on interface {ifname}') + + if ipv6_name and not dict_search_args(policy, 'route6', ipv6_name): + raise ConfigError(f'Policy route6 "{ipv6_name}" is still referenced on interface {ifname}') return None -def generate(policy): - if not policy: - if os.path.exists(nftables_conf): - os.unlink(nftables_conf) - return None +def cleanup_rule(table, jump_chain): + commands = [] + results = cmd(f'nft -a list table {table}').split("\n") + for line in results: + if f'jump {jump_chain}' in line: + handle_search = re.search('handle (\d+)', line) + if handle_search: + commands.append(f'delete rule {table} {chain} handle {handle_search[1]}') + return commands +def cleanup_commands(policy): + commands = [] + for table in ['ip mangle', 'ip6 mangle']: + json_str = cmd(f'nft -j list table {table}') + obj = loads(json_str) + if 'nftables' not in obj: + continue + for item in obj['nftables']: + if 'chain' in item: + chain = item['chain']['name'] + if not chain.startswith("VYOS_PBR"): + continue + if chain not in preserve_chains: + if table == 'ip mangle' and dict_search_args(policy, 'route', chain.replace("VYOS_PBR_", "", 1)): + commands.append(f'flush chain {table} {chain}') + elif table == 'ip6 mangle' and dict_search_args(policy, 'route6', chain.replace("VYOS_PBR6_", "", 1)): + commands.append(f'flush chain {table} {chain}') + else: + commands += cleanup_rule(table, chain) + commands.append(f'delete chain {table} {chain}') + return commands + +def generate(policy): if not os.path.exists(nftables_conf): policy['first_install'] = True + else: + policy['cleanup_commands'] = cleanup_commands(policy) render(nftables_conf, 'firewall/nftables-policy.tmpl', policy) return None def apply_table_marks(policy): - for route in ['route', 'ipv6_route']: + for route in ['route', 'route6']: if route in policy: + cmd_str = 'ip' if route == 'route' else 'ip -6' + tables = [] for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: for rule_id, rule_conf in pol_conf['rule'].items(): @@ -107,31 +218,27 @@ def apply_table_marks(policy): if set_table: if set_table == 'main': set_table = '254' + if set_table in tables: + continue + tables.append(set_table) table_mark = mark_offset - int(set_table) - cmd(f'ip rule add fwmark {table_mark} table {set_table}') + cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') def cleanup_table_marks(): - json_rules = cmd('ip -j -N rule list') - rules = loads(json_rules) - for rule in rules: - if 'fwmark' not in rule or 'table' not in rule: - continue - fwmark = rule['fwmark'] - table = int(rule['table']) - if fwmark[:2] == '0x': - fwmark = int(fwmark, 16) - if (int(fwmark) == (mark_offset - table)): - cmd(f'ip rule del fwmark {fwmark} table {table}') + for cmd_str in ['ip', 'ip -6']: + json_rules = cmd(f'{cmd_str} -j -N rule list') + rules = loads(json_rules) + for rule in rules: + if 'fwmark' not in rule or 'table' not in rule: + continue + fwmark = rule['fwmark'] + table = int(rule['table']) + if fwmark[:2] == '0x': + fwmark = int(fwmark, 16) + if (int(fwmark) == (mark_offset - table)): + cmd(f'{cmd_str} rule del fwmark {fwmark} table {table}') def apply(policy): - if not policy or 'first_install' not in policy: - run(f'nft flush table ip mangle') - run(f'nft flush table ip6 mangle') - - if not policy: - cleanup_table_marks() - return None - install_result = run(f'nft -f {nftables_conf}') if install_result == 1: raise ConfigError('Failed to apply policy based routing') diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index e251396c7..9d8fcfa36 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -87,6 +87,7 @@ def verify(policy): # human readable instance name (hypen instead of underscore) policy_hr = policy_type.replace('_', '-') + entries = [] for rule, rule_config in instance_config['rule'].items(): mandatory_error = f'must be specified for "{policy_hr} {instance} rule {rule}"!' if 'action' not in rule_config: @@ -113,6 +114,10 @@ def verify(policy): if 'prefix' not in rule_config: raise ConfigError(f'A prefix {mandatory_error}') + if rule_config in entries: + raise ConfigError(f'Rule "{rule}" contains a duplicate prefix definition!') + entries.append(rule_config) + # route-maps tend to be a bit more complex so they get their own verify() section if 'route_map' in policy: diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index d8704727c..dace53d37 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -159,13 +159,21 @@ def verify(bgp): # Only checks for ipv4 and ipv6 neighbors # Check if neighbor address is assigned as system interface address - if is_ip(peer) and is_addr_assigned(peer): - raise ConfigError(f'Can not configure a local address as neighbor "{peer}"') + vrf = None + vrf_error_msg = f' in default VRF!' + if 'vrf' in bgp: + vrf = bgp['vrf'] + vrf_error_msg = f' in VRF "{vrf}"!' + + if is_ip(peer) and is_addr_assigned(peer, vrf): + raise ConfigError(f'Can not configure local address as neighbor "{peer}"{vrf_error_msg}') elif is_interface(peer): if 'peer_group' in peer_config: raise ConfigError(f'peer-group must be set under the interface node of "{peer}"') if 'remote_as' in peer_config: raise ConfigError(f'remote-as must be set under the interface node of "{peer}"') + if 'source_interface' in peer_config['interface']: + raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"') for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec', 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', @@ -205,6 +213,11 @@ def verify(bgp): if 'non_exist_map' in afi_config['conditionally_advertise']: verify_route_map(afi_config['conditionally_advertise']['non_exist_map'], bgp) + # T4332: bgp deterministic-med cannot be disabled while addpath-tx-bestpath-per-AS is in use + if 'addpath_tx_per_as' in afi_config: + if dict_search('parameters.deterministic_med', bgp) == None: + raise ConfigError('addpath-tx-per-as requires BGP deterministic-med paramtere to be set!') + # Validate if configured Prefix list exists if 'prefix_list' in afi_config: for tmp in ['import', 'export']: diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 9b4b215de..f2501e38a 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -169,28 +169,40 @@ def verify(isis): # Segment routing checks if dict_search('segment_routing.global_block', isis): - high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) - low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) + g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) + g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) - # If segment routing global block high value is blank, throw error - if (low_label_value and not high_label_value) or (high_label_value and not low_label_value): - raise ConfigError('Segment routing global block requires both low and high value!') + # If segment routing global block high or low value is blank, throw error + if not (g_low_label_value or g_high_label_value): + raise ConfigError('Segment routing global-block requires both low and high value!') # If segment routing global block low value is higher than the high value, throw error - if int(low_label_value) > int(high_label_value): - raise ConfigError('Segment routing global block low value must be lower than high value') + if int(g_low_label_value) > int(g_high_label_value): + raise ConfigError('Segment routing global-block low value must be lower than high value') if dict_search('segment_routing.local_block', isis): - high_label_value = dict_search('segment_routing.local_block.high_label_value', isis) - low_label_value = dict_search('segment_routing.local_block.low_label_value', isis) + if dict_search('segment_routing.global_block', isis) == None: + raise ConfigError('Segment routing local-block requires global-block to be configured!') - # If segment routing local block high value is blank, throw error - if (low_label_value and not high_label_value) or (high_label_value and not low_label_value): - raise ConfigError('Segment routing local block requires both high and low value!') + l_high_label_value = dict_search('segment_routing.local_block.high_label_value', isis) + l_low_label_value = dict_search('segment_routing.local_block.low_label_value', isis) - # If segment routing local block low value is higher than the high value, throw error - if int(low_label_value) > int(high_label_value): - raise ConfigError('Segment routing local block low value must be lower than high value') + # If segment routing local-block high or low value is blank, throw error + if not (l_low_label_value or l_high_label_value): + raise ConfigError('Segment routing local-block requires both high and low value!') + + # If segment routing local-block low value is higher than the high value, throw error + if int(l_low_label_value) > int(l_high_label_value): + raise ConfigError('Segment routing local-block low value must be lower than high value') + + # local-block most live outside global block + global_range = range(int(g_low_label_value), int(g_high_label_value) +1) + local_range = range(int(l_low_label_value), int(l_high_label_value) +1) + + # Check for overlapping ranges + if list(set(global_range) & set(local_range)): + raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ + f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') return None diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 0b0c7d07b..933e23065 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,11 +20,10 @@ from sys import exit from glob import glob from vyos.config import Config -from vyos.configdict import node_changed from vyos.template import render_to_string -from vyos.util import call from vyos.util import dict_search from vyos.util import read_file +from vyos.util import sysctl_write from vyos import ConfigError from vyos import frr from vyos import airbag @@ -89,21 +88,21 @@ def apply(mpls): labels = '0' if 'interface' in mpls: labels = '1048575' - call(f'sysctl -wq net.mpls.platform_labels={labels}') + sysctl_write('net.mpls.platform_labels', labels) # Check for changes in global MPLS options if 'parameters' in mpls: # Choose whether to copy IP TTL to MPLS header TTL if 'no_propagate_ttl' in mpls['parameters']: - call('sysctl -wq net.mpls.ip_ttl_propagate=0') + sysctl_write('net.mpls.ip_ttl_propagate', 0) # Choose whether to limit maximum MPLS header TTL if 'maximum_ttl' in mpls['parameters']: ttl = mpls['parameters']['maximum_ttl'] - call(f'sysctl -wq net.mpls.default_ttl={ttl}') + sysctl_write('net.mpls.default_ttl', ttl) else: # Set default global MPLS options if not defined. - call('sysctl -wq net.mpls.ip_ttl_propagate=1') - call('sysctl -wq net.mpls.default_ttl=255') + sysctl_write('net.mpls.ip_ttl_propagate', 1) + sysctl_write('net.mpls.default_ttl', 255) # Enable and disable MPLS processing on interfaces per configuration if 'interface' in mpls: @@ -117,11 +116,11 @@ def apply(mpls): if '1' in interface_state: if system_interface not in mpls['interface']: system_interface = system_interface.replace('.', '/') - call(f'sysctl -wq net.mpls.conf.{system_interface}.input=0') + sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) elif '0' in interface_state: if system_interface in mpls['interface']: system_interface = system_interface.replace('.', '/') - call(f'sysctl -wq net.mpls.conf.{system_interface}.input=1') + sysctl_write(f'net.mpls.conf.{system_interface}.input', 1) else: system_interfaces = [] # If MPLS interfaces are not configured, set MPLS processing disabled @@ -129,7 +128,7 @@ def apply(mpls): system_interfaces.append(os.path.basename(interface)) for system_interface in system_interfaces: system_interface = system_interface.replace('.', '/') - call(f'sysctl -wq net.mpls.conf.{system_interface}.input=0') + sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) return None diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 4895cde6f..26d491838 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -25,6 +25,7 @@ from vyos.configdict import node_changed from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_route_map from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_access_list from vyos.template import render_to_string from vyos.util import dict_search from vyos.util import get_interface_config @@ -159,6 +160,16 @@ def verify(ospf): route_map_name = dict_search('default_information.originate.route_map', ospf) if route_map_name: verify_route_map(route_map_name, ospf) + # Validate if configured Access-list exists + if 'area' in ospf: + for area, area_config in ospf['area'].items(): + if 'import_list' in area_config: + acl_import = area_config['import_list'] + if acl_import: verify_access_list(acl_import, ospf) + if 'export_list' in area_config: + acl_export = area_config['export_list'] + if acl_export: verify_access_list(acl_export, ospf) + if 'interface' in ospf: for interface, interface_config in ospf['interface'].items(): verify_interface_exists(interface) diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index c1e427b16..f0ec48de4 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -82,6 +82,10 @@ def verify(static): for interface, interface_config in prefix_options[type].items(): verify_vrf(interface_config) + if {'blackhole', 'reject'} <= set(prefix_options): + raise ConfigError(f'Can not use both blackhole and reject for '\ + 'prefix "{prefix}"!') + return None def generate(static): diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py new file mode 100755 index 000000000..dbe3be225 --- /dev/null +++ b/src/conf_mode/qos.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['qos'] + if not conf.exists(base): + return None + + qos = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + if 'policy' in qos: + for policy in qos['policy']: + # CLI mangles - to _ for better Jinja2 compatibility - do we need + # Jinja2 here? + policy = policy.replace('-','_') + + default_values = defaults(base + ['policy', policy]) + + # class is another tag node which requires individual handling + class_default_values = defaults(base + ['policy', policy, 'class']) + if 'class' in default_values: + del default_values['class'] + + for p_name, p_config in qos['policy'][policy].items(): + qos['policy'][policy][p_name] = dict_merge( + default_values, qos['policy'][policy][p_name]) + + if 'class' in p_config: + for p_class in p_config['class']: + qos['policy'][policy][p_name]['class'][p_class] = dict_merge( + class_default_values, qos['policy'][policy][p_name]['class'][p_class]) + + import pprint + pprint.pprint(qos) + return qos + +def verify(qos): + if not qos: + return None + + # network policy emulator + # reorder rerquires delay to be set + + raise ConfigError('123') + return None + +def generate(qos): + return None + +def apply(qos): + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index f676fdbbe..2ebee8018 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -41,6 +41,7 @@ default_config_data = { 'interfaces': [], 'dnsv4': [], 'dnsv6': [], + 'client_named_ip_pool': [], 'client_ipv6_pool': [], 'client_ipv6_delegate_prefix': [], 'radius_server': [], @@ -219,6 +220,22 @@ def get_config(config=None): conf.set_level(base_path) + # Named client-ip-pool + if conf.exists(['client-ip-pool', 'name']): + for name in conf.list_nodes(['client-ip-pool', 'name']): + tmp = { + 'name': name, + 'gateway_address': '', + 'subnet': '' + } + + if conf.exists(['client-ip-pool', 'name', name, 'gateway-address']): + tmp['gateway_address'] += conf.return_value(['client-ip-pool', 'name', name, 'gateway-address']) + if conf.exists(['client-ip-pool', 'name', name, 'subnet']): + tmp['subnet'] += conf.return_value(['client-ip-pool', 'name', name, 'subnet']) + + ipoe['client_named_ip_pool'].append(tmp) + if conf.exists(['client-ipv6-pool', 'prefix']): for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): tmp = { @@ -254,10 +271,6 @@ def verify(ipoe): if not ipoe['interfaces']: raise ConfigError('No IPoE interface configured') - for interface in ipoe['interfaces']: - if not interface['range']: - raise ConfigError(f'No IPoE client subnet defined on interface "{ interface }"') - if len(ipoe['dnsv4']) > 2: raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index a1e7a7286..8a972b9fe 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,6 +22,7 @@ from shutil import rmtree from vyos.config import Config from vyos.configdict import dict_merge +from vyos.ifconfig import Section from vyos.template import render from vyos.util import call from vyos.util import chown @@ -42,6 +43,24 @@ systemd_telegraf_override_dir = '/etc/systemd/system/vyos-telegraf.service.d' systemd_override = f'{systemd_telegraf_override_dir}/10-override.conf' +def get_interfaces(type='', vlan=True): + """ + Get interfaces + get_interfaces() + ['dum0', 'eth0', 'eth1', 'eth1.5', 'lo', 'tun0'] + + get_interfaces("dummy") + ['dum0'] + """ + interfaces = [] + ifaces = Section.interfaces(type) + for iface in ifaces: + if vlan == False and '.' in iface: + continue + interfaces.append(iface) + + return interfaces + def get_nft_filter_chains(): """ Get nft chains for table filter @@ -57,6 +76,7 @@ def get_nft_filter_chains(): return chain_list + def get_config(config=None): if config: @@ -75,8 +95,9 @@ def get_config(config=None): default_values = defaults(base) monitoring = dict_merge(default_values, monitoring) - monitoring['nft_chains'] = get_nft_filter_chains() monitoring['custom_scripts_dir'] = custom_scripts_dir + monitoring['interfaces_ethernet'] = get_interfaces('ethernet', vlan=False) + monitoring['nft_chains'] = get_nft_filter_chains() return monitoring diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py new file mode 100755 index 000000000..d21b31990 --- /dev/null +++ b/src/conf_mode/service_upnp.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit +import uuid +import netifaces +from ipaddress import IPv4Network +from ipaddress import IPv6Network + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_vrf +from vyos.util import call +from vyos.template import render +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/upnp/miniupnp.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'upnp'] + upnpd = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + if not upnpd: + return None + + if 'rule' in upnpd: + default_member_values = defaults(base + ['rule']) + for rule,rule_config in upnpd['rule'].items(): + upnpd['rule'][rule] = dict_merge(default_member_values, upnpd['rule'][rule]) + + uuidgen = uuid.uuid1() + upnpd.update({'uuid': uuidgen}) + + return upnpd + +def get_all_interface_addr(prefix, filter_dev, filter_family): + list_addr = [] + interfaces = netifaces.interfaces() + + for interface in interfaces: + if filter_dev and interface in filter_dev: + continue + addrs = netifaces.ifaddresses(interface) + if netifaces.AF_INET in addrs.keys(): + if netifaces.AF_INET in filter_family: + for addr in addrs[netifaces.AF_INET]: + if prefix: + # we need to manually assemble a list of IPv4 address/prefix + prefix = '/' + \ + str(IPv4Network('0.0.0.0/' + addr['netmask']).prefixlen) + list_addr.append(addr['addr'] + prefix) + else: + list_addr.append(addr['addr']) + if netifaces.AF_INET6 in addrs.keys(): + if netifaces.AF_INET6 in filter_family: + for addr in addrs[netifaces.AF_INET6]: + if prefix: + # we need to manually assemble a list of IPv4 address/prefix + bits = bin(int(addr['netmask'].replace(':', '').split('/')[0], 16)).count('1') + prefix = '/' + str(bits) + list_addr.append(addr['addr'] + prefix) + else: + list_addr.append(addr['addr']) + + return list_addr + +def verify(upnpd): + if not upnpd: + return None + + if 'wan_interface' not in upnpd: + raise ConfigError('To enable UPNP, you must have the "wan-interface" option!') + + if 'rule' in upnpd: + for rule, rule_config in upnpd['rule'].items(): + for option in ['external_port_range', 'internal_port_range', 'ip', 'action']: + if option not in rule_config: + tmp = option.replace('_', '-') + raise ConfigError(f'Every UPNP rule requires "{tmp}" to be set!') + + if 'stun' in upnpd: + for option in ['host', 'port']: + if option not in upnpd['stun']: + raise ConfigError(f'A UPNP stun support must have an "{option}" option!') + + # Check the validity of the IP address + listen_dev = [] + system_addrs_cidr = get_all_interface_addr(True, [], [netifaces.AF_INET, netifaces.AF_INET6]) + system_addrs = get_all_interface_addr(False, [], [netifaces.AF_INET, netifaces.AF_INET6]) + for listen_if_or_addr in upnpd['listen']: + if listen_if_or_addr not in netifaces.interfaces(): + listen_dev.append(listen_if_or_addr) + if (listen_if_or_addr not in system_addrs) and (listen_if_or_addr not in system_addrs_cidr) and (listen_if_or_addr not in netifaces.interfaces()): + if is_ipv4(listen_if_or_addr) and IPv4Network(listen_if_or_addr).is_multicast: + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') + if is_ipv6(listen_if_or_addr) and IPv6Network(listen_if_or_addr).is_multicast: + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed to listen on. It is not an interface address nor a multicast address!') + + system_listening_dev_addrs_cidr = get_all_interface_addr(True, listen_dev, [netifaces.AF_INET6]) + system_listening_dev_addrs = get_all_interface_addr(False, listen_dev, [netifaces.AF_INET6]) + for listen_if_or_addr in upnpd['listen']: + if listen_if_or_addr not in netifaces.interfaces() and (listen_if_or_addr not in system_listening_dev_addrs_cidr) and (listen_if_or_addr not in system_listening_dev_addrs) and is_ipv6(listen_if_or_addr) and (not IPv6Network(listen_if_or_addr).is_multicast): + raise ConfigError(f'{listen_if_or_addr} must listen on the interface of the network card') + +def generate(upnpd): + if not upnpd: + return None + + if os.path.isfile(config_file): + os.unlink(config_file) + + render(config_file, 'firewall/upnpd.conf.tmpl', upnpd) + +def apply(upnpd): + systemd_service_name = 'miniupnpd.service' + if not upnpd: + # Stop the UPNP service + call(f'systemctl stop {systemd_service_name}') + else: + # Start the UPNP service + call(f'systemctl restart {systemd_service_name}') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 32cb2f036..05fc3a97a 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system-ip.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,14 +20,13 @@ from vyos.config import Config from vyos.configdict import dict_merge from vyos.util import call from vyos.util import dict_search +from vyos.util import sysctl_write +from vyos.util import write_file from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() -def sysctl(name, value): - call(f'sysctl -wq {name}={value}') - def get_config(config=None): if config: conf = config @@ -50,29 +49,29 @@ def generate(opt): pass def apply(opt): + # Apply ARP threshold values + # table_size has a default value - thus the key always exists size = int(dict_search('arp.table_size', opt)) - if size: - # apply ARP threshold values - sysctl('net.ipv4.neigh.default.gc_thresh3', str(size)) - sysctl('net.ipv4.neigh.default.gc_thresh2', str(size // 2)) - sysctl('net.ipv4.neigh.default.gc_thresh1', str(size // 8)) + # Amount upon reaching which the records begin to be cleared immediately + sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) + # Amount after which the records begin to be cleaned after 5 seconds + sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) + # Minimum number of stored records is indicated which is not cleared + sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8) # enable/disable IPv4 forwarding - tmp = '1' - if 'disable_forwarding' in opt: - tmp = '0' - sysctl('net.ipv4.conf.all.forwarding', tmp) + tmp = dict_search('disable_forwarding', opt) + value = '0' if (tmp != None) else '1' + write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) - tmp = '0' - # configure multipath - dict_search() returns an empty dict if key was found - if isinstance(dict_search('multipath.ignore_unreachable_nexthops', opt), dict): - tmp = '1' - sysctl('net.ipv4.fib_multipath_use_neigh', tmp) + # configure multipath + tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv4.fib_multipath_use_neigh', value) - tmp = '0' - if isinstance(dict_search('multipath.layer4_hashing', opt), dict): - tmp = '1' - sysctl('net.ipv4.fib_multipath_hash_policy', tmp) + tmp = dict_search('multipath.layer4_hashing', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv4.fib_multipath_hash_policy', value) if __name__ == '__main__': try: diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system-ipv6.py index f70ec2631..26aacf46b 100755 --- a/src/conf_mode/system-ipv6.py +++ b/src/conf_mode/system-ipv6.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,95 +15,68 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import sys from sys import exit -from copy import deepcopy from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.util import dict_search +from vyos.util import sysctl_write +from vyos.util import write_file +from vyos.xml import defaults from vyos import ConfigError -from vyos.util import call - from vyos import airbag airbag.enable() -ipv6_disable_file = '/etc/modprobe.d/vyos_disable_ipv6.conf' - -default_config_data = { - 'reboot_message': False, - 'ipv6_forward': '1', - 'disable_addr_assignment': False, - 'mp_layer4_hashing': '0', - 'neighbor_cache': 8192, - 'strict_dad': '1' - -} - -def sysctl(name, value): - call('sysctl -wq {}={}'.format(name, value)) - def get_config(config=None): - ip_opt = deepcopy(default_config_data) if config: conf = config else: conf = Config() - conf.set_level('system ipv6') - if conf.exists(''): - ip_opt['disable_addr_assignment'] = conf.exists('disable') - if conf.exists_effective('disable') != conf.exists('disable'): - ip_opt['reboot_message'] = True - - if conf.exists('disable-forwarding'): - ip_opt['ipv6_forward'] = '0' + base = ['system', 'ipv6'] - if conf.exists('multipath layer4-hashing'): - ip_opt['mp_layer4_hashing'] = '1' + opt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - if conf.exists('neighbor table-size'): - ip_opt['neighbor_cache'] = int(conf.return_value('neighbor table-size')) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + opt = dict_merge(default_values, opt) - if conf.exists('strict-dad'): - ip_opt['strict_dad'] = 2 + return opt - return ip_opt - -def verify(ip_opt): +def verify(opt): pass -def generate(ip_opt): +def generate(opt): pass -def apply(ip_opt): - # disable IPv6 address assignment - if ip_opt['disable_addr_assignment']: - with open(ipv6_disable_file, 'w') as f: - f.write('options ipv6 disable_ipv6=1') - else: - if os.path.exists(ipv6_disable_file): - os.unlink(ipv6_disable_file) - - if ip_opt['reboot_message']: - print('Changing IPv6 disable parameter will only take affect\n' \ - 'when the system is rebooted.') - +def apply(opt): # configure multipath - sysctl('net.ipv6.fib_multipath_hash_policy', ip_opt['mp_layer4_hashing']) - - # apply neighbor table threshold values - sysctl('net.ipv6.neigh.default.gc_thresh3', ip_opt['neighbor_cache']) - sysctl('net.ipv6.neigh.default.gc_thresh2', ip_opt['neighbor_cache'] // 2) - sysctl('net.ipv6.neigh.default.gc_thresh1', ip_opt['neighbor_cache'] // 8) + tmp = dict_search('multipath.layer4_hashing', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv6.fib_multipath_hash_policy', value) + + # Apply ND threshold values + # table_size has a default value - thus the key always exists + size = int(dict_search('neighbor.table_size', opt)) + # Amount upon reaching which the records begin to be cleared immediately + sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) + # Amount after which the records begin to be cleaned after 5 seconds + sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) + # Minimum number of stored records is indicated which is not cleared + sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) # enable/disable IPv6 forwarding - with open('/proc/sys/net/ipv6/conf/all/forwarding', 'w') as f: - f.write(ip_opt['ipv6_forward']) + tmp = dict_search('disable_forwarding', opt) + value = '0' if (tmp != None) else '1' + write_file('/proc/sys/net/ipv6/conf/all/forwarding', value) # configure IPv6 strict-dad + tmp = dict_search('strict_dad', opt) + value = '2' if (tmp != None) else '1' for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'): for name in files: - if name == "accept_dad": - with open(os.path.join(root, name), 'w') as f: - f.write(str(ip_opt['strict_dad'])) + if name == 'accept_dad': + write_file(os.path.join(root, name), value) if __name__ == '__main__': try: diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index 4dd7f936d..c9c6aa187 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,6 +23,7 @@ from pwd import getpwall from pwd import getpwnam from spwd import getspnam from sys import exit +from time import sleep from vyos.config import Config from vyos.configdict import dict_merge @@ -31,6 +32,7 @@ from vyos.template import render from vyos.template import is_ipv4 from vyos.util import cmd from vyos.util import call +from vyos.util import run from vyos.util import DEVNULL from vyos.util import dict_search from vyos.xml import defaults @@ -250,13 +252,22 @@ def apply(login): if 'rm_users' in login: for user in login['rm_users']: try: + # Disable user to prevent re-login + call(f'usermod -s /sbin/nologin {user}') + # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') - call(f'pkill -HUP -u {user}') - - # Remove user account but leave home directory to be safe - call(f'userdel --remove {user}', stderr=DEVNULL) + # re-run command until user is logged out + while run(f'pkill -HUP -u {user}'): + sleep(0.250) + + # Remove user account but leave home directory in place. Re-run + # command until user is removed - userdel might return 8 as + # SSH sessions are not all yet properly cleaned away, thus we + # simply re-run the command until the account wen't away + while run(f'userdel --remove {user}', stderr=DEVNULL): + sleep(0.250) except Exception as e: raise ConfigError(f'Deleting user "{user}" raised exception: {e}') diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py index 3d8a51cd8..309b4bdb0 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system-syslog.py @@ -17,6 +17,7 @@ import os import re +from pathlib import Path from sys import exit from vyos.config import Config @@ -89,7 +90,7 @@ def get_config(config=None): filename: { 'log-file': '/var/log/user/' + filename, 'max-files': '5', - 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/' + filename, + 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog-generated-' + filename, 'selectors': '*.err', 'max-size': 262144 } @@ -205,10 +206,17 @@ def generate(c): conf = '/etc/rsyslog.d/vyos-rsyslog.conf' render(conf, 'syslog/rsyslog.conf.tmpl', c) + # cleanup current logrotate config files + logrotate_files = Path('/etc/logrotate.d/').glob('vyos-rsyslog-generated-*') + for file in logrotate_files: + file.unlink() + # eventually write for each file its own logrotate file, since size is # defined it shouldn't matter - conf = '/etc/logrotate.d/vyos-rsyslog' - render(conf, 'syslog/logrotate.tmpl', c) + for filename, fileconfig in c.get('files', {}).items(): + if fileconfig['log-file'].startswith('/var/log/user/'): + conf = '/etc/logrotate.d/vyos-rsyslog-generated-' + filename + render(conf, 'syslog/logrotate.tmpl', { 'config_render': fileconfig }) def verify(c): diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 38c0c4463..f79c8a21e 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -29,6 +29,7 @@ from vyos.util import dict_search from vyos.util import get_interface_config from vyos.util import popen from vyos.util import run +from vyos.util import sysctl_write from vyos import ConfigError from vyos import frr from vyos import airbag @@ -37,10 +38,16 @@ airbag.enable() config_file = '/etc/iproute2/rt_tables.d/vyos-vrf.conf' nft_vrf_config = '/tmp/nftables-vrf-zones' -def list_rules(): - command = 'ip -j -4 rule show' - answer = loads(cmd(command)) - return [_ for _ in answer if _] +def has_rule(af : str, priority : int, table : str): + """ Check if a given ip rule exists """ + if af not in ['-4', '-6']: + raise ValueError() + command = f'ip -j {af} rule show' + for tmp in loads(cmd(command)): + if {'priority', 'table'} <= set(tmp): + if tmp['priority'] == priority and tmp['table'] == table: + return True + return False def vrf_interfaces(c, match): matched = [] @@ -69,7 +76,6 @@ def vrf_routing(c, match): c.set_level(old_level) return matched - def get_config(config=None): if config: conf = config @@ -148,13 +154,11 @@ def apply(vrf): bind_all = '0' if 'bind-to-all' in vrf: bind_all = '1' - call(f'sysctl -wq net.ipv4.tcp_l3mdev_accept={bind_all}') - call(f'sysctl -wq net.ipv4.udp_l3mdev_accept={bind_all}') + sysctl_write('net.ipv4.tcp_l3mdev_accept', bind_all) + sysctl_write('net.ipv4.udp_l3mdev_accept', bind_all) for tmp in (dict_search('vrf_remove', vrf) or []): if os.path.isdir(f'/sys/class/net/{tmp}'): - call(f'ip -4 route del vrf {tmp} unreachable default metric 4278198272') - call(f'ip -6 route del vrf {tmp} unreachable default metric 4278198272') call(f'ip link delete dev {tmp}') # Remove nftables conntrack zone map item nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' @@ -165,31 +169,59 @@ def apply(vrf): # check if table already exists _, err = popen('nft list table inet vrf_zones') # If not, create a table - if err: - if os.path.exists(nft_vrf_config): - cmd(f'nft -f {nft_vrf_config}') - os.unlink(nft_vrf_config) + if err and os.path.exists(nft_vrf_config): + cmd(f'nft -f {nft_vrf_config}') + os.unlink(nft_vrf_config) + + # Linux routing uses rules to find tables - routing targets are then + # looked up in those tables. If the lookup got a matching route, the + # process ends. + # + # TL;DR; first table with a matching entry wins! + # + # You can see your routing table lookup rules using "ip rule", sadly the + # local lookup is hit before any VRF lookup. Pinging an addresses from the + # VRF will usually find a hit in the local table, and never reach the VRF + # routing table - this is usually not what you want. Thus we will + # re-arrange the tables and move the local lookup further down once VRFs + # are enabled. + # + # Thanks to https://stbuehler.de/blog/article/2020/02/29/using_vrf__virtual_routing_and_forwarding__on_linux.html + + for afi in ['-4', '-6']: + # move lookup local to pref 32765 (from 0) + if not has_rule(afi, 32765, 'local'): + call(f'ip {afi} rule add pref 32765 table local') + if has_rule(afi, 0, 'local'): + call(f'ip {afi} rule del pref 0') + # make sure that in VRFs after failed lookup in the VRF specific table + # nothing else is reached + if not has_rule(afi, 1000, 'l3mdev'): + # this should be added by the kernel when a VRF is created + # add it here for completeness + call(f'ip {afi} rule add pref 1000 l3mdev protocol kernel') + + # add another rule with an unreachable target which only triggers in VRF context + # if a route could not be reached + if not has_rule(afi, 2000, 'l3mdev'): + call(f'ip {afi} rule add pref 2000 l3mdev unreachable') for name, config in vrf['name'].items(): table = config['table'] - if not os.path.isdir(f'/sys/class/net/{name}'): # For each VRF apart from your default context create a VRF # interface with a separate routing table call(f'ip link add {name} type vrf table {table}') - # The kernel Documentation/networking/vrf.txt also recommends - # adding unreachable routes to the VRF routing tables so that routes - # afterwards are taken. - call(f'ip -4 route add vrf {name} unreachable default metric 4278198272') - call(f'ip -6 route add vrf {name} unreachable default metric 4278198272') - # We also should add proper loopback IP addresses to the newly - # created VRFs for services bound to the loopback address (SNMP, NTP) - call(f'ip -4 addr add 127.0.0.1/8 dev {name}') - call(f'ip -6 addr add ::1/128 dev {name}') # set VRF description for e.g. SNMP monitoring vrf_if = Interface(name) + # We also should add proper loopback IP addresses to the newly added + # VRF for services bound to the loopback address (SNMP, NTP) + vrf_if.add_addr('127.0.0.1/8') + vrf_if.add_addr('::1/128') + # add VRF description if available vrf_if.set_alias(config.get('description', '')) + # Enable/Disable of an interface must always be done at the end of the # derived class to make use of the ref-counting set_admin_state() # function. We will only enable the interface if 'up' was called as @@ -203,37 +235,9 @@ def apply(vrf): nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' cmd(f'nft {nft_add_element}') - # Linux routing uses rules to find tables - routing targets are then - # looked up in those tables. If the lookup got a matching route, the - # process ends. - # - # TL;DR; first table with a matching entry wins! - # - # You can see your routing table lookup rules using "ip rule", sadly the - # local lookup is hit before any VRF lookup. Pinging an addresses from the - # VRF will usually find a hit in the local table, and never reach the VRF - # routing table - this is usually not what you want. Thus we will - # re-arrange the tables and move the local lookup furhter down once VRFs - # are enabled. - - # get current preference on local table - local_pref = [r.get('priority') for r in list_rules() if r.get('table') == 'local'][0] - - # change preference when VRFs are enabled and local lookup table is default - if not local_pref and 'name' in vrf: - for af in ['-4', '-6']: - call(f'ip {af} rule add pref 32765 table local') - call(f'ip {af} rule del pref 0') # return to default lookup preference when no VRF is configured if 'name' not in vrf: - for af in ['-4', '-6']: - call(f'ip {af} rule add pref 0 table local') - call(f'ip {af} rule del pref 32765') - - # clean out l3mdev-table rule if present - if 1000 in [r.get('priority') for r in list_rules() if r.get('priority') == 1000]: - call(f'ip {af} rule del pref 1000') # Remove VRF zones table from nftables tmp = run('nft list table inet vrf_zones') if tmp == 0: diff --git a/src/conf_mode/zone_policy.py b/src/conf_mode/zone_policy.py index 683f8f034..dc0617353 100755 --- a/src/conf_mode/zone_policy.py +++ b/src/conf_mode/zone_policy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -20,10 +20,12 @@ from json import loads from sys import exit from vyos.config import Config +from vyos.configdict import dict_merge from vyos.template import render from vyos.util import cmd from vyos.util import dict_search_args from vyos.util import run +from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() @@ -36,12 +38,22 @@ def get_config(config=None): else: conf = Config() base = ['zone-policy'] - zone_policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) + zone_policy = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) - if zone_policy: - zone_policy['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) + zone_policy['firewall'] = conf.get_config_dict(['firewall'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + if 'zone' in zone_policy: + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base + ['zone']) + for zone in zone_policy['zone']: + zone_policy['zone'][zone] = dict_merge(default_values, + zone_policy['zone'][zone]) return zone_policy |