From b8db1a9d7baf91b70c1b735e58710f1e2bc9fc7a Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 5 Jan 2024 22:27:45 +0100 Subject: pki: T5886: add support for ACME protocol (LetsEncrypt) The "idea" of this PR is to add new CLI nodes under the pki subsystem to activate ACME for any given certificate. vyos@vyos# set pki certificate NAME acme Possible completions: + domain-name Domain Name email Email address to associate with certificate listen-address Local IPv4 addresses to listen on rsa-key-size Size of the RSA key (default: 2048) url Remote URL (default: https://acme-v02.api.letsencrypt.org/directory) Users choose if the CLI based custom certificates are used set pki certificate EXAMPLE acme certificate or if it should be generated via ACME. The ACME server URL defaults to LetsEncrypt but can be changed to their staging API for testing to not get blacklisted. set pki certificate EXAMPLE acme url https://acme-staging-v02.api.letsencrypt.org/directory Certificate retrieval has a certbot --dry-run stage in verify() to see if it can be generated. After successful generation, the certificate is stored in under /config/auth/letsencrypt. Once a certificate is referenced in the CLI (e.g. set interfaces ethernet eth0 eapol certificate EXAMPLE) we call vyos.config.get_config_dict() which will (if with_pki=True is set) blend in the base64 encoded certificate into the JSON data structure normally used when using a certificate set by the CLI. Using this "design" does not need any change to any other code referencing the PKI system, as the base64 encoded certificate is already there. certbot renewal will call the PKI python script to trigger dependency updates. --- .../include/constraint/email.xml.i | 3 ++ interface-definitions/pki.xml.in | 54 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 interface-definitions/include/constraint/email.xml.i (limited to 'interface-definitions') diff --git a/interface-definitions/include/constraint/email.xml.i b/interface-definitions/include/constraint/email.xml.i new file mode 100644 index 000000000..b19a88d64 --- /dev/null +++ b/interface-definitions/include/constraint/email.xml.i @@ -0,0 +1,3 @@ + +[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,} + diff --git a/interface-definitions/pki.xml.in b/interface-definitions/pki.xml.in index 097c541ac..0ed199539 100644 --- a/interface-definitions/pki.xml.in +++ b/interface-definitions/pki.xml.in @@ -81,6 +81,60 @@ Certificate is not base64-encoded + + + Automatic Certificate Management Environment (ACME) request + + + #include + + https://acme-v02.api.letsencrypt.org/directory + + + + Domain Name + + + + Invalid domain name (RFC 1123 section 2).\nMay only contain letters, numbers and .-_ + + + + + + Email address to associate with certificate + + #include + + + + #include + + + Size of the RSA key + + 2048 3072 4096 + + + 2048 + RSA key length 2048 bit + + + 3072 + RSA key length 3072 bit + + + 4096 + RSA key length 4096 bit + + + (2048|3072|4096) + + + 2048 + + + #include -- cgit v1.2.3 From 9ab6665c80c30bf446d94620fc9d85b052d48072 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 5 Jan 2024 22:35:59 +0100 Subject: https: T5886: migrate https certbot to new "pki certificate" CLI tree --- data/templates/https/nginx.default.j2 | 12 +-- debian/control | 1 - .../include/version/https-version.xml.i | 2 +- interface-definitions/service_https.xml.in | 18 ---- src/conf_mode/service_https.py | 67 +++--------- .../service_https_certificates_certbot.py | 114 --------------------- src/migration-scripts/https/5-to-6 | 69 +++++++++++++ 7 files changed, 89 insertions(+), 194 deletions(-) delete mode 100755 src/conf_mode/service_https_certificates_certbot.py create mode 100755 src/migration-scripts/https/5-to-6 (limited to 'interface-definitions') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 80239ea56..a530c14ba 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -18,12 +18,7 @@ server { root /srv/localui; -{% if server.certbot %} - ssl_certificate {{ server.certbot_dir }}/live/{{ server.certbot_domain_dir }}/fullchain.pem; - ssl_certificate_key {{ server.certbot_dir }}/live/{{ server.certbot_domain_dir }}/privkey.pem; - include {{ server.certbot_dir }}/options-ssl-nginx.conf; - ssl_dhparam {{ server.certbot_dir }}/ssl-dhparams.pem; -{% elif server.vyos_cert %} +{% if server.vyos_cert %} ssl_certificate {{ server.vyos_cert.crt }}; ssl_certificate_key {{ server.vyos_cert.key }}; {% else %} @@ -33,7 +28,12 @@ server { # include snippets/snakeoil.conf; {% endif %} + ssl_session_cache shared:le_nginx_SSL:10m; + ssl_session_timeout 1440m; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; # proxy settings for HTTP API, if enabled; 503, if not location ~ ^/(retrieve|configure|config-file|image|container-image|generate|show|reboot|reset|poweroff|docs|openapi.json|redoc|graphql) { diff --git a/debian/control b/debian/control index 8343144c4..726a083f2 100644 --- a/debian/control +++ b/debian/control @@ -43,7 +43,6 @@ Depends: ## End of Fundamentals ## Python libraries used in multiple modules and scripts python3, - python3-certbot-nginx, python3-cryptography, python3-hurry.filesize, python3-inotify, diff --git a/interface-definitions/include/version/https-version.xml.i b/interface-definitions/include/version/https-version.xml.i index fa18278f3..525314dbd 100644 --- a/interface-definitions/include/version/https-version.xml.i +++ b/interface-definitions/include/version/https-version.xml.i @@ -1,3 +1,3 @@ - + diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in index 223f10962..57f36a982 100644 --- a/interface-definitions/service_https.xml.in +++ b/interface-definitions/service_https.xml.in @@ -192,24 +192,6 @@ #include #include - - - Request or apply a letsencrypt certificate for domain-name - - - - - Domain name(s) for which to obtain certificate - - - - - - Email address to associate with certificate - - - - #include 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 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 . - -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/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6 new file mode 100755 index 000000000..b4159f02f --- /dev/null +++ b/src/migration-scripts/https/5-to-6 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 . + +# T5886: Add support for ACME protocol (LetsEncrypt), migrate https certbot +# to new "pki certificate" CLI tree + +import os +import sys + +from vyos.configtree import ConfigTree +from vyos.defaults import directories + +vyos_certbot_dir = directories['certbot'] + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'https', 'certificates'] +if not config.exists(base): + # Nothing to do + sys.exit(0) + +# both domain-name and email must be set on CLI - ensured by previous verify() +domain_names = config.return_values(base + ['certbot', 'domain-name']) +email = config.return_value(base + ['certbot', 'email']) +config.delete(base) + +# Set default certname based on domain-name +cert_name = 'https-' + domain_names[0].split('.')[0] +# Overwrite certname from previous certbot calls if available +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()]: + cert_name = cert + break + +for domain in domain_names: + config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False) + config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email) + +# Update Webserver certificate +config.set(base + ['certificate'], value=cert_name) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) -- cgit v1.2.3