diff options
Diffstat (limited to 'src/conf_mode')
-rwxr-xr-x | src/conf_mode/dns_dynamic.py | 92 | ||||
-rwxr-xr-x | src/conf_mode/http-api.py | 154 | ||||
-rwxr-xr-x | src/conf_mode/https.py | 133 | ||||
-rwxr-xr-x | src/conf_mode/interfaces-pppoe.py | 6 | ||||
-rwxr-xr-x | src/conf_mode/interfaces-vxlan.py | 35 | ||||
-rwxr-xr-x | src/conf_mode/protocols_igmp.py | 140 | ||||
-rwxr-xr-x | src/conf_mode/protocols_pim.py | 207 | ||||
-rwxr-xr-x | src/conf_mode/protocols_pim6.py | 57 |
8 files changed, 327 insertions, 497 deletions
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py index 2bccaee0f..3ddc8e7fd 100755 --- a/src/conf_mode/dns_dynamic.py +++ b/src/conf_mode/dns_dynamic.py @@ -30,16 +30,18 @@ config_file = r'/run/ddclient/ddclient.conf' systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' # Protocols that require zone -zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', 'nfsn'] +zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', + 'nfsn', 'nsupdate'] zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1'] # Protocols that do not require username username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla', - 'regfishde'] + 'nsupdate', 'regfishde'] # Protocols that support TTL -ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn'] +ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn', + 'nsupdate'] # Protocols that support both IPv4 and IPv6 dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns', @@ -70,63 +72,65 @@ def get_config(config=None): def verify(dyndns): # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: + if not dyndns or 'name' not in dyndns: return None - for address in dyndns['address']: - # If dyndns address is an interface, ensure it exists - if address != 'web': - verify_interface_exists(address) + # Dynamic DNS service provider - configuration validation + for service, config in dyndns['name'].items(): - # RFC2136 - configuration validation - if 'rfc2136' in dyndns['address'][address]: - for config in dyndns['address'][address]['rfc2136'].values(): - for field in ['host_name', 'zone', 'server', 'key']: - if field not in config: - raise ConfigError(f'"{field.replace("_", "-")}" is required for RFC2136 ' - f'based Dynamic DNS service on "{address}"') + error_msg_req = f'is required for Dynamic DNS service "{service}"' + error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' - # Dynamic DNS service provider - configuration validation - if 'web_options' in dyndns['address'][address] and address != 'web': - raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address') + for field in ['protocol', 'address', 'host_name']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') - # Dynamic DNS service provider - configuration validation - if 'service' in dyndns['address'][address]: - for service, config in dyndns['address'][address]['service'].items(): - error_msg_req = f'is required for Dynamic DNS service "{service}" on "{address}"' - error_msg_uns = f'is not supported for Dynamic DNS service "{service}" on "{address}" with protocol "{config["protocol"]}"' + # If dyndns address is an interface, ensure that it exists + # and that web-options are not set + if config['address'] != 'web': + verify_interface_exists(config['address']) + if 'web_options' in config: + raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address') - for field in ['host_name', 'password', 'protocol']: - if field not in config: - raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') + # RFC2136 uses 'key' instead of 'password' + if config['protocol'] != 'nsupdate' and 'password' not in config: + raise ConfigError(f'"password" {error_msg_req}') - if config['protocol'] in zone_necessary and 'zone' not in config: - raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') + # Other RFC2136 specific configuration validation + if config['protocol'] == 'nsupdate': + if 'password' in config: + raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"') + for field in ['server', 'key']: + if field not in config: + raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"') - if config['protocol'] not in zone_supported and 'zone' in config: - raise ConfigError(f'"zone" {error_msg_uns}') + if config['protocol'] in zone_necessary and 'zone' not in config: + raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') - if config['protocol'] not in username_unnecessary and 'username' not in config: - raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"') + if config['protocol'] not in zone_supported and 'zone' in config: + raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"') - if config['protocol'] not in ttl_supported and 'ttl' in config: - raise ConfigError(f'"ttl" {error_msg_uns}') + if config['protocol'] not in username_unnecessary and 'username' not in config: + raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"') - if config['ip_version'] == 'both': - if config['protocol'] not in dualstack_supported: - raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns}') - # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) - if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: - raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}"') + if config['protocol'] not in ttl_supported and 'ttl' in config: + raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"') - if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): - raise ConfigError(f'"expiry-time" must be greater than "wait-time"') + if config['ip_version'] == 'both': + if config['protocol'] not in dualstack_supported: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} with protocol "{config["protocol"]}"') + # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) + if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}" with protocol "{config["protocol"]}"') + + if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): + raise ConfigError(f'"expiry-time" must be greater than "wait-time" for Dynamic DNS service "{service}"') return None def generate(dyndns): # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: + if not dyndns or 'name' not in dyndns: return None render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) @@ -139,7 +143,7 @@ def apply(dyndns): call('systemctl daemon-reload') # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: + if not dyndns or 'name' not in dyndns: call(f'systemctl stop {systemd_service}') if os.path.exists(config_file): os.unlink(config_file) diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py deleted file mode 100755 index d8fe3b736..000000000 --- a/src/conf_mode/http-api.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 sys -import os -import json - -from time import sleep -from copy import deepcopy - -import vyos.defaults - -from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -api_conf_file = '/etc/vyos/http-api.conf' -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') - if x is None: - default_key = None - else: - default_key = x[0] - keys_added = False - - if config: - conf = config - 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, - with_recursive_defaults=True) - - # One needs to 'flatten' the keys dict from the config into the - # http-api.conf format for api_keys: - if 'keys' in api_dict: - api_dict['api_keys'] = [] - for el in list(api_dict['keys'].get('id', {})): - key = api_dict['keys']['id'][el].get('key', '') - if key: - api_dict['api_keys'].append({'id': el, 'key': key}) - del api_dict['keys'] - - # Do we run inside a VRF context? - vrf_path = ['service', 'https', 'vrf'] - if conf.exists(vrf_path): - http_api['vrf'] = conf.return_value(vrf_path) - - if 'api_keys' in api_dict: - keys_added = True - - if api_dict.from_defaults(['graphql']): - del api_dict['graphql'] - - 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): - return None - -def generate(http_api): - if http_api is None: - if os.path.exists(systemd_service): - os.unlink(systemd_service) - return None - - if not os.path.exists('/etc/vyos'): - os.mkdir('/etc/vyos') - - with open(api_conf_file, 'w') as f: - json.dump(http_api, f, indent=2) - - render(systemd_service, 'https/vyos-http-api.service.j2', http_api) - return None - -def apply(http_api): - # Reload systemd manager configuration - call('systemctl daemon-reload') - service_name = 'vyos-http-api.service' - - if http_api is not None: - if is_systemd_service_running(f'{service_name}'): - call(f'systemctl reload {service_name}') - else: - call(f'systemctl restart {service_name}') - else: - call(f'systemctl stop {service_name}') - - # Let uvicorn settle before restarting Nginx - sleep(1) - - call_dependents() - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index 010490c7e..40b7de557 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -16,19 +16,24 @@ import os import sys +import json from copy import deepcopy +from time import sleep import vyos.defaults import vyos.certbot_util from vyos.config import Config +from vyos.configdiff import get_config_diff from vyos.configverify import verify_vrf from vyos import ConfigError from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running +from vyos.utils.process import is_systemd_service_active from vyos.utils.network import check_port_availability from vyos.utils.network import is_listen_port_bind_service from vyos.utils.file import write_file @@ -42,6 +47,9 @@ cert_dir = '/etc/ssl/certs' key_dir = '/etc/ssl/private' certbot_dir = vyos.defaults.directories['certbot'] +api_config_state = '/run/http-api-state' +systemd_service = '/run/systemd/system/vyos-http-api.service' + # https config needs to coordinate several subsystems: api, certbot, # self-signed certificate, as well as the virtual hosts defined within the # https config definition itself. Consequently, one needs a general dict, @@ -52,7 +60,7 @@ default_server_block = { 'address' : '*', 'port' : '443', 'name' : ['_'], - 'api' : {}, + 'api' : False, 'vyos_cert' : {}, 'certbot' : False } @@ -67,15 +75,41 @@ def get_config(config=None): if not conf.exists(base): return None + diff = get_config_diff(conf) + https = conf.get_config_dict(base, get_first_key=True) if https: https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + no_tag_node_value_mangle=True, + get_first_key=True) + + https['children_changed'] = diff.node_changed_children(base) + https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) + + if 'api' not in https: + return https + + http_api = conf.get_config_dict(base + ['api'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + if http_api.from_defaults(['graphql']): + del http_api['graphql'] + + # 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) + + https['api'] = http_api return https def verify(https): + from vyos.utils.dict import dict_search + if https is None: return None @@ -101,7 +135,7 @@ def verify(https): if 'certbot' in https['certificates']: vhost_names = [] - for vh, vh_conf in https.get('virtual-host', {}).items(): + for _, vh_conf in https.get('virtual-host', {}).items(): vhost_names += vh_conf.get('server-name', []) domains = https['certificates']['certbot'].get('domain-name', []) domains_found = [domain for domain in domains if domain in vhost_names] @@ -122,7 +156,7 @@ def verify(https): 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['port'] = data.get('port', '443') server_block_list.append(server_block) for entry in server_block_list: @@ -135,12 +169,44 @@ def verify(https): raise ConfigError(f'"{proto}" port "{_port}" is used by another service') verify_vrf(https) + + # Verify API server settings, if present + if 'api' in https: + keys = dict_search('api.keys.id', https) + gql_auth_type = dict_search('api.graphql.authentication.type', https) + + # If "api graphql" is not defined and `gql_auth_type` is None, + # there's certainly no JWT auth option, and keys are required + jwt_auth = (gql_auth_type == "token") + + # Check for incomplete key configurations in every case + valid_keys_exist = False + if keys: + for k in keys: + if 'key' not in keys[k]: + raise ConfigError(f'Missing HTTPS API key string for key id "{k}"') + else: + valid_keys_exist = True + + # If only key-based methods are enabled, + # fail the commit if no valid key configurations are found + if (not valid_keys_exist) and (not jwt_auth): + raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled') + return None def generate(https): if https is None: return None + if 'api' not in https: + if os.path.exists(systemd_service): + os.unlink(systemd_service) + else: + render(systemd_service, 'https/vyos-http-api.service.j2', https['api']) + with open(api_config_state, 'w') as f: + json.dump(https['api'], f, indent=2) + server_block_list = [] # organize by vhosts @@ -156,7 +222,7 @@ def generate(https): server_block['id'] = vhost data = vhost_dict.get(vhost, {}) server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('listen-port', '443') + server_block['port'] = data.get('port', '443') name = data.get('server-name', ['_']) server_block['name'] = name allow_client = data.get('allow-client', {}) @@ -206,40 +272,18 @@ def generate(https): # certbot organizes certificates by first domain sb['certbot_domain_dir'] = cert_domains[0] - # get api data - - api_set = False - api_data = {} if 'api' in list(https): - api_set = True - api_data = vyos.defaults.api_data - api_settings = https.get('api', {}) - if api_settings: - port = api_settings.get('port', '') - if port: - api_data['port'] = port - vhosts = https.get('api-restrict', {}).get('virtual-host', []) - if vhosts: - api_data['vhost'] = vhosts[:] - if 'socket' in list(api_settings): - api_data['socket'] = True - - if api_data: - vhost_list = api_data.get('vhost', []) + vhost_list = https.get('api-restrict', {}).get('virtual-host', []) if not vhost_list: for block in server_block_list: - block['api'] = api_data + block['api'] = True else: for block in server_block_list: if block['id'] in vhost_list: - block['api'] = api_data - - if 'server_block_list' not in https or not https['server_block_list']: - https['server_block_list'] = [default_server_block] + block['api'] = True data = { 'server_block_list': server_block_list, - 'api_set': api_set, 'certbot': certbot } @@ -250,10 +294,31 @@ def generate(https): def apply(https): # Reload systemd manager configuration call('systemctl daemon-reload') - if https is not None: - call('systemctl restart nginx.service') - else: - call('systemctl stop nginx.service') + http_api_service_name = 'vyos-http-api.service' + https_service_name = 'nginx.service' + + if https is None: + if is_systemd_service_active(f'{http_api_service_name}'): + call(f'systemctl stop {http_api_service_name}') + call(f'systemctl stop {https_service_name}') + return + + if 'api' in https['children_changed']: + if 'api' in https: + if is_systemd_service_running(f'{http_api_service_name}'): + call(f'systemctl reload {http_api_service_name}') + else: + call(f'systemctl restart {http_api_service_name}') + # Let uvicorn settle before (possibly) restarting nginx + sleep(1) + else: + if is_systemd_service_active(f'{http_api_service_name}'): + call(f'systemctl stop {http_api_service_name}') + + if (not is_systemd_service_running(f'{https_service_name}') or + https['api_add_or_delete'] or + set(https['children_changed']) - set(['api'])): + call(f'systemctl restart {https_service_name}') if __name__ == '__main__': try: diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 0a03a172c..42f084309 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -61,6 +61,12 @@ def get_config(config=None): # bail out early - no need to further process other nodes break + if 'deleted' not in pppoe: + # We always set the MRU value to the MTU size. This code path only re-creates + # the old behavior if MRU is not set on the CLI. + if 'mru' not in pppoe: + pppoe['mru'] = pppoe['mtu'] + return pppoe def verify(pppoe): diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index 6bf3227d5..4251e611b 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -60,8 +60,14 @@ def get_config(config=None): vxlan.update({'rebuild_required': {}}) break + # When dealing with VNI filtering we need to know what VNI was actually removed, + # so build up a dict matching the vlan_to_vni structure but with removed values. tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True) - if tmp: vxlan.update({'vlan_to_vni_removed': tmp}) + if tmp: + vxlan.update({'vlan_to_vni_removed': {}}) + for vlan in tmp: + vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) + vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation @@ -98,14 +104,31 @@ def verify(vxlan): if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None: raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!') - if dict_search('parameters.external', vxlan): + if dict_search('parameters.external', vxlan) != None: if 'vni' in vxlan: raise ConfigError('Can not specify both "external" and "VNI"!') if 'other_tunnels' in vxlan: - other_tunnels = ', '.join(vxlan['other_tunnels']) - raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ - f'CLI option is used. Additional tunnels: {other_tunnels}') + # When multiple VXLAN interfaces are defined and "external" is used, + # all VXLAN interfaces need to have vni-filter enabled! + # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9 + other_vni_filter = False + for tunnel, tunnel_config in vxlan['other_tunnels'].items(): + if dict_search('parameters.vni_filter', tunnel_config) != None: + other_vni_filter = True + break + # eqivalent of the C foo ? 'a' : 'b' statement + vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False + # If either one is enabled, so must be the other. Both can be off and both can be on + if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): + raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\ + 'requires all VXLAN interfaces to have "vni-filter" configured!') + + if not vni_filter and not other_vni_filter: + other_tunnels = ', '.join(vxlan['other_tunnels']) + raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ + f'CLI option is used and "vni-filter" is unset. '\ + f'Additional tunnels: {other_tunnels}') if 'gpe' in vxlan and 'external' not in vxlan: raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ @@ -165,7 +188,7 @@ def verify(vxlan): raise ConfigError(f'VNI "{vni}" is already assigned to a different VLAN!') vnis_used.append(vni) - if dict_search('parameters.neighbor_suppress', vxlan): + if dict_search('parameters.neighbor_suppress', vxlan) != None: if 'is_bridge_member' not in vxlan: raise ConfigError('Neighbor suppression requires that VXLAN interface '\ 'is member of a bridge interface!') diff --git a/src/conf_mode/protocols_igmp.py b/src/conf_mode/protocols_igmp.py deleted file mode 100755 index 435189025..000000000 --- a/src/conf_mode/protocols_igmp.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# 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 ipaddress import IPv4Address -from sys import exit - -from vyos import ConfigError -from vyos.config import Config -from vyos.utils.process import process_named_running -from vyos.utils.process import call -from vyos.template import render -from signal import SIGTERM - -from vyos import airbag -airbag.enable() - -# Required to use the full path to pimd, in another case daemon will not be started -pimd_cmd = f'/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1' - -config_file = r'/tmp/igmp.frr' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - igmp_conf = { - 'igmp_conf' : False, - 'pim_conf' : False, - 'igmp_proxy_conf' : False, - 'old_ifaces' : {}, - 'ifaces' : {} - } - if not (conf.exists('protocols igmp') or conf.exists_effective('protocols igmp')): - return None - - if conf.exists('protocols igmp-proxy'): - igmp_conf['igmp_proxy_conf'] = True - - if conf.exists('protocols pim'): - igmp_conf['pim_conf'] = True - - if conf.exists('protocols igmp'): - igmp_conf['igmp_conf'] = True - - conf.set_level('protocols igmp') - - # # Get interfaces - for iface in conf.list_effective_nodes('interface'): - igmp_conf['old_ifaces'].update({ - iface : { - 'version' : conf.return_effective_value('interface {0} version'.format(iface)), - 'query_interval' : conf.return_effective_value('interface {0} query-interval'.format(iface)), - 'query_max_resp_time' : conf.return_effective_value('interface {0} query-max-response-time'.format(iface)), - 'gr_join' : {} - } - }) - for gr_join in conf.list_effective_nodes('interface {0} join'.format(iface)): - igmp_conf['old_ifaces'][iface]['gr_join'][gr_join] = conf.return_effective_values('interface {0} join {1} source'.format(iface, gr_join)) - - for iface in conf.list_nodes('interface'): - igmp_conf['ifaces'].update({ - iface : { - 'version' : conf.return_value('interface {0} version'.format(iface)), - 'query_interval' : conf.return_value('interface {0} query-interval'.format(iface)), - 'query_max_resp_time' : conf.return_value('interface {0} query-max-response-time'.format(iface)), - 'gr_join' : {} - } - }) - for gr_join in conf.list_nodes('interface {0} join'.format(iface)): - igmp_conf['ifaces'][iface]['gr_join'][gr_join] = conf.return_values('interface {0} join {1} source'.format(iface, gr_join)) - - return igmp_conf - -def verify(igmp): - if igmp is None: - return None - - if igmp['igmp_conf']: - # Check conflict with IGMP-Proxy - if igmp['igmp_proxy_conf']: - raise ConfigError(f"IGMP proxy and PIM cannot be both configured at the same time") - - # Check interfaces - if not igmp['ifaces']: - raise ConfigError(f"IGMP require defined interfaces!") - # Check, is this multicast group - for intfc in igmp['ifaces']: - for gr_addr in igmp['ifaces'][intfc]['gr_join']: - if not IPv4Address(gr_addr).is_multicast: - raise ConfigError(gr_addr + " not a multicast group") - -def generate(igmp): - if igmp is None: - return None - - render(config_file, 'frr/igmp.frr.j2', igmp) - return None - -def apply(igmp): - if igmp is None: - return None - - pim_pid = process_named_running('pimd') - if igmp['igmp_conf'] or igmp['pim_conf']: - if not pim_pid: - call(pimd_cmd) - - if os.path.exists(config_file): - call(f'vtysh -d pimd -f {config_file}') - os.remove(config_file) - elif pim_pid: - os.kill(int(pim_pid), SIGTERM) - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 0aaa0d2c6..09c3be8df 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -16,144 +16,139 @@ import os -from ipaddress import IPv4Address +from ipaddress import IPv4Network +from signal import SIGTERM from sys import exit from vyos.config import Config -from vyos import ConfigError +from vyos.config import config_dict_merge +from vyos.configdict import node_changed +from vyos.configverify import verify_interface_exists from vyos.utils.process import process_named_running from vyos.utils.process import call -from vyos.template import render -from signal import SIGTERM - +from vyos.template import render_to_string +from vyos import ConfigError +from vyos import frr from vyos import airbag airbag.enable() -# Required to use the full path to pimd, in another case daemon will not be started -pimd_cmd = f'/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1' - -config_file = r'/tmp/pimd.frr' - def get_config(config=None): if config: conf = config else: conf = Config() - pim_conf = { - 'pim_conf' : False, - 'igmp_conf' : False, - 'igmp_proxy_conf' : False, - 'old_pim' : { - 'ifaces' : {}, - 'rp' : {} - }, - 'pim' : { - 'ifaces' : {}, - 'rp' : {} - } - } - if not (conf.exists('protocols pim') or conf.exists_effective('protocols pim')): - return None - - if conf.exists('protocols igmp-proxy'): - pim_conf['igmp_proxy_conf'] = True - - if conf.exists('protocols igmp'): - pim_conf['igmp_conf'] = True - - if conf.exists('protocols pim'): - pim_conf['pim_conf'] = True - - conf.set_level('protocols pim') - - # Get interfaces - for iface in conf.list_effective_nodes('interface'): - pim_conf['old_pim']['ifaces'].update({ - iface : { - 'hello' : conf.return_effective_value('interface {0} hello'.format(iface)), - 'dr_prio' : conf.return_effective_value('interface {0} dr-priority'.format(iface)) - } - }) - for iface in conf.list_nodes('interface'): - pim_conf['pim']['ifaces'].update({ - iface : { - 'hello' : conf.return_value('interface {0} hello'.format(iface)), - 'dr_prio' : conf.return_value('interface {0} dr-priority'.format(iface)), - } - }) - - conf.set_level('protocols pim rp') - - # Get RPs addresses - for rp_addr in conf.list_effective_nodes('address'): - pim_conf['old_pim']['rp'][rp_addr] = conf.return_effective_values('address {0} group'.format(rp_addr)) - - for rp_addr in conf.list_nodes('address'): - pim_conf['pim']['rp'][rp_addr] = conf.return_values('address {0} group'.format(rp_addr)) - - # Get RP keep-alive-timer - if conf.exists_effective('rp keep-alive-timer'): - pim_conf['old_pim']['rp_keep_alive'] = conf.return_effective_value('rp keep-alive-timer') - if conf.exists('rp keep-alive-timer'): - pim_conf['pim']['rp_keep_alive'] = conf.return_value('rp keep-alive-timer') - - return pim_conf + base = ['protocols', 'pim'] + + pim = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + # We can not run both IGMP proxy and PIM at the same time - get IGMP + # proxy status + if conf.exists(['protocols', 'igmp-proxy']): + pim.update({'igmp_proxy_enabled' : {}}) + + # FRR has VRF support for different routing daemons. As interfaces belong + # to VRFs - or the global VRF, we need to check for changed interfaces so + # that they will be properly rendered for the FRR config. Also this eases + # removal of interfaces from the running configuration. + interfaces_removed = node_changed(conf, base + ['interface']) + if interfaces_removed: + pim['interface_removed'] = list(interfaces_removed) + + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. + if not conf.exists(base): + pim.update({'deleted' : ''}) + return pim + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(**pim.kwargs, recursive=True) + + # We have to cleanup the default dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: default-information + # originate comes with a default metric-type of 2, which will enable the + # entire default-information originate tree, even when not set via CLI so we + # need to check this first and probably drop that key. + for interface in pim.get('interface', []): + # We need to reload the defaults on every pass b/c of + # hello-multiplier dependency on dead-interval + # If hello-multiplier is set, we need to remove the default from + # dead-interval. + if 'igmp' not in pim['interface'][interface]: + del default_values['interface'][interface]['igmp'] + + pim = config_dict_merge(default_values, pim) + return pim def verify(pim): - if pim is None: + if not pim or 'deleted' in pim: return None - if pim['pim_conf']: - # Check conflict with IGMP-Proxy - if pim['igmp_proxy_conf']: - raise ConfigError(f"IGMP proxy and PIM cannot be both configured at the same time") - - # Check interfaces - if not pim['pim']['ifaces']: - raise ConfigError(f"PIM require defined interfaces!") + if 'igmp_proxy_enabled' in pim: + raise ConfigError('IGMP proxy and PIM cannot be configured at the same time!') - if not pim['pim']['rp']: - raise ConfigError(f"RP address required") + if 'interface' not in pim: + raise ConfigError('PIM require defined interfaces!') - # Check unique multicast groups - uniq_groups = [] - for rp_addr in pim['pim']['rp']: - if not pim['pim']['rp'][rp_addr]: - raise ConfigError(f"Group should be specified for RP " + rp_addr) - for group in pim['pim']['rp'][rp_addr]: - if (group in uniq_groups): - raise ConfigError(f"Group range " + group + " specified cannot exact match another") + for interface in pim['interface']: + verify_interface_exists(interface) - # Check, is this multicast group - gr_addr = group.split('/') - if IPv4Address(gr_addr[0]) < IPv4Address('224.0.0.0'): - raise ConfigError(group + " not a multicast group") + if 'rp' in pim: + if 'address' not in pim['rp']: + raise ConfigError('PIM rendezvous point needs to be defined!') - uniq_groups.extend(pim['pim']['rp'][rp_addr]) + # Check unique multicast groups + unique = [] + pim_base_error = 'PIM rendezvous point group' + for address, address_config in pim['rp']['address'].items(): + if 'group' not in address_config: + raise ConfigError(f'{pim_base_error} should be defined for "{address}"!') + + # Check if it is a multicast group + for gr_addr in address_config['group']: + if not IPv4Network(gr_addr).is_multicast: + raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!') + if gr_addr in unique: + raise ConfigError(f'{pim_base_error} must be unique!') + unique.append(gr_addr) def generate(pim): - if pim is None: + if not pim or 'deleted' in pim: return None - - render(config_file, 'frr/pimd.frr.j2', pim) + pim['frr_pimd_config'] = render_to_string('frr/pimd.frr.j2', pim) return None def apply(pim): - if pim is None: + pim_daemon = 'pimd' + pim_pid = process_named_running(pim_daemon) + + if not pim or 'deleted' in pim: + if 'deleted' in pim: + os.kill(int(pim_pid), SIGTERM) + return None - pim_pid = process_named_running('pimd') - if pim['igmp_conf'] or pim['pim_conf']: - if not pim_pid: - call(pimd_cmd) + if not pim_pid: + call('/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1') + + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + frr_cfg.load_configuration(pim_daemon) + frr_cfg.modify_section(f'^ip pim') + frr_cfg.modify_section(f'^ip igmp') - if os.path.exists(config_file): - call("vtysh -d pimd -f " + config_file) - os.remove(config_file) - elif pim_pid: - os.kill(int(pim_pid), SIGTERM) + for key in ['interface', 'interface_removed']: + if key not in pim: + continue + for interface in pim[key]: + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + if 'frr_pimd_config' in pim: + frr_cfg.add_before(frr.default_add_before, pim['frr_pimd_config']) + frr_cfg.commit_configuration(pim_daemon) return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py index 6a1235ba5..2003a1014 100755 --- a/src/conf_mode/protocols_pim6.py +++ b/src/conf_mode/protocols_pim6.py @@ -15,18 +15,19 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from ipaddress import IPv6Address +from ipaddress import IPv6Network from sys import exit -from typing import Optional -from vyos import ConfigError, airbag, frr -from vyos.config import Config, ConfigDict +from vyos.config import Config +from vyos.config import config_dict_merge from vyos.configdict import node_changed from vyos.configverify import verify_interface_exists from vyos.template import render_to_string - +from vyos import ConfigError +from vyos import frr +from vyos import airbag airbag.enable() - def get_config(config=None): if config: conf = config @@ -44,11 +45,21 @@ def get_config(config=None): if interfaces_removed: pim6['interface_removed'] = list(interfaces_removed) - return pim6 + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. + if not conf.exists(base): + pim6.update({'deleted' : ''}) + return pim6 + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = conf.get_config_defaults(**pim6.kwargs, recursive=True) + + pim6 = config_dict_merge(default_values, pim6) + return pim6 def verify(pim6): - if pim6 is None: + if not pim6 or 'deleted' in pim6: return for interface, interface_config in pim6.get('interface', {}).items(): @@ -60,13 +71,34 @@ def verify(pim6): if not IPv6Address(group).is_multicast: raise ConfigError(f"{group} is not a multicast group") + if 'rp' in pim6: + if 'address' not in pim6['rp']: + raise ConfigError('PIM6 rendezvous point needs to be defined!') + + # Check unique multicast groups + unique = [] + pim_base_error = 'PIM6 rendezvous point group' + + if {'address', 'prefix-list6'} <= set(pim6['rp']): + raise ConfigError(f'{pim_base_error} supports either address or a prefix-list!') + + for address, address_config in pim6['rp']['address'].items(): + if 'group' not in address_config: + raise ConfigError(f'{pim_base_error} should be defined for "{address}"!') + + # Check if it is a multicast group + for gr_addr in address_config['group']: + if not IPv6Network(gr_addr).is_multicast: + raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!') + if gr_addr in unique: + raise ConfigError(f'{pim_base_error} must be unique!') + unique.append(gr_addr) def generate(pim6): - if pim6 is None: + if not pim6 or 'deleted' in pim6: return - pim6['new_frr_config'] = render_to_string('frr/pim6d.frr.j2', pim6) - + return None def apply(pim6): if pim6 is None: @@ -83,13 +115,12 @@ def apply(pim6): if key not in pim6: continue for interface in pim6[key]: - frr_cfg.modify_section( - f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) if 'new_frr_config' in pim6: frr_cfg.add_before(frr.default_add_before, pim6['new_frr_config']) frr_cfg.commit_configuration(pim6_daemon) - + return None if __name__ == '__main__': try: |