diff options
Diffstat (limited to 'src/conf_mode')
58 files changed, 1939 insertions, 1566 deletions
diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py index 1cd8f5451..7dc5206e0 100755 --- a/src/conf_mode/arp.py +++ b/src/conf_mode/arp.py @@ -61,7 +61,7 @@ def apply(arp): continue for address, address_config in interface_config['address'].items(): mac = address_config['mac'] - call(f'ip neigh add {address} lladdr {mac} dev {interface}') + call(f'ip neigh replace {address} lladdr {mac} dev {interface}') if __name__ == '__main__': try: diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 2110fd9e0..8efeaed54 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -40,20 +40,7 @@ airbag.enable() config_containers_registry = '/etc/containers/registries.conf' config_containers_storage = '/etc/containers/storage.conf' - -def _run_rerun(container_cmd): - counter = 0 - while True: - if counter >= 10: - break - try: - _cmd(container_cmd) - break - except: - counter = counter +1 - sleep(0.5) - - return None +systemd_unit_path = '/run/systemd/system' def _cmd(command): if os.path.exists('/tmp/vyos.container.debug'): @@ -90,10 +77,10 @@ def get_config(config=None): container['name'][name] = dict_merge(default_values, container['name'][name]) # Delete container network, delete containers - tmp = node_changed(conf, base + ['container', 'network']) + tmp = node_changed(conf, base + ['network']) if tmp: container.update({'network_remove' : tmp}) - tmp = node_changed(conf, base + ['container', 'name']) + tmp = node_changed(conf, base + ['name']) if tmp: container.update({'container_remove' : tmp}) return container @@ -122,7 +109,7 @@ def verify(container): # of image upgrade and deletion. image = container_config['image'] if run(f'podman image exists {image}') != 0: - Warning(f'Image "{image}" used in contianer "{name}" does not exist '\ + Warning(f'Image "{image}" used in container "{name}" does not exist '\ f'locally. Please use "add container image {image}" to add it '\ f'to the system! Container "{name}" will not be started!') @@ -132,13 +119,10 @@ def verify(container): # Check if the specified container network exists network_name = list(container_config['network'])[0] - if network_name not in container['network']: + if network_name not in container.get('network', {}): raise ConfigError(f'Container network "{network_name}" does not exist!') if 'address' in container_config['network'][network_name]: - if 'network' not in container_config: - raise ConfigError(f'Can not use "address" without "network" for container "{name}"!') - address = container_config['network'][network_name]['address'] network = None if is_ipv4(address): @@ -220,6 +204,72 @@ def verify(container): return None +def generate_run_arguments(name, container_config): + image = container_config['image'] + memory = container_config['memory'] + shared_memory = container_config['shared_memory'] + restart = container_config['restart'] + + # Add capability options. Should be in uppercase + cap_add = '' + if 'cap_add' in container_config: + for c in container_config['cap_add']: + c = c.upper() + 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: + for k, v in container_config['environment'].items(): + env_opt += f" -e \"{k}={v['value']}\"" + + # Publish ports + port = '' + if 'port' in container_config: + protocol = '' + for portmap in container_config['port']: + if 'protocol' in container_config['port'][portmap]: + protocol = container_config['port'][portmap]['protocol'] + protocol = f'/{protocol}' + else: + protocol = '/tcp' + sport = container_config['port'][portmap]['source'] + dport = container_config['port'][portmap]['destination'] + port += f' -p {sport}:{dport}{protocol}' + + # Bind volume + volume = '' + if 'volume' in container_config: + for vol, vol_config in container_config['volume'].items(): + svol = vol_config['source'] + dvol = vol_config['destination'] + volume += f' -v {svol}:{dvol}' + + container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \ + f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ + f'--name {name} {device} {port} {volume} {env_opt}' + + if 'allow_host_networks' in container_config: + return f'{container_base_cmd} --net host {image}' + + ip_param = '' + networks = ",".join(container_config['network']) + for network in container_config['network']: + if 'address' in container_config['network'][network]: + address = container_config['network'][network]['address'] + ip_param = f'--ip {address}' + + return f'{container_base_cmd} --net {networks} {ip_param} {image}' + def generate(container): # bail out early - looks like removal from running config if not container: @@ -263,6 +313,15 @@ def generate(container): render(config_containers_registry, 'container/registries.conf.j2', container) render(config_containers_storage, 'container/storage.conf.j2', container) + if 'name' in container: + for name, container_config in container['name'].items(): + if 'disable' in container_config: + continue + + file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') + run_args = generate_run_arguments(name, container_config) + render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args}) + return None def apply(container): @@ -270,17 +329,23 @@ def apply(container): # Option "--force" allows to delete containers with any status if 'container_remove' in container: for name in container['container_remove']: - call(f'podman stop {name}') - call(f'podman rm --force {name}') + file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') + call(f'systemctl stop vyos-container-{name}.service') + if os.path.exists(file_path): + os.unlink(file_path) + + call('systemctl daemon-reload') # Delete old networks if needed if 'network_remove' in container: for network in container['network_remove']: + call(f'podman network rm {network}') tmp = f'/etc/cni/net.d/{network}.conflist' if os.path.exists(tmp): os.unlink(tmp) # Add container + disabled_new = False if 'name' in container: for name, container_config in container['name'].items(): image = container_config['image'] @@ -294,70 +359,17 @@ def apply(container): # check if there is a container by that name running tmp = _cmd('podman ps -a --format "{{.Names}}"') if name in tmp: - _cmd(f'podman stop {name}') - _cmd(f'podman rm --force {name}') + file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') + call(f'systemctl stop vyos-container-{name}.service') + if os.path.exists(file_path): + disabled_new = True + os.unlink(file_path) continue - memory = container_config['memory'] - restart = container_config['restart'] - - # Add capability options. Should be in uppercase - cap_add = '' - if 'cap_add' in container_config: - for c in container_config['cap_add']: - c = c.upper() - 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: - for k, v in container_config['environment'].items(): - env_opt += f" -e \"{k}={v['value']}\"" - - # Publish ports - port = '' - if 'port' in container_config: - protocol = '' - for portmap in container_config['port']: - if 'protocol' in container_config['port'][portmap]: - protocol = container_config['port'][portmap]['protocol'] - protocol = f'/{protocol}' - else: - protocol = '/tcp' - sport = container_config['port'][portmap]['source'] - dport = container_config['port'][portmap]['destination'] - port += f' -p {sport}:{dport}{protocol}' - - # Bind volume - volume = '' - if 'volume' in container_config: - for vol, vol_config in container_config['volume'].items(): - svol = vol_config['source'] - dvol = vol_config['destination'] - volume += f' -v {svol}:{dvol}' - - container_base_cmd = f'podman run --detach --interactive --tty --replace {cap_add} ' \ - f'--memory {memory}m --memory-swap 0 --restart {restart} ' \ - f'--name {name} {device} {port} {volume} {env_opt}' - if 'allow_host_networks' in container_config: - _run_rerun(f'{container_base_cmd} --net host {image}') - else: - for network in container_config['network']: - ipparam = '' - if 'address' in container_config['network'][network]: - address = container_config['network'][network]['address'] - ipparam = f'--ip {address}' + cmd(f'systemctl restart vyos-container-{name}.service') - _run_rerun(f'{container_base_cmd} --net {network} {ipparam} {image}') + if disabled_new: + call('systemctl daemon-reload') return None diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py index f1c2d1f43..d0d87d73e 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/dns_forwarding.py @@ -98,6 +98,9 @@ def get_config(config=None): dns['authoritative_zone_errors'].append('{}.{}: at least one address is required'.format(subnode, node)) continue + if subnode == 'any': + subnode = '*' + for address in rdata['address']: zone['records'].append({ 'name': subnode, @@ -263,6 +266,12 @@ def verify(dns): if 'server' not in dns['domain'][domain]: raise ConfigError(f'No server configured for domain {domain}!') + if 'dns64_prefix' in dns: + dns_prefix = dns['dns64_prefix'].split('/')[1] + # RFC 6147 requires prefix /96 + if int(dns_prefix) != 96: + raise ConfigError('DNS 6to4 prefix must be of length /96') + if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']: for error in dns['authoritative_zone_errors']: print(error) diff --git a/src/conf_mode/firewall-interface.py b/src/conf_mode/firewall-interface.py deleted file mode 100755 index 9a5d278e9..000000000 --- a/src/conf_mode/firewall-interface.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os -import re - -from sys import argv -from sys import exit - -from vyos.config import Config -from vyos.configdict import leaf_node_changed -from vyos.ifconfig import Section -from vyos.template import render -from vyos.util import cmd -from vyos.util import dict_search_args -from vyos.util import run -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', - 'local': 'VYOS_FW_LOCAL' -} -NFT6_CHAINS = { - 'in': 'VYOS_FW6_FORWARD', - 'out': 'VYOS_FW6_FORWARD', - 'local': 'VYOS_FW6_LOCAL' -} - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - ifname = argv[1] - ifpath = Section.get_config_path(ifname) - if_firewall_path = f'interfaces {ifpath} firewall' - - if_firewall = conf.get_config_dict(if_firewall_path, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - if_firewall['ifname'] = ifname - if_firewall['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - return if_firewall - -def verify(if_firewall): - # bail out early - looks like removal from running config - if not if_firewall: - return None - - for direction in ['in', 'out', 'local']: - if direction in if_firewall: - if 'name' in if_firewall[direction]: - name = if_firewall[direction]['name'] - - if 'name' not in if_firewall['firewall']: - raise ConfigError('Firewall name not configured') - - if name not in if_firewall['firewall']['name']: - raise ConfigError(f'Invalid firewall name "{name}"') - - if 'ipv6_name' in if_firewall[direction]: - name = if_firewall[direction]['ipv6_name'] - - if 'ipv6_name' not in if_firewall['firewall']: - raise ConfigError('Firewall ipv6-name not configured') - - if name not in if_firewall['firewall']['ipv6_name']: - raise ConfigError(f'Invalid firewall ipv6-name "{name}"') - - return None - -def generate(if_firewall): - return None - -def cleanup_rule(table, chain, prefix, ifname, new_name=None): - results = cmd(f'nft -a list chain {table} {chain}').split("\n") - retval = None - for line in results: - if f'{prefix}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 - retval = True - continue - - handle_search = re.search('handle (\d+)', line) - if handle_search: - run(f'nft delete rule {table} {chain} handle {handle_search[1]}') - return retval - -def state_policy_handle(table, chain): - # Find any state-policy rule to ensure interface rules are only inserted afterwards - results = cmd(f'nft -a list chain {table} {chain}').split("\n") - for line in results: - if 'jump VYOS_STATE_POLICY' in line: - handle_search = re.search('handle (\d+)', line) - if handle_search: - return handle_search[1] - return None - -def apply(if_firewall): - ifname = if_firewall['ifname'] - - for direction in ['in', 'out', 'local']: - chain = NFT_CHAINS[direction] - ipv6_chain = NFT6_CHAINS[direction] - if_prefix = 'i' if direction in ['in', 'local'] else 'o' - - name = dict_search_args(if_firewall, direction, 'name') - if name: - rule_exists = cleanup_rule('ip filter', chain, if_prefix, ifname, f'{NAME_PREFIX}{name}') - - if not rule_exists: - rule_action = 'insert' - rule_prefix = '' - - handle = state_policy_handle('ip filter', chain) - if handle: - 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_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, f'{NAME6_PREFIX}{ipv6_name}') - - if not rule_exists: - rule_action = 'insert' - rule_prefix = '' - - 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 {NAME6_PREFIX}{ipv6_name}') - else: - cleanup_rule('ip6 filter', ipv6_chain, if_prefix, ifname) - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 6924bf555..f68acfe02 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.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 @@ -26,20 +26,26 @@ from vyos.config import Config from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdiff import get_config_diff, Diff +from vyos.configdep import set_dependents, call_dependents +# from vyos.configverify import verify_interface_exists +from vyos.firewall import fqdn_config_parse +from vyos.firewall import geoip_update from vyos.template import render +from vyos.util import call from vyos.util import cmd from vyos.util import dict_search_args +from vyos.util import dict_search_recursive from vyos.util import process_named_running -from vyos.util import run +from vyos.util import rc_cmd from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() -policy_route_conf_script = '/usr/libexec/vyos/conf_mode/policy-route.py' +nat_conf_script = 'nat.py' +policy_route_conf_script = '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'}, @@ -55,34 +61,18 @@ sysfs_config = { 'twa_hazards_protection': {'sysfs': '/proc/sys/net/ipv4/tcp_rfc1337'} } -NAME_PREFIX = 'NAME_' -NAME6_PREFIX = 'NAME6_' - -preserve_chains = [ - 'INPUT', - 'FORWARD', - 'OUTPUT', - 'VYOS_FW_FORWARD', - 'VYOS_FW_LOCAL', - 'VYOS_FW_OUTPUT', - 'VYOS_POST_FW', - 'VYOS_FRAG_MARK', - 'VYOS_FW6_FORWARD', - 'VYOS_FW6_LOCAL', - 'VYOS_FW6_OUTPUT', - 'VYOS_POST_FW6', - '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', + 'domain_group', 'network_group', 'port_group' ] +nested_group_types = [ + 'address_group', 'network_group', 'mac_group', + 'port_group', 'ipv6_address_group', 'ipv6_network_group' +] + snmp_change_type = { 'unknown': 0, 'add': 1, @@ -93,50 +83,39 @@ snmp_event_source = 1 snmp_trap_mib = 'VYATTA-TRAP-MIB' snmp_trap_name = 'mgmtEventTrap' -def get_firewall_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 'firewall' in if_conf: - output[prefix + ifname] = if_conf['firewall'] - 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_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 geoip_updated(conf, firewall): + diff = get_config_diff(conf) + node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True) + + out = { + 'name': [], + 'ipv6_name': [], + 'deleted_name': [], + 'deleted_ipv6_name': [] + } + updated = False + + for key, path in dict_search_recursive(firewall, 'geoip'): + set_name = f'GEOIP_CC_{path[1]}_{path[3]}' + if path[0] == 'name': + out['name'].append(set_name) + elif path[0] == 'ipv6_name': + out['ipv6_name'].append(set_name) + updated = True + + if 'delete' in node_diff: + for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): + set_name = f'GEOIP_CC_{path[1]}_{path[3]}' + if path[0] == 'name': + out['deleted_name'].append(set_name) + elif path[0] == 'ipv6-name': + out['deleted_ipv6_name'].append(set_name) + updated = True + + if updated: + return out + + return False def get_config(config=None): if config: @@ -148,12 +127,43 @@ def get_config(config=None): firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + # We have gathered the dict representation of the CLI, but there are + # default options which we need to update into the dictionary retrived. + # XXX: T2665: we currently have no nice way for defaults under tag + # nodes, thus we load the defaults "by hand" default_values = defaults(base) + for tmp in ['name', 'ipv6_name']: + if tmp in default_values: + del default_values[tmp] + + if 'zone' in default_values: + del default_values['zone'] + firewall = dict_merge(default_values, firewall) - 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) + # Merge in defaults for IPv4 ruleset + if 'name' in firewall: + default_values = defaults(base + ['name']) + for name in firewall['name']: + firewall['name'][name] = dict_merge(default_values, + firewall['name'][name]) + + # Merge in defaults for IPv6 ruleset + if 'ipv6_name' in firewall: + default_values = defaults(base + ['ipv6-name']) + for ipv6_name in firewall['ipv6_name']: + firewall['ipv6_name'][ipv6_name] = dict_merge(default_values, + firewall['ipv6_name'][ipv6_name]) + + if 'zone' in firewall: + default_values = defaults(base + ['zone']) + for zone in firewall['zone']: + firewall['zone'][zone] = dict_merge(default_values, firewall['zone'][zone]) + + firewall['group_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) + if firewall['group_resync']: + # Update nat and policy-route as firewall groups were updated + set_dependents('group_resync', conf) if 'config_trap' in firewall and firewall['config_trap'] == 'enable': diff = get_config_diff(conf) @@ -162,12 +172,30 @@ def get_config(config=None): key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + firewall['geoip_updated'] = geoip_updated(conf, firewall) + + fqdn_config_parse(firewall) + return firewall def verify_rule(firewall, rule_conf, ipv6): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') + if 'jump' in rule_conf['action'] and 'jump_target' not in rule_conf: + raise ConfigError('Action set to jump, but no jump-target specified') + + if 'jump_target' in rule_conf: + if 'jump' not in rule_conf['action']: + raise ConfigError('jump-target defined, but action jump needed and it is not defined') + target = rule_conf['jump_target'] + if not ipv6: + if target not in dict_search_args(firewall, 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + else: + if target not in dict_search_args(firewall, 'ipv6_name'): + raise ConfigError(f'Invalid jump-target. Firewall ipv6-name {target} does not exist on the system') + if 'fragment' in rule_conf: if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') @@ -207,19 +235,28 @@ def verify_rule(firewall, rule_conf, ipv6): if side in rule_conf: side_conf = rule_conf[side] + if len({'address', 'fqdn', 'geoip'} & set(side_conf)) > 1: + raise ConfigError('Only one of address, fqdn or geoip can be specified') + 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') + if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group or domain-group can be specified') for group in valid_groups: if group in side_conf['group']: group_name = side_conf['group'][group] + fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + error_group = fw_group.replace("_", "-") + + if group in ['address_group', 'network_group', 'domain_group']: + types = [t for t in ['address', 'fqdn', 'geoip'] if t in side_conf] + if types: + raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') + if group_name and group_name[0] == '!': 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(firewall, 'group', fw_group, group_name) if group_obj is None: @@ -235,100 +272,144 @@ def verify_rule(firewall, rule_conf, ipv6): if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') +def verify_nested_group(group_name, group, groups, seen): + if 'include' not in group: + return + + seen.append(group_name) + + for g in group['include']: + if g not in groups: + raise ConfigError(f'Nested group "{g}" does not exist') + + if g in seen: + raise ConfigError(f'Group "{group_name}" has a circular reference') + + if 'include' in groups[g]: + verify_nested_group(g, groups[g], groups, seen) + def verify(firewall): if 'config_trap' in firewall and firewall['config_trap'] == 'enable': if not firewall['trap_targets']: raise ConfigError(f'Firewall config-trap enabled but "service snmp trap-target" is not defined') + if 'group' in firewall: + for group_type in nested_group_types: + if group_type in firewall['group']: + groups = firewall['group'][group_type] + for group_name, group in groups.items(): + verify_nested_group(group_name, group, groups, []) + for name in ['name', 'ipv6_name']: if name in firewall: for name_id, name_conf in firewall[name].items(): - if name_id in preserve_chains: - raise ConfigError(f'Firewall name "{name_id}" is reserved for VyOS') - - if name_id.startswith("VZONE"): - raise ConfigError(f'Firewall name "{name_id}" uses reserved prefix') + if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in name_conf: + target = name_conf['default_jump_target'] + if 'jump' not in name_conf['default_action']: + raise ConfigError('default-jump-target defined,but default-action jump needed and it is not defined') + if name_conf['default_jump_target'] == name_id: + raise ConfigError(f'Loop detected on default-jump-target.') + ## Now need to check that default-jump-target exists (other firewall chain/name) + if target not in dict_search_args(firewall, name): + raise ConfigError(f'Invalid jump-target. Firewall {name} {target} does not exist on the system') if 'rule' in name_conf: for rule_id, rule_conf in name_conf['rule'].items(): verify_rule(firewall, rule_conf, name == 'ipv6_name') - for ifname, if_firewall in firewall['interfaces'].items(): - for direction in ['in', 'out', 'local']: - name = dict_search_args(if_firewall, direction, 'name') - ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') + if 'interface' in firewall: + for ifname, if_firewall in firewall['interface'].items(): + # verify ifname needs to be disabled, dynamic devices come up later + # verify_interface_exists(ifname) - if name and dict_search_args(firewall, 'name', name) == None: - raise ConfigError(f'Firewall name "{name}" is still referenced on interface {ifname}') + for direction in ['in', 'out', 'local']: + name = dict_search_args(if_firewall, direction, 'name') + ipv6_name = dict_search_args(if_firewall, direction, 'ipv6_name') - if 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}') + if name and dict_search_args(firewall, 'name', name) == None: + raise ConfigError(f'Invalid firewall name "{name}" 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') + if ipv6_name and dict_search_args(firewall, 'ipv6_name', ipv6_name) == None: + raise ConfigError(f'Invalid firewall ipv6-name "{ipv6_name}" referenced on interface {ifname}') - return None + local_zone = False + zone_interfaces = [] + + if 'zone' in firewall: + for zone, zone_conf in firewall['zone'].items(): + if 'local_zone' not in zone_conf and 'interface' not in zone_conf: + raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') + + if 'local_zone' in zone_conf: + if local_zone: + raise ConfigError('There cannot be multiple local zones') + if 'interface' in zone_conf: + raise ConfigError('Local zone cannot have interfaces assigned') + if 'intra_zone_filtering' in zone_conf: + raise ConfigError('Local zone cannot use intra-zone-filtering') + local_zone = True + + if 'interface' in zone_conf: + found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] + + if found_duplicates: + raise ConfigError(f'Interfaces cannot be assigned to multiple zones') -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}') - obj = loads(json_str) - if 'nftables' not in obj: - continue - for item in obj['nftables']: - if 'chain' in item: - chain = item['chain']['name'] - if chain in ['VYOS_STATE_POLICY', 'VYOS_STATE_POLICY6']: - if 'state_policy' not in firewall: - commands.append(f'delete chain {table} {chain}') - 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.replace(NAME_PREFIX, "", 1)) != None: - commands.append(f'flush chain {table} {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'] - if rule['chain'] in ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL', 'VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL']: - if 'expr' in rule and any([True for expr in rule['expr'] if dict_search_args(expr, 'jump', 'target') == state_chain]): - if 'state_policy' not in firewall: - chain = rule['chain'] - handle = rule['handle'] - commands.append(f'delete rule {table} {chain} handle {handle}') - elif 'set' in item: - set_name = item['set']['name'] - commands_end.append(f'delete set {table} {set_name}') - return commands + commands_end + zone_interfaces += zone_conf['interface'] + + if 'intra_zone_filtering' in zone_conf: + intra_zone = zone_conf['intra_zone_filtering'] + + if len(intra_zone) > 1: + raise ConfigError('Only one intra-zone-filtering action must be specified') + + if 'firewall' in intra_zone: + v4_name = dict_search_args(intra_zone, 'firewall', 'name') + if v4_name and not dict_search_args(firewall, 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name') + if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + if not v4_name and not v6_name: + raise ConfigError('No firewall names specified for intra-zone-filtering') + + if 'from' in zone_conf: + for from_zone, from_conf in zone_conf['from'].items(): + if from_zone not in firewall['zone']: + raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') + + v4_name = dict_search_args(from_conf, 'firewall', 'name') + if v4_name and not dict_search_args(firewall, 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') + if v6_name and not dict_search_args(firewall, 'ipv6_name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + return None def generate(firewall): if not os.path.exists(nftables_conf): firewall['first_install'] = True - else: - firewall['cleanup_commands'] = cleanup_commands(firewall) + + if 'zone' in firewall: + for local_zone, local_zone_conf in firewall['zone'].items(): + if 'local_zone' not in local_zone_conf: + continue + + local_zone_conf['from_local'] = {} + + for zone, zone_conf in firewall['zone'].items(): + if zone == local_zone or 'from' not in zone_conf: + continue + if local_zone in zone_conf['from']: + local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] render(nftables_conf, 'firewall/nftables.j2', firewall) - render(nftables_defines_conf, 'firewall/nftables-defines.j2', firewall) return None def apply_sysfs(firewall): @@ -387,38 +468,29 @@ def post_apply_trap(firewall): cmd(base_cmd + ' '.join(objects)) -def state_policy_rule_exists(): - # Determine if state policy rules already exist in nft - 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: - Warning('Failed to re-apply policy route configuration!') - def apply(firewall): - if 'first_install' in firewall: - run('nfct helper add rpc inet tcp') - run('nfct helper add rpc inet udp') - run('nfct helper add tns inet tcp') - - install_result = run(f'nft -f {nftables_conf}') + install_result, output = rc_cmd(f'nft -f {nftables_conf}') if install_result == 1: - raise ConfigError('Failed to apply firewall') - - if 'state_policy' in firewall and not state_policy_rule_exists(): - for chain in ['VYOS_FW_FORWARD', 'VYOS_FW_OUTPUT', 'VYOS_FW_LOCAL']: - cmd(f'nft insert rule ip filter {chain} jump VYOS_STATE_POLICY') - - for chain in ['VYOS_FW6_FORWARD', 'VYOS_FW6_OUTPUT', 'VYOS_FW6_LOCAL']: - cmd(f'nft insert rule ip6 filter {chain} jump VYOS_STATE_POLICY6') + raise ConfigError(f'Failed to apply firewall: {output}') apply_sysfs(firewall) - if firewall['policy_resync']: - resync_policy_route() + if firewall['group_resync']: + call_dependents() + + # T970 Enable a resolver (systemd daemon) that checks + # domain-group/fqdn addresses and update entries for domains by timeout + # If router loaded without internet connection or for synchronization + domain_action = 'stop' + if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'] or firewall['ip6_fqdn']: + domain_action = 'restart' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + + if firewall['geoip_updated']: + # Call helper script to Update set contents + if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: + print('Updating GeoIP. Please wait...') + geoip_update(firewall) post_apply_trap(firewall) diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 7750c1247..7e16235c1 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -192,6 +192,11 @@ def verify(flow_config): raise ConfigError("All sFlow servers must use the same IP protocol") else: sflow_collector_ipver = ip_address(server).version + + # check if vrf is defined for Sflow + sflow_vrf = None + if 'vrf' in flow_config: + sflow_vrf = flow_config['vrf'] # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa for server in flow_config['sflow']['server']: @@ -203,12 +208,12 @@ def verify(flow_config): if 'agent_address' in flow_config['sflow']: tmp = flow_config['sflow']['agent_address'] - if not is_addr_assigned(tmp): + if not is_addr_assigned(tmp, sflow_vrf): raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') # Check if configured netflow source-address exist in the system if 'source_address' in flow_config['sflow']: - if not is_addr_assigned(flow_config['sflow']['source_address']): + if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf): tmp = flow_config['sflow']['source_address'] raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index e14050dd3..8a959dc79 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -88,15 +88,12 @@ def verify(ha): if not {'password', 'type'} <= set(group_config['authentication']): raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') - # We can not use a VRID once per interface + # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction + # We also need to make sure VRID is not used twice on the same interface with the + # same address family. + interface = group_config['interface'] vrid = group_config['vrid'] - tmp = {'interface': interface, 'vrid': vrid} - if tmp in used_vrid_if: - raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface}"!') - used_vrid_if.append(tmp) - - # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction # XXX: filter on map object is destructive, so we force it to list. # Additionally, filter objects always evaluate to True, empty or not, @@ -109,6 +106,11 @@ def verify(ha): raise ConfigError(f'VRRP group "{group}" mixes IPv4 and IPv6 virtual addresses, this is not allowed.\n' \ 'Create individual groups for IPv4 and IPv6!') if vaddrs4: + tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv4'} + if tmp in used_vrid_if: + raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv4"!') + used_vrid_if.append(tmp) + if 'hello_source_address' in group_config: if is_ipv6(group_config['hello_source_address']): raise ConfigError(f'VRRP group "{group}" uses IPv4 but hello-source-address is IPv6!') @@ -118,6 +120,11 @@ def verify(ha): raise ConfigError(f'VRRP group "{group}" uses IPv4 but peer-address is IPv6!') if vaddrs6: + tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv6'} + if tmp in used_vrid_if: + raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv6"!') + used_vrid_if.append(tmp) + if 'hello_source_address' in group_config: if is_ipv4(group_config['hello_source_address']): raise ConfigError(f'VRRP group "{group}" uses IPv6 but hello-source-address is IPv4!') diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 4a7906c17..6328294c1 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -24,9 +24,12 @@ from copy import deepcopy import vyos.defaults from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdep import set_dependents, call_dependents from vyos.template import render from vyos.util import cmd from vyos.util import call +from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() @@ -36,6 +39,15 @@ systemd_service = '/run/systemd/system/vyos-http-api.service' vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] +def _translate_values_to_boolean(d: dict) -> dict: + for k in list(d): + if d[k] == {}: + d[k] = True + elif isinstance(d[k], dict): + _translate_values_to_boolean(d[k]) + else: + pass + def get_config(config=None): http_api = deepcopy(vyos.defaults.api_data) x = http_api.get('api_keys') @@ -50,56 +62,49 @@ def get_config(config=None): else: conf = Config() + # reset on creation/deletion of 'api' node + https_base = ['service', 'https'] + if conf.exists(https_base): + set_dependents("https", conf) + base = ['service', 'https', 'api'] if not conf.exists(base): return None + api_dict = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + + # One needs to 'flatten' the keys dict from the config into the + # http-api.conf format for api_keys: + if 'keys' in api_dict: + api_dict['api_keys'] = [] + for el in list(api_dict['keys']['id']): + key = api_dict['keys']['id'][el]['key'] + api_dict['api_keys'].append({'id': el, 'key': key}) + del api_dict['keys'] + # Do we run inside a VRF context? vrf_path = ['service', 'https', 'vrf'] if conf.exists(vrf_path): http_api['vrf'] = conf.return_value(vrf_path) - conf.set_level('service https api') - if conf.exists('strict'): - http_api['strict'] = True - - 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 - - if conf.exists('port'): - port = conf.return_value('port') - http_api['port'] = port - - if conf.exists('cors'): - http_api['cors'] = {} - if conf.exists('cors allow-origin'): - origins = conf.return_values('cors allow-origin') - http_api['cors']['origins'] = origins[:] - - if conf.exists('keys'): - for name in conf.list_nodes('keys id'): - if conf.exists('keys id {0} key'.format(name)): - key = conf.return_value('keys id {0} key'.format(name)) - new_key = { 'id': name, 'key': key } - http_api['api_keys'].append(new_key) - keys_added = True + if 'api_keys' in api_dict: + keys_added = True + + if 'graphql' in api_dict: + api_dict = dict_merge(defaults(base), api_dict) + + http_api.update(api_dict) if keys_added and default_key: if default_key in http_api['api_keys']: http_api['api_keys'].remove(default_key) + # Finally, translate entries in http_api into boolean settings for + # backwards compatability of JSON http-api.conf file + _translate_values_to_boolean(http_api) + return http_api def verify(http_api): @@ -133,7 +138,7 @@ def apply(http_api): # Let uvicorn settle before restarting Nginx sleep(1) - cmd(f'{vyos_conf_scripts_dir}/https.py', raising=ConfigError) + call_dependents() if __name__ == '__main__': try: diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index 3057357fc..7cd7ea42e 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.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 @@ -29,6 +29,8 @@ from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call +from vyos.util import check_port_availability +from vyos.util import is_listen_port_bind_service from vyos.util import write_file from vyos import airbag @@ -107,6 +109,31 @@ def verify(https): raise ConfigError("At least one 'virtual-host <id> server-name' " "matching the 'certbot domain-name' is required.") + server_block_list = [] + + # organize by vhosts + vhost_dict = https.get('virtual-host', {}) + + if not vhost_dict: + # no specified virtual hosts (server blocks); use default + server_block_list.append(default_server_block) + else: + for vhost in list(vhost_dict): + server_block = deepcopy(default_server_block) + data = vhost_dict.get(vhost, {}) + server_block['address'] = data.get('listen-address', '*') + server_block['port'] = data.get('listen-port', '443') + server_block_list.append(server_block) + + for entry in server_block_list: + _address = entry.get('address') + _address = '0.0.0.0' if _address == '*' else _address + _port = entry.get('port') + proto = 'tcp' + if check_port_availability(_address, int(_port), proto) is not True and \ + not is_listen_port_bind_service(int(_port), 'nginx'): + raise ConfigError(f'"{proto}" port "{_port}" is used by another service') + verify_vrf(https) return None diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 4167594e3..b883ebef2 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-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 @@ -36,6 +36,7 @@ from vyos.ifconfig import BondIf from vyos.ifconfig import Section from vyos.util import dict_search from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured from vyos import ConfigError from vyos import airbag airbag.enable() @@ -72,57 +73,83 @@ def get_config(config=None): # To make our own life easier transfor the list of member interfaces # into a dictionary - we will use this to add additional information - # later on for wach member + # later on for each member if 'member' in bond and 'interface' in bond['member']: - # convert list if member interfaces to a dictionary - bond['member']['interface'] = dict.fromkeys( - bond['member']['interface'], {}) + # convert list of member interfaces to a dictionary + bond['member']['interface'] = {k: {} for k in bond['member']['interface']} if 'mode' in bond: bond['mode'] = get_bond_mode(bond['mode']) tmp = leaf_node_changed(conf, base + [ifname, 'mode']) - if tmp: bond.update({'shutdown_required': {}}) + if tmp: bond['shutdown_required'] = {} tmp = leaf_node_changed(conf, base + [ifname, 'lacp-rate']) - if tmp: bond.update({'shutdown_required': {}}) + if tmp: bond['shutdown_required'] = {} # determine which members have been removed interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) + + # Reset config level to interfaces + old_level = conf.get_level() + conf.set_level(['interfaces']) + if interfaces_removed: - bond.update({'shutdown_required': {}}) + bond['shutdown_required'] = {} if 'member' not in bond: - bond.update({'member': {}}) + bond['member'] = {} tmp = {} for interface in interfaces_removed: section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists(['insterfaces', section, interface, 'disable']): - tmp.update({interface : {'disable': ''}}) + if conf.exists([section, interface, 'disable']): + tmp[interface] = {'disable': ''} else: - tmp.update({interface : {}}) + tmp[interface] = {} # also present the interfaces to be removed from the bond as dictionary - bond['member'].update({'interface_remove': tmp}) + bond['member']['interface_remove'] = tmp + + # Restore existing config level + conf.set_level(old_level) if dict_search('member.interface', bond): for interface, interface_config in bond['member']['interface'].items(): + # Check if member interface is a new member + if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]): + bond['shutdown_required'] = {} + + # Check if member interface is disabled + conf.set_level(['interfaces']) + + section = Section.section(interface) # this will be 'ethernet' for 'eth0' + if conf.exists([section, interface, 'disable']): + interface_config['disable'] = '' + + conf.set_level(old_level) + # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') - if tmp: interface_config.update({'is_bridge_member' : tmp}) + if tmp: interface_config['is_bridge_member'] = tmp # Check if member interface is already member of a bond tmp = is_member(conf, interface, 'bonding') - if tmp and bond['ifname'] not in tmp: - interface_config.update({'is_bond_member' : tmp}) + for tmp in is_member(conf, interface, 'bonding'): + if bond['ifname'] == tmp: + continue + interface_config['is_bond_member'] = tmp # Check if member interface is used as source-interface on another interface tmp = is_source_interface(conf, interface) - if tmp: interface_config.update({'is_source_interface' : tmp}) + if tmp: interface_config['is_source_interface'] = tmp # bond members must not have an assigned address tmp = has_address_configured(conf, interface) - if tmp: interface_config.update({'has_address' : ''}) + if tmp: interface_config['has_address'] = {} + + # bond members must not have a VRF attached + tmp = has_vrf_configured(conf, interface) + if tmp: interface_config['has_vrf'] = {} return bond @@ -167,11 +194,11 @@ def verify(bond): raise ConfigError(error_msg + 'it does not exist!') if 'is_bridge_member' in interface_config: - tmp = next(iter(interface_config['is_bridge_member'])) + tmp = interface_config['is_bridge_member'] raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') if 'is_bond_member' in interface_config: - tmp = next(iter(interface_config['is_bond_member'])) + tmp = interface_config['is_bond_member'] raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') if 'is_source_interface' in interface_config: @@ -181,6 +208,8 @@ def verify(bond): if 'has_address' in interface_config: raise ConfigError(error_msg + 'it has an address assigned!') + if 'has_vrf' in interface_config: + raise ConfigError(error_msg + 'it has a VRF assigned!') if 'primary' in bond: if bond['primary'] not in bond['member']['interface']: diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index 38ae727c1..b961408db 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -31,6 +31,7 @@ from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf from vyos.ifconfig import BridgeIf from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured from vyos.xml import defaults from vyos.util import cmd @@ -60,7 +61,7 @@ def get_config(config=None): else: bridge.update({'member' : {'interface_remove' : tmp }}) - if dict_search('member.interface', bridge): + if dict_search('member.interface', bridge) != None: # XXX: T2665: we need a copy of the dict keys for iteration, else we will get: # RuntimeError: dictionary changed size during iteration for interface in list(bridge['member']['interface']): @@ -93,11 +94,23 @@ def get_config(config=None): tmp = has_address_configured(conf, interface) if tmp: bridge['member']['interface'][interface].update({'has_address' : ''}) + # Bridge members must not have a VRF attached + tmp = has_vrf_configured(conf, interface) + if tmp: bridge['member']['interface'][interface].update({'has_vrf' : ''}) + # VLAN-aware bridge members must not have VLAN interface configuration tmp = has_vlan_subinterface_configured(conf,interface) if 'enable_vlan' in bridge and tmp: bridge['member']['interface'][interface].update({'has_vlan' : ''}) + # delete empty dictionary keys - no need to run code paths if nothing is there to do + if 'member' in bridge: + if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: + del bridge['member']['interface'] + + if len(bridge['member']) == 0: + del bridge['member'] + return bridge def verify(bridge): @@ -118,11 +131,11 @@ def verify(bridge): raise ConfigError('Loopback interface "lo" can not be added to a bridge') if 'is_bridge_member' in interface_config: - tmp = next(iter(interface_config['is_bridge_member'])) + tmp = interface_config['is_bridge_member'] raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') if 'is_bond_member' in interface_config: - tmp = next(iter(interface_config['is_bond_member'])) + tmp = interface_config['is_bond_member'] raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') if 'is_source_interface' in interface_config: @@ -132,9 +145,12 @@ def verify(bridge): if 'has_address' in interface_config: raise ConfigError(error_msg + 'it has an address assigned!') + if 'has_vrf' in interface_config: + raise ConfigError(error_msg + 'it has a VRF assigned!') + if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: - raise ConfigError(error_msg + 'it has an VLAN subinterface assigned!') + raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') if 'wlan' in interface: raise ConfigError(error_msg + 'VLAN aware cannot be set!') diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index fec4456fb..b49c945cd 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -31,6 +31,7 @@ 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.configverify import verify_bond_bridge_member from vyos.ethtool import Ethtool from vyos.ifconfig import EthernetIf from vyos.pki import find_chain @@ -83,6 +84,7 @@ def verify(ethernet): verify_dhcpv6(ethernet) verify_address(ethernet) verify_vrf(ethernet) + verify_bond_bridge_member(ethernet) verify_eapol(ethernet) verify_mirror_redirect(ethernet) @@ -151,11 +153,20 @@ def verify(ethernet): return None def generate(ethernet): - if 'eapol' in ethernet: - render(wpa_suppl_conf.format(**ethernet), - 'ethernet/wpa_supplicant.conf.j2', ethernet) + # render real configuration file once + wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) + + if 'deleted' in ethernet: + # delete configuration on interface removal + if os.path.isfile(wpa_supplicant_conf): + os.unlink(wpa_supplicant_conf) + return None + if 'eapol' in ethernet: ifname = ethernet['ifname'] + + render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) + cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') @@ -164,7 +175,7 @@ def generate(ethernet): loaded_pki_cert = load_certificate(pki_cert['certificate']) loaded_ca_certs = {load_certificate(c['certificate']) - for c in ethernet['pki']['ca'].values()} + for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {} cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) @@ -182,10 +193,6 @@ def generate(ethernet): write_file(ca_cert_file_path, '\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)): - os.unlink(wpa_suppl_conf.format(**ethernet)) return None @@ -201,9 +208,9 @@ def apply(ethernet): else: e.update(ethernet) if 'eapol' in ethernet: - eapol_action='restart' + eapol_action='reload-or-restart' - call(f'systemctl {eapol_action} wpa_supplicant-macsec@{ifname}') + call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') if __name__ == '__main__': try: diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index b9cf2fa3c..08cc3a48d 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.py @@ -27,6 +27,7 @@ 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.configverify import verify_bond_bridge_member from vyos.ifconfig import GeneveIf from vyos import ConfigError @@ -64,6 +65,7 @@ def verify(geneve): verify_mtu_ipv6(geneve) verify_address(geneve) + verify_bond_bridge_member(geneve) verify_mirror_redirect(geneve) if 'remote' not in geneve: diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index 6a486f969..ca321e01d 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -26,6 +26,7 @@ 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_bond_bridge_member from vyos.ifconfig import L2TPv3If from vyos.util import check_kmod from vyos.validate import is_addr_assigned @@ -77,6 +78,7 @@ def verify(l2tpv3): verify_mtu_ipv6(l2tpv3) verify_address(l2tpv3) + verify_bond_bridge_member(l2tpv3) verify_mirror_redirect(l2tpv3) return None diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index 279dd119b..649ea8d50 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 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,16 +21,21 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict -from vyos.ifconfig import MACsecIf -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.util import call +from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface 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.configverify import verify_bond_bridge_member +from vyos.ifconfig import MACsecIf +from vyos.ifconfig import Interface +from vyos.template import render +from vyos.util import call +from vyos.util import dict_search +from vyos.util import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -52,9 +57,19 @@ def get_config(config=None): # Check if interface has been removed if 'deleted' in macsec: - source_interface = conf.return_effective_value(['source-interface']) + source_interface = conf.return_effective_value(base + [ifname, 'source-interface']) macsec.update({'source_interface': source_interface}) + if is_node_changed(conf, base + [ifname, 'security']): + macsec.update({'shutdown_required': {}}) + + if is_node_changed(conf, base + [ifname, 'source_interface']): + macsec.update({'shutdown_required': {}}) + + if 'source_interface' in macsec: + tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) + if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + return macsec @@ -67,22 +82,25 @@ def verify(macsec): verify_vrf(macsec) verify_mtu_ipv6(macsec) verify_address(macsec) + verify_bond_bridge_member(macsec) verify_mirror_redirect(macsec) - if not (('security' in macsec) and - ('cipher' in macsec['security'])): - raise ConfigError( - 'Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) + if dict_search('security.cipher', macsec) == None: + raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) + + if dict_search('security.encrypt', macsec) != None: + if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: + raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') + + cak_len = len(dict_search('security.mka.cak', macsec)) - if (('security' in macsec) and - ('encrypt' in macsec['security'])): - tmp = macsec.get('security') + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != 32: + # gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit + raise ConfigError('gcm-aes-128 requires a 128bit long key!') - if not (('mka' in tmp) and - ('cak' in tmp['mka']) and - ('ckn' in tmp['mka'])): - raise ConfigError('Missing mandatory MACsec security ' - 'keys as encryption is enabled!') + elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != 64: + # gcm-aes-128 requires a 128bit long key - 64 characters (string) = 32byte = 256bit + raise ConfigError('gcm-aes-128 requires a 256bit long key!') if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad @@ -97,33 +115,35 @@ def verify(macsec): def generate(macsec): - render(wpa_suppl_conf.format(**macsec), - 'macsec/wpa_supplicant.conf.j2', macsec) + render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec) return None def apply(macsec): - # Remove macsec interface - if 'deleted' in macsec: - call('systemctl stop wpa_supplicant-macsec@{source_interface}' - .format(**macsec)) + systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec) + + # Remove macsec interface on deletion or mandatory parameter change + if 'deleted' in macsec or 'shutdown_required' in macsec: + call(f'systemctl stop {systemd_service}') if macsec['ifname'] in interfaces(): tmp = MACsecIf(macsec['ifname']) tmp.remove() - # delete configuration on interface removal - if os.path.isfile(wpa_suppl_conf.format(**macsec)): - os.unlink(wpa_suppl_conf.format(**macsec)) + if 'deleted' in macsec: + # delete configuration on interface removal + if os.path.isfile(wpa_suppl_conf.format(**macsec)): + os.unlink(wpa_suppl_conf.format(**macsec)) - else: - # It is safe to "re-create" the interface always, there is a sanity - # check that the interface will only be create if its non existent - i = MACsecIf(**macsec) - i.update(macsec) + return None + + # It is safe to "re-create" the interface always, there is a sanity + # check that the interface will only be create if its non existent + i = MACsecIf(**macsec) + i.update(macsec) - call('systemctl restart wpa_supplicant-macsec@{source_interface}' - .format(**macsec)) + if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: + call(f'systemctl reload-or-restart {systemd_service}') return None diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 4750ca3e8..8155f36c2 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -36,9 +36,12 @@ from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import VTunIf from vyos.pki import load_dh_parameters from vyos.pki import load_private_key +from vyos.pki import sort_ca_chain +from vyos.pki import verify_ca_chain from vyos.pki import wrap_certificate from vyos.pki import wrap_crl from vyos.pki import wrap_dh_parameters @@ -52,6 +55,7 @@ from vyos.util import chown from vyos.util import cmd from vyos.util import dict_search from vyos.util import dict_search_args +from vyos.util import is_list_equal from vyos.util import makedir from vyos.util import read_file from vyos.util import write_file @@ -148,8 +152,14 @@ def verify_pki(openvpn): if 'ca_certificate' not in tls: raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface}') - if tls['ca_certificate'] not in pki['ca']: - raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + for ca_name in tls['ca_certificate']: + if ca_name not in pki['ca']: + raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + + if len(tls['ca_certificate']) > 1: + sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) + if not verify_ca_chain(sorted_chain, pki['ca']): + raise ConfigError(f'CA certificates are not a valid chain') if mode != 'client' and 'auth_key' not in tls: if 'certificate' not in tls: @@ -265,7 +275,7 @@ def verify(openvpn): elif v6remAddr and not v6loAddr: raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') - if (v4loAddr == v4remAddr) or (v6remAddr == v4remAddr): + if is_list_equal(v4loAddr, v4remAddr) or is_list_equal(v6loAddr, v6remAddr): raise ConfigError('"local-address" and "remote-address" cannot be the same') if dict_search('local_host', openvpn) in dict_search('local_address', openvpn): @@ -495,6 +505,7 @@ def verify(openvpn): raise ConfigError('Username for authentication is missing') verify_vrf(openvpn) + verify_bond_bridge_member(openvpn) verify_mirror_redirect(openvpn) return None @@ -516,21 +527,28 @@ def generate_pki_files(openvpn): if tls: if 'ca_certificate' in tls: - cert_name = tls['ca_certificate'] - pki_ca = pki['ca'][cert_name] + cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') + crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') - if 'certificate' in pki_ca: - cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') - write_file(cert_path, wrap_certificate(pki_ca['certificate']), - user=user, group=group, mode=0o600) + if os.path.exists(cert_path): + os.unlink(cert_path) + + if os.path.exists(crl_path): + os.unlink(crl_path) + + for cert_name in sort_ca_chain(tls['ca_certificate'], pki['ca']): + pki_ca = pki['ca'][cert_name] + + if 'certificate' in pki_ca: + write_file(cert_path, wrap_certificate(pki_ca['certificate']) + "\n", + user=user, group=group, mode=0o600, append=True) - if 'crl' in pki_ca: - for crl in pki_ca['crl']: - crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') - write_file(crl_path, wrap_crl(crl), user=user, group=group, - mode=0o600) + if 'crl' in pki_ca: + for crl in pki_ca['crl']: + write_file(crl_path, wrap_crl(crl) + "\n", user=user, group=group, + mode=0o600, append=True) - openvpn['tls']['crl'] = True + openvpn['tls']['crl'] = True if 'certificate' in tls: cert_name = tls['certificate'] diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index e2fdc7a42..ee4defa0d 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -23,7 +23,6 @@ from netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed -from vyos.configdict import leaf_node_changed from vyos.configdict import get_pppoe_interfaces from vyos.configverify import verify_authentication from vyos.configverify import verify_source_interface diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 1cd3fe276..4c65bc0b6 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-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 @@ -15,10 +15,13 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit +from netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface +from vyos.configdict import leaf_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete @@ -26,6 +29,7 @@ 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.configverify import verify_bond_bridge_member from vyos.ifconfig import MACVLANIf from vyos import ConfigError @@ -47,9 +51,16 @@ def get_config(config=None): mode = is_node_changed(conf, ['mode']) if mode: peth.update({'shutdown_required' : {}}) + if leaf_node_changed(conf, base + [ifname, 'mode']): + peth.update({'rebuild_required': {}}) + if 'source_interface' in peth: _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], peth['source_interface']) + # test if source-interface is maybe already used by another interface + tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) + if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) + return peth def verify(peth): @@ -71,21 +82,18 @@ def generate(peth): return None def apply(peth): - if 'deleted' in peth: - # delete interface - MACVLANIf(peth['ifname']).remove() - return None + # Check if the MACVLAN interface already exists + if 'rebuild_required' in peth or 'deleted' in peth: + if peth['ifname'] in interfaces(): + p = MACVLANIf(peth['ifname']) + # MACVLAN is always needs to be recreated, + # thus we can simply always delete it first. + p.remove() - # Check if MACVLAN interface already exists. Parameters like the underlaying - # source-interface device or mode can not be changed on the fly and the - # interface needs to be recreated from the bottom. - if 'mode_old' in peth: - MACVLANIf(peth['ifname']).remove() + if 'deleted' not in peth: + p = MACVLANIf(**peth) + p.update(peth) - # It is safe to "re-create" the interface always, there is a sanity check - # that the interface will only be create if its non existent - p = MACVLANIf(**peth) - p.update(peth) return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces-sstpc.py b/src/conf_mode/interfaces-sstpc.py new file mode 100755 index 000000000..6b8094c51 --- /dev/null +++ b/src/conf_mode/interfaces-sstpc.py @@ -0,0 +1,142 @@ +#!/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/>. + +import os +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_authentication +from vyos.configverify import verify_vrf +from vyos.ifconfig import SSTPCIf +from vyos.pki import encode_certificate +from vyos.pki import find_chain +from vyos.pki import load_certificate +from vyos.template import render +from vyos.util import call +from vyos.util import dict_search +from vyos.util import is_systemd_service_running +from vyos.util import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'sstpc'] + ifname, sstpc = get_interface_dict(conf, base) + + # We should only terminate the SSTP client session if critical parameters + # change. All parameters that can be changed on-the-fly (like interface + # description) should not lead to a reconnect! + for options in ['authentication', 'no_peer_dns', 'no_default_route', + 'server', 'ssl']: + if is_node_changed(conf, base + [ifname, options]): + sstpc.update({'shutdown_required': {}}) + # bail out early - no need to further process other nodes + break + + # Load PKI certificates for later processing + sstpc['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + return sstpc + +def verify(sstpc): + if 'deleted' in sstpc: + return None + + verify_authentication(sstpc) + verify_vrf(sstpc) + + if dict_search('ssl.ca_certificate', sstpc) == None: + raise ConfigError('Missing mandatory CA certificate!') + + return None + +def generate(sstpc): + ifname = sstpc['ifname'] + config_sstpc = f'/etc/ppp/peers/{ifname}' + + sstpc['ca_file_path'] = f'/run/sstpc/{ifname}_ca-cert.pem' + + if 'deleted' in sstpc: + for file in [sstpc['ca_file_path'], config_sstpc]: + if os.path.exists(file): + os.unlink(file) + return None + + ca_name = sstpc['ssl']['ca_certificate'] + pki_ca_cert = sstpc['pki']['ca'][ca_name] + + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + loaded_ca_certs = {load_certificate(c['certificate']) + for c in sstpc['pki']['ca'].values()} if 'ca' in sstpc['pki'] else {} + + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + + write_file(sstpc['ca_file_path'], '\n'.join(encode_certificate(c) for c in ca_full_chain)) + render(config_sstpc, 'sstp-client/peer.j2', sstpc, permission=0o640) + + return None + +def apply(sstpc): + ifname = sstpc['ifname'] + if 'deleted' in sstpc or 'disable' in sstpc: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.remove() + call(f'systemctl stop ppp@{ifname}.service') + return None + + # reconnect should only be necessary when specific options change, + # like server, authentication ... (see get_config() for details) + if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or + 'shutdown_required' in sstpc): + + # cleanup system (e.g. FRR routes first) + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.remove() + + call(f'systemctl restart ppp@{ifname}.service') + # When interface comes "live" a hook is called: + # /etc/ppp/ip-up.d/96-vyos-sstpc-callback + # which triggers SSTPCIf.update() + else: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.update(sstpc) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index eff7f373c..acef1fda7 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -29,6 +29,7 @@ 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.configverify import verify_bond_bridge_member from vyos.ifconfig import Interface from vyos.ifconfig import Section from vyos.ifconfig import TunnelIf @@ -158,6 +159,7 @@ def verify(tunnel): verify_mtu_ipv6(tunnel) verify_address(tunnel) verify_vrf(tunnel) + verify_bond_bridge_member(tunnel) verify_mirror_redirect(tunnel) if 'source_interface' in tunnel: diff --git a/src/conf_mode/interfaces-virtual-ethernet.py b/src/conf_mode/interfaces-virtual-ethernet.py new file mode 100755 index 000000000..8efe89c41 --- /dev/null +++ b/src/conf_mode/interfaces-virtual-ethernet.py @@ -0,0 +1,114 @@ +#!/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 netifaces import interfaces +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_vrf +from vyos.ifconfig import VethIf + +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'virtual-ethernet'] + ifname, veth = get_interface_dict(conf, base) + + # We need to know all other veth related interfaces as veth requires a 1:1 + # mapping for the peer-names. The Linux kernel automatically creates both + # interfaces, the local one and the peer-name, but VyOS also needs a peer + # interfaces configrued on the CLI so we can assign proper IP addresses etc. + veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return veth + + +def verify(veth): + if 'deleted' in veth: + verify_bridge_delete(veth) + # Prevent to delete veth interface which used for another "vethX peer-name" + for iface, iface_config in veth['other_interfaces'].items(): + if veth['ifname'] in iface_config['peer_name']: + ifname = veth['ifname'] + raise ConfigError( + f'Cannot delete "{ifname}" used for "interface {iface} peer-name"' + ) + return None + + verify_vrf(veth) + verify_address(veth) + + if 'peer_name' not in veth: + raise ConfigError(f'Remote peer name must be set for "{veth["ifname"]}"!') + + peer_name = veth['peer_name'] + ifname = veth['ifname'] + + if veth['peer_name'] not in veth['other_interfaces']: + raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ + 'is not configured!') + + if veth['other_interfaces'][peer_name]['peer_name'] != ifname: + raise ConfigError( + f'Configuration mismatch between "{ifname}" and "{peer_name}"!') + + if peer_name == ifname: + raise ConfigError( + f'Peer-name "{peer_name}" cannot be the same as interface "{ifname}"!') + + return None + + +def generate(peth): + return None + +def apply(veth): + # Check if the Veth interface already exists + if 'rebuild_required' in veth or 'deleted' in veth: + if veth['ifname'] in interfaces(): + p = VethIf(veth['ifname']) + p.remove() + + if 'deleted' not in veth: + p = VethIf(**veth) + p.update(veth) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index f44d754ba..af2d0588d 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -29,6 +29,7 @@ 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.configverify import verify_bond_bridge_member from vyos.ifconfig import Interface from vyos.ifconfig import VXLANIf from vyos.template import is_ipv6 @@ -117,6 +118,11 @@ def verify(vxlan): # in use. vxlan_overhead += 20 + # If source_address is not used - check IPv6 'remote' list + elif 'remote' in vxlan: + if any(is_ipv6(a) for a in vxlan['remote']): + vxlan_overhead += 20 + lower_mtu = Interface(vxlan['source_interface']).get_mtu() if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ @@ -144,6 +150,7 @@ def verify(vxlan): verify_mtu_ipv6(vxlan) verify_address(vxlan) + verify_bond_bridge_member(vxlan) verify_mirror_redirect(vxlan) return None diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 180ffa507..762bad94f 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -14,21 +14,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os - from sys import exit -from copy import deepcopy 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.configdict import is_node_changed 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_bond_bridge_member from vyos.ifconfig import WireGuardIf from vyos.util import check_kmod from vyos.util import check_port_availability @@ -49,17 +46,20 @@ def get_config(config=None): ifname, wireguard = get_interface_dict(conf, base) # Check if a port was changed - wireguard['port_changed'] = leaf_node_changed(conf, base + [ifname, 'port']) + tmp = is_node_changed(conf, base + [ifname, 'port']) + if tmp: wireguard['port_changed'] = {} # Determine which Wireguard peer has been removed. # Peers can only be removed with their public key! - dict = {} - tmp = node_changed(conf, base + [ifname, 'peer'], key_mangling=('-', '_')) - for peer in (tmp or []): - public_key = leaf_node_changed(conf, base + [ifname, 'peer', peer, 'public_key']) - if public_key: - dict = dict_merge({'peer_remove' : {peer : {'public_key' : public_key[0]}}}, dict) - wireguard.update(dict) + if 'peer' in wireguard: + peer_remove = {} + for peer, peer_config in wireguard['peer'].items(): + # T4702: If anything on a peer changes we remove the peer first and re-add it + if is_node_changed(conf, base + [ifname, 'peer', peer]): + if 'public_key' in peer_config: + peer_remove = dict_merge({'peer_remove' : {peer : peer_config['public_key']}}, peer_remove) + if peer_remove: + wireguard.update(peer_remove) return wireguard @@ -71,6 +71,7 @@ def verify(wireguard): verify_mtu_ipv6(wireguard) verify_address(wireguard) verify_vrf(wireguard) + verify_bond_bridge_member(wireguard) verify_mirror_redirect(wireguard) if 'private_key' not in wireguard: @@ -79,14 +80,15 @@ def verify(wireguard): if 'peer' not in wireguard: raise ConfigError('At least one Wireguard peer is required!') - if 'port' in wireguard and wireguard['port_changed']: + if 'port' in wireguard and 'port_changed' in wireguard: listen_port = int(wireguard['port']) if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: - raise ConfigError( - f'The UDP port {listen_port} is busy or unavailable and cannot be used for the interface' - ) + raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' + 'cannot be used for the interface!') # run checks on individual configured WireGuard peer + public_keys = [] + for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] @@ -100,6 +102,11 @@ def verify(wireguard): raise ConfigError('Both Wireguard port and address must be defined ' f'for peer "{tmp}" if either one of them is set!') + if peer['public_key'] in public_keys: + raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + + public_keys.append(peer['public_key']) + def apply(wireguard): tmp = WireGuardIf(wireguard['ifname']) if 'deleted' in wireguard: diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index d34297063..dd798b5a2 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_source_interface from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import WiFiIf from vyos.template import render from vyos.util import call @@ -194,6 +195,7 @@ def verify(wifi): verify_address(wifi) verify_vrf(wifi) + verify_bond_bridge_member(wifi) verify_mirror_redirect(wifi) # use common function to verify VLAN configuration diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index e275ace84..a14a992ae 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -76,7 +76,7 @@ def get_config(config=None): # We need to know the amount of other WWAN interfaces as ModemManager needs # to be started or stopped. conf.set_level(base) - _, wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), + wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) @@ -116,7 +116,7 @@ def generate(wwan): # 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') + write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n') return None diff --git a/src/conf_mode/load-balancing-wan.py b/src/conf_mode/load-balancing-wan.py new file mode 100755 index 000000000..11840249f --- /dev/null +++ b/src/conf_mode/load-balancing-wan.py @@ -0,0 +1,65 @@ +#!/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 node_changed +from vyos.util import call +from vyos import ConfigError +from pprint import pprint +from vyos import airbag +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, get_first_key=True, + no_tag_node_value_mangle=True) + + pprint(lb) + return lb + +def verify(lb): + return None + + +def generate(lb): + if not lb: + return None + + return None + + +def apply(lb): + + 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/nat.py b/src/conf_mode/nat.py index 85819a77e..9f8221514 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -32,6 +32,7 @@ from vyos.util import cmd from vyos.util import run from vyos.util import check_kmod from vyos.util import dict_search +from vyos.util import dict_search_args from vyos.validate import is_addr_assigned from vyos.xml import defaults from vyos import ConfigError @@ -44,7 +45,15 @@ if LooseVersion(kernel_version()) > LooseVersion('5.1'): else: k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] -nftables_nat_config = '/tmp/vyos-nat-rules.nft' +nftables_nat_config = '/run/nftables_nat.conf' +nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' + +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group' +] def get_handler(json, chain, target): """ Get nftable rule handler number of given chain/target combination. @@ -59,7 +68,7 @@ def get_handler(json, chain, target): return None -def verify_rule(config, err_msg): +def verify_rule(config, err_msg, groups_dict): """ Common verify steps used for both source and destination NAT """ if (dict_search('translation.port', config) != None or @@ -77,6 +86,45 @@ def verify_rule(config, err_msg): 'statically maps a whole network of addresses onto another\n' \ 'network of addresses') + for side in ['destination', 'source']: + if side in config: + side_conf = config[side] + + if len({'address', 'fqdn'} & set(side_conf)) > 1: + raise ConfigError('Only one of address, fqdn or geoip can be specified') + + if 'group' in side_conf: + if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group or domain-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + error_group = group.replace("_", "-") + + if group in ['address_group', 'network_group', 'domain_group']: + types = [t for t in ['address', 'fqdn'] if t in side_conf] + if types: + raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') + + if group_name and group_name[0] == '!': + group_name = group_name[1:] + + group_obj = dict_search_args(groups_dict, group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members!') + + if dict_search_args(side_conf, 'group', 'port_group'): + if 'protocol' not in config: + raise ConfigError('Protocol must be defined if specifying a port-group') + + if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') + def get_config(config=None): if config: conf = config @@ -88,7 +136,7 @@ def get_config(config=None): # T2665: we must add the tagNode defaults individually until this is # moved to the base class - for direction in ['source', 'destination']: + for direction in ['source', 'destination', 'static']: if direction in nat: default_values = defaults(base + [direction, 'rule']) for rule in dict_search(f'{direction}.rule', nat) or []: @@ -104,16 +152,20 @@ def get_config(config=None): condensed_json = jmespath.search(pattern, nftable_json) if not conf.exists(base): - nat['helper_functions'] = 'remove' - - # Retrieve current table handler positions - nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER') - nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') - nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_HELPER') - nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') + if get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER'): + nat['helper_functions'] = 'remove' + + # Retrieve current table handler positions + nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER') + nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') + nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_HELPER') + nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') nat['deleted'] = '' return nat + nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + # check if NAT connection tracking helpers need to be set up - this has to # be done only once if not get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK'): @@ -145,18 +197,18 @@ def verify(nat): if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') + if not dict_search('translation.address', config) and not dict_search('translation.port', config): + if 'exclude' not in config: + raise ConfigError(f'{err_msg} translation requires address and/or port') + addr = dict_search('translation.address', config) - if addr != None: - if addr != 'masquerade' and not is_ip_network(addr): - for ip in addr.split('-'): - if not is_addr_assigned(ip): - Warning(f'IP address {ip} does not exist on the system!') - elif 'exclude' not in config: - raise ConfigError(f'{err_msg}\n' \ - 'translation address not specified') + if addr != None and addr != 'masquerade' and not is_ip_network(addr): + for ip in addr.split('-'): + if not is_addr_assigned(ip): + Warning(f'IP address {ip} does not exist on the system!') # common rule verification - verify_rule(config, err_msg) + verify_rule(config, err_msg, nat['firewall_group']) if dict_search('destination.rule', nat): @@ -166,36 +218,54 @@ def verify(nat): if 'inbound_interface' not in config: raise ConfigError(f'{err_msg}\n' \ 'inbound-interface not specified') - else: - if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): - Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') + elif config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): + Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') + if not dict_search('translation.address', config) and not dict_search('translation.port', config): + if 'exclude' not in config: + raise ConfigError(f'{err_msg} translation requires address and/or port') - if dict_search('translation.address', config) == None and 'exclude' not in config: + # common rule verification + verify_rule(config, err_msg, nat['firewall_group']) + + if dict_search('static.rule', nat): + for rule, config in dict_search('static.rule', nat).items(): + err_msg = f'Static NAT configuration error in rule {rule}:' + + if 'inbound_interface' not in config: raise ConfigError(f'{err_msg}\n' \ - 'translation address not specified') + 'inbound-interface not specified') # common rule verification - verify_rule(config, err_msg) + verify_rule(config, err_msg, nat['firewall_group']) return None def generate(nat): + if not os.path.exists(nftables_nat_config): + nat['first_install'] = True + render(nftables_nat_config, 'firewall/nftables-nat.j2', nat) + render(nftables_static_nat_conf, 'firewall/nftables-static-nat.j2', nat) # dry-run newly generated configuration tmp = run(f'nft -c -f {nftables_nat_config}') if tmp > 0: - if os.path.exists(nftables_nat_config): - os.unlink(nftables_nat_config) + raise ConfigError('Configuration file errors encountered!') + + tmp = run(f'nft -c -f {nftables_static_nat_conf}') + if tmp > 0: raise ConfigError('Configuration file errors encountered!') return None def apply(nat): cmd(f'nft -f {nftables_nat_config}') - if os.path.isfile(nftables_nat_config): + cmd(f'nft -f {nftables_static_nat_conf}') + + if not nat or 'deleted' in nat: os.unlink(nftables_nat_config) + os.unlink(nftables_static_nat_conf) return None diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 0972151a0..d8f913b0c 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -36,7 +36,7 @@ airbag.enable() k_mod = ['nft_nat', 'nft_chain_nat'] -nftables_nat66_config = '/tmp/vyos-nat66-rules.nft' +nftables_nat66_config = '/run/nftables_nat66.nft' ndppd_config = '/run/ndppd/ndppd.conf' def get_handler(json, chain, target): @@ -125,7 +125,8 @@ def verify(nat): if addr != 'masquerade' and not is_ipv6(addr): raise ConfigError(f'IPv6 address {addr} is not a valid address') else: - raise ConfigError(f'{err_msg} translation address not specified') + if 'exclude' not in config: + raise ConfigError(f'{err_msg} translation address not specified') prefix = dict_search('source.prefix', config) if prefix != None: @@ -146,6 +147,9 @@ def verify(nat): return None def generate(nat): + if not os.path.exists(nftables_nat66_config): + nat['first_install'] = True + render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755) render(ndppd_config, 'ndppd/ndppd.conf.j2', nat, permission=0o755) return None @@ -153,15 +157,15 @@ def generate(nat): def apply(nat): if not nat: return None - cmd(f'{nftables_nat66_config}') + + cmd(f'nft -f {nftables_nat66_config}') + if 'deleted' in nat or not dict_search('source.rule', nat): cmd('systemctl stop ndppd') if os.path.isfile(ndppd_config): os.unlink(ndppd_config) else: cmd('systemctl restart ndppd') - if os.path.isfile(nftables_nat66_config): - os.unlink(nftables_nat66_config) return None diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py index 0d6ec9ace..0ecb4d736 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/ntp.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 @@ -17,10 +17,13 @@ import os from vyos.config import Config +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos import ConfigError +from vyos.configverify import verify_interface_exists from vyos.util import call +from vyos.util import get_interface_config from vyos.template import render +from vyos import ConfigError from vyos import airbag airbag.enable() @@ -38,6 +41,10 @@ def get_config(config=None): ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) ntp['config_file'] = config_file + + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: ntp.update({'restart_required': {}}) + return ntp def verify(ntp): @@ -49,6 +56,20 @@ def verify(ntp): raise ConfigError('NTP server not configured') verify_vrf(ntp) + + if 'interface' in ntp: + # If ntpd should listen on a given interface, ensure it exists + for interface in ntp['interface']: + verify_interface_exists(interface) + + # If we run in a VRF, our interface must belong to this VRF, too + if 'vrf' in ntp: + tmp = get_interface_config(interface) + vrf_name = ntp['vrf'] + if 'master' not in tmp or tmp['master'] != vrf_name: + raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ + f'does not belong to this VRF!') + return None def generate(ntp): @@ -62,19 +83,25 @@ def generate(ntp): return None def apply(ntp): + systemd_service = 'ntp.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + if not ntp: # NTP support is removed in the commit - call('systemctl stop ntp.service') + call(f'systemctl stop {systemd_service}') if os.path.exists(config_file): os.unlink(config_file) if os.path.isfile(systemd_override): os.unlink(systemd_override) + return - # Reload systemd manager configuration - call('systemctl daemon-reload') - if ntp: - call('systemctl restart ntp.service') + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in ntp: + systemd_action = 'restart' + call(f'systemctl {systemd_action} {systemd_service}') return None if __name__ == '__main__': diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 29ed7b1b7..e8f3cc87a 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -16,20 +16,16 @@ from sys import exit -import jmespath - from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.pki import is_ca_certificate from vyos.pki import load_certificate -from vyos.pki import load_certificate_request from vyos.pki import load_public_key from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters -from vyos.util import ask_input -from vyos.util import call from vyos.util import dict_search_args from vyos.util import dict_search_recursive from vyos.xml import defaults @@ -121,6 +117,39 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) + if 'changed' in pki: + for search in sync_search: + for key in search['keys']: + changed_key = sync_translate[key] + + if changed_key not in pki['changed']: + continue + + for item_name in pki['changed'][changed_key]: + node_present = False + if changed_key == 'openvpn': + node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) + else: + node_present = dict_search_args(pki, changed_key, item_name) + + if node_present: + search_dict = dict_search_args(pki['system'], *search['path']) + + if not search_dict: + continue + + for found_name, found_path in dict_search_recursive(search_dict, key): + if found_name == item_name: + path = search['path'] + path_str = ' '.join(path + found_path) + print(f'pki: Updating config: {path_str} {found_name}') + + if path[0] == 'interfaces': + ifname = found_path[0] + set_dependents(path[1], conf, ifname) + else: + set_dependents(path[1], conf) + return pki def is_valid_certificate(raw_data): @@ -259,37 +288,7 @@ def apply(pki): return None if 'changed' in pki: - for search in sync_search: - for key in search['keys']: - changed_key = sync_translate[key] - - if changed_key not in pki['changed']: - continue - - for item_name in pki['changed'][changed_key]: - node_present = False - if changed_key == 'openvpn': - node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) - else: - node_present = dict_search_args(pki, changed_key, item_name) - - if node_present: - search_dict = dict_search_args(pki['system'], *search['path']) - - if not search_dict: - continue - - for found_name, found_path in dict_search_recursive(search_dict, key): - if found_name == item_name: - path_str = ' '.join(search['path'] + found_path) - print(f'pki: Updating config: {path_str} {found_name}') - - script = search['script'] - if found_path[0] == 'interfaces': - ifname = found_path[2] - call(f'VYOS_TAGNODE_VALUE={ifname} {script}') - else: - call(script) + call_dependents() return None diff --git a/src/conf_mode/policy-route-interface.py b/src/conf_mode/policy-route-interface.py deleted file mode 100755 index 1108aebe6..000000000 --- a/src/conf_mode/policy-route-interface.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os -import re - -from sys import argv -from sys import exit - -from vyos.config import Config -from vyos.ifconfig import Section -from vyos.template import render -from vyos.util import cmd -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - ifname = argv[1] - ifpath = Section.get_config_path(ifname) - if_policy_path = f'interfaces {ifpath} policy' - - if_policy = conf.get_config_dict(if_policy_path, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - if_policy['ifname'] = ifname - if_policy['policy'] = conf.get_config_dict(['policy'], key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - return if_policy - -def verify(if_policy): - # bail out early - looks like removal from running config - if not if_policy: - return None - - for route in ['route', 'route6']: - if route in if_policy: - if route not in if_policy['policy']: - raise ConfigError('Policy route not configured') - - route_name = if_policy[route] - - if route_name not in if_policy['policy'][route]: - raise ConfigError(f'Invalid policy route name "{name}"') - - return None - -def generate(if_policy): - return None - -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'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 - retval = True - continue - - handle_search = re.search('handle (\d+)', line) - if handle_search: - cmd(f'nft delete rule {table} {chain} handle {handle_search[1]}') - return retval - -def apply(if_policy): - ifname = if_policy['ifname'] - - route_chain = 'VYOS_PBR_PREROUTING' - ipv6_route_chain = 'VYOS_PBR6_PREROUTING' - - if 'route' in if_policy: - name = 'VYOS_PBR_' + if_policy['route'] - rule_exists = cleanup_rule('ip mangle', route_chain, ifname, name) - - if not rule_exists: - cmd(f'nft insert rule ip mangle {route_chain} iifname {ifname} counter jump {name}') - else: - cleanup_rule('ip mangle', route_chain, ifname) - - 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: - cmd(f'nft insert rule ip6 mangle {ipv6_route_chain} iifname {ifname} counter jump {name}') - else: - cleanup_rule('ip6 mangle', ipv6_route_chain, ifname) - - 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/policy-route.py b/src/conf_mode/policy-route.py index 5de341beb..1d016695e 100755 --- a/src/conf_mode/policy-route.py +++ b/src/conf_mode/policy-route.py @@ -15,7 +15,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re from json import loads from sys import exit @@ -33,35 +32,13 @@ 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', + 'domain_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 @@ -74,11 +51,10 @@ def get_config(config=None): 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_rule(policy, name, rule_conf, ipv6): +def verify_rule(policy, name, rule_conf, ipv6, rule_id): icmp = 'icmp' if not ipv6 else 'icmpv6' if icmp in rule_conf: icmp_defined = False @@ -118,8 +94,8 @@ def verify_rule(policy, name, rule_conf, ipv6): 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') + if len({'address_group', 'domain_group', 'network_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, domain-group or network-group can be specified') for group in valid_groups: if group in side_conf['group']: @@ -152,57 +128,13 @@ def verify(policy): for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: 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}') + verify_rule(policy, name, rule_conf, ipv6, rule_id) 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.j2', policy) return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index 3008a20e0..331194fec 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -23,8 +23,42 @@ from vyos.util import dict_search from vyos import ConfigError from vyos import frr from vyos import airbag + airbag.enable() + +def community_action_compatibility(actions: dict) -> bool: + """ + Check compatibility of values in community and large community sections + :param actions: dictionary with community + :type actions: dict + :return: true if compatible, false if not + :rtype: bool + """ + if ('none' in actions) and ('replace' in actions or 'add' in actions): + return False + if 'replace' in actions and 'add' in actions: + return False + if ('delete' in actions) and ('none' in actions or 'replace' in actions): + return False + return True + + +def extcommunity_action_compatibility(actions: dict) -> bool: + """ + Check compatibility of values in extended community sections + :param actions: dictionary with community + :type actions: dict + :return: true if compatible, false if not + :rtype: bool + """ + if ('none' in actions) and ( + 'rt' in actions or 'soo' in actions or 'bandwidth' in actions or 'bandwidth_non_transitive' in actions): + return False + if ('bandwidth_non_transitive' in actions) and ('bandwidth' not in actions): + return False + return True + def routing_policy_find(key, dictionary): # Recursively traverse a dictionary and extract the value assigned to # a given key as generator object. This is made for routing policies, @@ -46,6 +80,7 @@ def routing_policy_find(key, dictionary): for result in routing_policy_find(key, d): yield result + def get_config(config=None): if config: conf = config @@ -53,7 +88,8 @@ def get_config(config=None): conf = Config() base = ['policy'] - policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + policy = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) # We also need some additional information from the config, prefix-lists @@ -67,12 +103,14 @@ def get_config(config=None): policy = dict_merge(tmp, policy) return policy + def verify(policy): if not policy: return None for policy_type in ['access_list', 'access_list6', 'as_path_list', - 'community_list', 'extcommunity_list', 'large_community_list', + 'community_list', 'extcommunity_list', + 'large_community_list', 'prefix_list', 'prefix_list6', 'route_map']: # Bail out early and continue with next policy type if policy_type not in policy: @@ -97,15 +135,18 @@ def verify(policy): if 'source' not in rule_config: raise ConfigError(f'A source {mandatory_error}') - if int(instance) in range(100, 200) or int(instance) in range(2000, 2700): + if int(instance) in range(100, 200) or int( + instance) in range(2000, 2700): if 'destination' not in rule_config: - raise ConfigError(f'A destination {mandatory_error}') + raise ConfigError( + f'A destination {mandatory_error}') if policy_type == 'access_list6': if 'source' not in rule_config: raise ConfigError(f'A source {mandatory_error}') - if policy_type in ['as_path_list', 'community_list', 'extcommunity_list', + if policy_type in ['as_path_list', 'community_list', + 'extcommunity_list', 'large_community_list']: if 'regex' not in rule_config: raise ConfigError(f'A regex {mandatory_error}') @@ -115,10 +156,10 @@ def verify(policy): raise ConfigError(f'A prefix {mandatory_error}') if rule_config in entries: - raise ConfigError(f'Rule "{rule}" contains a duplicate prefix definition!') + 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: for route_map, route_map_config in policy['route_map'].items(): @@ -126,20 +167,29 @@ def verify(policy): continue for rule, rule_config in route_map_config['rule'].items(): + # Action 'deny' cannot be used with "continue" + # FRR does not validate it T4827 + if rule_config['action'] == 'deny' and 'continue' in rule_config: + raise ConfigError(f'rule {rule} "continue" cannot be used with action deny!') + # Specified community-list must exist - tmp = dict_search('match.community.community_list', rule_config) + tmp = dict_search('match.community.community_list', + rule_config) if tmp and tmp not in policy.get('community_list', []): raise ConfigError(f'community-list {tmp} does not exist!') # Specified extended community-list must exist tmp = dict_search('match.extcommunity', rule_config) if tmp and tmp not in policy.get('extcommunity_list', []): - raise ConfigError(f'extcommunity-list {tmp} does not exist!') + raise ConfigError( + f'extcommunity-list {tmp} does not exist!') # Specified large-community-list must exist - tmp = dict_search('match.large_community.large_community_list', rule_config) + tmp = dict_search('match.large_community.large_community_list', + rule_config) if tmp and tmp not in policy.get('large_community_list', []): - raise ConfigError(f'large-community-list {tmp} does not exist!') + raise ConfigError( + f'large-community-list {tmp} does not exist!') # Specified prefix-list must exist tmp = dict_search('match.ip.address.prefix_list', rule_config) @@ -147,49 +197,87 @@ def verify(policy): raise ConfigError(f'prefix-list {tmp} does not exist!') # Specified prefix-list must exist - tmp = dict_search('match.ipv6.address.prefix_list', rule_config) + tmp = dict_search('match.ipv6.address.prefix_list', + rule_config) if tmp and tmp not in policy.get('prefix_list6', []): raise ConfigError(f'prefix-list6 {tmp} does not exist!') - + # Specified access_list6 in nexthop must exist - tmp = dict_search('match.ipv6.nexthop.access_list', rule_config) + tmp = dict_search('match.ipv6.nexthop.access_list', + rule_config) if tmp and tmp not in policy.get('access_list6', []): raise ConfigError(f'access_list6 {tmp} does not exist!') # Specified prefix-list6 in nexthop must exist - tmp = dict_search('match.ipv6.nexthop.prefix_list', rule_config) + tmp = dict_search('match.ipv6.nexthop.prefix_list', + rule_config) if tmp and tmp not in policy.get('prefix_list6', []): raise ConfigError(f'prefix-list6 {tmp} does not exist!') + tmp = dict_search('set.community.delete', rule_config) + if tmp and tmp not in policy.get('community_list', []): + raise ConfigError(f'community-list {tmp} does not exist!') + + tmp = dict_search('set.large_community.delete', + rule_config) + if tmp and tmp not in policy.get('large_community_list', []): + raise ConfigError( + f'large-community-list {tmp} does not exist!') + + if 'set' in rule_config: + rule_action = rule_config['set'] + if 'community' in rule_action: + if not community_action_compatibility( + rule_action['community']): + raise ConfigError( + f'Unexpected combination between action replace, add, delete or none in community') + if 'large_community' in rule_action: + if not community_action_compatibility( + rule_action['large_community']): + raise ConfigError( + f'Unexpected combination between action replace, add, delete or none in large-community') + if 'extcommunity' in rule_action: + if not extcommunity_action_compatibility( + rule_action['extcommunity']): + raise ConfigError( + f'Unexpected combination between none, rt, soo, bandwidth, bandwidth-non-transitive in extended-community') # When routing protocols are active some use prefix-lists, route-maps etc. # to apply the systems routing policy to the learned or redistributed routes. # When the "routing policy" changes and policies, route-maps etc. are deleted, # it is our responsibility to verify that the policy can not be deleted if it # is used by any routing protocol if 'protocols' in policy: - for policy_type in ['access_list', 'access_list6', 'as_path_list', 'community_list', - 'extcommunity_list', 'large_community_list', 'prefix_list', 'route_map']: + for policy_type in ['access_list', 'access_list6', 'as_path_list', + 'community_list', + 'extcommunity_list', 'large_community_list', + 'prefix_list', 'route_map']: if policy_type in policy: - for policy_name in list(set(routing_policy_find(policy_type, policy['protocols']))): + for policy_name in list(set(routing_policy_find(policy_type, + policy[ + 'protocols']))): found = False if policy_name in policy[policy_type]: found = True # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related # list - we need to go the extra mile here and check both prefix-lists - if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in policy['prefix_list6']: + if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \ + policy['prefix_list6']: found = True if not found: - tmp = policy_type.replace('_','-') - raise ConfigError(f'Can not delete {tmp} "{policy_name}", still in use!') + tmp = policy_type.replace('_', '-') + raise ConfigError( + f'Can not delete {tmp} "{policy_name}", still in use!') return None + def generate(policy): if not policy: return None policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy) return None + def apply(policy): bgp_daemon = 'bgpd' zebra_daemon = 'zebra' @@ -203,7 +291,8 @@ def apply(policy): frr_cfg.modify_section(r'^bgp community-list .*') frr_cfg.modify_section(r'^bgp extcommunity-list .*') frr_cfg.modify_section(r'^bgp large-community-list .*') - frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', + remove_stop_mark=True) if 'new_frr_config' in policy: frr_cfg.add_before(frr.default_add_before, policy['new_frr_config']) frr_cfg.commit_configuration(bgp_daemon) @@ -214,13 +303,15 @@ def apply(policy): frr_cfg.modify_section(r'^ipv6 access-list .*') frr_cfg.modify_section(r'^ip prefix-list .*') frr_cfg.modify_section(r'^ipv6 prefix-list .*') - frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit', + remove_stop_mark=True) if 'new_frr_config' in policy: frr_cfg.add_before(frr.default_add_before, policy['new_frr_config']) frr_cfg.commit_configuration(zebra_daemon) return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index cd46cbcb4..ff568d470 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -19,6 +19,7 @@ import os from sys import exit from sys import argv +from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_prefix_list @@ -100,6 +101,17 @@ def verify_remote_as(peer_config, bgp_config): return None +def verify_afi(peer_config, bgp_config): + if 'address_family' in peer_config: + return True + + if 'peer_group' in peer_config: + peer_group_name = peer_config['peer_group'] + tmp = dict_search(f'peer_group.{peer_group_name}.address_family', bgp_config) + if tmp: return True + + return False + def verify(bgp): if not bgp or 'deleted' in bgp: if 'dependent_vrfs' in bgp: @@ -109,8 +121,8 @@ def verify(bgp): 'dependent VRF instance(s) exist!') return None - if 'local_as' not in bgp: - raise ConfigError('BGP local-as number must be defined!') + if 'system_as' not in bgp: + raise ConfigError('BGP system-as number must be defined!') # Common verification for both peer-group and neighbor statements for neighbor in ['neighbor', 'peer_group']: @@ -135,8 +147,8 @@ def verify(bgp): # Neighbor local-as override can not be the same as the local-as # we use for this BGP instane! asn = list(peer_config['local_as'].keys())[0] - if asn == bgp['local_as']: - raise ConfigError('Cannot have local-as same as BGP AS number') + if asn == bgp['system_as']: + raise ConfigError('Cannot have local-as same as system-as number') # Neighbor AS specified for local-as and remote-as can not be the same if dict_search('remote_as', peer_config) == asn: @@ -147,6 +159,11 @@ def verify(bgp): if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config: raise ConfigError('You can not set both ebgp-multihop and ttl-security hops') + # interface and ebgp-multihop can't be used in the same configration + if 'ebgp_multihop' in peer_config and 'interface' in peer_config: + raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\ + f'neighbor "{peer}"') + # Check if neighbor has both override capability and strict capability match # configured at the same time. if 'override_capability' in peer_config and 'strict_capability_match' in peer_config: @@ -164,6 +181,9 @@ def verify(bgp): if not verify_remote_as(peer_config, bgp): raise ConfigError(f'Neighbor "{peer}" remote-as must be set!') + if not verify_afi(peer_config, bgp): + Warning(f'BGP neighbor "{peer}" requires address-family!') + # Peer-group member cannot override remote-as of peer-group if 'peer_group' in peer_config: peer_group = peer_config['peer_group'] @@ -198,6 +218,12 @@ def verify(bgp): if 'source_interface' in peer_config['interface']: raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"') + # Local-AS allowed only for EBGP peers + if 'local_as' in peer_config: + remote_as = verify_remote_as(peer_config, bgp) + if remote_as == bgp['system_as']: + raise ConfigError(f'local-as configured for "{peer}", allowed only for eBGP peers!') + for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec', 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', 'l2vpn_evpn']: @@ -258,12 +284,12 @@ def verify(bgp): verify_route_map(afi_config['route_map'][tmp], bgp) if 'route_reflector_client' in afi_config: - if 'remote_as' in peer_config and peer_config['remote_as'] != 'internal' and peer_config['remote_as'] != bgp['local_as']: + if 'remote_as' in peer_config and peer_config['remote_as'] != 'internal' and peer_config['remote_as'] != bgp['system_as']: raise ConfigError('route-reflector-client only supported for iBGP peers') else: if 'peer_group' in peer_config: peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp) - if peer_group_as != None and peer_group_as != 'internal' and peer_group_as != bgp['local_as']: + if peer_group_as != None and peer_group_as != 'internal' and peer_group_as != bgp['system_as']: raise ConfigError('route-reflector-client only supported for iBGP peers') # Throw an error if a peer group is not configured for allow range diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 5dafd26d0..cb8ea3be4 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -203,6 +203,28 @@ def verify(isis): if list(set(global_range) & set(local_range)): raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\ f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!') + + # Check for a blank or invalid value per prefix + if dict_search('segment_routing.prefix', isis): + for prefix, prefix_config in isis['segment_routing']['prefix'].items(): + if 'absolute' in prefix_config: + if prefix_config['absolute'].get('value') is None: + raise ConfigError(f'Segment routing prefix {prefix} absolute value cannot be blank.') + elif 'index' in prefix_config: + if prefix_config['index'].get('value') is None: + raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.') + + # Check for explicit-null and no-php-flag configured at the same time per prefix + if dict_search('segment_routing.prefix', isis): + for prefix, prefix_config in isis['segment_routing']['prefix'].items(): + if 'absolute' in prefix_config: + if ("explicit_null" in prefix_config['absolute']) and ("no_php_flag" in prefix_config['absolute']): + raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ + f'and no-php-flag configured at the same time.') + elif 'index' in prefix_config: + if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): + raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ + f'and no-php-flag configured at the same time.') return None diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 5da8e7b06..73af6595b 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -24,6 +24,7 @@ from vyos.template import render_to_string from vyos.util import dict_search from vyos.util import read_file from vyos.util import sysctl_write +from vyos.configverify import verify_interface_exists from vyos import ConfigError from vyos import frr from vyos import airbag @@ -46,6 +47,10 @@ def verify(mpls): if not mpls: return None + if 'interface' in mpls: + for interface in mpls['interface']: + verify_interface_exists(interface) + # Checks to see if LDP is properly configured if 'ldp' in mpls: # If router ID not defined diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 56939955d..d28ced4fd 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,10 +14,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import os + from vyos.config import Config from vyos.configdict import node_changed -from vyos.firewall import find_nftables_rule -from vyos.firewall import remove_nftables_rule from vyos.template import render from vyos.util import process_named_running from vyos.util import run @@ -26,6 +26,7 @@ from vyos import airbag airbag.enable() opennhrp_conf = '/run/opennhrp/opennhrp.conf' +nhrp_nftables_conf = '/run/nftables_nhrp.conf' def get_config(config=None): if config: @@ -81,36 +82,26 @@ def verify(nhrp): for map_name, map_conf in nhrp_conf['dynamic_map'].items(): if 'nbma_domain_name' not in map_conf: raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') - - if 'cisco_authentication' in nhrp_conf: - if len(nhrp_conf['cisco_authentication']) > 8: - raise ConfigError('Maximum length of the secret is 8 characters!') - return None def generate(nhrp): + if not os.path.exists(nhrp_nftables_conf): + nhrp['first_install'] = True + render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp) + render(nhrp_nftables_conf, 'nhrp/nftables.conf.j2', nhrp) return None def apply(nhrp): - if 'tunnel' in nhrp: - for tunnel, tunnel_conf in nhrp['tunnel'].items(): - if 'source_address' in nhrp['if_tunnel'][tunnel]: - comment = f'VYOS_NHRP_{tunnel}' - source_address = nhrp['if_tunnel'][tunnel]['source_address'] - - rule_handle = find_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', ['ip protocol gre', f'ip saddr {source_address}', 'ip daddr 224.0.0.0/4']) - if not rule_handle: - run(f'sudo nft insert rule ip filter VYOS_FW_OUTPUT ip protocol gre ip saddr {source_address} ip daddr 224.0.0.0/4 counter drop comment "{comment}"') - - for tunnel in nhrp['del_tunnels']: - comment = f'VYOS_NHRP_{tunnel}' - rule_handle = find_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', [f'comment "{comment}"']) - if rule_handle: - remove_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', rule_handle) + nft_rc = run(f'nft -f {nhrp_nftables_conf}') + if nft_rc != 0: + raise ConfigError('Failed to apply NHRP tunnel firewall rules') action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' - run(f'systemctl {action} opennhrp.service') + service_rc = run(f'systemctl {action} opennhrp.service') + if service_rc != 0: + raise ConfigError(f'Failed to {action} the NHRP service') + return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 5b4874ba2..0582d32be 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -198,6 +198,58 @@ def verify(ospf): if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface {interface} is not a member of VRF {vrf}!') + # Segment routing checks + if dict_search('segment_routing.global_block', ospf): + g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf) + g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf) + + # 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(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', ospf): + if dict_search('segment_routing.global_block', ospf) == None: + raise ConfigError('Segment routing local-block requires global-block to be configured!') + + l_high_label_value = dict_search('segment_routing.local_block.high_label_value', ospf) + l_low_label_value = dict_search('segment_routing.local_block.low_label_value', ospf) + + # 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})!') + + # Check for a blank or invalid value per prefix + if dict_search('segment_routing.prefix', ospf): + for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + if prefix_config['index'].get('value') is None: + raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.') + + # Check for explicit-null and no-php-flag configured at the same time per prefix + if dict_search('segment_routing.prefix', ospf): + for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): + raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ + f'and no-php-flag configured at the same time.') + return None def generate(ospf): diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py index a2e411e49..ee4fe42ab 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -61,6 +61,7 @@ def verify(proxy): if not proxy: return None + aliases = [] processes = process_iter(['name', 'cmdline']) if 'device' in proxy: for device, device_config in proxy['device'].items(): @@ -75,6 +76,12 @@ def verify(proxy): if 'ssh' in device_config and 'port' not in device_config['ssh']: raise ConfigError(f'Port "{device}" requires SSH port to be set!') + if 'alias' in device_config: + if device_config['alias'] in aliases: + raise ConfigError("Console aliases must be unique") + else: + aliases.append(device_config['alias']) + return None def generate(proxy): diff --git a/src/conf_mode/service_event_handler.py b/src/conf_mode/service_event_handler.py new file mode 100755 index 000000000..5440d1056 --- /dev/null +++ b/src/conf_mode/service_event_handler.py @@ -0,0 +1,91 @@ +#!/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/>. + +import json +from pathlib import Path + +from vyos.config import Config +from vyos.util import call, dict_search +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +service_name = 'vyos-event-handler' +service_conf = Path(f'/run/{service_name}.conf') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'event-handler', 'event'] + config = conf.get_config_dict(base, + get_first_key=True, + no_tag_node_value_mangle=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config: + return None + + for name, event_config in config.items(): + if not dict_search('filter.pattern', event_config) or not dict_search( + 'script.path', event_config): + raise ConfigError( + 'Event-handler: both pattern and script path items are mandatory' + ) + + if dict_search('script.environment.message', event_config): + raise ConfigError( + 'Event-handler: "message" environment variable is reserved for log message text' + ) + + +def generate(config): + if not config: + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + return None + + +def apply(config): + if config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index ae7e582ec..c58f8db9a 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-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 @@ -19,14 +19,17 @@ import os from sys import exit from vyos.config import Config -from vyos import ConfigError -from vyos.util import call +from vyos.configdict import dict_merge from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = r'/etc/fastnetmon.conf' -networks_list = r'/etc/networks_list' +config_file = r'/run/fastnetmon/fastnetmon.conf' +networks_list = r'/run/fastnetmon/networks_list' +excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' def get_config(config=None): if config: @@ -34,50 +37,55 @@ def get_config(config=None): else: conf = Config() base = ['service', 'ids', 'ddos-protection'] + if not conf.exists(base): + return None + fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + fastnetmon = dict_merge(default_values, fastnetmon) + return fastnetmon def verify(fastnetmon): if not fastnetmon: return None - if not "mode" in fastnetmon: - raise ConfigError('ddos-protection mode is mandatory!') - - if not "network" in fastnetmon: - raise ConfigError('Required define network!') + if 'mode' not in fastnetmon: + raise ConfigError('Specify operating mode!') - if not "listen_interface" in fastnetmon: - raise ConfigError('Define listen-interface is mandatory!') + if 'listen_interface' not in fastnetmon: + raise ConfigError('Specify interface(s) for traffic capture') - if "alert_script" in fastnetmon: - if os.path.isfile(fastnetmon["alert_script"]): + if 'alert_script' in fastnetmon: + if os.path.isfile(fastnetmon['alert_script']): # Check script permissions - if not os.access(fastnetmon["alert_script"], os.X_OK): - raise ConfigError('Script {0} does not have permissions for execution'.format(fastnetmon["alert_script"])) + if not os.access(fastnetmon['alert_script'], os.X_OK): + raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) else: - raise ConfigError('File {0} does not exists!'.format(fastnetmon["alert_script"])) + raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) def generate(fastnetmon): if not fastnetmon: - if os.path.isfile(config_file): - os.unlink(config_file) - if os.path.isfile(networks_list): - os.unlink(networks_list) + for file in [config_file, networks_list]: + if os.path.isfile(file): + os.unlink(file) - return + return None render(config_file, 'ids/fastnetmon.j2', fastnetmon) render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) - + render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) return None def apply(fastnetmon): + systemd_service = 'fastnetmon.service' if not fastnetmon: # Stop fastnetmon service if removed - call('systemctl stop fastnetmon.service') + call(f'systemctl stop {systemd_service}') else: - call('systemctl restart fastnetmon.service') + call(f'systemctl reload-or-restart {systemd_service}') return None diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index 559d1bcd5..e9afd6a55 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -15,252 +15,34 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re -from copy import deepcopy -from stat import S_IRUSR, S_IWUSR, S_IRGRP from sys import exit from vyos.config import Config +from vyos.configdict import get_accel_dict +from vyos.configverify import verify_accel_ppp_base_service +from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 -from vyos.util import call, get_half_cpus +from vyos.util import call +from vyos.util import dict_search from vyos import ConfigError - from vyos import airbag airbag.enable() ipoe_conf = '/run/accel-pppd/ipoe.conf' ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets' -default_config_data = { - 'auth_mode': 'local', - 'auth_interfaces': [], - 'chap_secrets_file': ipoe_chap_secrets, # used in Jinja2 template - 'interfaces': [], - 'dnsv4': [], - 'dnsv6': [], - 'client_named_ip_pool': [], - 'client_ipv6_pool': [], - 'client_ipv6_delegate_prefix': [], - 'radius_server': [], - 'radius_acct_inter_jitter': '', - 'radius_acct_tmo': '3', - 'radius_max_try': '3', - 'radius_timeout': '3', - 'radius_nas_id': '', - 'radius_nas_ip': '', - 'radius_source_address': '', - 'radius_shaper_attr': '', - 'radius_shaper_vendor': '', - 'radius_dynamic_author': '', - 'thread_cnt': get_half_cpus() -} - def get_config(config=None): if config: conf = config else: conf = Config() - base_path = ['service', 'ipoe-server'] - if not conf.exists(base_path): + base = ['service', 'ipoe-server'] + if not conf.exists(base): return None - conf.set_level(base_path) - ipoe = deepcopy(default_config_data) - - for interface in conf.list_nodes(['interface']): - tmp = { - 'mode': 'L2', - 'name': interface, - 'shared': '1', - # may need a config option, can be dhcpv4 or up for unclassified pkts - 'sess_start': 'dhcpv4', - 'range': None, - 'ifcfg': '1', - 'vlan_mon': [] - } - - conf.set_level(base_path + ['interface', interface]) - - if conf.exists(['network-mode']): - tmp['mode'] = conf.return_value(['network-mode']) - - if conf.exists(['network']): - mode = conf.return_value(['network']) - if mode == 'vlan': - tmp['shared'] = '0' - - if conf.exists(['vlan-id']): - tmp['vlan_mon'] += conf.return_values(['vlan-id']) - - if conf.exists(['vlan-range']): - tmp['vlan_mon'] += conf.return_values(['vlan-range']) - - if conf.exists(['client-subnet']): - tmp['range'] = conf.return_value(['client-subnet']) - - ipoe['interfaces'].append(tmp) - - conf.set_level(base_path) - - if conf.exists(['name-server']): - for name_server in conf.return_values(['name-server']): - if is_ipv4(name_server): - ipoe['dnsv4'].append(name_server) - else: - ipoe['dnsv6'].append(name_server) - - if conf.exists(['authentication', 'mode']): - ipoe['auth_mode'] = conf.return_value(['authentication', 'mode']) - - if conf.exists(['authentication', 'interface']): - for interface in conf.list_nodes(['authentication', 'interface']): - tmp = { - 'name': interface, - 'mac': [] - } - for mac in conf.list_nodes(['authentication', 'interface', interface, 'mac-address']): - client = { - 'address': mac, - 'rate_download': '', - 'rate_upload': '', - 'vlan_id': '' - } - conf.set_level(base_path + ['authentication', 'interface', interface, 'mac-address', mac]) - - if conf.exists(['rate-limit', 'download']): - client['rate_download'] = conf.return_value(['rate-limit', 'download']) - - if conf.exists(['rate-limit', 'upload']): - client['rate_upload'] = conf.return_value(['rate-limit', 'upload']) - - if conf.exists(['vlan-id']): - client['vlan'] = conf.return_value(['vlan-id']) - - tmp['mac'].append(client) - - ipoe['auth_interfaces'].append(tmp) - - conf.set_level(base_path) - - # - # authentication mode radius servers and settings - if conf.exists(['authentication', 'mode', 'radius']): - for server in conf.list_nodes(['authentication', 'radius', 'server']): - radius = { - 'server' : server, - 'key' : '', - 'fail_time' : 0, - 'port' : '1812', - 'acct_port' : '1813' - } - - conf.set_level(base_path + ['authentication', 'radius', 'server', server]) - - if conf.exists(['fail-time']): - radius['fail_time'] = conf.return_value(['fail-time']) - - if conf.exists(['port']): - radius['port'] = conf.return_value(['port']) - - if conf.exists(['acct-port']): - radius['acct_port'] = conf.return_value(['acct-port']) - - if conf.exists(['key']): - radius['key'] = conf.return_value(['key']) - - if not conf.exists(['disable']): - ipoe['radius_server'].append(radius) - - # - # advanced radius-setting - conf.set_level(base_path + ['authentication', 'radius']) - - if conf.exists(['acct-interim-jitter']): - ipoe['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) - - if conf.exists(['acct-timeout']): - ipoe['radius_acct_tmo'] = conf.return_value(['acct-timeout']) - - if conf.exists(['max-try']): - ipoe['radius_max_try'] = conf.return_value(['max-try']) - - if conf.exists(['timeout']): - ipoe['radius_timeout'] = conf.return_value(['timeout']) - - if conf.exists(['nas-identifier']): - ipoe['radius_nas_id'] = conf.return_value(['nas-identifier']) - - if conf.exists(['nas-ip-address']): - ipoe['radius_nas_ip'] = conf.return_value(['nas-ip-address']) - - if conf.exists(['source-address']): - ipoe['radius_source_address'] = conf.return_value(['source-address']) - - # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) - if conf.exists(['dynamic-author']): - dae = { - 'port' : '', - 'server' : '', - 'key' : '' - } - - if conf.exists(['dynamic-author', 'server']): - dae['server'] = conf.return_value(['dynamic-author', 'server']) - - if conf.exists(['dynamic-author', 'port']): - dae['port'] = conf.return_value(['dynamic-author', 'port']) - - if conf.exists(['dynamic-author', 'key']): - dae['key'] = conf.return_value(['dynamic-author', 'key']) - - ipoe['radius_dynamic_author'] = dae - - - 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 = { - 'prefix': prefix, - 'mask': '64' - } - - if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): - tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) - - ipoe['client_ipv6_pool'].append(tmp) - - - if conf.exists(['client-ipv6-pool', 'delegate']): - for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): - tmp = { - 'prefix': prefix, - 'mask': '' - } - - if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): - tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) - - ipoe['client_ipv6_delegate_prefix'].append(tmp) - + # retrieve common dictionary keys + ipoe = get_accel_dict(conf, base, ipoe_chap_secrets) return ipoe @@ -268,26 +50,17 @@ def verify(ipoe): if not ipoe: return None - if not ipoe['interfaces']: + if 'interface' not in ipoe: raise ConfigError('No IPoE interface configured') - if len(ipoe['dnsv4']) > 2: - raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') - - if len(ipoe['dnsv6']) > 3: - raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') - - if ipoe['auth_mode'] == 'radius': - if len(ipoe['radius_server']) == 0: - raise ConfigError('RADIUS authentication requires at least one server') + for interface in ipoe['interface']: + verify_interface_exists(interface) - for radius in ipoe['radius_server']: - if not radius['key']: - server = radius['server'] - raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') + #verify_accel_ppp_base_service(ipoe, local_users=False) - if ipoe['client_ipv6_delegate_prefix'] and not ipoe['client_ipv6_pool']: - raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!') + if 'client_ipv6_pool' in ipoe: + if 'delegate' in ipoe['client_ipv6_pool'] and 'prefix' not in ipoe['client_ipv6_pool']: + raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!') return None @@ -298,27 +71,23 @@ def generate(ipoe): render(ipoe_conf, 'accel-ppp/ipoe.config.j2', ipoe) - if ipoe['auth_mode'] == 'local': - render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', ipoe) - os.chmod(ipoe_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) - - else: - if os.path.exists(ipoe_chap_secrets): - os.unlink(ipoe_chap_secrets) - + if dict_search('authentication.mode', ipoe) == 'local': + render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2', + ipoe, permission=0o640) return None def apply(ipoe): + systemd_service = 'accel-ppp@ipoe.service' if ipoe == None: - call('systemctl stop accel-ppp@ipoe.service') + call(f'systemctl stop {systemd_service}') for file in [ipoe_conf, ipoe_chap_secrets]: if os.path.exists(file): os.unlink(file) return None - call('systemctl restart accel-ppp@ipoe.service') + call(f'systemctl reload-or-restart {systemd_service}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index daf75d740..aafece47a 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -22,6 +22,8 @@ from shutil import rmtree from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf from vyos.ifconfig import Section from vyos.template import render from vyos.util import call @@ -32,40 +34,19 @@ from vyos import ConfigError from vyos import airbag airbag.enable() - -base_dir = '/run/telegraf' cache_dir = f'/etc/telegraf/.cache' -config_telegraf = f'{base_dir}/vyos-telegraf.conf' +config_telegraf = f'/run/telegraf/telegraf.conf' custom_scripts_dir = '/etc/telegraf/custom_scripts' syslog_telegraf = '/etc/rsyslog.d/50-telegraf.conf' -systemd_telegraf_service = '/etc/systemd/system/vyos-telegraf.service' -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 +systemd_override = '/etc/systemd/system/telegraf.service.d/10-override.conf' def get_nft_filter_chains(): - """ - Get nft chains for table filter - """ - nft = cmd('nft --json list table ip filter') + """ Get nft chains for table filter """ + try: + nft = cmd('nft --json list table ip vyos_filter') + except Exception: + print('nft table ip vyos_filter not found') + return [] nft = json.loads(nft) chain_list = [] @@ -76,9 +57,7 @@ def get_nft_filter_chains(): return chain_list - def get_config(config=None): - if config: conf = config else: @@ -87,8 +66,12 @@ def get_config(config=None): if not conf.exists(base): return None - monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) + monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: monitoring.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. @@ -96,13 +79,9 @@ def get_config(config=None): monitoring = dict_merge(default_values, monitoring) monitoring['custom_scripts_dir'] = custom_scripts_dir - monitoring['interfaces_ethernet'] = get_interfaces('ethernet', vlan=False) + monitoring['interfaces_ethernet'] = Section.interfaces('ethernet', vlan=False) monitoring['nft_chains'] = get_nft_filter_chains() - if 'authentication' in monitoring or \ - 'url' in monitoring: - monitoring['influxdb_configured'] = True - # Redefine azure group-metrics 'single-table' and 'table-per-metric' if 'azure_data_explorer' in monitoring: if 'single-table' in monitoring['azure_data_explorer']['group_metrics']: @@ -119,6 +98,9 @@ def get_config(config=None): # Ignore default XML values if config doesn't exists # Delete key from dict + if not conf.exists(base + ['influxdb']): + del monitoring['influxdb'] + if not conf.exists(base + ['prometheus-client']): del monitoring['prometheus_client'] @@ -132,14 +114,17 @@ def verify(monitoring): if not monitoring: return None - if 'influxdb_configured' in monitoring: - if 'authentication' not in monitoring or \ - 'organization' not in monitoring['authentication'] or \ - 'token' not in monitoring['authentication']: - raise ConfigError(f'Authentication "organization and token" are mandatory!') + verify_vrf(monitoring) - if 'url' not in monitoring: - raise ConfigError(f'Monitoring "url" is mandatory!') + # Verify influxdb + if 'influxdb' in monitoring: + if 'authentication' not in monitoring['influxdb'] or \ + 'organization' not in monitoring['influxdb']['authentication'] or \ + 'token' not in monitoring['influxdb']['authentication']: + raise ConfigError(f'influxdb authentication "organization and token" are mandatory!') + + if 'url' not in monitoring['influxdb']: + raise ConfigError(f'Monitoring influxdb "url" is mandatory!') # Verify azure-data-explorer if 'azure_data_explorer' in monitoring: @@ -173,7 +158,7 @@ def verify(monitoring): def generate(monitoring): if not monitoring: # Delete config and systemd files - config_files = [config_telegraf, systemd_telegraf_service, systemd_override, syslog_telegraf] + config_files = [config_telegraf, systemd_override, syslog_telegraf] for file in config_files: if os.path.isfile(file): os.unlink(file) @@ -190,33 +175,34 @@ def generate(monitoring): chown(cache_dir, 'telegraf', 'telegraf') - # Create systemd override dir - if not os.path.exists(systemd_telegraf_override_dir): - os.mkdir(systemd_telegraf_override_dir) - # Create custome scripts dir if not os.path.exists(custom_scripts_dir): os.mkdir(custom_scripts_dir) # Render telegraf configuration and systemd override - render(config_telegraf, 'monitoring/telegraf.j2', monitoring) - render(systemd_telegraf_service, 'monitoring/systemd_vyos_telegraf_service.j2', monitoring) - render(systemd_override, 'monitoring/override.conf.j2', monitoring, permission=0o640) - render(syslog_telegraf, 'monitoring/syslog_telegraf.j2', monitoring) - - chown(base_dir, 'telegraf', 'telegraf') + render(config_telegraf, 'telegraf/telegraf.j2', monitoring, user='telegraf', group='telegraf') + render(systemd_override, 'telegraf/override.conf.j2', monitoring) + render(syslog_telegraf, 'telegraf/syslog_telegraf.j2', monitoring) return None def apply(monitoring): # Reload systemd manager configuration + systemd_service = 'telegraf.service' call('systemctl daemon-reload') - if monitoring: - call('systemctl restart vyos-telegraf.service') - else: - call('systemctl stop vyos-telegraf.service') + if not monitoring: + call(f'systemctl stop {systemd_service}') + return + + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in monitoring: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {systemd_service}') + # Telegraf include custom rsyslog config changes - call('systemctl restart rsyslog') + call('systemctl reload-or-restart rsyslog') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 6086ef859..600ba4e92 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-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 @@ -20,15 +20,14 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict +from vyos.configdict import is_node_changed from vyos.configverify import verify_accel_ppp_base_service +from vyos.configverify import verify_interface_exists from vyos.template import render from vyos.util import call from vyos.util import dict_search -from vyos.util import get_interface_config from vyos import ConfigError from vyos import airbag -from vyos.range_regex import range_to_regex - airbag.enable() pppoe_conf = r'/run/accel-pppd/pppoe.conf' @@ -45,6 +44,13 @@ def get_config(config=None): # retrieve common dictionary keys pppoe = get_accel_dict(conf, base, pppoe_chap_secrets) + + # reload-or-restart does not implemented in accel-ppp + # use this workaround until it will be implemented + # https://phabricator.accel-ppp.org/T3 + if is_node_changed(conf, base + ['client-ip-pool']) or is_node_changed( + conf, base + ['client-ipv6-pool']): + pppoe.update({'restart_required': {}}) return pppoe def verify(pppoe): @@ -54,15 +60,14 @@ def verify(pppoe): verify_accel_ppp_base_service(pppoe) if 'wins_server' in pppoe and len(pppoe['wins_server']) > 2: - raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') + raise ConfigError('Not more then two WINS name-servers can be configured') if 'interface' not in pppoe: raise ConfigError('At least one listen interface must be defined!') # Check is interface exists in the system - for iface in pppoe['interface']: - if not get_interface_config(iface): - raise ConfigError(f'Interface {iface} does not exist!') + for interface in pppoe['interface']: + verify_interface_exists(interface) # local ippool and gateway settings config checks if not (dict_search('client_ip_pool.subnet', pppoe) or @@ -81,35 +86,27 @@ def generate(pppoe): if not pppoe: return None - # Generate special regex for dynamic interfaces - for iface in pppoe['interface']: - if 'vlan_range' in pppoe['interface'][iface]: - pppoe['interface'][iface]['regex'] = [] - for vlan_range in pppoe['interface'][iface]['vlan_range']: - pppoe['interface'][iface]['regex'].append(range_to_regex(vlan_range)) - render(pppoe_conf, 'accel-ppp/pppoe.config.j2', pppoe) if dict_search('authentication.mode', pppoe) == 'local': render(pppoe_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2', pppoe, permission=0o640) - else: - if os.path.exists(pppoe_chap_secrets): - os.unlink(pppoe_chap_secrets) - return None def apply(pppoe): + systemd_service = 'accel-ppp@pppoe.service' if not pppoe: - call('systemctl stop accel-ppp@pppoe.service') + call(f'systemctl stop {systemd_service}') for file in [pppoe_conf, pppoe_chap_secrets]: if os.path.exists(file): os.unlink(file) - return None - call('systemctl restart accel-ppp@pppoe.service') + if 'restart_required' in pppoe: + call(f'systemctl restart {systemd_service}') + else: + call(f'systemctl reload-or-restart {systemd_service}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 71b758399..1b8377a4a 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-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 @@ -17,7 +17,7 @@ import os from sys import exit - +from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render @@ -79,21 +79,34 @@ def verify(rtradv): if 'interface' not in rtradv: return None - for interface in rtradv['interface']: - interface = rtradv['interface'][interface] + for interface, interface_config in rtradv['interface'].items(): if 'prefix' in interface: - for prefix in interface['prefix']: - prefix = interface['prefix'][prefix] - valid_lifetime = prefix['valid_lifetime'] + for prefix, prefix_config in interface_config['prefix'].items(): + valid_lifetime = prefix_config['valid_lifetime'] if valid_lifetime == 'infinity': valid_lifetime = 4294967295 - preferred_lifetime = prefix['preferred_lifetime'] + preferred_lifetime = prefix_config['preferred_lifetime'] if preferred_lifetime == 'infinity': preferred_lifetime = 4294967295 - if not (int(valid_lifetime) > int(preferred_lifetime)): - raise ConfigError('Prefix valid-lifetime must be greater then preferred-lifetime') + if not (int(valid_lifetime) >= int(preferred_lifetime)): + raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime') + + if 'name_server_lifetime' in interface_config: + # man page states: + # The maximum duration how long the RDNSS entries are used for name + # resolution. A value of 0 means the nameserver must no longer be + # used. The value, if not 0, must be at least MaxRtrAdvInterval. To + # ensure stale RDNSS info gets removed in a timely fashion, this + # should not be greater than 2*MaxRtrAdvInterval. + lifetime = int(interface_config['name_server_lifetime']) + interval_max = int(interface_config['interval']['max']) + if lifetime > 0: + if lifetime < int(interval_max): + raise ConfigError(f'RDNSS lifetime must be at least "{interval_max}" seconds!') + if lifetime > 2* interval_max: + Warning(f'RDNSS lifetime should not exceed "{2 * interval_max}" which is two times "interval max"!') return None @@ -105,15 +118,16 @@ def generate(rtradv): return None def apply(rtradv): + systemd_service = 'radvd.service' if not rtradv: # bail out early - looks like removal from running config - call('systemctl stop radvd.service') + call(f'systemctl stop {systemd_service}') if os.path.exists(config_file): os.unlink(config_file) return None - call('systemctl restart radvd.service') + call(f'systemctl reload-or-restart {systemd_service}') return None diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py index 36f3e18a7..c798fd515 100755 --- a/src/conf_mode/service_upnp.py +++ b/src/conf_mode/service_upnp.py @@ -24,8 +24,6 @@ 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 @@ -113,19 +111,28 @@ def verify(upnpd): 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]) + if 'listen' not in upnpd: + raise ConfigError(f'Listen address or interface is required!') 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 (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!') + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' + f'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!') + raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' + f'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): + 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): diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py index 28669694b..8746cc701 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/ssh.py @@ -22,6 +22,7 @@ from syslog import LOG_INFO from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.util import call from vyos.template import render @@ -50,6 +51,10 @@ def get_config(config=None): return None ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: ssh.update({'restart_required': {}}) + # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = defaults(base) @@ -68,6 +73,9 @@ def verify(ssh): if not ssh: return None + if 'rekey' in ssh and 'data' not in ssh['rekey']: + raise ConfigError(f'Rekey data is required!') + verify_vrf(ssh) return None @@ -104,17 +112,25 @@ def generate(ssh): return None def apply(ssh): + systemd_service_ssh = 'ssh.service' + systemd_service_sshguard = 'sshguard.service' if not ssh: # SSH access is removed in the commit - call('systemctl stop ssh.service') - call('systemctl stop sshguard.service') + call(f'systemctl stop {systemd_service_ssh}') + call(f'systemctl stop {systemd_service_sshguard}') return None + if 'dynamic_protection' not in ssh: - call('systemctl stop sshguard.service') + call(f'systemctl stop {systemd_service_sshguard}') else: - call('systemctl restart sshguard.service') + call(f'systemctl reload-or-restart {systemd_service_sshguard}') + + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in ssh: + systemd_action = 'restart' - call('systemctl restart ssh.service') + call(f'systemctl {systemd_action} {systemd_service_ssh}') return None if __name__ == '__main__': diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 05fc3a97a..0c5063ed3 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system-ip.py @@ -64,6 +64,11 @@ def apply(opt): value = '0' if (tmp != None) else '1' write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) + # enable/disable IPv4 directed broadcast forwarding + tmp = dict_search('disable_directed_broadcast', opt) + value = '0' if (tmp != None) else '1' + write_file('/proc/sys/net/ipv4/conf/all/bc_forwarding', value) + # configure multipath tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) value = '1' if (tmp != None) else '0' diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index c717286ae..e26b81e3d 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -40,6 +40,7 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +autologout_file = "/etc/profile.d/autologout.sh" radius_config_file = "/etc/pam_radius_auth.conf" def get_local_users(): @@ -203,6 +204,13 @@ def generate(login): if os.path.isfile(radius_config_file): os.unlink(radius_config_file) + if 'timeout' in login: + render(autologout_file, 'login/autologout.j2', login, + permission=0o755, user='root', group='root') + else: + if os.path.isfile(autologout_file): + os.unlink(autologout_file) + return None @@ -231,7 +239,7 @@ def apply(login): if tmp: command += f" --home '{tmp}'" else: command += f" --home '/home/{user}'" - command += f' --groups frrvty,vyattacfg,sudo,adm,dip,disk {user}' + command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}' try: cmd(command) @@ -249,6 +257,15 @@ def apply(login): except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') + # Generate 2FA/MFA One-Time-Pad configuration + if dict_search('authentication.otp.key', user_config): + render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + user_config, permission=0o400, user=user, group='users') + else: + # delete configuration as it's not enabled for the user + if os.path.exists(f'{home_dir}/.google_authenticator'): + os.remove(f'{home_dir}/.google_authenticator') + if 'rm_users' in login: for user in login['rm_users']: try: diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py index a9d3bbe31..20132456c 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system-syslog.py @@ -52,8 +52,6 @@ def get_config(config=None): { 'global': { 'log-file': '/var/log/messages', - 'max-size': 262144, - 'action-on-max-size': '/usr/sbin/logrotate /etc/logrotate.d/vyos-rsyslog', 'selectors': '*.notice;local7.debug', 'max-files': '5', 'preserver_fqdn': False diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 86985d765..e922edc4e 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -16,6 +16,7 @@ import os import re +from pathlib import Path from vyos.config import Config from vyos.configdict import dict_merge @@ -68,18 +69,15 @@ def verify(console): # amount of connected devices. We will resolve the fixed device name # to its dynamic device file - and create a new dict entry for it. by_bus_device = f'{by_bus_dir}/{device}' - if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): - device = os.path.basename(os.readlink(by_bus_device)) - - # If the device name still starts with usbXXX no matching tty was found - # and it can not be used as a serial interface - if device.startswith('usb'): - raise ConfigError(f'Device {device} does not support beeing used as tty') + # If the device name still starts with usbXXX no matching tty was found + # and it can not be used as a serial interface + if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device): + raise ConfigError(f'Device {device} does not support beeing used as tty') return None def generate(console): - base_dir = '/etc/systemd/system' + base_dir = '/run/systemd/system' # Remove all serial-getty configuration files in advance for root, dirs, files in os.walk(base_dir): for basename in files: @@ -90,7 +88,8 @@ def generate(console): if not console or 'device' not in console: return None - for device, device_config in console['device'].items(): + # replace keys in the config for ttyUSB items to use them in `apply()` later + for device in console['device'].copy(): if device.startswith('usb'): # It is much easiert to work with the native ttyUSBn name when using # getty, but that name may change across reboots - depending on the @@ -98,9 +97,17 @@ def generate(console): # to its dynamic device file - and create a new dict entry for it. by_bus_device = f'{by_bus_dir}/{device}' if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device): - device = os.path.basename(os.readlink(by_bus_device)) + device_updated = os.path.basename(os.readlink(by_bus_device)) + + # replace keys in the config to use them in `apply()` later + console['device'][device_updated] = console['device'][device] + del console['device'][device] + else: + raise ConfigError(f'Device {device} does not support beeing used as tty') + for device, device_config in console['device'].items(): config_file = base_dir + f'/serial-getty@{device}.service' + Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True) getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service' render(config_file, 'getty/serial-getty.service.j2', device_config) diff --git a/src/conf_mode/system_update_check.py b/src/conf_mode/system_update_check.py new file mode 100755 index 000000000..08ecfcb81 --- /dev/null +++ b/src/conf_mode/system_update_check.py @@ -0,0 +1,93 @@ +#!/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/>. + +import os +import json +import jmespath + +from pathlib import Path +from sys import exit + +from vyos.config import Config +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +base = ['system', 'update-check'] +service_name = 'vyos-system-update' +service_conf = Path(f'/run/{service_name}.conf') +motd_file = Path('/run/motd.d/10-vyos-update') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + if not conf.exists(base): + return None + + config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if config is None: + return + + if 'url' not in config: + raise ConfigError('URL is required!') + + +def generate(config): + # bail out early - looks like removal from running config + if config is None: + # Remove old config and return + service_conf.unlink(missing_ok=True) + # MOTD used in /run/motd.d/10-update + motd_file.unlink(missing_ok=True) + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + return None + + +def apply(config): + if config: + if 'auto_check' in config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index bad9cfbd8..b79e9847a 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 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 @@ -16,11 +16,13 @@ import ipaddress import os +import re from sys import exit from time import sleep from time import time +from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists @@ -116,13 +118,26 @@ def get_config(config=None): ipsec['ike_group'][group]['proposal'][proposal] = dict_merge(default_values, ipsec['ike_group'][group]['proposal'][proposal]) - if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']: + # XXX: T2665: we can not safely rely on the defaults() when there are + # tagNodes in place, it is better to blend in the defaults manually. + if dict_search('remote_access.connection', ipsec): default_values = defaults(base + ['remote-access', 'connection']) for rw in ipsec['remote_access']['connection']: ipsec['remote_access']['connection'][rw] = dict_merge(default_values, ipsec['remote_access']['connection'][rw]) - if 'remote_access' in ipsec and 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: + # XXX: T2665: we can not safely rely on the defaults() when there are + # tagNodes in place, it is better to blend in the defaults manually. + if dict_search('remote_access.radius.server', ipsec): + # Fist handle the "base" stuff like RADIUS timeout + default_values = defaults(base + ['remote-access', 'radius']) + if 'server' in default_values: + del default_values['server'] + ipsec['remote_access']['radius'] = dict_merge(default_values, + ipsec['remote_access']['radius']) + + # Take care about individual RADIUS servers implemented as tagNodes - this + # requires special treatment default_values = defaults(base + ['remote-access', 'radius', 'server']) for server in ipsec['remote_access']['radius']['server']: ipsec['remote_access']['radius']['server'][server] = dict_merge(default_values, @@ -264,7 +279,7 @@ def verify(ipsec): ike = ra_conf['ike_group'] if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': - raise ConfigError('IPSec remote-access connections requires IKEv2!') + raise ConfigError('IPsec remote-access connections requires IKEv2!') else: raise ConfigError(f"Missing ike-group on {name} remote-access config") @@ -307,10 +322,10 @@ def verify(ipsec): for pool in ra_conf['pool']: if pool == 'dhcp': if dict_search('remote_access.dhcp.server', ipsec) == None: - raise ConfigError('IPSec DHCP server is not configured!') + raise ConfigError('IPsec DHCP server is not configured!') elif pool == 'radius': if dict_search('remote_access.radius.server', ipsec) == None: - raise ConfigError('IPSec RADIUS server is not configured!') + raise ConfigError('IPsec RADIUS server is not configured!') if dict_search('authentication.client_mode', ra_conf) != 'eap-radius': raise ConfigError('RADIUS IP pool requires eap-radius client authentication!') @@ -348,6 +363,14 @@ def verify(ipsec): if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: for peer, peer_conf in ipsec['site_to_site']['peer'].items(): has_default_esp = False + # Peer name it is swanctl connection name and shouldn't contain dots or colons, T4118 + if bool(re.search(':|\.', peer)): + raise ConfigError(f'Incorrect peer name "{peer}" ' + f'Peer name can contain alpha-numeric letters, hyphen and underscore') + + if 'remote_address' not in peer_conf: + print(f'You should set correct remote-address "peer {peer} remote-address x.x.x.x"\n') + if 'default_esp_group' in peer_conf: has_default_esp = True if 'esp_group' not in ipsec or peer_conf['default_esp_group'] not in ipsec['esp_group']: @@ -416,6 +439,10 @@ def verify(ipsec): if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") + if dict_search('options.disable_route_autoinstall', + ipsec) == None: + Warning('It\'s recommended to use ipsec vty with the next command\n[set vpn ipsec option disable-route-autoinstall]') + if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] if not os.path.exists(f'/sys/class/net/{vti_interface}'): @@ -595,13 +622,11 @@ def wait_for_vici_socket(timeout=5, sleep_interval=0.1): sleep(sleep_interval) def apply(ipsec): + systemd_service = 'strongswan-starter.service' if not ipsec: - call('sudo ipsec stop') + call(f'systemctl stop {systemd_service}') else: - call('sudo ipsec restart') - call('sudo ipsec rereadall') - call('sudo ipsec reload') - + call(f'systemctl reload-or-restart {systemd_service}') if wait_for_vici_socket(): call('sudo swanctl -q') diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index fd5a4acd8..27e78db99 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -26,7 +26,10 @@ from ipaddress import ip_network from vyos.config import Config from vyos.template import is_ipv4 from vyos.template import render -from vyos.util import call, get_half_cpus +from vyos.util import call +from vyos.util import get_half_cpus +from vyos.util import check_port_availability +from vyos.util import is_listen_port_bind_service from vyos import ConfigError from vyos import airbag @@ -43,6 +46,7 @@ default_config_data = { 'client_ip_pool': None, 'client_ip_subnets': [], 'client_ipv6_pool': [], + 'client_ipv6_pool_configured': False, 'client_ipv6_delegate_prefix': [], 'dnsv4': [], 'dnsv6': [], @@ -64,7 +68,7 @@ default_config_data = { 'radius_source_address': '', 'radius_shaper_attr': '', 'radius_shaper_vendor': '', - 'radius_dynamic_author': '', + 'radius_dynamic_author': {}, 'wins': [], 'ip6_column': [], 'thread_cnt': get_half_cpus() @@ -205,21 +209,21 @@ def get_config(config=None): l2tp['radius_source_address'] = conf.return_value(['source-address']) # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) - if conf.exists(['dynamic-author']): + if conf.exists(['dae-server']): dae = { 'port' : '', 'server' : '', 'key' : '' } - if conf.exists(['dynamic-author', 'server']): - dae['server'] = conf.return_value(['dynamic-author', 'server']) + if conf.exists(['dae-server', 'ip-address']): + dae['server'] = conf.return_value(['dae-server', 'ip-address']) - if conf.exists(['dynamic-author', 'port']): - dae['port'] = conf.return_value(['dynamic-author', 'port']) + if conf.exists(['dae-server', 'port']): + dae['port'] = conf.return_value(['dae-server', 'port']) - if conf.exists(['dynamic-author', 'key']): - dae['key'] = conf.return_value(['dynamic-author', 'key']) + if conf.exists(['dae-server', 'secret']): + dae['key'] = conf.return_value(['dae-server', 'secret']) l2tp['radius_dynamic_author'] = dae @@ -244,6 +248,7 @@ def get_config(config=None): l2tp['client_ip_subnets'] = conf.return_values(['client-ip-pool', 'subnet']) if conf.exists(['client-ipv6-pool', 'prefix']): + l2tp['client_ipv6_pool_configured'] = True l2tp['ip6_column'].append('ip6') for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): tmp = { @@ -306,6 +311,9 @@ def get_config(config=None): if conf.exists(['ppp-options', 'lcp-echo-interval']): l2tp['ppp_echo_interval'] = conf.return_value(['ppp-options', 'lcp-echo-interval']) + if conf.exists(['ppp-options', 'ipv6']): + l2tp['ppp_ipv6'] = conf.return_value(['ppp-options', 'ipv6']) + return l2tp @@ -329,6 +337,19 @@ def verify(l2tp): if not radius['key']: raise ConfigError(f"Missing RADIUS secret for server { radius['key'] }") + if l2tp['radius_dynamic_author']: + if not l2tp['radius_dynamic_author']['server']: + raise ConfigError("Missing ip-address for dae-server") + if not l2tp['radius_dynamic_author']['key']: + raise ConfigError("Missing secret for dae-server") + address = l2tp['radius_dynamic_author']['server'] + port = l2tp['radius_dynamic_author']['port'] + proto = 'tcp' + # check if dae listen port is not used by another service + if check_port_availability(address, int(port), proto) is not True and \ + not is_listen_port_bind_service(int(port), 'accel-pppd'): + raise ConfigError(f'"{proto}" port "{port}" is used by another service') + # check for the existence of a client ip pool if not (l2tp['client_ip_pool'] or l2tp['client_ip_subnets']): raise ConfigError( diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index 8e0e30bbf..af3c51efc 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-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 @@ -23,7 +23,9 @@ from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call +from vyos.util import check_port_availability from vyos.util import is_systemd_service_running +from vyos.util import is_listen_port_bind_service from vyos.util import dict_search from vyos.xml import defaults from vyos import ConfigError @@ -56,15 +58,16 @@ def get_config(): default_values = defaults(base) ocserv = dict_merge(default_values, ocserv) - # workaround a "know limitation" - https://phabricator.vyos.net/T2665 - del ocserv['authentication']['local_users']['username']['otp'] - if not ocserv["authentication"]["local_users"]["username"]: - raise ConfigError('openconnect mode local required at least one user') - default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] - for user, params in ocserv['authentication']['local_users']['username'].items(): - # Not every configuration requires OTP settings - if ocserv['authentication']['local_users']['username'][user].get('otp'): - ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp']) + if 'mode' in ocserv["authentication"] and "local" in ocserv["authentication"]["mode"]: + # workaround a "know limitation" - https://phabricator.vyos.net/T2665 + del ocserv['authentication']['local_users']['username']['otp'] + if not ocserv["authentication"]["local_users"]["username"]: + raise ConfigError('openconnect mode local required at least one user') + default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] + for user, params in ocserv['authentication']['local_users']['username'].items(): + # Not every configuration requires OTP settings + if ocserv['authentication']['local_users']['username'][user].get('otp'): + ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp']) if ocserv: ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), @@ -75,6 +78,13 @@ def get_config(): def verify(ocserv): if ocserv is None: return None + # Check if listen-ports not binded other services + # It can be only listen by 'ocserv-main' + for proto, port in ocserv.get('listen_ports').items(): + if check_port_availability(ocserv['listen_address'], int(port), proto) is not True and \ + not is_listen_port_bind_service(int(port), 'ocserv-main'): + raise ConfigError(f'"{proto}" port "{port}" is used by another service') + # Check authentication if "authentication" in ocserv: if "mode" in ocserv["authentication"]: @@ -147,7 +157,7 @@ def verify(ocserv): ocserv["network_settings"]["push_route"].remove("0.0.0.0/0") ocserv["network_settings"]["push_route"].append("default") else: - ocserv["network_settings"]["push_route"] = "default" + ocserv["network_settings"]["push_route"] = ["default"] else: raise ConfigError('openconnect network settings required') @@ -237,7 +247,7 @@ def apply(ocserv): if os.path.exists(file): os.unlink(file) else: - call('systemctl restart ocserv.service') + call('systemctl reload-or-restart ocserv.service') counter = 0 while True: # exit early when service runs diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index db53463cf..2949ab290 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-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 @@ -20,12 +20,15 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict +from vyos.configdict import dict_merge from vyos.configverify import verify_accel_ppp_base_service from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call +from vyos.util import check_port_availability from vyos.util import dict_search +from vyos.util import is_listen_port_bind_service from vyos.util import write_file from vyos import ConfigError from vyos import airbag @@ -50,10 +53,10 @@ def get_config(config=None): # retrieve common dictionary keys sstp = get_accel_dict(conf, base, sstp_chap_secrets) - if sstp: sstp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + get_first_key=True, + no_tag_node_value_mangle=True) return sstp @@ -61,6 +64,12 @@ def verify(sstp): if not sstp: return None + port = sstp.get('port') + proto = 'tcp' + if check_port_availability('0.0.0.0', int(port), proto) is not True and \ + not is_listen_port_bind_service(int(port), 'accel-pppd'): + raise ConfigError(f'"{proto}" port "{port}" is used by another service') + verify_accel_ppp_base_service(sstp) if 'client_ip_pool' not in sstp and 'client_ipv6_pool' not in sstp: @@ -121,7 +130,6 @@ def generate(sstp): ca_cert_name = sstp['ssl']['ca_certificate'] pki_ca = sstp['pki']['ca'][ca_cert_name] - write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) write_file(ca_cert_file_path, wrap_certificate(pki_ca['certificate'])) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 972d0289b..1b4156895 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -113,8 +113,14 @@ def verify(vrf): f'static routes installed!') if 'name' in vrf: + reserved_names = ["add", "all", "broadcast", "default", "delete", "dev", "get", "inet", "mtu", "link", "type", + "vrf"] table_ids = [] for name, config in vrf['name'].items(): + # Reserved VRF names + if name in reserved_names: + raise ConfigError(f'VRF name "{name}" is reserved and connot be used!') + # table id is mandatory if 'table' not in config: raise ConfigError(f'VRF "{name}" table id is mandatory!') diff --git a/src/conf_mode/zone_policy.py b/src/conf_mode/zone_policy.py deleted file mode 100755 index 070a4deea..000000000 --- a/src/conf_mode/zone_policy.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/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 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() - -nftables_conf = '/run/nftables_zone.conf' - -def get_config(config=None): - if config: - conf = config - 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['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 - -def verify(zone_policy): - # bail out early - looks like removal from running config - if not zone_policy: - return None - - local_zone = False - interfaces = [] - - if 'zone' in zone_policy: - for zone, zone_conf in zone_policy['zone'].items(): - if 'local_zone' not in zone_conf and 'interface' not in zone_conf: - raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') - - if 'local_zone' in zone_conf: - if local_zone: - raise ConfigError('There cannot be multiple local zones') - if 'interface' in zone_conf: - raise ConfigError('Local zone cannot have interfaces assigned') - if 'intra_zone_filtering' in zone_conf: - raise ConfigError('Local zone cannot use intra-zone-filtering') - local_zone = True - - if 'interface' in zone_conf: - found_duplicates = [intf for intf in zone_conf['interface'] if intf in interfaces] - - if found_duplicates: - raise ConfigError(f'Interfaces cannot be assigned to multiple zones') - - interfaces += zone_conf['interface'] - - if 'intra_zone_filtering' in zone_conf: - intra_zone = zone_conf['intra_zone_filtering'] - - if len(intra_zone) > 1: - raise ConfigError('Only one intra-zone-filtering action must be specified') - - if 'firewall' in intra_zone: - v4_name = dict_search_args(intra_zone, 'firewall', 'name') - if v4_name and not dict_search_args(zone_policy, 'firewall', 'name', v4_name): - raise ConfigError(f'Firewall name "{v4_name}" does not exist') - - v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6-name') - if v6_name and not dict_search_args(zone_policy, 'firewall', 'ipv6-name', v6_name): - raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - - if not v4_name and not v6_name: - raise ConfigError('No firewall names specified for intra-zone-filtering') - - if 'from' in zone_conf: - for from_zone, from_conf in zone_conf['from'].items(): - if from_zone not in zone_policy['zone']: - raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') - - v4_name = dict_search_args(from_conf, 'firewall', 'name') - if v4_name: - if 'name' not in zone_policy['firewall']: - raise ConfigError(f'Firewall name "{v4_name}" does not exist') - - if not dict_search_args(zone_policy, 'firewall', 'name', v4_name): - raise ConfigError(f'Firewall name "{v4_name}" does not exist') - - v6_name = dict_search_args(from_conf, 'firewall', 'v6_name') - if v6_name: - if 'ipv6_name' not in zone_policy['firewall']: - raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - - if not dict_search_args(zone_policy, 'firewall', 'ipv6_name', v6_name): - raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') - - return None - -def has_ipv4_fw(zone_conf): - if 'from' not in zone_conf: - return False - zone_from = zone_conf['from'] - return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'name')]) - -def has_ipv6_fw(zone_conf): - if 'from' not in zone_conf: - return False - zone_from = zone_conf['from'] - return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'ipv6_name')]) - -def get_local_from(zone_policy, local_zone_name): - # Get all zone firewall names from the local zone - out = {} - for zone, zone_conf in zone_policy['zone'].items(): - if zone == local_zone_name: - continue - if 'from' not in zone_conf: - continue - if local_zone_name in zone_conf['from']: - out[zone] = zone_conf['from'][local_zone_name] - return out - -def cleanup_commands(): - commands = [] - for table in ['ip filter', 'ip6 filter']: - 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 'rule' in item: - chain = item['rule']['chain'] - handle = item['rule']['handle'] - if 'expr' not in item['rule']: - continue - for expr in item['rule']['expr']: - target = dict_search_args(expr, 'jump', 'target') - if not target: - continue - if target.startswith("VZONE") or target.startswith("VYOS_STATE_POLICY"): - commands.append(f'delete rule {table} {chain} handle {handle}') - for item in obj['nftables']: - if 'chain' in item: - if item['chain']['name'].startswith("VZONE"): - chain = item['chain']['name'] - commands.append(f'delete chain {table} {chain}') - return commands - -def generate(zone_policy): - data = zone_policy or {} - - if os.path.exists(nftables_conf): # Check to see if we've run before - data['cleanup_commands'] = cleanup_commands() - - if 'zone' in data: - for zone, zone_conf in data['zone'].items(): - zone_conf['ipv4'] = has_ipv4_fw(zone_conf) - zone_conf['ipv6'] = has_ipv6_fw(zone_conf) - - if 'local_zone' in zone_conf: - zone_conf['from_local'] = get_local_from(data, zone) - - render(nftables_conf, 'zone_policy/nftables.j2', data) - return None - -def apply(zone_policy): - install_result = run(f'nft -f {nftables_conf}') - if install_result != 0: - raise ConfigError('Failed to apply zone-policy') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) |