diff options
Diffstat (limited to 'src/conf_mode')
24 files changed, 525 insertions, 523 deletions
diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 19b206c59..26822b755 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -87,11 +87,6 @@ def verify_rule(config, err_msg, groups_dict): raise ConfigError(f'{err_msg} ports can only be specified when '\ 'protocol is either tcp, udp or tcp_udp!') - if is_ip_network(dict_search('translation.address', config)): - raise ConfigError(f'{err_msg} cannot use ports with an IPv4 network as '\ - 'translation address as it statically maps a whole network '\ - 'of addresses onto another network of addresses!') - for side in ['destination', 'source']: if side in config: side_conf = config[side] diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index f7e14aa16..4be40e99e 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,59 +14,66 @@ # 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 argv from sys import exit from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents +from vyos.config import config_dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import node_changed +from vyos.configdiff import Diff +from vyos.defaults import directories from vyos.pki import is_ca_certificate from vyos.pki import load_certificate 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.utils.boot import boot_configuration_complete +from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag airbag.enable() -# keys to recursively search for under specified path, script to call if update required +vyos_certbot_dir = directories['certbot'] + +# keys to recursively search for under specified path sync_search = [ { 'keys': ['certificate'], 'path': ['service', 'https'], - 'script': '/usr/libexec/vyos/conf_mode/service_https.py' }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces_ethernet.py' }, { 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'], 'path': ['interfaces', 'openvpn'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces_openvpn.py' }, { 'keys': ['ca_certificate'], 'path': ['interfaces', 'sstpc'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces_sstpc.py' }, { 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], 'path': ['vpn', 'ipsec'], - 'script': '/usr/libexec/vyos/conf_mode/vpn_ipsec.py' }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'openconnect'], - 'script': '/usr/libexec/vyos/conf_mode/vpn_openconnect.py' }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'sstp'], - 'script': '/usr/libexec/vyos/conf_mode/vpn_sstp.py' } ] @@ -82,6 +89,33 @@ sync_translate = { 'crypt_key': 'openvpn' } +def certbot_delete(certificate): + if not boot_configuration_complete(): + return + if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'): + cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}') + +def certbot_request(name: str, config: dict, dry_run: bool=True): + # We do not call certbot when booting the system - there is no need to do so and + # request new certificates during boot/image upgrade as the certbot configuration + # is stored persistent under /config - thus we do not open the door to transient + # errors + if not boot_configuration_complete(): + return + + domains = '--domains ' + ' --domains '.join(config['domain_name']) + tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\ + f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ + f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ + f'{domains}' + if 'listen_address' in config: + tmp += f' --http-01-address {config["listen_address"]}' + # verify() does not need to actually request a cert but only test for plausability + if dry_run: + tmp += ' --dry-run' + + cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!') + def get_config(config=None): if config: conf = config @@ -93,25 +127,60 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) - pki['changed'] = {} - tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'ca' : tmp}) + if len(argv) > 1 and argv[1] == 'certbot_renew': + pki['certbot_renew'] = {} - tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'certificate' : tmp}) + tmp = node_changed(conf, base + ['ca'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'ca' : tmp}) - tmp = node_changed(conf, base + ['dh'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'dh' : tmp}) + tmp = node_changed(conf, base + ['certificate'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'certificate' : tmp}) - tmp = node_changed(conf, base + ['key-pair'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'key_pair' : tmp}) + tmp = node_changed(conf, base + ['dh'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'dh' : tmp}) - tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'openvpn' : tmp}) + tmp = node_changed(conf, base + ['key-pair'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'key_pair' : tmp}) + + tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'openvpn' : tmp}) # We only merge on the defaults of there is a configuration at all if conf.exists(base): - pki = conf.merge_defaults(pki, recursive=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 = conf.get_config_defaults(**pki.kwargs, recursive=True) + # remove ACME default configuration if unused by CLI + if 'certificate' in pki: + for name, cert_config in pki['certificate'].items(): + if 'acme' not in cert_config: + # Remove ACME default values + del default_values['certificate'][name]['acme'] + + # merge CLI and default dictionary + pki = config_dict_merge(default_values, pki) + + # Certbot triggered an external renew of the certificates. + # Mark all ACME based certificates as "changed" to trigger + # update of dependent services + if 'certificate' in pki and 'certbot_renew' in pki: + renew = [] + for name, cert_config in pki['certificate'].items(): + if 'acme' in cert_config: + renew.append(name) + # If triggered externally by certbot, certificate key is not present in changed + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'certificate' : renew}) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! @@ -119,38 +188,34 @@ 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) + for search in sync_search: + for key in search['keys']: + changed_key = sync_translate[key] + if 'changed' not in pki or 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 @@ -223,6 +288,22 @@ def verify(pki): if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on certificate "{name}"') + if 'acme' in cert_conf: + if 'domain_name' not in cert_conf['acme']: + raise ConfigError(f'At least one domain-name is required to request '\ + f'certificate for "{name}" via ACME!') + + if 'email' not in cert_conf['acme']: + raise ConfigError(f'An email address is required to request '\ + f'certificate for "{name}" via ACME!') + + if 'certbot_renew' not in pki: + # Only run the ACME command if something on this entity changed, + # as this is time intensive + tmp = dict_search('changed.certificate', pki) + if tmp != None and name in tmp: + certbot_request(name, cert_conf['acme']) + if 'dh' in pki: for name, dh_conf in pki['dh'].items(): if 'parameters' in dh_conf: @@ -283,12 +364,58 @@ def generate(pki): if not pki: return None + # Certbot renewal only needs to re-trigger the services to load up the + # new PEM file + if 'certbot_renew' in pki: + return None + + certbot_list = [] + certbot_list_on_disk = [] + if os.path.exists(f'{vyos_certbot_dir}/live'): + certbot_list_on_disk = [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()] + + if 'certificate' in pki: + changed_certificates = dict_search('changed.certificate', pki) + for name, cert_conf in pki['certificate'].items(): + if 'acme' in cert_conf: + certbot_list.append(name) + # generate certificate if not found on disk + if name not in certbot_list_on_disk: + certbot_request(name, cert_conf['acme'], dry_run=False) + elif changed_certificates != None and name in changed_certificates: + # when something for the certificate changed, we should delete it + if name in certbot_list_on_disk: + certbot_delete(name) + certbot_request(name, cert_conf['acme'], dry_run=False) + + # Cleanup certbot configuration and certificates if no longer in use by CLI + # Get foldernames under vyos_certbot_dir which each represent a certbot cert + if os.path.exists(f'{vyos_certbot_dir}/live'): + for cert in certbot_list_on_disk: + if cert not in certbot_list: + # certificate is no longer active on the CLI - remove it + certbot_delete(cert) + return None def apply(pki): + systemd_certbot_name = 'certbot.timer' if not pki: + call(f'systemctl stop {systemd_certbot_name}') return None + has_certbot = False + if 'certificate' in pki: + for name, cert_conf in pki['certificate'].items(): + if 'acme' in cert_conf: + has_certbot = True + break + + if not has_certbot: + call(f'systemctl stop {systemd_certbot_name}') + elif has_certbot and not is_systemd_service_active(systemd_certbot_name): + call(f'systemctl restart {systemd_certbot_name}') + if 'changed' in pki: call_dependents() diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index dab784662..37421efb4 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -72,6 +72,9 @@ def verify(bfd): if 'source' in peer_config and 'interface' in peer_config['source']: raise ConfigError('BFD multihop and source interface cannot be used together') + if 'minimum_ttl' in peer_config and 'multihop' not in peer_config: + raise ConfigError('Minimum TTL is only available for multihop BFD sessions!') + if 'profile' in peer_config: profile_name = peer_config['profile'] if 'profile' not in bfd or profile_name not in bfd['profile']: diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index bf807fa5f..d90dfe45b 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -206,6 +206,10 @@ def verify_remote_as(peer_config, bgp_config): if 'v6only' in peer_config['interface']: if 'remote_as' in peer_config['interface']['v6only']: return peer_config['interface']['v6only']['remote_as'] + if 'peer_group' in peer_config['interface']['v6only']: + peer_group_name = peer_config['interface']['v6only']['peer_group'] + tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config) + if tmp: return tmp return None @@ -216,9 +220,12 @@ def verify_afi(peer_config, bgp_config): # If address_family configured under peer-group # if neighbor interface configured - peer_group_name = '' + peer_group_name = None if dict_search('interface.peer_group', peer_config): peer_group_name = peer_config['interface']['peer_group'] + elif dict_search('interface.v6only.peer_group', peer_config): + peer_group_name = peer_config['interface']['v6only']['peer_group'] + # if neighbor IP configured. if 'peer_group' in peer_config: peer_group_name = peer_config['peer_group'] @@ -502,6 +509,14 @@ def verify(bgp): if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): raise ConfigError( 'Command "import vrf" conflicts with "route-target vpn both" command!') + if dict_search('route_target.vpn.export', afi_config): + raise ConfigError( + 'Command "route-target vpn export" conflicts '\ + 'with "route-target vpn both" command!') + if dict_search('route_target.vpn.import', afi_config): + raise ConfigError( + 'Command "route-target vpn import" conflicts '\ + 'with "route-target vpn both" command!') if dict_search('route_target.vpn.import', afi_config): if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): @@ -538,6 +553,10 @@ def verify(bgp): tmp = dict_search(f'route_map.vpn.{export_import}', afi_config) if tmp: verify_route_map(tmp, bgp) + # per-vrf sid and per-af sid are mutually exclusive + if 'sid' in afi_config and 'sid' in bgp: + raise ConfigError('SID per VRF and SID per address-family are mutually exclusive!') + # Checks only required for L2VPN EVPN if afi in ['l2vpn_evpn']: if 'vni' in afi_config: diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index ce67ccff7..8d594bb68 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -220,7 +220,20 @@ def verify(isis): 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.') - + + # Check for index ranges being larger than the segment routing global block + if dict_search('segment_routing.global_block', isis): + g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) + g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) + g_label_difference = int(g_high_label_value) - int(g_low_label_value) + if dict_search('segment_routing.prefix', isis): + for prefix, prefix_config in isis['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + index_size = isis['segment_routing']['prefix'][prefix]['index']['value'] + if int(index_size) > int(g_label_difference): + raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ + f'index base size larger than the SRGB label base.') + # Check for LFA tiebreaker index duplication if dict_search('fast_reroute.lfa.local.tiebreaker', isis): comparison_dictionary = {} diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 2f07142a3..34cf49286 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -213,6 +213,19 @@ def verify(ospf): raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ f'and no-php-flag configured at the same time.') + # Check for index ranges being larger than the segment routing global block + 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) + g_label_difference = int(g_high_label_value) - int(g_low_label_value) + if dict_search('segment_routing.prefix', ospf): + for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + index_size = ospf['segment_routing']['prefix'][prefix]['index']['value'] + if int(index_size) > int(g_label_difference): + raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ + f'index base size larger than the SRGB label base.') + # Check route summarisation if 'summary_address' in ospf: for prefix, prefix_options in ospf['summary_address'].items(): diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index ad4121a49..40d7a6c16 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -149,7 +149,7 @@ def verify(qos): if 'class' in policy_config: for cls, cls_config in policy_config['class'].items(): # bandwidth is not mandatory for priority-queue - that is why this is on the exception list - if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin']: + if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError(f'Bandwidth must be defined for policy "{policy}" class "{cls}"!') if 'match' in cls_config: for match, match_config in cls_config['match'].items(): @@ -173,7 +173,7 @@ def verify(qos): if 'default' not in policy_config: raise ConfigError(f'Policy {policy} misses "default" class!') if 'default' in policy_config: - if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin']: + if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError('Bandwidth not defined for default traffic!') # we should check interface ingress/egress configuration after verifying that diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 7ebc560ba..91ea354b6 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -31,6 +31,7 @@ from vyos.utils.file import chmod_775 from vyos.utils.file import makedir from vyos.utils.file import write_file from vyos.utils.process import call +from vyos.utils.network import interface_exists from vyos.utils.network import is_subnet_connected from vyos.utils.network import is_addr_assigned from vyos import ConfigError @@ -164,6 +165,7 @@ def verify(dhcp): shared_networks = len(dhcp['shared_network_name']) disabled_shared_networks = 0 + subnet_ids = [] # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): @@ -175,6 +177,14 @@ def verify(dhcp): 'lease subnet must be configured.') for subnet, subnet_config in network_config['subnet'].items(): + if 'subnet_id' not in subnet_config: + raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) + # All delivered static routes require a next-hop to be set if 'static_route' in subnet_config: for route, route_option in subnet_config['static_route'].items(): @@ -222,6 +232,9 @@ def verify(dhcp): if 'static_mapping' in subnet_config: # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) + used_ips = [] + used_mac = [] + used_duid = [] for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ip_address' in mapping_config: if ip_address(mapping_config['ip_address']) not in ip_network(subnet): @@ -233,6 +246,22 @@ def verify(dhcp): raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + if 'disable' not in mapping_config: + if mapping_config['ip_address'] in used_ips: + raise ConfigError(f'Configured IP address for static mapping "{mapping}" already exists on another static mapping') + used_ips.append(mapping_config['ip_address']) + + if 'disable' not in mapping_config: + if 'mac' in mapping_config: + if mapping_config['mac'] in used_mac: + raise ConfigError(f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping') + used_mac.append(mapping_config['mac']) + + if 'duid' in mapping_config: + if mapping_config['duid'] in used_duid: + raise ConfigError(f'Configured DUID for static mapping "{mapping}" already exists on another static mapping') + used_duid.append(mapping_config['duid']) + # There must be one subnet connected to a listen interface. # This only counts if the network itself is not disabled! if 'disable' not in network_config: @@ -294,12 +323,18 @@ def verify(dhcp): else: raise ConfigError(f'listen-address "{address}" not configured on any interface') - if not listen_ok: raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n' 'broadcast interface configured, nor was there an explicit listen-address\n' 'configured for serving DHCP relay packets!') + if 'listen_address' in dhcp and 'listen_interface' in dhcp: + raise ConfigError(f'Cannot define listen-address and listen-interface at the same time') + + for interface in (dict_search('listen_interface', dhcp) or []): + if not interface_exists(interface): + raise ConfigError(f'listen-interface "{interface}" does not exist') + return None def generate(dhcp): diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 9cc57dbcf..214531904 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -63,6 +63,7 @@ def verify(dhcpv6): # Inspect shared-network/subnet subnets = [] + subnet_ids = [] listen_ok = False for network, network_config in dhcpv6['shared_network_name'].items(): # A shared-network requires a subnet definition @@ -72,26 +73,37 @@ def verify(dhcpv6): 'each shared network!') for subnet, subnet_config in network_config['subnet'].items(): - if 'address_range' in subnet_config: - if 'start' in subnet_config['address_range']: - range6_start = [] - range6_stop = [] - for start, start_config in subnet_config['address_range']['start'].items(): - if 'stop' not in start_config: - raise ConfigError(f'address-range stop address for start "{start}" is not defined!') - stop = start_config['stop'] + if 'subnet_id' not in subnet_config: + raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) + + if 'range' in subnet_config: + range6_start = [] + range6_stop = [] + + for num, range_config in subnet_config['range'].items(): + if 'start' in range_config: + start = range_config['start'] + + if 'stop' not in range_config: + raise ConfigError(f'Range stop address for start "{start}" is not defined!') + stop = range_config['stop'] # Start address must be inside network if not ip_address(start) in ip_network(subnet): - raise ConfigError(f'address-range start address "{start}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range start address "{start}" is not in subnet "{subnet}"!') # Stop address must be inside network if not ip_address(stop) in ip_network(subnet): - raise ConfigError(f'address-range stop address "{stop}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!') # Stop address must be greater or equal to start address if not ip_address(stop) >= ip_address(start): - raise ConfigError(f'address-range stop address "{stop}" must be greater then or equal ' \ + raise ConfigError(f'Range stop address "{stop}" must be greater then or equal ' \ f'to the range start address "{start}"!') # DHCPv6 range start address must be unique - two ranges can't @@ -99,6 +111,7 @@ def verify(dhcpv6): if start in range6_start: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool start address "{start}" defined multipe times!') + range6_start.append(start) # DHCPv6 range stop address must be unique - two ranges can't @@ -106,12 +119,14 @@ def verify(dhcpv6): if stop in range6_stop: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool stop address "{stop}" defined multipe times!') + range6_stop.append(stop) - if 'prefix' in subnet_config: - for prefix in subnet_config['prefix']: - if ip_network(prefix) not in ip_network(subnet): - raise ConfigError(f'address-range prefix "{prefix}" is not in subnet "{subnet}""') + if 'prefix' in range_config: + prefix = range_config['prefix'] + + if not ip_network(prefix).subnet_of(ip_network(subnet)): + raise ConfigError(f'Range prefix "{prefix}" is not in subnet "{subnet}"') # Prefix delegation sanity checks if 'prefix_delegation' in subnet_config: @@ -142,13 +157,15 @@ def verify(dhcpv6): raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') - if 'vendor_option' in subnet_config: - if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: - raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') + if 'option' in subnet_config: + if 'vendor_option' in subnet_config['option']: + if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2: + raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') # Subnets must be unique if subnet in subnets: raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') + subnets.append(subnet) # DHCPv6 requires at least one configured address range or one static mapping diff --git a/src/conf_mode/service_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py index 99fa8feee..a551a9891 100755 --- a/src/conf_mode/service_dns_dynamic.py +++ b/src/conf_mode/service_dns_dynamic.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -21,8 +21,10 @@ from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_interface_exists +from vyos.configverify import dynamic_interface_pattern from vyos.template import render from vyos.utils.process import call +from vyos.utils.network import interface_exists from vyos import ConfigError from vyos import airbag airbag.enable() @@ -30,9 +32,6 @@ airbag.enable() config_file = r'/run/ddclient/ddclient.conf' systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' -# Dynamic interfaces that might not exist when the configuration is loaded -dynamic_interfaces = ('pppoe', 'sstpc') - # Protocols that require zone zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', 'nfsn', 'nsupdate'] @@ -81,7 +80,6 @@ def verify(dyndns): # Dynamic DNS service provider - configuration validation for service, config in dyndns['name'].items(): - error_msg_req = f'is required for Dynamic DNS service "{service}"' error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' @@ -89,29 +87,36 @@ def verify(dyndns): if field not in config: raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') - # If dyndns address is an interface, ensure - # that the interface exists (or just warn if dynamic interface) - # and that web-options are not set - if config['address'] != 'web': + if not any(x in config['address'] for x in ['interface', 'web']): + raise ConfigError(f'Either "interface" or "web" {error_msg_req} ' + f'with protocol "{config["protocol"]}"') + if all(x in config['address'] for x in ['interface', 'web']): + raise ConfigError(f'Both "interface" and "web" at the same time {error_msg_uns} ' + f'with protocol "{config["protocol"]}"') + + # If dyndns address is an interface, ensure that the interface exists + # and warn if a non-active dynamic interface is used + if 'interface' in config['address']: + tmp = re.compile(dynamic_interface_pattern) # exclude check interface for dynamic interfaces - if config['address'].startswith(dynamic_interfaces): - Warning(f'Interface "{config["address"]}" does not exist yet and cannot ' - f'be used for Dynamic DNS service "{service}" until it is up!') + if tmp.match(config['address']['interface']): + if not interface_exists(config['address']['interface']): + Warning(f'Interface "{config["address"]["interface"]}" does not exist yet and ' + f'cannot be used for Dynamic DNS service "{service}" until it is up!') else: - verify_interface_exists(config['address']) - if 'web_options' in config: - raise ConfigError(f'"web-options" is applicable only when using HTTP(S) ' - f'web request to obtain the IP address') - - # Warn if using checkip.dyndns.org, as it does not support HTTPS - # See: https://github.com/ddclient/ddclient/issues/597 - if 'web_options' in config: - if 'url' not in config['web_options']: - raise ConfigError(f'"url" in "web-options" {error_msg_req} ' + verify_interface_exists(config['address']['interface']) + + if 'web' in config['address']: + # If 'skip' is specified, 'url' is required as well + if 'skip' in config['address']['web'] and 'url' not in config['address']['web']: + raise ConfigError(f'"url" along with "skip" {error_msg_req} ' f'with protocol "{config["protocol"]}"') - elif re.search("^(https?://)?checkip\.dyndns\.org", config['web_options']['url']): - Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address ' - f'lookup. Please use a different IP address lookup service.') + if 'url' in config['address']['web']: + # Warn if using checkip.dyndns.org, as it does not support HTTPS + # See: https://github.com/ddclient/ddclient/issues/597 + if re.search("^(https?://)?checkip\.dyndns\.org", config['address']['web']['url']): + Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address ' + f'lookup. Please use a different IP address lookup service.') # RFC2136 uses 'key' instead of 'password' if config['protocol'] != 'nsupdate' and 'password' not in config: diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py index c186f47af..ecad765f4 100755 --- a/src/conf_mode/service_dns_forwarding.py +++ b/src/conf_mode/service_dns_forwarding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -26,18 +26,18 @@ from vyos.template import render from vyos.template import bracketize_ipv6 from vyos.utils.process import call from vyos.utils.permission import chown -from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() -pdns_rec_user = pdns_rec_group = 'pdns' -pdns_rec_run_dir = '/run/powerdns' +pdns_rec_user_group = 'pdns' +pdns_rec_run_dir = '/run/pdns-recursor' pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' +pdns_rec_systemd_override = '/run/systemd/system/pdns-recursor.service.d/override.conf' hostsd_tag = 'static' @@ -55,6 +55,9 @@ def get_config(config=None): get_first_key=True, with_recursive_defaults=True) + dns['config_file'] = pdns_rec_config_file + dns['config_dir'] = os.path.dirname(pdns_rec_config_file) + # some additions to the default dictionary if 'system' in dns: base_nameservers = ['system', 'name-server'] @@ -251,11 +254,16 @@ def generate(dns): if not dns: return None - render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) + render(pdns_rec_systemd_override, 'dns-forwarding/override.conf.j2', dns) + + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) - render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) + + render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): os.unlink(zone_filename) @@ -263,21 +271,25 @@ def generate(dns): if 'authoritative_zones' in dns: for zone in dns['authoritative_zones']: render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', - zone, user=pdns_rec_user, group=pdns_rec_group) + zone, user=pdns_rec_user_group, group=pdns_rec_user_group) # if vyos-hostsd didn't create its files yet, create them (empty) for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: with open(file, 'a'): pass - chown(file, user=pdns_rec_user, group=pdns_rec_group) + chown(file, user=pdns_rec_user_group, group=pdns_rec_user_group) return None def apply(dns): + systemd_service = 'pdns-recursor.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + if not dns: # DNS forwarding is removed in the commit - call('systemctl stop pdns-recursor.service') + call(f'systemctl stop {systemd_service}') if os.path.isfile(pdns_rec_config_file): os.unlink(pdns_rec_config_file) @@ -345,7 +357,7 @@ def apply(dns): hc.apply() ### finally (re)start pdns-recursor - call('systemctl restart pdns-recursor.service') + call(f'systemctl reload-or-restart {systemd_service}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index cb40acc9f..46efc3c93 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -15,56 +15,41 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import socket import sys import json -from copy import deepcopy from time import sleep -import vyos.defaults -import vyos.certbot_util - from vyos.base import Warning from vyos.config import Config +from vyos.config import config_dict_merge from vyos.configdiff import get_config_diff from vyos.configverify import verify_vrf -from vyos import ConfigError +from vyos.defaults import api_config_state from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key +from vyos.pki import wrap_dh_parameters +from vyos.pki import load_dh_parameters from vyos.template import render +from vyos.utils.dict import dict_search 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 - +from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = '/etc/nginx/sites-available/default' +config_file = '/etc/nginx/sites-enabled/default' systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' -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, -# encompassing the https and other configs, and a list of such virtual hosts -# (server blocks in nginx terminology) to pass to the jinja2 template. -default_server_block = { - 'id' : '', - 'address' : '*', - 'port' : '443', - 'name' : ['_'], - 'api' : False, - 'vyos_cert' : {}, - 'certbot' : False -} +cert_dir = '/run/nginx/certs' + +user = 'www-data' +group = 'www-data' + +systemd_service_api = '/run/systemd/system/vyos-http-api.service' def get_config(config=None): if config: @@ -76,93 +61,70 @@ 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, with_pki=True) - - https['children_changed'] = diff.node_changed_children(base) - https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) + https = conf.get_config_dict(base, get_first_key=True, + key_mangling=('-', '_'), + with_pki=True) - if 'api' not in https: - return https + # store path to API config file for later use in templates + https['api_config_state'] = api_config_state + # get fully qualified system hsotname + https['hostname'] = socket.getfqdn() - 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 + # 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(**https.kwargs, recursive=True) + if 'api' not in https or 'graphql' not in https['api']: + del default_values['api'] + # merge CLI and default dictionary + https = config_dict_merge(default_values, https) return https def verify(https): - from vyos.utils.dict import dict_search - if https is None: return None - if 'certificates' in https: - certificates = https['certificates'] + if 'certificates' in https and 'certificate' in https['certificates']: + cert_name = https['certificates']['certificate'] + if 'pki' not in https: + raise ConfigError('PKI is not configured!') - if 'certificate' in certificates: - if not https['pki']: - raise ConfigError('PKI is not configured') + if cert_name not in https['pki']['certificate']: + raise ConfigError('Invalid certificate in configuration!') - cert_name = certificates['certificate'] - - if cert_name not in https['pki']['certificate']: - raise ConfigError("Invalid certificate on https configuration") + pki_cert = https['pki']['certificate'][cert_name] - pki_cert = https['pki']['certificate'][cert_name] + if 'certificate' not in pki_cert: + raise ConfigError('Missing certificate in configuration!') - if 'certificate' not in pki_cert: - raise ConfigError("Missing certificate on https configuration") + if 'private' not in pki_cert or 'key' not in pki_cert['private']: + raise ConfigError('Missing certificate private key in configuration!') - if 'private' not in pki_cert or 'key' not in pki_cert['private']: - raise ConfigError("Missing certificate private key on https configuration") + if 'dh_params' in https['certificates']: + dh_name = https['certificates']['dh_params'] + if dh_name not in https['pki']['dh']: + raise ConfigError('Invalid DH parameter in configuration!') - if 'certbot' in https['certificates']: - vhost_names = [] - 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] - if not domains_found: - raise ConfigError("At least one 'virtual-host <id> server-name' " - "matching the 'certbot domain-name' is required.") + pki_dh = https['pki']['dh'][dh_name] + dh_params = load_dh_parameters(pki_dh['parameters']) + dh_numbers = dh_params.parameter_numbers() + dh_bits = dh_numbers.p.bit_length() + if dh_bits < 2048: + raise ConfigError(f'Minimum DH key-size is 2048 bits') - server_block_list = [] + else: + Warning('No certificate specified, using build-in self-signed certificates. '\ + 'Do not use them in a production environment!') - # organize by vhosts - vhost_dict = https.get('virtual-host', {}) + # Check if server port is already in use by a different appliaction + listen_address = ['0.0.0.0'] + port = int(https['port']) + if 'listen_address' in https: + listen_address = https['listen_address'] - 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('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') + for address in listen_address: + if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'): + raise ConfigError(f'TCP port "{port}" is used by another service!') verify_vrf(https) @@ -187,106 +149,61 @@ def verify(https): # 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') + raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!') if (not valid_keys_exist) and jwt_auth: - Warning(f'API keys are not configured: the classic (non-GraphQL) API will be unavailable.') + Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!') return None def generate(https): if https is None: + for file in [systemd_service_api, config_file, systemd_override]: + if os.path.exists(file): + os.unlink(file) 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']) + if 'api' in https: + render(systemd_service_api, 'https/vyos-http-api.service.j2', https) with open(api_config_state, 'w') as f: json.dump(https['api'], f, indent=2) - - 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) - server_block['id'] = vhost - data = vhost_dict.get(vhost, {}) - server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('port', '443') - name = data.get('server-name', ['_']) - server_block['name'] = name - allow_client = data.get('allow-client', {}) - server_block['allow_client'] = allow_client.get('address', []) - server_block_list.append(server_block) + if os.path.exists(systemd_service_api): + os.unlink(systemd_service_api) # get certificate data - - cert_dict = https.get('certificates', {}) - - if 'certificate' in cert_dict: - cert_name = cert_dict['certificate'] + if 'certificates' in https and 'certificate' in https['certificates']: + cert_name = https['certificates']['certificate'] pki_cert = https['pki']['certificate'][cert_name] - cert_path = os.path.join(cert_dir, f'{cert_name}.pem') - key_path = os.path.join(key_dir, f'{cert_name}.pem') + cert_path = os.path.join(cert_dir, f'{cert_name}_cert.pem') + key_path = os.path.join(cert_dir, f'{cert_name}_key.pem') server_cert = str(wrap_certificate(pki_cert['certificate'])) - if 'ca-certificate' in cert_dict: - ca_cert = cert_dict['ca-certificate'] + + # Append CA certificate if specified to form a full chain + if 'ca_certificate' in https['certificates']: + ca_cert = https['certificates']['ca_certificate'] server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) - write_file(cert_path, server_cert) - write_file(key_path, wrap_private_key(pki_cert['private']['key'])) - - vyos_cert_data = { - 'crt': cert_path, - 'key': key_path - } - - for block in server_block_list: - block['vyos_cert'] = vyos_cert_data - - # letsencrypt certificate using certbot - - certbot = False - cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) - if cert_domains: - certbot = True - for domain in cert_domains: - sub_list = vyos.certbot_util.choose_server_block(server_block_list, - domain) - if sub_list: - for sb in sub_list: - sb['certbot'] = True - sb['certbot_dir'] = certbot_dir - # certbot organizes certificates by first domain - sb['certbot_domain_dir'] = cert_domains[0] - - if 'api' in list(https): - vhost_list = https.get('api-restrict', {}).get('virtual-host', []) - if not vhost_list: - for block in server_block_list: - block['api'] = True - else: - for block in server_block_list: - if block['id'] in vhost_list: - block['api'] = True - - data = { - 'server_block_list': server_block_list, - 'certbot': certbot - } - - render(config_file, 'https/nginx.default.j2', data) + write_file(cert_path, server_cert, user=user, group=group, mode=0o644) + write_file(key_path, wrap_private_key(pki_cert['private']['key']), + user=user, group=group, mode=0o600) + + tmp_path = {'cert_path': cert_path, 'key_path': key_path} + + if 'dh_params' in https['certificates']: + dh_name = https['certificates']['dh_params'] + pki_dh = https['pki']['dh'][dh_name] + if 'parameters' in pki_dh: + dh_path = os.path.join(cert_dir, f'{dh_name}_dh.pem') + write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']), + user=user, group=group, mode=0o600) + tmp_path.update({'dh_file' : dh_path}) + + https['certificates'].update(tmp_path) + + render(config_file, 'https/nginx.default.j2', https) render(systemd_override, 'https/override.conf.j2', https) return None @@ -297,27 +214,18 @@ def apply(https): 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 {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 'api' in https: + call(f'systemctl reload-or-restart {http_api_service_name}') + # Let uvicorn settle before (possibly) restarting nginx + sleep(1) + elif is_systemd_service_active(http_api_service_name): + call(f'systemctl stop {http_api_service_name}') + + call(f'systemctl reload-or-restart {https_service_name}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_https_certificates_certbot.py b/src/conf_mode/service_https_certificates_certbot.py deleted file mode 100755 index 1a6a498de..000000000 --- a/src/conf_mode/service_https_certificates_certbot.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 vyos.defaults -from vyos.config import Config -from vyos import ConfigError -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running - -from vyos import airbag -airbag.enable() - -vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] -vyos_certbot_dir = vyos.defaults.directories['certbot'] - -dependencies = [ - 'service_https.py', -] - -def request_certbot(cert): - email = cert.get('email') - if email is not None: - email_flag = '-m {0}'.format(email) - else: - email_flag = '' - - domains = cert.get('domains') - if domains is not None: - domain_flag = '-d ' + ' -d '.join(domains) - else: - domain_flag = '' - - certbot_cmd = f'certbot certonly --config-dir {vyos_certbot_dir} -n --nginx --agree-tos --no-eff-email --expand {email_flag} {domain_flag}' - - cmd(certbot_cmd, - raising=ConfigError, - message="The certbot request failed for the specified domains.") - -def get_config(): - conf = Config() - if not conf.exists('service https certificates certbot'): - return None - else: - conf.set_level('service https certificates certbot') - - cert = {} - - if conf.exists('domain-name'): - cert['domains'] = conf.return_values('domain-name') - - if conf.exists('email'): - cert['email'] = conf.return_value('email') - - return cert - -def verify(cert): - if cert is None: - return None - - if 'domains' not in cert: - raise ConfigError("At least one domain name is required to" - " request a letsencrypt certificate.") - - if 'email' not in cert: - raise ConfigError("An email address is required to request" - " a letsencrypt certificate.") - -def generate(cert): - if cert is None: - return None - - # certbot will attempt to reload nginx, even with 'certonly'; - # start nginx if not active - if not is_systemd_service_running('nginx.service'): - call('systemctl start nginx.service') - - request_certbot(cert) - -def apply(cert): - if cert is not None: - call('systemctl restart certbot.timer') - else: - call('systemctl stop certbot.timer') - return None - - for dep in dependencies: - cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) - -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/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index 36f00dec5..6df6f3dc7 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -79,9 +79,6 @@ def verify(ipoe): if 'key' not in radius_config: raise ConfigError(f'Missing RADIUS secret key for server "{server}"') - 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 diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 1cc23a7df..f11690ee6 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -42,7 +42,7 @@ def get_config(config=None): if not conf.exists(base): return None - ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_defaults=True) ntp['config_file'] = config_file ntp['user'] = user_group diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 7c624f034..31299a15c 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-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -85,6 +85,7 @@ def verify(pppoe): if not dict_search('authentication.radius.dynamic_author.key', pppoe): raise ConfigError('DA/CoE server key required!') + return None diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index ebf9a113b..a888b125e 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -22,6 +22,7 @@ from vyos.config import Config from vyos.utils.process import call from vyos.utils.file import read_file from vyos.utils.file import write_file +from vyos.system import grub_util from vyos.template import render from vyos import ConfigError from vyos import airbag @@ -114,30 +115,7 @@ def generate(console): return None speed = console['device']['ttyS0']['speed'] - grub_config = '/boot/grub/grub.cfg' - if not os.path.isfile(grub_config): - return None - - lines = read_file(grub_config).split('\n') - p = re.compile(r'^(.* console=ttyS0),[0-9]+(.*)$') - write = False - newlines = [] - for line in lines: - if line.startswith('serial --unit'): - newline = f'serial --unit=0 --speed={speed}' - elif p.match(line): - newline = '{},{}{}'.format(p.search(line)[1], speed, p.search(line)[2]) - else: - newline = line - - if newline != line: - write = True - - newlines.append(newline) - newlines.append('') - - if write: - write_file(grub_config, '\n'.join(newlines)) + grub_util.update_console_speed(speed) return None diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index d92121b3d..3b5b67437 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,6 +22,7 @@ from time import sleep from vyos.config import Config from vyos.configverify import verify_source_interface +from vyos.system import grub_util from vyos.template import render from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running @@ -39,7 +40,6 @@ time_format_to_locale = { '24-hour': 'en_GB.UTF-8' } - def get_config(config=None): if config: conf = config @@ -87,6 +87,13 @@ def verify(options): def generate(options): render(curlrc_config, 'system/curlrc.j2', options) render(ssh_config, 'system/ssh_config.j2', options) + + cmdline_options = [] + if 'kernel' in options: + if 'disable_mitigations' in options['kernel']: + cmdline_options.append('mitigations=off') + grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) + return None def apply(options): diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index 2df1bbb7a..41119b494 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -19,6 +19,7 @@ import os from sys import exit from vyos.config import Config +from vyos.configverify import verify_vrf from vyos.template import render from vyos.utils.process import call from vyos.utils.network import is_addr_assigned @@ -46,7 +47,6 @@ def get_config(config=None): return sflow - def verify(sflow): if not sflow: return None @@ -68,9 +68,8 @@ def verify(sflow): if 'server' not in sflow: raise ConfigError('You need to configure at least one sFlow server!') - # return True if all checks were passed - return True - + verify_vrf(sflow) + return None def generate(sflow): if not sflow: @@ -81,7 +80,6 @@ def generate(sflow): # Reload systemd manager configuration call('systemctl daemon-reload') - def apply(sflow): if not sflow: # Stop flow-accounting daemon and remove configuration file @@ -93,7 +91,6 @@ def apply(sflow): # Start/reload flow-accounting daemon call(f'systemctl restart {systemd_service}') - if __name__ == '__main__': try: config = get_config() diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 7fd32c230..d074ed159 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -27,6 +27,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists +from vyos.configverify import dynamic_interface_pattern from vyos.defaults import directories from vyos.ifconfig import Interface from vyos.pki import encode_certificate @@ -43,6 +44,7 @@ from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.template import render from vyos.utils.network import is_ipv6_link_local +from vyos.utils.network import interface_exists from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import call @@ -65,11 +67,11 @@ default_install_routes = 'yes' vici_socket = '/var/run/charon.vici' -CERT_PATH = f'{swanctl_dir}/x509/' +CERT_PATH = f'{swanctl_dir}/x509/' PUBKEY_PATH = f'{swanctl_dir}/pubkey/' -KEY_PATH = f'{swanctl_dir}/private/' -CA_PATH = f'{swanctl_dir}/x509ca/' -CRL_PATH = f'{swanctl_dir}/x509crl/' +KEY_PATH = f'{swanctl_dir}/private/' +CA_PATH = f'{swanctl_dir}/x509ca/' +CRL_PATH = f'{swanctl_dir}/x509crl/' DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting' @@ -158,9 +160,16 @@ def verify(ipsec): if 'id' not in psk_config or 'secret' not in psk_config: raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"') - if 'interfaces' in ipsec : - for ifname in ipsec['interface']: - verify_interface_exists(ifname) + if 'interface' in ipsec: + tmp = re.compile(dynamic_interface_pattern) + for interface in ipsec['interface']: + # exclude check interface for dynamic interfaces + if tmp.match(interface): + if not interface_exists(interface): + Warning(f'Interface "{interface}" does not exist yet and cannot be used ' + f'for IPsec until it is up!') + else: + verify_interface_exists(interface) if 'l2tp' in ipsec: if 'esp_group' in ipsec['l2tp']: @@ -394,7 +403,7 @@ def verify(ipsec): if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] - if not os.path.exists(f'/sys/class/net/{vti_interface}'): + if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') if 'vti' not in peer_conf and 'tunnel' not in peer_conf: diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 03a27d3cd..b569ca140 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -70,15 +70,9 @@ def verify(l2tp): if not dict_search('authentication.radius.dynamic_author.key', l2tp): raise ConfigError('DA/CoE server key required!') - if dict_search('authentication.mode', l2tp) in ['local', 'noauth']: - if not l2tp['client_ip_pool'] and not l2tp['client_ipv6_pool']: - raise ConfigError( - "L2TP local auth mode requires local client-ip-pool or client-ipv6-pool to be configured!") - if dict_search('client_ip_pool', l2tp) and not dict_search('default_pool', l2tp): - Warning("'default-pool' is not defined") - verify_accel_ppp_ip_pool(l2tp) + if 'wins_server' in l2tp and len(l2tp['wins_server']) > 2: raise ConfigError( 'Not more then two WINS name-servers can be configured') diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index f769be39f..0629625bf 100755 --- a/src/conf_mode/vpn_pptp.py +++ b/src/conf_mode/vpn_pptp.py @@ -80,12 +80,6 @@ def verify(pptp): raise ConfigError( f'Missing RADIUS secret key for server "{server}"') - if auth_mode == 'local' or auth_mode == 'noauth': - if not dict_search('client_ip_pool', pptp): - raise ConfigError( - 'PPTP local auth mode requires local client-ip-pool ' - 'to be configured!') - verify_accel_ppp_ip_pool(pptp) if 'name_server' in pptp: diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 6bf9307e1..a84513a0f 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -74,11 +74,8 @@ def verify(sstp): 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: - raise ConfigError('Client IP subnet required') - verify_accel_ppp_ip_pool(sstp) + # # SSL certificate checks # diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 9b1b6355f..f2c544aa6 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -27,13 +27,12 @@ from vyos.ifconfig import Interface from vyos.template import render from vyos.template import render_to_string from vyos.utils.dict import dict_search +from vyos.utils.kernel import check_kmod from vyos.utils.network import get_interface_config from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists from vyos.utils.process import call from vyos.utils.process import cmd -from vyos.utils.process import popen -from vyos.utils.process import run from vyos.utils.system import sysctl_write from vyos import ConfigError from vyos import frr @@ -41,17 +40,29 @@ from vyos import airbag airbag.enable() config_file = '/etc/iproute2/rt_tables.d/vyos-vrf.conf' -nft_vrf_config = '/tmp/nftables-vrf-zones' - -def has_rule(af : str, priority : int, table : str): - """ Check if a given ip rule exists """ +k_mod = ['vrf'] + +def has_rule(af : str, priority : int, table : str=None): + """ + Check if a given ip rule exists + $ ip --json -4 rule show + [{'l3mdev': None, 'priority': 1000, 'src': 'all'}, + {'action': 'unreachable', 'l3mdev': None, 'priority': 2000, 'src': 'all'}, + {'priority': 32765, 'src': 'all', 'table': 'local'}, + {'priority': 32766, 'src': 'all', 'table': 'main'}, + {'priority': 32767, 'src': 'all', 'table': 'default'}] + """ if af not in ['-4', '-6']: raise ValueError() - command = f'ip -j {af} rule show' + command = f'ip --detail --json {af} rule show' for tmp in loads(cmd(command)): - if {'priority', 'table'} <= set(tmp): + if 'priority' in tmp and 'table' in tmp: if tmp['priority'] == priority and tmp['table'] == table: return True + elif 'priority' in tmp and table in tmp: + # l3mdev table has a different layout + if tmp['priority'] == priority: + return True return False def vrf_interfaces(c, match): @@ -173,8 +184,6 @@ def verify(vrf): def generate(vrf): # Render iproute2 VR helper names render(config_file, 'iproute2/vrf.conf.j2', vrf) - # Render nftables zones config - render(nft_vrf_config, 'firewall/nftables-vrf-zones.j2', vrf) # Render VRF Kernel/Zebra route-map filters vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) @@ -227,14 +236,6 @@ def apply(vrf): sysctl_write('net.vrf.strict_mode', strict_mode) if 'name' in vrf: - # Separate VRFs in conntrack table - # check if table already exists - _, err = popen('nft list table inet vrf_zones') - # If not, create a table - if err and os.path.exists(nft_vrf_config): - cmd(f'nft -f {nft_vrf_config}') - os.unlink(nft_vrf_config) - # Linux routing uses rules to find tables - routing targets are then # looked up in those tables. If the lookup got a matching route, the # process ends. @@ -318,17 +319,11 @@ def apply(vrf): frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config']) frr_cfg.commit_configuration(zebra_daemon) - # return to default lookup preference when no VRF is configured - if 'name' not in vrf: - # Remove VRF zones table from nftables - tmp = run('nft list table inet vrf_zones') - if tmp == 0: - cmd('nft delete table inet vrf_zones') - return None if __name__ == '__main__': try: + check_kmod(k_mod) c = get_config() verify(c) generate(c) |