From c9d5dff5af979e0d0f7466e238aaf2ada7fa1aba Mon Sep 17 00:00:00 2001 From: Arun Isaac Date: Tue, 1 Aug 2023 22:08:07 +0100 Subject: acme: Add ACME service. * guix/forge/acme.scm: Import shadow from (gnu packages admin), nss-certs from (gnu packages certs), (gnu services), (gnu services mcron), (gnu system shadow), (guix diagnostics), (guix i18n), (guix profiles), (guix records), (srfi srfi-1) and (ice-9 match). (%letsencrypt-production-url, %letsencrypt-staging-url): New variables. (, , , ): New record types. (acme-http-01-webroot-authorization-hook, acme-http-01-webroot-cleanup-hook): New public functions. (acme-key-length, uacme-hook, acme-activation, acme-renew, acme-helper, acme-helper-sudo-wrapper, acme-special-files, acme-cron-job, raise-to-top): New functions. (%acme-accounts, acme-service-type): New variables. * doc/forge.skb (Services)[ACME service]: New section. --- doc/forge.skb | 94 ++++++++++++ guix/forge/acme.scm | 432 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 525 insertions(+), 1 deletion(-) diff --git a/doc/forge.skb b/doc/forge.skb index 3095947..c1c43c7 100644 --- a/doc/forge.skb +++ b/doc/forge.skb @@ -187,6 +187,100 @@ per your needs. The overall configuration used in this tutorial is repeated below for your reference.]) (prog (source :language scheme :file "doc/snippets/tutorial.scm"))) + (chapter :title [Services] + :ident "chapter-services" + (section :title [ACME service] + :ident "section-acme-service" + (p [,(abbr :short "ACME" :long "Automatic Certificate Management +Environment") is a protocol popularized by the Let's Encrypt +certificate authority for the automatic issue and renewal of TLS +certificates. The guix-forge ACME service featuers] + (itemize + (item [a ,(samp [/usr/bin/acme]) script to help with +certificate management tasks]) + (item [cron job that runs at a random minute during the day and +renews certificates older than 30 days]) + (item [HTTP-01 challenge support. The DNS-01 challenge and +wildcard certificates will be supported in the future.]) + (item [completely rootless operation]))) + (p [The first time the ACME service is set up or each time new +certificates are configured, self-signed certificates are created so +that processes (such as nginx) that depend on these certificates can +start up successfully. You must replace these with certificate +authority issued certificates by running ,(samp [/usr/bin/acme +renew]). ,(samp [/usr/bin/acme renew]) automatically registers an ACME +account unless one already exists and renews all configured +certificates. It uses parameters that were configured in the ACME +service and does not need any additional command-line arguments.]) + (p [The ACME service does not use ,(ref :url +"https://certbot.eff.org/" :text "certbot"), the official Let's +Encrypt client. It instead uses ,(ref :url +"https://github.com/ndilieto/uacme/" :text "uacme"). uacme is smaller, +simpler, manages far less state, does no magic, and is better suited +to automation. However, the choice of backend tool is an +implementation detail. The ACME service is an ,(emph [abstract]) +service that is largely independent of the backend tool that powers +it.]) + (p [By using the ACME service, you agree to the Terms of Service +of your ACME server.]) + (description + (record-documentation "guix/forge/acme.scm" ' + (record-field "uacme" + [,(code [uacme]) package to use]) + (record-field "email" + [Email ID to register ACME account with]) + (record-field "acme-url" + [URL of the ACME server to use. This field can be set to +,(code [%letsencrypt-staging-url]) when testing your deployment.]) + (record-field "state-directory" + [State directory in which private keys and certificates are +stored]) + (record-field "http-01-challenge-directory" + [Directory served by the web server at ,(samp +"/.well-known/acme-challenge/") in response to ACME HTTP-01 +challenges]) + (record-field "http-01-authorization-hook" + [Script invoked to complete a HTTP-01 challenge]) + (record-field "http-01-cleanup-hook" + [Script invoked after the completion of a HTTP-01 +challenge]) + (record-field "key" + [,(record-ref "") or ,(record-ref +"") object describing the ACME account and TLS +certificate keys. Changing this field does not affect keys already +generated and stored on disk.]) + (record-field "certificates" + [List of ,(record-ref "") objects +describing certificates to configure]))) + (p [The ,(code [http-01-authorization-hook]) and ,(code +[http-01-cleanup-hook]) scripts are invoked with the following three +command-line arguments.]) + (description + (item :key (code [identifier]) + [Primary domain name on the certificate]) + (item :key (code [token]) + [Filename of the resource requested under ,(samp +"/.well-known/acme-challenge/") during the HTTP-01 challenge]) + (item :key (code [auth]) + [Authorization string expected to be at the requested +resource])) + (description + (record-documentation "guix/forge/acme.scm" ' + (record-field "domains" + [List of domain names that the certificate is valid +for. Each domain name is a string.]) + (record-field "deploy-hook" + [Script invoked to deploy a new certificate after +successful renewal. This script is invoked without any command-line +arguments.])) + (record-documentation "guix/forge/acme.scm" ' + (record-field "length" + [Length of the RSA key in number of bits. Must be a multiple +of 8 between 2048 and 8192.])) + (record-documentation "guix/forge/acme.scm" ' + (record-field "length" + [Length of the ECDSA key in number of bits. Must be either +256 or 384.]))))) (chapter :title [Reference] :ident "chapter-reference" (description diff --git a/guix/forge/acme.scm b/guix/forge/acme.scm index ee715ae..690f1e4 100644 --- a/guix/forge/acme.scm +++ b/guix/forge/acme.scm @@ -18,16 +18,57 @@ ;;; . (define-module (forge acme) + #:use-module ((gnu packages admin) #:select (shadow)) #:use-module ((gnu packages autotools) #:select (autoconf automake)) + #:use-module ((gnu packages certs) #:select (nss-certs)) #:use-module ((gnu packages curl) #:select (curl)) #:use-module ((gnu packages documentation) #:select (asciidoc)) #:use-module ((gnu packages pkg-config) #:select (pkg-config)) #:use-module ((gnu packages tls) #:select (gnutls)) + #:use-module (gnu services) + #:use-module (gnu services mcron) + #:use-module (gnu system shadow) #:use-module (guix build-system gnu) + #:use-module (guix diagnostics) #:use-module (guix gexp) #:use-module (guix git-download) + #:use-module (guix i18n) #:use-module ((guix licenses) #:prefix license:) - #:use-module (guix packages)) + #:use-module (guix packages) + #:use-module (guix profiles) + #:use-module (guix records) + #:use-module (ice-9 match) + #:use-module (srfi srfi-1) + #:export (acme-service-type + + acme-configuration + acme-configuration? + acme-configuration-uacme + acme-configuration-email + acme-configuration-acme-url + acme-configuration-state-directory + acme-configuration-http-01-challenge-directory + acme-configuration-http-01-authorization-hook + acme-configuration-http-01-cleanup-hook + acme-configuration-key + acme-configuration-certificates + %letsencrypt-production-url + %letsencrypt-staging-url + acme-http-01-webroot-authorization-hook + acme-http-01-webroot-cleanup-hook + + acme-rsa-key + acme-rsa-key? + acme-rsa-key-length + + acme-ecdsa-key + acme-ecdsa-key? + acme-ecdsa-key-length + + acme-certificate + acme-certificate? + acme-certificate-domains + acme-certificate-deploy-hook)) (define-public uacme (package @@ -77,3 +118,392 @@ challenge environment to the user or hook. uacme also includes ualpn, a lightweight proxying tls-alpn-01 challenge responder compliant with RFC8737 and RFC8738.") (license license:gpl3+))) + +(define-record-type* + acme-rsa-key make-acme-rsa-key + acme-rsa-key? + (length acme-rsa-key-length + (default 2048) + (sanitize sanitize-acme-rsa-key-length))) + +(define (sanitize-acme-rsa-key-length length) + (if (and (zero? (remainder length 8)) + (>= length 2048) + (<= length 8192)) + length + (leave (G_ "RSA ACME key length ~a is invalid. It must be a multiple of 8 between 2048 and 8192.~%") + length))) + +(define-record-type* + acme-ecdsa-key make-acme-ecdsa-key + acme-ecdsa-key? + (length acme-ecdsa-key-length + (default 256) + (sanitize sanitize-acme-ecdsa-key-length))) + +(define (sanitize-acme-ecdsa-key-length length) + (if (memv length (list 256 384)) + length + (leave (G_ "ECDSA ACME key length ~a is invalid. It must either be 256 or 384.~%") + length))) + +(define (acme-key-length key) + (cond + ((acme-rsa-key? key) + (acme-rsa-key-length key)) + ((acme-ecdsa-key? key) + (acme-ecdsa-key-length key)))) + +(define %letsencrypt-production-url + "https://acme-v02.api.letsencrypt.org/directory") + +(define %letsencrypt-staging-url + "https://acme-staging-v02.api.letsencrypt.org/directory") + +(define-record-type* + acme-configuration make-acme-configuration + acme-configuration? + this-acme-configuration + (uacme acme-configuration-uacme + (default uacme)) + (email acme-configuration-email + (default #false)) + (acme-url acme-configuration-acme-url + (default %letsencrypt-production-url)) + (state-directory acme-configuration-state-directory + (default "/var/lib/acme")) + (http-01-challenge-directory acme-configuration-http-01-challenge-directory + (default "/var/run/acme/acme-challenge")) + (http-01-authorization-hook acme-configuration-http-01-authorization-hook + (default (program-file "acme-http-01-authorization-hook" + (acme-http-01-webroot-authorization-gexp this-acme-configuration))) + (thunked)) + (http-01-cleanup-hook acme-configuration-http-01-cleanup-hook + (default (program-file "acme-http-01-cleanup-hook" + (acme-http-01-webroot-cleanup-gexp this-acme-configuration))) + (thunked)) + (key acme-configuration-key + (default (acme-ecdsa-key)) + (sanitize sanitize-acme-key)) + (certificates acme-configuration-certificates + (default '()))) + +(define (sanitize-acme-key key) + (if (or (acme-rsa-key? key) + (acme-ecdsa-key? key)) + key + (leave (G_ "Unsupported ACME key ~s~%") + key))) + +(define-record-type* + acme-certificate make-acme-certificate? + acme-certificate? + (domains acme-certificate-domains) + (deploy-hook acme-certificate-deploy-hook + (default #false))) + +(define (acme-http-01-webroot-authorization-gexp config) + "Return a HTTP-01 authorization G-expression for the ACME service +described by @var{config}. The G-expression installs a challenge file +in the configured challenge directory." + (match-record config + (http-01-challenge-directory) + #~(begin + (use-modules (srfi srfi-26) + (ice-9 match)) + + (match (command-line) + ((_ identifier token auth) + (let ((challenge-file (string-append #$http-01-challenge-directory "/" token))) + (call-with-output-file challenge-file + (cut display auth <>)))))))) + +(define (acme-http-01-webroot-cleanup-gexp config) + "Return a HTTP-01 cleanup G-expression for the ACME service described +by @var{config}. The G-expression cleans up the challenge file +installed by the authorization hook in the configured challenge +directory." + (match-record config + (http-01-challenge-directory) + #~(begin + (use-modules (ice-9 match)) + + (match (command-line) + ((_ identifier token auth) + (delete-file (string-append #$http-01-challenge-directory "/" token))))))) + +(define (uacme-hook-gexp config) + "Put together authorization and cleanup hooks described in +@var{config} into a hook suitable for passing to uacme." + (match-record config + (http-01-authorization-hook http-01-cleanup-hook) + (with-imported-modules '((guix build utils)) + #~(begin + (use-modules (guix build utils) + (ice-9 match)) + + (match (command-line) + ((_ "begin" "http-01" identifier token auth) + (invoke #$http-01-authorization-hook + identifier token auth)) + ((_ (or "done" "failed") "http-01" identifier token auth) + (invoke #$http-01-cleanup-hook + identifier token auth)) + ;; Decline other types of challenges. + (_ (exit #false))))))) + +(define %acme-accounts + (list (user-account + (name "acme") + (group "acme") + (system? #true) + (comment "ACME user") + (home-directory "/var/empty") + (shell (file-append shadow "/sbin/nologin"))) + (user-group + (name "acme") + (system? #true)))) + +(define acme-activation + (match-record-lambda + (state-directory http-01-challenge-directory key certificates) + (with-imported-modules '((guix build utils)) + #~(begin + (use-modules (guix build utils) + (ice-9 match) + (srfi srfi-26)) + + ;; Create state directory. + (mkdir-p #$state-directory) + + ;; Create keys and self-signed certificates if none exist so + ;; that services (such as nginx) that need the certificates + ;; don't fail to start. + (for-each (match-lambda + ((identifier certificate-template-file) + (let* ((key-file (string-append #$state-directory "/private/" identifier "/key.pem")) + (certificate-file (string-append #$state-directory "/" identifier "/cert.pem"))) + (unless (and (file-exists? key-file) + (file-exists? certificate-file)) + (mkdir-p (dirname key-file)) + (invoke #$(file-append gnutls "/bin/certtool") + "--generate-privkey" + "--key-type" #$(cond + ((acme-rsa-key? key) "rsa") + ((acme-ecdsa-key? key) "ecdsa")) + "--bits" #$(number->string (acme-key-length key)) + "--outfile" key-file) + (chmod key-file #o400) + (mkdir-p (dirname certificate-file)) + (invoke #$(file-append gnutls "/bin/certtool") + "--generate-self-signed" + "--template" certificate-template-file + "--load-privkey" key-file + "--outfile" certificate-file))))) + '#$(map (match-record-lambda + (domains) + (let ((identifier (first domains))) + (list identifier + (plain-file "certtool-template.cfg" + (format #f + "cn = \"~a\" +expiration_days = 1 +~{dns_name = \"~a\"~%~} +signing_key +encryption_key +tls_www_server +" + identifier + domains))))) + certificates)) + + ;; Set ownership of state directory and its contents. + (let ((user (getpw "acme"))) + (for-each (cut chown <> (passwd:uid user) (passwd:gid user)) + (find-files #$state-directory #:directories? #t)) + + ;; Create challenge directory and make it world readable, + ;; but only writable by the acme user. + (mkdir-p #$http-01-challenge-directory) + (chown #$http-01-challenge-directory (passwd:uid user) (passwd:gid user)) + (chmod #$http-01-challenge-directory #o755)) + + ;; Advise user about setup unless account key already + ;; exists. + (unless (file-exists? #$(string-append state-directory "/private/key.pem")) + (display " +If this is the first time you are using the acme service, please +register by running `/usr/bin/acme register' and initialize your +certificates by running `/usr/bin/acme renew' + +")))))) + +(define* (acme-renew config #:key quiet?) + "Return a G-expression that renews certificates described in +@var{config}. Unless @var{quiet?} is #true, be verbose." + (match-record config + (uacme email acme-url state-directory key certificates) + (with-imported-modules '((guix build utils)) + #~(begin + (use-modules (guix build utils)) + + ;; Set path to TLS certificates for verification of HTTPS + ;; servers. + (setenv "SSL_CERT_FILE" + #$(file-append (profile + (content (packages->manifest (list curl nss-certs)))) + "/etc/ssl/certs/ca-certificates.crt")) + + ;; Register ACME account if account does not already exist. We + ;; assume the presence of an account key implies the existence + ;; of an account. + (unless (file-exists? #$(string-append state-directory "/private/key.pem")) + (system* #$(file-append uacme "/bin/uacme") + "--verbose" + "--confdir" #$state-directory + "--acme-url" #$acme-url + "--yes" + "new" + #$@(if email + (list email) + (list)))) + + ;; Renew configured certificates. + (for-each (match-lambda + ((domains deploy-hook) + ;; uacme returns 0 on successful renewal, 1 when + ;; there is no need to renew, and 2 on other + ;; failures. + (case (status:exit-val + (apply system* + #$(file-append uacme "/bin/uacme") + "--confdir" #$state-directory + "--acme-url" #$acme-url + "--type" #$(cond + ((acme-rsa-key? key) "RSA") + ((acme-ecdsa-key? key) "EC")) + "--bits" #$(number->string (acme-key-length key)) + "--hook" #$(program-file "uacme-hook" + (uacme-hook-gexp config)) + "issue" + #$@(if quiet? + (list) + (list "--verbose")) + domains)) + ((0) (invoke deploy-hook)) + ((2) (exit #false))))) + '#$(map (match-record-lambda + (domains deploy-hook) + (list domains + deploy-hook)) + certificates)))))) + +(define (acme-helper config) + "Return a G-expression for a interactive ACME helper tool. Bake in +parameters described in @var{config}." + (match-record config + (uacme state-directory acme-url email) + #~(begin + (use-modules (ice-9 match)) + + (match (command-line) + ((command "renew") + #$(acme-renew config)) + ((command _ ...) + (format (current-error-port) + "Usage: ~a COMMAND + +Valid COMMANDs are + +renew: Issue or renew configured certificates + +" + command) + (exit #false)))))) + +(define (acme-helper-sudo-wrapper config) + #~(begin + (use-modules (ice-9 match)) + + (match (command-line) + ((_ arguments ...) + (apply system* + ;; We cannot refer to sudo in the store since that sudo + ;; does not have the setuid bit set. See "(guix) Setuid + ;; Programs". + "/run/setuid-programs/sudo" + "--user" "acme" + "--group" "acme" + #$(program-file "acme-helper" (acme-helper config)) + arguments))))) + +(define (acme-special-files config) + `(("/usr/bin/acme" ,(program-file "acme" + (acme-helper-sudo-wrapper config))))) + +(define (acme-cron-job config) + ;; Attempt to renew certificates once a day, at a random minute + ;; within the day. This helps even out the load on the ACME + ;; server. + #~(job '(next-minute-from (next-hour '(0)) + (list (random (* 24 60)))) + #$(program-file "acme-renew-cron" + (acme-renew config #:quiet? #true)) + #:user "acme")) + +(define (raise-to-top pred lst) + "Reorder @var{lst} so that the first element that satisfies @var{pred} +is the first element. The order of the remaining elements is +unspecified. If no element matching @var{pred} is found, return +@code{#f}." + (and (find pred lst) + (append (find-tail pred lst) + (take-while (negate pred) lst)))) + +(define acme-service-type + (service-type + (name 'acme) + (description "Automatically fetch and renew ACME certificates.") + (extensions (list (service-extension account-service-type + (const %acme-accounts)) + (service-extension activation-service-type + acme-activation) + (service-extension special-files-service-type + acme-special-files) + (service-extension mcron-service-type + (compose list acme-cron-job)))) + (compose concatenate) + (extend (lambda (config certificates) + (acme-configuration + (inherit config) + (certificates + ;; Append new certificates onto existing ones. When any + ;; of the new certificates are for the same domains as + ;; existing certificates, combine them into a single + ;; certificate. + (fold (lambda (certificate previous-certificates) + (cond + ((raise-to-top (lambda (this-certificate) + (lset= string=? + (acme-certificate-domains this-certificate) + (acme-certificate-domains certificate))) + previous-certificates) + => (match-lambda + ((matched-certificate other-certificates ...) + (cons (acme-certificate + (inherit certificate) + ;; Combine deploy hooks of the + ;; two certificates. + (deploy-hook + (with-imported-modules '((guix build utils)) + #~(begin + (use-modules (guix build utils)) + + (invoke #$(acme-certificate-deploy-hook certificate)) + (invoke #$(acme-certificate-deploy-hook matched-certificate)))))) + other-certificates)))) + (else + (cons certificate previous-certificates)))) + (acme-configuration-certificates config) + certificates))))) + (default-value (acme-configuration)))) -- cgit v1.2.3