diff options
author | Christian Breunig <christian@breunig.cc> | 2024-01-07 07:25:58 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-07 07:25:58 +0100 |
commit | dff740f3cfb57757146d465d994499c552876359 (patch) | |
tree | 6ad08283c75363f154fc8d1567b4a16bee8dd878 /src/conf_mode | |
parent | 31d824d9b6bce13ea8fa2a838d47cdf24b345fb1 (diff) | |
parent | 9ab6665c80c30bf446d94620fc9d85b052d48072 (diff) | |
download | vyos-1x-dff740f3cfb57757146d465d994499c552876359.tar.gz vyos-1x-dff740f3cfb57757146d465d994499c552876359.zip |
Merge pull request #2758 from c-po/certbot-T5886
pki: T5886: add support for ACME protocol (LetsEncrypt)
Diffstat (limited to 'src/conf_mode')
-rwxr-xr-x | src/conf_mode/pki.py | 223 | ||||
-rwxr-xr-x | src/conf_mode/service_https.py | 67 | ||||
-rwxr-xr-x | src/conf_mode/service_https_certificates_certbot.py | 114 |
3 files changed, 185 insertions, 219 deletions
diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index f7e14aa16..310519abd 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 --config-dir {vyos_certbot_dir} --cert-name {name} '\ + f'--non-interactive --standalone --agree-tos --no-eff-email --expand '\ + f'--server {config["url"]} --email {config["email"]} '\ + f'--key-type rsa --rsa-key-size {config["rsa_key_size"]} {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,62 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) - pki['changed'] = {} + if len(argv) > 1 and argv[1] == 'certbot_renew': + pki['certbot_renew'] = {} + tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'ca' : tmp}) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'ca' : tmp}) - tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'certificate' : tmp}) + tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), + recursive=True, expand_nodes=Diff.ADD|Diff.DELETE) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'certificate' : tmp}) tmp = node_changed(conf, base + ['dh'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'dh' : tmp}) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'dh' : tmp}) tmp = node_changed(conf, base + ['key-pair'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'key_pair' : tmp}) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'key_pair' : 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 + ['openvpn', 'shared-secret'], key_mangling=('-', '_'), + 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 +190,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 +290,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 +366,50 @@ 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 + + # list of certificates issued via certbot + certbot_list = [] + if 'certificate' in pki: + for name, cert_conf in pki['certificate'].items(): + if 'acme' in cert_conf: + certbot_list.append(name) + # when something for the certificate changed, we should delete it + if name in dict_search('changed.certificate', pki): + 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 [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()]: + 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/service_https.py b/src/conf_mode/service_https.py index cb40acc9f..2e7ebda5a 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -22,7 +22,6 @@ 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 @@ -33,8 +32,6 @@ from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running -from vyos.utils.process import is_systemd_service_active from vyos.utils.network import check_port_availability from vyos.utils.network import is_listen_port_bind_service from vyos.utils.file import write_file @@ -46,12 +43,11 @@ config_file = '/etc/nginx/sites-available/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, +# https config needs to coordinate several subsystems: api, # 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 @@ -63,7 +59,6 @@ default_server_block = { 'name' : ['_'], 'api' : False, 'vyos_cert' : {}, - 'certbot' : False } def get_config(config=None): @@ -80,7 +75,6 @@ def get_config(config=None): 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']) if 'api' not in https: @@ -100,7 +94,6 @@ def get_config(config=None): http_api['vrf'] = conf.return_value(vrf_path) https['api'] = http_api - return https def verify(https): @@ -119,25 +112,17 @@ def verify(https): cert_name = certificates['certificate'] if cert_name not in https['pki']['certificate']: - raise ConfigError("Invalid certificate on https configuration") + raise ConfigError('Invalid certificate on https configuration') pki_cert = https['pki']['certificate'][cert_name] if 'certificate' not in pki_cert: - raise ConfigError("Missing certificate on https configuration") + 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 on https 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.") + else: + Warning('No certificate specified, using buildin self-signed certificates!') server_block_list = [] @@ -255,22 +240,6 @@ def generate(https): 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: @@ -283,7 +252,6 @@ def generate(https): data = { 'server_block_list': server_block_list, - 'certbot': certbot } render(config_file, 'https/nginx.default.j2', data) @@ -297,27 +265,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 'api' in https: + call(f'systemctl reload-or-restart {http_api_service_name}') + # Let uvicorn settle before (possibly) restarting nginx + sleep(1) + else: + 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}') + 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) |