diff --git a/interface-definitions/pki.xml.in b/interface-definitions/pki.xml.in index b922771c1..c69886a4b 100644 --- a/interface-definitions/pki.xml.in +++ b/interface-definitions/pki.xml.in @@ -1,287 +1,293 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="pki" owner="${vyos_conf_scripts_dir}/pki.py"> <properties> <help>Public key infrastructure (PKI)</help> <priority>300</priority> </properties> <children> <tagNode name="ca"> <properties> <help>Certificate Authority</help> <constraint> #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> </constraint> </properties> <children> #include <include/pki/cli-certificate-base64.xml.i> #include <include/generic-description.xml.i> <node name="private"> <properties> <help>CA private key in PEM format</help> </properties> <children> #include <include/pki/cli-private-key-base64.xml.i> #include <include/pki/password-protected.xml.i> </children> </node> <leafNode name="crl"> <properties> <help>Certificate revocation list in PEM format</help> <constraint> <validator name="base64"/> </constraint> <constraintErrorMessage>CRL is not base64-encoded</constraintErrorMessage> <multi/> </properties> </leafNode> + <leafNode name="system-install"> + <properties> + <help>Install into CA certificate store on router</help> + <valueless/> + </properties> + </leafNode> #include <include/pki/cli-revoke.xml.i> </children> </tagNode> <tagNode name="certificate"> <properties> <help>Certificate</help> <constraint> #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> </constraint> </properties> <children> #include <include/pki/cli-certificate-base64.xml.i> <node name="acme"> <properties> <help>Automatic Certificate Management Environment (ACME) request</help> </properties> <children> #include <include/url-http-https.xml.i> <leafNode name="url"> <defaultValue>https://acme-v02.api.letsencrypt.org/directory</defaultValue> </leafNode> <leafNode name="domain-name"> <properties> <help>Domain Name</help> <constraint> <validator name="fqdn"/> </constraint> <constraintErrorMessage>Invalid domain name (RFC 1123 section 2).\nMay only contain letters, numbers and .-_</constraintErrorMessage> <multi/> </properties> </leafNode> <leafNode name="email"> <properties> <help>Email address to associate with certificate</help> <constraint> #include <include/constraint/email.xml.i> </constraint> </properties> </leafNode> #include <include/listen-address-ipv4-single.xml.i> <leafNode name="rsa-key-size"> <properties> <help>Size of the RSA key</help> <completionHelp> <list>2048 3072 4096</list> </completionHelp> <valueHelp> <format>2048</format> <description>RSA key length 2048 bit</description> </valueHelp> <valueHelp> <format>3072</format> <description>RSA key length 3072 bit</description> </valueHelp> <valueHelp> <format>4096</format> <description>RSA key length 4096 bit</description> </valueHelp> <constraint> <regex>(2048|3072|4096)</regex> </constraint> </properties> <defaultValue>2048</defaultValue> </leafNode> </children> </node> #include <include/generic-description.xml.i> <node name="private"> <properties> <help>Certificate private key</help> </properties> <children> #include <include/pki/cli-private-key-base64.xml.i> #include <include/pki/password-protected.xml.i> </children> </node> #include <include/pki/cli-revoke.xml.i> </children> </tagNode> <tagNode name="dh"> <properties> <help>Diffie-Hellman parameters</help> <constraint> #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> </constraint> </properties> <children> <leafNode name="parameters"> <properties> <help>DH parameters in PEM format</help> <constraint> <validator name="base64"/> </constraint> <constraintErrorMessage>DH parameters are not base64-encoded</constraintErrorMessage> </properties> </leafNode> </children> </tagNode> <tagNode name="key-pair"> <properties> <help>Public and private keys</help> </properties> <children> <node name="public"> <properties> <help>Public key</help> </properties> <children> #include <include/pki/cli-public-key-base64.xml.i> </children> </node> <node name="private"> <properties> <help>Private key</help> </properties> <children> #include <include/pki/cli-private-key-base64.xml.i> #include <include/pki/password-protected.xml.i> </children> </node> </children> </tagNode> <tagNode name="openssh"> <properties> <help>OpenSSH public and private keys</help> </properties> <children> <node name="public"> <properties> <help>Public key</help> </properties> <children> #include <include/pki/cli-public-key-base64.xml.i> <leafNode name="type"> <properties> <help>SSH public key type</help> <completionHelp> <list>ssh-rsa</list> </completionHelp> <valueHelp> <format>ssh-rsa</format> <description>Key pair based on RSA algorithm</description> </valueHelp> <constraint> <regex>(ssh-rsa)</regex> </constraint> </properties> </leafNode> </children> </node> <node name="private"> <properties> <help>Private key</help> </properties> <children> #include <include/pki/cli-private-key-base64.xml.i> #include <include/pki/password-protected.xml.i> </children> </node> </children> </tagNode> <tagNode name="openssh"> <properties> <help>OpenSSH public and private keys</help> </properties> <children> <node name="public"> <properties> <help>Public key</help> </properties> <children> #include <include/pki/cli-public-key-base64.xml.i> </children> </node> <node name="private"> <properties> <help>Private key</help> </properties> <children> #include <include/pki/cli-private-key-base64.xml.i> #include <include/pki/password-protected.xml.i> </children> </node> </children> </tagNode> <node name="openvpn"> <properties> <help>OpenVPN keys</help> </properties> <children> <tagNode name="shared-secret"> <properties> <help>OpenVPN shared secret key</help> </properties> <children> <leafNode name="key"> <properties> <help>OpenVPN shared secret key data</help> </properties> </leafNode> <leafNode name="version"> <properties> <help>OpenVPN shared secret key version</help> </properties> </leafNode> </children> </tagNode> </children> </node> <node name="x509"> <properties> <help>X509 Settings</help> </properties> <children> <node name="default"> <properties> <help>X509 Default Values</help> </properties> <children> <leafNode name="country"> <properties> <help>Default country</help> </properties> <defaultValue>GB</defaultValue> </leafNode> <leafNode name="state"> <properties> <help>Default state</help> </properties> <defaultValue>Some-State</defaultValue> </leafNode> <leafNode name="locality"> <properties> <help>Default locality</help> </properties> <defaultValue>Some-City</defaultValue> </leafNode> <leafNode name="organization"> <properties> <help>Default organization</help> </properties> <defaultValue>VyOS</defaultValue> </leafNode> </children> </node> </children> </node> </children> </node> </interfaceDefinition> diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index dec619d3e..425990967 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -1,63 +1,64 @@ # Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library 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 # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see <http://www.gnu.org/licenses/>. import os base_dir = '/usr/libexec/vyos/' directories = { 'base' : base_dir, 'data' : '/usr/share/vyos/', 'conf_mode' : f'{base_dir}/conf_mode', 'op_mode' : f'{base_dir}/op_mode', 'services' : f'{base_dir}/services', 'config' : '/opt/vyatta/etc/config', 'migrate' : '/opt/vyatta/etc/config-migrate/migrate', 'activate' : f'{base_dir}/activate', 'log' : '/var/log/vyatta', 'templates' : '/usr/share/vyos/templates/', 'certbot' : '/config/auth/letsencrypt', 'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/', 'api_client_op': f'{base_dir}/services/api/graphql/graphql/client_op/', 'api_templates': f'{base_dir}/services/api/graphql/session/templates/', 'vyos_udev_dir' : '/run/udev/vyos', 'isc_dhclient_dir' : '/run/dhclient', 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', - 'completion_dir' : f'{base_dir}/completion' + 'completion_dir' : f'{base_dir}/completion', + 'ca_certificates' : '/usr/local/share/ca-certificates/vyos' } config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' cfg_group = 'vyattacfg' cfg_vintage = 'vyos' commit_lock = os.path.join(directories['vyos_configdir'], '.lock') component_version_json = os.path.join(directories['data'], 'component-versions.json') config_default = os.path.join(directories['data'], 'config.boot.default') rt_symbolic_names = { # Standard routing tables for Linux & reserved IDs for VyOS 'default': 253, # Confusingly, a final fallthru, not the default. 'main': 254, # The actual global table used by iproute2 unless told otherwise. 'local': 255, # Special kernel loopback table. } rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 4ff15d56e..acea2c9be 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -1,497 +1,526 @@ #!/usr/bin/env python3 # # Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from sys import argv from sys import exit from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos.configdict import node_changed from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories from vyos.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate from vyos.pki import load_public_key from vyos.pki import load_openssh_public_key from vyos.pki import load_openssh_private_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.configfs import add_cli_node from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.file import read_file from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag airbag.enable() vyos_certbot_dir = directories['certbot'] +vyos_ca_certificates_dir = directories['ca_certificates'] # keys to recursively search for under specified path sync_search = [ { 'keys': ['certificate'], 'path': ['service', 'https'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], }, { 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'], 'path': ['interfaces', 'openvpn'], }, { 'keys': ['ca_certificate'], 'path': ['interfaces', 'sstpc'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['load_balancing', 'haproxy'], }, { 'keys': ['key'], 'path': ['protocols', 'rpki', 'cache'], }, { 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], 'path': ['vpn', 'ipsec'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'openconnect'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['vpn', 'sstp'], }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['service', 'stunnel'], } ] # key from other config nodes -> key in pki['changed'] and pki sync_translate = { 'certificate': 'certificate', 'ca_certificate': 'ca', 'dh_params': 'dh', 'local_key': 'key_pair', 'remote_key': 'key_pair', 'shared_secret_key': 'openvpn', 'auth_key': 'openvpn', 'crypt_key': 'openvpn', 'key': 'openssh', } 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 else: conf = Config() base = ['pki'] pki = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) if len(argv) > 1 and argv[1] == 'certbot_renew': pki['certbot_renew'] = {} changed_keys = ['ca', 'certificate', 'dh', 'key-pair', 'openssh', 'openvpn'] for key in changed_keys: tmp = node_changed(conf, base + [key], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) if 'changed' not in pki: pki.update({'changed':{}}) pki['changed'].update({key.replace('-', '_') : tmp}) # We only merge on the defaults of there is a configuration at all if conf.exists(base): # 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! pki['system'] = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) D = get_config_diff(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 isinstance(found_name, list) and item_name not in found_name: continue if isinstance(found_name, str) and found_name != item_name: continue path = search['path'] path_str = ' '.join(path + found_path) #print(f'PKI: Updating config: {path_str} {item_name}') if path[0] == 'interfaces': ifname = found_path[0] if not D.node_changed_presence(path + [ifname]): set_dependents(path[1], conf, ifname) else: if not D.node_changed_presence(path): set_dependents(path[1], conf) return pki def is_valid_certificate(raw_data): # If it loads correctly we're good, or return False return load_certificate(raw_data, wrap_tags=True) def is_valid_ca_certificate(raw_data): # Check if this is a valid certificate with CA attributes cert = load_certificate(raw_data, wrap_tags=True) if not cert: return False return is_ca_certificate(cert) def is_valid_public_key(raw_data): # If it loads correctly we're good, or return False return load_public_key(raw_data, wrap_tags=True) def is_valid_private_key(raw_data, protected=False): # If it loads correctly we're good, or return False # With encrypted private keys, we always return true as we cannot ask for password to verify if protected: return True return load_private_key(raw_data, passphrase=None, wrap_tags=True) def is_valid_openssh_public_key(raw_data, type): # If it loads correctly we're good, or return False return load_openssh_public_key(raw_data, type) def is_valid_openssh_private_key(raw_data, protected=False): # If it loads correctly we're good, or return False # With encrypted private keys, we always return true as we cannot ask for password to verify if protected: return True return load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True) def is_valid_crl(raw_data): # If it loads correctly we're good, or return False return load_crl(raw_data, wrap_tags=True) def is_valid_dh_parameters(raw_data): # If it loads correctly we're good, or return False return load_dh_parameters(raw_data, wrap_tags=True) def verify(pki): if not pki: return None if 'ca' in pki: for name, ca_conf in pki['ca'].items(): if 'certificate' in ca_conf: if not is_valid_ca_certificate(ca_conf['certificate']): raise ConfigError(f'Invalid certificate on CA certificate "{name}"') if 'private' in ca_conf and 'key' in ca_conf['private']: private = ca_conf['private'] protected = 'password_protected' in private if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on CA certificate "{name}"') if 'crl' in ca_conf: ca_crls = ca_conf['crl'] if isinstance(ca_crls, str): ca_crls = [ca_crls] for crl in ca_crls: if not is_valid_crl(crl): raise ConfigError(f'Invalid CRL on CA certificate "{name}"') if 'certificate' in pki: for name, cert_conf in pki['certificate'].items(): if 'certificate' in cert_conf: if not is_valid_certificate(cert_conf['certificate']): raise ConfigError(f'Invalid certificate on certificate "{name}"') if 'private' in cert_conf and 'key' in cert_conf['private']: private = cert_conf['private'] protected = 'password_protected' in private 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: if not is_valid_dh_parameters(dh_conf['parameters']): raise ConfigError(f'Invalid DH parameters on "{name}"') if 'key_pair' in pki: for name, key_conf in pki['key_pair'].items(): if 'public' in key_conf and 'key' in key_conf['public']: if not is_valid_public_key(key_conf['public']['key']): raise ConfigError(f'Invalid public key on key-pair "{name}"') if 'private' in key_conf and 'key' in key_conf['private']: private = key_conf['private'] protected = 'password_protected' in private if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on key-pair "{name}"') if 'openssh' in pki: for name, key_conf in pki['openssh'].items(): if 'public' in key_conf and 'key' in key_conf['public']: if 'type' not in key_conf['public']: raise ConfigError(f'Must define OpenSSH public key type for "{name}"') if not is_valid_openssh_public_key(key_conf['public']['key'], key_conf['public']['type']): raise ConfigError(f'Invalid OpenSSH public key "{name}"') if 'private' in key_conf and 'key' in key_conf['private']: private = key_conf['private'] protected = 'password_protected' in private if not is_valid_openssh_private_key(private['key'], protected): raise ConfigError(f'Invalid OpenSSH private key "{name}"') if 'x509' in pki: if 'default' in pki['x509']: default_values = pki['x509']['default'] if 'country' in default_values: country = default_values['country'] if len(country) != 2 or not country.isalpha(): raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the # search key as value from line 173 or 176 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 not node_present: search_dict = dict_search_args(pki['system'], *search['path']) if not search_dict: continue for found_name, found_path in dict_search_recursive(search_dict, key): if found_name == item_name: path_str = " ".join(search['path'] + found_path) raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"') return None +def cleanup_system_ca(): + if not os.path.exists(vyos_ca_certificates_dir): + os.mkdir(vyos_ca_certificates_dir) + else: + for filename in os.listdir(vyos_ca_certificates_dir): + full_path = os.path.join(vyos_ca_certificates_dir, filename) + if os.path.isfile(full_path): + os.unlink(full_path) + def generate(pki): if not pki: + cleanup_system_ca() return None + # Create or cleanup CA install directory + if 'changed' in pki and 'ca' in pki['changed']: + cleanup_system_ca() + + if 'ca' in pki: + for ca, ca_conf in pki['ca'].items(): + if 'system_install' in ca_conf: + ca_obj = load_certificate(ca_conf['certificate']) + ca_path = os.path.join(vyos_ca_certificates_dir, f'{ca}.crt') + + with open(ca_path, 'w') as f: + f.write(encode_certificate(ca_obj)) + # Certbot renewal only needs to re-trigger the services to load up the # new PEM file if 'certbot_renew' in pki: 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: # ACME certificate is no longer in use by CLI remove it if cert not in certbot_list: certbot_delete(cert) continue # ACME not enabled for individual certificate - bail out early if 'acme' not in pki['certificate'][cert]: continue # Read in ACME certificate chain information tmp = read_file(f'{vyos_certbot_dir}/live/{cert}/chain.pem') tmp = load_certificate(tmp, wrap_tags=False) cert_chain_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1]) # Check if CA chain certificate is already present on CLI to avoid adding # a duplicate. This only checks for manual added CA certificates and not # auto added ones with the AUTOCHAIN_ prefix autochain_prefix = 'AUTOCHAIN_' ca_cert_present = False if 'ca' in pki: for ca_base64, cli_path in dict_search_recursive(pki['ca'], 'certificate'): # Ignore automatic added CA certificates if any(item.startswith(autochain_prefix) for item in cli_path): continue if cert_chain_base64 == ca_base64: ca_cert_present = True if not ca_cert_present: tmp = dict_search_args(pki, 'ca', f'{autochain_prefix}{cert}', 'certificate') if not bool(tmp) or tmp != cert_chain_base64: print(f'Adding/replacing automatically imported CA certificate for "{cert}" ...') add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64) return None def apply(pki): systemd_certbot_name = 'certbot.timer' if not pki: call(f'systemctl stop {systemd_certbot_name}') + call('update-ca-certificates') return None has_certbot = False 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() + # Rebuild ca-certificates bundle + if 'ca' in pki['changed']: + call('update-ca-certificates') + 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/init/vyos-router b/src/init/vyos-router index 8825cc16a..f8cc87507 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -1,575 +1,581 @@ #!/bin/bash # Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. . /lib/lsb/init-functions : ${vyatta_env:=/etc/default/vyatta} source $vyatta_env declare progname=${0##*/} declare action=$1; shift declare -x BOOTFILE=$vyatta_sysconfdir/config/config.boot declare -x DEFAULT_BOOTFILE=$vyatta_sysconfdir/config.boot.default # If vyos-config= boot option is present, use that file instead for x in $(cat /proc/cmdline); do [[ $x = vyos-config=* ]] || continue VYOS_CONFIG="${x#vyos-config=}" done if [ ! -z "$VYOS_CONFIG" ]; then if [ -r "$VYOS_CONFIG" ]; then echo "Config selected manually: $VYOS_CONFIG" declare -x BOOTFILE="$VYOS_CONFIG" else echo "WARNING: Could not read selected config file, using default!" fi fi declare -a subinit declare -a all_subinits=( firewall ) if [ $# -gt 0 ] ; then for s in $@ ; do [ -x ${vyatta_sbindir}/${s}.init ] && subinit[${#subinit}]=$s done else for s in ${all_subinits[@]} ; do [ -x ${vyatta_sbindir}/${s}.init ] && subinit[${#subinit}]=$s done fi GROUP=vyattacfg # easy way to make empty file without any command empty() { >$1 } # check if bootup of this portion is disabled disabled () { grep -q -w no-vyos-$1 /proc/cmdline } # Load encrypted config volume mount_encrypted_config() { persist_path=$(/opt/vyatta/sbin/vyos-persistpath) if [ $? == 0 ]; then if [ -e $persist_path/boot ]; then image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') if [ -z "$image_name" ]; then return fi if [ ! -f $persist_path/luks/$image_name ]; then return fi vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null) if [ $? -ne 0 ]; then echo "ERROR: Failed to fetch encryption key from TPM. Encrypted config volume has not been mounted" echo "Use 'encryption load' to load volume with recovery key" echo "or 'encryption disable' to decrypt volume with recovery key" return fi echo $vyos_tpm_key | tr -d '\r\n' | cryptsetup open $persist_path/luks/$image_name vyos_config --key-file=- if [ $? -ne 0 ]; then echo "ERROR: Failed to decrypt config volume. Encrypted config volume has not been mounted" echo "Use 'encryption load' to load volume with recovery key" echo "or 'encryption disable' to decrypt volume with recovery key" return fi mount /dev/mapper/vyos_config /config mount /dev/mapper/vyos_config $vyatta_sysconfdir/config echo "Mounted encrypted config volume" fi fi } unmount_encrypted_config() { persist_path=$(/opt/vyatta/sbin/vyos-persistpath) if [ $? == 0 ]; then if [ -e $persist_path/boot ]; then image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') if [ -z "$image_name" ]; then return fi if [ ! -f $persist_path/luks/$image_name ]; then return fi umount /config umount $vyatta_sysconfdir/config cryptsetup close vyos_config fi fi } # if necessary, provide initial config init_bootfile () { # define and version default boot config if not present if [ ! -r $DEFAULT_BOOTFILE ]; then if [ -f $vyos_data_dir/config.boot.default ]; then cp $vyos_data_dir/config.boot.default $DEFAULT_BOOTFILE $vyos_libexec_dir/add-system-version.py >> $DEFAULT_BOOTFILE fi fi if [ ! -r $BOOTFILE ] ; then if [ -f $DEFAULT_BOOTFILE ]; then cp $DEFAULT_BOOTFILE $BOOTFILE else $vyos_libexec_dir/add-system-version.py > $BOOTFILE fi chgrp ${GROUP} $BOOTFILE chmod 660 $BOOTFILE fi } # if necessary, migrate initial config migrate_bootfile () { if [ -x $vyos_libexec_dir/run-config-migration.py ]; then log_progress_msg migrate sg ${GROUP} -c "$vyos_libexec_dir/run-config-migration.py $BOOTFILE" fi } # configure system-specific settings system_config () { if [ -x $vyos_libexec_dir/run-config-activation.py ]; then log_progress_msg system sg ${GROUP} -c "$vyos_libexec_dir/run-config-activation.py $BOOTFILE" fi } # load the initial config load_bootfile () { log_progress_msg configure ( if [ -f /etc/default/vyatta-load-boot ]; then # build-specific environment for boot-time config loading source /etc/default/vyatta-load-boot fi if [ -x $vyos_libexec_dir/vyos-boot-config-loader.py ]; then sg ${GROUP} -c "$vyos_libexec_dir/vyos-boot-config-loader.py $BOOTFILE" fi ) } # restore if missing pre-config script restore_if_missing_preconfig_script () { if [ ! -x ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script ]; then mkdir -p ${vyatta_sysconfdir}/config/scripts chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts chmod 775 ${vyatta_sysconfdir}/config/scripts cp ${vyos_rootfs_dir}/opt/vyatta/etc/config/scripts/vyos-preconfig-bootup.script ${vyatta_sysconfdir}/config/scripts/ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script chmod 750 ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script fi } # execute the pre-config script run_preconfig_script () { if [ -x $vyatta_sysconfdir/config/scripts/vyos-preconfig-bootup.script ]; then $vyatta_sysconfdir/config/scripts/vyos-preconfig-bootup.script fi } # restore if missing post-config script restore_if_missing_postconfig_script () { if [ ! -x ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script ]; then mkdir -p ${vyatta_sysconfdir}/config/scripts chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts chmod 775 ${vyatta_sysconfdir}/config/scripts cp ${vyos_rootfs_dir}/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script ${vyatta_sysconfdir}/config/scripts/ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script chmod 750 ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script fi } # execute the post-config scripts run_postconfig_scripts () { if [ -x $vyatta_sysconfdir/config/scripts/vyatta-postconfig-bootup.script ]; then $vyatta_sysconfdir/config/scripts/vyatta-postconfig-bootup.script fi if [ -x $vyatta_sysconfdir/config/scripts/vyos-postconfig-bootup.script ]; then $vyatta_sysconfdir/config/scripts/vyos-postconfig-bootup.script fi } run_postupgrade_script () { if [ -f $vyatta_sysconfdir/config/.upgraded ]; then # Run the system script /usr/libexec/vyos/system/post-upgrade # Run user scripts if [ -d $vyatta_sysconfdir/config/scripts/post-upgrade.d ]; then run-parts $vyatta_sysconfdir/config/scripts/post-upgrade.d fi rm -f $vyatta_sysconfdir/config/.upgraded fi } # # On image booted machines, we need to mount /boot from the image-specific # boot directory so that kernel package installation will put the # files in the right place. We also have to mount /boot/grub from the # system-wide grub directory so that tools that edit the grub.cfg # file will find it in the expected location. # bind_mount_boot () { persist_path=$(/opt/vyatta/sbin/vyos-persistpath) if [ $? == 0 ]; then if [ -e $persist_path/boot ]; then image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') if [ -n "$image_name" ]; then mount --bind $persist_path/boot/$image_name /boot if [ $? -ne 0 ]; then echo "Couldn't bind mount /boot" fi if [ ! -d /boot/grub ]; then mkdir /boot/grub fi mount --bind $persist_path/boot/grub /boot/grub if [ $? -ne 0 ]; then echo "Couldn't bind mount /boot/grub" fi fi fi fi } clear_or_override_config_files () { for conf in snmp/snmpd.conf snmp/snmptrapd.conf snmp/snmp.conf \ keepalived/keepalived.conf cron.d/vyos-crontab \ ipvsadm.rules default/ipvsadm resolv.conf do if [ -s /etc/$conf ] ; then empty /etc/$conf chmod 0644 /etc/$conf fi done } update_interface_config () { if [ -d /run/udev/vyos ]; then $vyos_libexec_dir/vyos-interface-rescan.py $BOOTFILE fi } cleanup_post_commit_hooks () { # Remove links from the post-commit hooks directory. # note that this approach only supports hooks that are "configured", # i.e., it does not support hooks that need to always be present. cpostdir=$(cli-shell-api getPostCommitHookDir) # exclude commit hooks that need to always be present excluded="00vyos-sync 10vyatta-log-commit.pl 99vyos-user-postcommit-hooks" if [ -d "$cpostdir" ]; then for f in $cpostdir/*; do if [[ ! $excluded =~ $(basename $f) ]]; then rm -f $cpostdir/$(basename $f) fi done fi } # These are all the default security setting which are later # overridden when configuration is read. These are the values the # system defaults. security_reset () { # restore NSS cofniguration back to sane system defaults # will be overwritten later when configuration is loaded cat <<EOF >/etc/nsswitch.conf passwd: files group: files shadow: files gshadow: files # Per T2678, commenting out myhostname hosts: files dns #myhostname networks: files protocols: db files services: db files ethers: db files rpc: db files netgroup: nis EOF # restore PAM back to virgin state (no radius/tacacs services) pam-auth-update --disable radius-mandatory radius-optional rm -f /etc/pam_radius_auth.conf pam-auth-update --disable tacplus-mandatory tacplus-optional rm -f /etc/tacplus_nss.conf /etc/tacplus_servers # and no Google authenticator for 2FA/MFA pam-auth-update --disable mfa-google-authenticator # Certain configuration files are re-generated by the configuration # subsystem and must reside under /etc and can not easily be moved to /run. # So on every boot we simply delete any remaining files and let the CLI # regenearte them. # PPPoE rm -f /etc/ppp/peers/pppoe* /etc/ppp/peers/wlm* # IPSec rm -rf /etc/ipsec.conf /etc/ipsec.secrets find /etc/swanctl -type f | xargs rm -f # limit cleanup rm -f /etc/security/limits.d/10-vyos.conf # iproute2 cleanup rm -f /etc/iproute2/rt_tables.d/vyos-*.conf # Container rm -f /etc/containers/storage.conf /etc/containers/registries.conf /etc/containers/containers.conf # Clean all networks and re-create them from our CLI rm -f /etc/containers/networks/* # System Options (SSH/cURL) rm -f /etc/ssh/ssh_config.d/*vyos*.conf rm -f /etc/curlrc } # XXX: T3885 - generate persistend DHCPv6 DUID (Type4 - UUID based) gen_duid () { DUID_FILE="/var/lib/dhcpv6/dhcp6c_duid" UUID_FILE="/sys/class/dmi/id/product_uuid" UUID_FILE_ALT="/sys/class/dmi/id/product_serial" if [ ! -f ${UUID_FILE} ] && [ ! -f ${UUID_FILE_ALT} ]; then return 1 fi # DUID is based on the BIOS/EFI UUID. We omit additional - characters if [ -f ${UUID_FILE} ]; then UUID=$(cat ${UUID_FILE} | tr -d -) fi if [ -z ${UUID} ]; then UUID=$(uuidgen --sha1 --namespace @dns --name $(cat ${UUID_FILE_ALT}) | tr -d -) fi # Add DUID type4 (UUID) information DUID_TYPE="0004" # The length-information (as per RFC6355 UUID is 128 bits long) is in big-endian # format - beware when porting to ARM64. The length field consists out of the # UUID (128 bit + 16 bits DUID type) resulting in hex 12. DUID_LEN="0012" if [ "$(echo -n I | od -to2 | head -n1 | cut -f2 -d" " | cut -c6 )" -eq 1 ]; then # true on little-endian (x86) systems DUID_LEN="1200" fi for i in $(echo -n ${DUID_LEN}${DUID_TYPE}${UUID} | sed 's/../& /g'); do echo -ne "\x$i" done > ${DUID_FILE} } start () { # reset and clean config files security_reset || log_failure_msg "security reset failed" # some legacy directories migrated over from old rl-system.init mkdir -p /var/run/vyatta /var/log/vyatta chgrp vyattacfg /var/run/vyatta /var/log/vyatta chmod 775 /var/run/vyatta /var/log/vyatta log_daemon_msg "Waiting for NICs to settle down" # On boot time udev migth take a long time to reorder nic's, this will ensure that # all udev activity is completed and all nics presented at boot-time will have their # final name before continuing with vyos-router initialization. SECONDS=0 udevadm settle STATUS=$? log_progress_msg "settled in ${SECONDS}sec." log_end_msg ${STATUS} # mountpoint for bpf maps required by xdp mount -t bpf none /sys/fs/bpf # Clear out Debian APT source config file empty /etc/apt/sources.list # Generate DHCPv6 DUID gen_duid || log_failure_msg "could not generate DUID" # Mount a temporary filesystem for container networks. # Configuration should be loaded from VyOS cli. cni_dir="/etc/cni/net.d" [ ! -d ${cni_dir} ] && mkdir -p ${cni_dir} mount -t tmpfs none ${cni_dir} # Init firewall nfct helper add rpc inet tcp nfct helper add rpc inet udp nfct helper add tns inet tcp nfct helper add rpc inet6 tcp nfct helper add rpc inet6 udp nfct helper add tns inet6 tcp nft --file /usr/share/vyos/vyos-firewall-init.conf || log_failure_msg "could not initiate firewall rules" # As VyOS does not execute commands that are not present in the CLI we call # the script by hand to have a single source for the login banner and MOTD ${vyos_conf_scripts_dir}/system_console.py || log_failure_msg "could not reset serial console" ${vyos_conf_scripts_dir}/system_login_banner.py || log_failure_msg "could not reset motd and issue files" ${vyos_conf_scripts_dir}/system_option.py || log_failure_msg "could not reset system option files" ${vyos_conf_scripts_dir}/system_ip.py || log_failure_msg "could not reset system IPv4 options" ${vyos_conf_scripts_dir}/system_ipv6.py || log_failure_msg "could not reset system IPv6 options" ${vyos_conf_scripts_dir}/system_conntrack.py || log_failure_msg "could not reset conntrack subsystem" ${vyos_conf_scripts_dir}/container.py || log_failure_msg "could not reset container subsystem" clear_or_override_config_files || log_failure_msg "could not reset config files" # enable some debugging before loading the configuration if grep -q vyos-debug /proc/cmdline; then log_action_begin_msg "Enable runtime debugging options" touch /tmp/vyos.container.debug touch /tmp/vyos.ifconfig.debug touch /tmp/vyos.frr.debug touch /tmp/vyos.container.debug touch /tmp/vyos.smoketest.debug fi + # Cleanup PKI CAs + if [ -d /usr/local/share/ca-certificates/vyos ]; then + rm -f /usr/local/share/ca-certificates/vyos/*.crt + update-ca-certificates >/dev/null 2>&1 + fi + log_action_begin_msg "Mounting VyOS Config" # ensure the vyatta_configdir supports a large number of inodes since # the config hierarchy is often inode-bound (instead of size). # impose a minimum and then scale up dynamically with the actual size # of the system memory. local tmem=$(sed -n 's/^MemTotal: \+\([0-9]\+\) kB$/\1/p' /proc/meminfo) local tpages local tmpfs_opts="nosuid,nodev,mode=775,nr_inodes=0" #automatically allocate inodes mount -o $tmpfs_opts -t tmpfs none ${vyatta_configdir} \ && chgrp ${GROUP} ${vyatta_configdir} log_action_end_msg $? mount_encrypted_config # T5239: early read of system hostname as this value is read-only once during # FRR initialisation tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "system host-name") hostnamectl set-hostname --static "$tmp" ${vyos_conf_scripts_dir}/system_frr.py || log_failure_msg "could not reset FRR config" # If for any reason FRR was not started by system_frr.py - start it anyways. # This is a safety net! systemctl start frr.service disabled bootfile || init_bootfile cleanup_post_commit_hooks log_daemon_msg "Starting VyOS router" disabled migrate || migrate_bootfile restore_if_missing_preconfig_script run_preconfig_script run_postupgrade_script update_interface_config disabled system_config || system_config for s in ${subinit[@]} ; do if ! disabled $s; then log_progress_msg $s if ! ${vyatta_sbindir}/${s}.init start then log_failure_msg exit 1 fi fi done bind_mount_boot disabled configure || load_bootfile log_end_msg $? telinit q chmod g-w,o-w / restore_if_missing_postconfig_script run_postconfig_scripts tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "protocols rpki cache") if [[ ! -z "$tmp" ]]; then vtysh -c "rpki start" fi } stop() { local -i status=0 log_daemon_msg "Stopping VyOS router" for ((i=${#sub_inits[@]} - 1; i >= 0; i--)) ; do s=${subinit[$i]} log_progress_msg $s ${vyatta_sbindir}/${s}.init stop let status\|=$? done log_end_msg $status log_action_begin_msg "Un-mounting VyOS Config" umount ${vyatta_configdir} log_action_end_msg $? systemctl stop frr.service unmount_encrypted_config } case "$action" in start) start ;; stop) stop ;; restart|force-reload) stop && start ;; *) log_failure_msg "usage: $progname [ start|stop|restart ] [ subinit ... ]" ; false ;; esac exit $? # Local Variables: # mode: shell-script # sh-indentation: 4 # End: