;;; guix-forge --- Guix software forge meta-service ;;; Copyright © 2023, 2024 Arun Isaac ;;; Copyright © 2024 jgart ;;; ;;; This file is part of guix-forge. ;;; ;;; guix-forge is free software: you can redistribute it and/or modify ;;; it under the terms of the GNU General Public License as published ;;; by the Free Software Foundation, either version 3 of the License, ;;; or (at your option) any later version. ;;; ;;; guix-forge 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 guix-forge. If not, see ;;; . (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 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 (name "uacme") (version "1.7.5") (source (origin ;; TODO: Unbundle libev. (method git-fetch) (uri (git-reference (url "https://github.com/ndilieto/uacme/") (commit (string-append "v" version)))) (file-name (git-file-name name version)) (sha256 (base32 "1br374d0lnn422rvg2g4m69vhdck8aihdsjydr064hif0lscr8ri")))) (build-system gnu-build-system) (arguments (list #:phases #~(modify-phases %standard-phases (add-after 'unpack 'set-version (lambda _ (substitute* "configure.ac" (("m4_esyscmd\\(\\[build-aux/git-version-gen \\.tarball-version\\]\\)") #$version))))))) (inputs (list curl gnutls)) (native-inputs (list asciidoc autoconf automake pkg-config)) (home-page "https://github.com/ndilieto/uacme") (synopsis "Minimal ACMEv2 client written in C") (description "uacme is a lightweight client for the RFC8555 ACMEv2 protocol, written in plain C with minimal dependencies. The ACMEv2 protocol allows a Certificate Authority such as Let's Encrypt and an applicant to automate the process of verification and certificate issuance. uacme calls an external hook program with the tokens required for domain authorization. The hook program can be an executable, shell script, perl script, python script, or any file that the operating system can execute. uacme is ACME challenge agnostic. It provides the user or hook program with all tokens and information required to complete any challenge type but leaves the task of setting up and cleaning up the 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 " This seems to be the first time you are using the acme service. The acme service starts out with self-signed certificates. Please run `/usr/bin/acme renew' to get CA-issued certificates. Thereafter, certificates will auto-renew via a cron job. ")))))) (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) (ice-9 match)) ;; 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 (program-file (string-append (first (acme-certificate-domains certificate)) "-certificate-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))))