690 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
			
		
		
	
	
			690 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
| { config, lib, pkgs, options, ... }:
 | |
| with lib;
 | |
| let
 | |
|   cfg = config.security.acme;
 | |
| 
 | |
|   # Used to calculate timer accuracy for coalescing
 | |
|   numCerts = length (builtins.attrNames cfg.certs);
 | |
|   _24hSecs = 60 * 60 * 24;
 | |
| 
 | |
|   # There are many services required to make cert renewals work.
 | |
|   # They all follow a common structure:
 | |
|   #   - They inherit this commonServiceConfig
 | |
|   #   - They all run as the acme user
 | |
|   #   - They all use BindPath and StateDirectory where possible
 | |
|   #     to set up a sort of build environment in /tmp
 | |
|   # The Group can vary depending on what the user has specified in
 | |
|   # security.acme.certs.<cert>.group on some of the services.
 | |
|   commonServiceConfig = {
 | |
|       Type = "oneshot";
 | |
|       User = "acme";
 | |
|       Group = mkDefault "acme";
 | |
|       UMask = 0027;
 | |
|       StateDirectoryMode = 750;
 | |
|       ProtectSystem = "full";
 | |
|       PrivateTmp = true;
 | |
| 
 | |
|       WorkingDirectory = "/tmp";
 | |
|   };
 | |
| 
 | |
|   # In order to avoid race conditions creating the CA for selfsigned certs,
 | |
|   # we have a separate service which will create the necessary files.
 | |
|   selfsignCAService = {
 | |
|     description = "Generate self-signed certificate authority";
 | |
| 
 | |
|     path = with pkgs; [ minica ];
 | |
| 
 | |
|     unitConfig = {
 | |
|       ConditionPathExists = "!/var/lib/acme/.minica/key.pem";
 | |
|     };
 | |
| 
 | |
|     serviceConfig = commonServiceConfig // {
 | |
|       StateDirectory = "acme/.minica";
 | |
|       BindPaths = "/var/lib/acme/.minica:/tmp/ca";
 | |
|     };
 | |
| 
 | |
|     # Working directory will be /tmp
 | |
|     script = ''
 | |
|       minica \
 | |
|         --ca-key ca/key.pem \
 | |
|         --ca-cert ca/cert.pem \
 | |
|         --domains selfsigned.local
 | |
| 
 | |
|       chmod 600 ca/*
 | |
|     '';
 | |
|   };
 | |
| 
 | |
|   # Previously, all certs were owned by whatever user was configured in
 | |
|   # config.security.acme.certs.<cert>.user. Now everything is owned by and
 | |
|   # run by the acme user.
 | |
|   userMigrationService = {
 | |
|     description = "Fix owner and group of all ACME certificates";
 | |
| 
 | |
|     script = with builtins; concatStringsSep "\n" (mapAttrsToList (cert: data: ''
 | |
|       for fixpath in /var/lib/acme/${escapeShellArg cert} /var/lib/acme/.lego/${escapeShellArg cert}; do
 | |
|         if [ -d "$fixpath" ]; then
 | |
|           chmod -R u=rwX,g=rX,o= "$fixpath"
 | |
|           chown -R acme:${data.group} "$fixpath"
 | |
|         fi
 | |
|       done
 | |
|     '') certConfigs);
 | |
| 
 | |
|     # We don't want this to run every time a renewal happens
 | |
|     serviceConfig.RemainAfterExit = true;
 | |
|   };
 | |
| 
 | |
|   certToConfig = cert: data: let
 | |
|     acmeServer = if data.server != null then data.server else cfg.server;
 | |
|     useDns = data.dnsProvider != null;
 | |
|     destPath = "/var/lib/acme/${cert}";
 | |
|     selfsignedDeps = optionals (cfg.preliminarySelfsigned) [ "acme-selfsigned-${cert}.service" ];
 | |
| 
 | |
|     # Minica and lego have a "feature" which replaces * with _. We need
 | |
|     # to make this substitution to reference the output files from both programs.
 | |
|     # End users never see this since we rename the certs.
 | |
|     keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
 | |
| 
 | |
|     # FIXME when mkChangedOptionModule supports submodules, change to that.
 | |
|     # This is a workaround
 | |
|     extraDomains = data.extraDomainNames ++ (
 | |
|       optionals
 | |
|       (data.extraDomains != "_mkMergedOptionModule")
 | |
|       (builtins.attrNames data.extraDomains)
 | |
|     );
 | |
| 
 | |
|     # Create hashes for cert data directories based on configuration
 | |
|     # Flags are separated to avoid collisions
 | |
|     hashData = with builtins; ''
 | |
|       ${concatStringsSep " " data.extraLegoFlags} -
 | |
|       ${concatStringsSep " " data.extraLegoRunFlags} -
 | |
|       ${concatStringsSep " " data.extraLegoRenewFlags} -
 | |
|       ${toString acmeServer} ${toString data.dnsProvider}
 | |
|       ${toString data.ocspMustStaple} ${data.keyType}
 | |
|     '';
 | |
|     mkHash = with builtins; val: substring 0 20 (hashString "sha256" val);
 | |
|     certDir = mkHash hashData;
 | |
|     domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}";
 | |
|     othersHash = mkHash "${toString acmeServer} ${data.keyType} ${data.email}";
 | |
|     accountDir = "/var/lib/acme/.lego/accounts/" + othersHash;
 | |
| 
 | |
|     protocolOpts = if useDns then (
 | |
|       [ "--dns" data.dnsProvider ]
 | |
|       ++ optionals (!data.dnsPropagationCheck) [ "--dns.disable-cp" ]
 | |
|       ++ optionals (data.dnsResolver != null) [ "--dns.resolvers" data.dnsResolver ]
 | |
|     ) else (
 | |
|       [ "--http" "--http.webroot" data.webroot ]
 | |
|     );
 | |
| 
 | |
|     commonOpts = [
 | |
|       "--accept-tos" # Checking the option is covered by the assertions
 | |
|       "--path" "."
 | |
|       "-d" data.domain
 | |
|       "--email" data.email
 | |
|       "--key-type" data.keyType
 | |
|     ] ++ protocolOpts
 | |
|       ++ optionals (acmeServer != null) [ "--server" acmeServer ]
 | |
|       ++ concatMap (name: [ "-d" name ]) extraDomains
 | |
|       ++ data.extraLegoFlags;
 | |
| 
 | |
|     # Although --must-staple is common to both modes, it is not declared as a
 | |
|     # mode-agnostic argument in lego and thus must come after the mode.
 | |
|     runOpts = escapeShellArgs (
 | |
|       commonOpts
 | |
|       ++ [ "run" ]
 | |
|       ++ optionals data.ocspMustStaple [ "--must-staple" ]
 | |
|       ++ data.extraLegoRunFlags
 | |
|     );
 | |
|     renewOpts = escapeShellArgs (
 | |
|       commonOpts
 | |
|       ++ [ "renew" "--reuse-key" ]
 | |
|       ++ optionals data.ocspMustStaple [ "--must-staple" ]
 | |
|       ++ data.extraLegoRenewFlags
 | |
|     );
 | |
| 
 | |
|   in {
 | |
|     inherit accountDir selfsignedDeps;
 | |
| 
 | |
|     webroot = data.webroot;
 | |
|     group = data.group;
 | |
| 
 | |
|     renewTimer = {
 | |
|       description = "Renew ACME Certificate for ${cert}";
 | |
|       wantedBy = [ "timers.target" ];
 | |
|       timerConfig = {
 | |
|         OnCalendar = cfg.renewInterval;
 | |
|         Unit = "acme-${cert}.service";
 | |
|         Persistent = "yes";
 | |
| 
 | |
|         # Allow systemd to pick a convenient time within the day
 | |
|         # to run the check.
 | |
|         # This allows the coalescing of multiple timer jobs.
 | |
|         # We divide by the number of certificates so that if you
 | |
|         # have many certificates, the renewals are distributed over
 | |
|         # the course of the day to avoid rate limits.
 | |
|         AccuracySec = "${toString (_24hSecs / numCerts)}s";
 | |
| 
 | |
|         # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
 | |
|         RandomizedDelaySec = "24h";
 | |
|       };
 | |
|     };
 | |
| 
 | |
|     selfsignService = {
 | |
|       description = "Generate self-signed certificate for ${cert}";
 | |
|       after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ];
 | |
|       requires = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ];
 | |
| 
 | |
|       path = with pkgs; [ minica ];
 | |
| 
 | |
|       unitConfig = {
 | |
|         ConditionPathExists = "!/var/lib/acme/${cert}/key.pem";
 | |
|       };
 | |
| 
 | |
|       serviceConfig = commonServiceConfig // {
 | |
|         Group = data.group;
 | |
| 
 | |
|         StateDirectory = "acme/${cert}";
 | |
| 
 | |
|         BindPaths = "/var/lib/acme/.minica:/tmp/ca /var/lib/acme/${cert}:/tmp/${keyName}";
 | |
|       };
 | |
| 
 | |
|       # Working directory will be /tmp
 | |
|       # minica will output to a folder sharing the name of the first domain
 | |
|       # in the list, which will be ${data.domain}
 | |
|       script = ''
 | |
|         minica \
 | |
|           --ca-key ca/key.pem \
 | |
|           --ca-cert ca/cert.pem \
 | |
|           --domains ${escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))}
 | |
| 
 | |
|         # Create files to match directory layout for real certificates
 | |
|         cd '${keyName}'
 | |
|         cp ../ca/cert.pem chain.pem
 | |
|         cat cert.pem chain.pem > fullchain.pem
 | |
|         cat key.pem fullchain.pem > full.pem
 | |
| 
 | |
|         chmod 640 *
 | |
| 
 | |
|         # Group might change between runs, re-apply it
 | |
|         chown 'acme:${data.group}' *
 | |
|       '';
 | |
|     };
 | |
| 
 | |
|     renewService = {
 | |
|       description = "Renew ACME certificate for ${cert}";
 | |
|       after = [ "network.target" "network-online.target" "acme-fixperms.service" "nss-lookup.target" ] ++ selfsignedDeps;
 | |
|       wants = [ "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps;
 | |
| 
 | |
|       # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099
 | |
|       wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ];
 | |
| 
 | |
|       path = with pkgs; [ lego coreutils diffutils ];
 | |
| 
 | |
|       serviceConfig = commonServiceConfig // {
 | |
|         Group = data.group;
 | |
| 
 | |
|         # AccountDir dir will be created by tmpfiles to ensure correct permissions
 | |
|         # And to avoid deletion during systemctl clean
 | |
|         # acme/.lego/${cert} is listed so that it is deleted during systemctl clean
 | |
|         StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir}";
 | |
| 
 | |
|         # Needs to be space separated, but can't use a multiline string because that'll include newlines
 | |
|         BindPaths =
 | |
|           "${accountDir}:/tmp/accounts " +
 | |
|           "/var/lib/acme/${cert}:/tmp/out " +
 | |
|           "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates ";
 | |
| 
 | |
|         # Only try loading the credentialsFile if the dns challenge is enabled
 | |
|         EnvironmentFile = mkIf useDns data.credentialsFile;
 | |
| 
 | |
|         # Run as root (Prefixed with +)
 | |
|         ExecStartPost = "+" + (pkgs.writeShellScript "acme-postrun" ''
 | |
|           cd /var/lib/acme/${escapeShellArg cert}
 | |
|           if [ -e renewed ]; then
 | |
|             rm renewed
 | |
|             ${data.postRun}
 | |
|           fi
 | |
|         '');
 | |
|       };
 | |
| 
 | |
|       # Working directory will be /tmp
 | |
|       script = ''
 | |
|         set -euo pipefail
 | |
| 
 | |
|         echo '${domainHash}' > domainhash.txt
 | |
| 
 | |
|         # Check if we can renew
 | |
|         # Certificates and account credentials must exist
 | |
|         if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a "$(ls -1 accounts)" ]; then
 | |
| 
 | |
|           # When domains are updated, there's no need to do a full
 | |
|           # Lego run, but it's likely renew won't work if days is too low.
 | |
|           if [ -e certificates/domainhash.txt ] && cmp -s domainhash.txt certificates/domainhash.txt; then
 | |
|             lego ${renewOpts} --days ${toString cfg.validMinDays}
 | |
|           else
 | |
|             # Any number > 90 works, but this one is over 9000 ;-)
 | |
|             lego ${renewOpts} --days 9001
 | |
|           fi
 | |
| 
 | |
|         # Otherwise do a full run
 | |
|         else
 | |
|           lego ${runOpts}
 | |
|         fi
 | |
| 
 | |
|         mv domainhash.txt certificates/
 | |
|         chmod 640 certificates/*
 | |
|         chmod -R u=rwX,g=,o= accounts/*
 | |
| 
 | |
|         # Group might change between runs, re-apply it
 | |
|         chown 'acme:${data.group}' certificates/*
 | |
| 
 | |
|         # Copy all certs to the "real" certs directory
 | |
|         CERT='certificates/${keyName}.crt'
 | |
|         if [ -e "$CERT" ] && ! cmp -s "$CERT" out/fullchain.pem; then
 | |
|           touch out/renewed
 | |
|           echo Installing new certificate
 | |
|           cp -vp 'certificates/${keyName}.crt' out/fullchain.pem
 | |
|           cp -vp 'certificates/${keyName}.key' out/key.pem
 | |
|           cp -vp 'certificates/${keyName}.issuer.crt' out/chain.pem
 | |
|           ln -sf fullchain.pem out/cert.pem
 | |
|           cat out/key.pem out/fullchain.pem > out/full.pem
 | |
|         fi
 | |
|       '';
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   certConfigs = mapAttrs certToConfig cfg.certs;
 | |
| 
 | |
|   certOpts = { name, ... }: {
 | |
|     options = {
 | |
|       # user option has been removed
 | |
|       user = mkOption {
 | |
|         visible = false;
 | |
|         default = "_mkRemovedOptionModule";
 | |
|       };
 | |
| 
 | |
|       # allowKeysForGroup option has been removed
 | |
|       allowKeysForGroup = mkOption {
 | |
|         visible = false;
 | |
|         default = "_mkRemovedOptionModule";
 | |
|       };
 | |
| 
 | |
|       # extraDomains was replaced with extraDomainNames
 | |
|       extraDomains = mkOption {
 | |
|         visible = false;
 | |
|         default = "_mkMergedOptionModule";
 | |
|       };
 | |
| 
 | |
|       webroot = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         example = "/var/lib/acme/acme-challenges";
 | |
|         description = ''
 | |
|           Where the webroot of the HTTP vhost is located.
 | |
|           <filename>.well-known/acme-challenge/</filename> directory
 | |
|           will be created below the webroot if it doesn't exist.
 | |
|           <literal>http://example.org/.well-known/acme-challenge/</literal> must also
 | |
|           be available (notice unencrypted HTTP).
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       server = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         description = ''
 | |
|           ACME Directory Resource URI. Defaults to Let's Encrypt's
 | |
|           production endpoint,
 | |
|           <link xlink:href="https://acme-v02.api.letsencrypt.org/directory"/>, if unset.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       domain = mkOption {
 | |
|         type = types.str;
 | |
|         default = name;
 | |
|         description = "Domain to fetch certificate for (defaults to the entry name).";
 | |
|       };
 | |
| 
 | |
|       email = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = cfg.email;
 | |
|         description = "Contact email address for the CA to be able to reach you.";
 | |
|       };
 | |
| 
 | |
|       group = mkOption {
 | |
|         type = types.str;
 | |
|         default = "acme";
 | |
|         description = "Group running the ACME client.";
 | |
|       };
 | |
| 
 | |
|       postRun = mkOption {
 | |
|         type = types.lines;
 | |
|         default = "";
 | |
|         example = "cp full.pem backup.pem";
 | |
|         description = ''
 | |
|           Commands to run after new certificates go live. Note that
 | |
|           these commands run as the root user.
 | |
| 
 | |
|           Executed in the same directory with the new certificate.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       directory = mkOption {
 | |
|         type = types.str;
 | |
|         readOnly = true;
 | |
|         default = "/var/lib/acme/${name}";
 | |
|         description = "Directory where certificate and other state is stored.";
 | |
|       };
 | |
| 
 | |
|       extraDomainNames = mkOption {
 | |
|         type = types.listOf types.str;
 | |
|         default = [];
 | |
|         example = literalExample ''
 | |
|           [
 | |
|             "example.org"
 | |
|             "mydomain.org"
 | |
|           ]
 | |
|         '';
 | |
|         description = ''
 | |
|           A list of extra domain names, which are included in the one certificate to be issued.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       keyType = mkOption {
 | |
|         type = types.str;
 | |
|         default = "ec256";
 | |
|         description = ''
 | |
|           Key type to use for private keys.
 | |
|           For an up to date list of supported values check the --key-type option
 | |
|           at <link xlink:href="https://go-acme.github.io/lego/usage/cli/#usage"/>.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       dnsProvider = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         example = "route53";
 | |
|         description = ''
 | |
|           DNS Challenge provider. For a list of supported providers, see the "code"
 | |
|           field of the DNS providers listed at <link xlink:href="https://go-acme.github.io/lego/dns/"/>.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       dnsResolver = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         example = "1.1.1.1:53";
 | |
|         description = ''
 | |
|           Set the resolver to use for performing recursive DNS queries. Supported:
 | |
|           host:port. The default is to use the system resolvers, or Google's DNS
 | |
|           resolvers if the system's cannot be determined.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       credentialsFile = mkOption {
 | |
|         type = types.path;
 | |
|         description = ''
 | |
|           Path to an EnvironmentFile for the cert's service containing any required and
 | |
|           optional environment variables for your selected dnsProvider.
 | |
|           To find out what values you need to set, consult the documentation at
 | |
|           <link xlink:href="https://go-acme.github.io/lego/dns/"/> for the corresponding dnsProvider.
 | |
|         '';
 | |
|         example = "/var/src/secrets/example.org-route53-api-token";
 | |
|       };
 | |
| 
 | |
|       dnsPropagationCheck = mkOption {
 | |
|         type = types.bool;
 | |
|         default = true;
 | |
|         description = ''
 | |
|           Toggles lego DNS propagation check, which is used alongside DNS-01
 | |
|           challenge to ensure the DNS entries required are available.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       ocspMustStaple = mkOption {
 | |
|         type = types.bool;
 | |
|         default = false;
 | |
|         description = ''
 | |
|           Turns on the OCSP Must-Staple TLS extension.
 | |
|           Make sure you know what you're doing! See:
 | |
|           <itemizedlist>
 | |
|             <listitem><para><link xlink:href="https://blog.apnic.net/2019/01/15/is-the-web-ready-for-ocsp-must-staple/" /></para></listitem>
 | |
|             <listitem><para><link xlink:href="https://blog.hboeck.de/archives/886-The-Problem-with-OCSP-Stapling-and-Must-Staple-and-why-Certificate-Revocation-is-still-broken.html" /></para></listitem>
 | |
|           </itemizedlist>
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       extraLegoFlags = mkOption {
 | |
|         type = types.listOf types.str;
 | |
|         default = [];
 | |
|         description = ''
 | |
|           Additional global flags to pass to all lego commands.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       extraLegoRenewFlags = mkOption {
 | |
|         type = types.listOf types.str;
 | |
|         default = [];
 | |
|         description = ''
 | |
|           Additional flags to pass to lego renew.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       extraLegoRunFlags = mkOption {
 | |
|         type = types.listOf types.str;
 | |
|         default = [];
 | |
|         description = ''
 | |
|           Additional flags to pass to lego run.
 | |
|         '';
 | |
|       };
 | |
|     };
 | |
|   };
 | |
| 
 | |
| in {
 | |
| 
 | |
|   options = {
 | |
|     security.acme = {
 | |
| 
 | |
|       validMinDays = mkOption {
 | |
|         type = types.int;
 | |
|         default = 30;
 | |
|         description = "Minimum remaining validity before renewal in days.";
 | |
|       };
 | |
| 
 | |
|       email = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         description = "Contact email address for the CA to be able to reach you.";
 | |
|       };
 | |
| 
 | |
|       renewInterval = mkOption {
 | |
|         type = types.str;
 | |
|         default = "daily";
 | |
|         description = ''
 | |
|           Systemd calendar expression when to check for renewal. See
 | |
|           <citerefentry><refentrytitle>systemd.time</refentrytitle>
 | |
|           <manvolnum>7</manvolnum></citerefentry>.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       server = mkOption {
 | |
|         type = types.nullOr types.str;
 | |
|         default = null;
 | |
|         description = ''
 | |
|           ACME Directory Resource URI. Defaults to Let's Encrypt's
 | |
|           production endpoint,
 | |
|           <link xlink:href="https://acme-v02.api.letsencrypt.org/directory"/>, if unset.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       preliminarySelfsigned = mkOption {
 | |
|         type = types.bool;
 | |
|         default = true;
 | |
|         description = ''
 | |
|           Whether a preliminary self-signed certificate should be generated before
 | |
|           doing ACME requests. This can be useful when certificates are required in
 | |
|           a webserver, but ACME needs the webserver to make its requests.
 | |
| 
 | |
|           With preliminary self-signed certificate the webserver can be started and
 | |
|           can later reload the correct ACME certificates.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       acceptTerms = mkOption {
 | |
|         type = types.bool;
 | |
|         default = false;
 | |
|         description = ''
 | |
|           Accept the CA's terms of service. The default provider is Let's Encrypt,
 | |
|           you can find their ToS at <link xlink:href="https://letsencrypt.org/repository/"/>.
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       certs = mkOption {
 | |
|         default = { };
 | |
|         type = with types; attrsOf (submodule certOpts);
 | |
|         description = ''
 | |
|           Attribute set of certificates to get signed and renewed. Creates
 | |
|           <literal>acme-''${cert}.{service,timer}</literal> systemd units for
 | |
|           each certificate defined here. Other services can add dependencies
 | |
|           to those units if they rely on the certificates being present,
 | |
|           or trigger restarts of the service if certificates get renewed.
 | |
|         '';
 | |
|         example = literalExample ''
 | |
|           {
 | |
|             "example.com" = {
 | |
|               webroot = "/var/www/challenges/";
 | |
|               email = "foo@example.com";
 | |
|               extraDomainNames = [ "www.example.com" "foo.example.com" ];
 | |
|             };
 | |
|             "bar.example.com" = {
 | |
|               webroot = "/var/www/challenges/";
 | |
|               email = "bar@example.com";
 | |
|             };
 | |
|           }
 | |
|         '';
 | |
|       };
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   imports = [
 | |
|     (mkRemovedOptionModule [ "security" "acme" "production" ] ''
 | |
|       Use security.acme.server to define your staging ACME server URL instead.
 | |
| 
 | |
|       To use the let's encrypt staging server, use security.acme.server =
 | |
|       "https://acme-staging-v02.api.letsencrypt.org/directory".
 | |
|     ''
 | |
|     )
 | |
|     (mkRemovedOptionModule [ "security" "acme" "directory" ] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.")
 | |
|     (mkRemovedOptionModule [ "security" "acme" "preDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
 | |
|     (mkRemovedOptionModule [ "security" "acme" "activationDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
 | |
|     (mkChangedOptionModule [ "security" "acme" "validMin" ] [ "security" "acme" "validMinDays" ] (config: config.security.acme.validMin / (24 * 3600)))
 | |
|   ];
 | |
| 
 | |
|   config = mkMerge [
 | |
|     (mkIf (cfg.certs != { }) {
 | |
| 
 | |
|       # FIXME Most of these custom warnings and filters for security.acme.certs.* are required
 | |
|       # because using mkRemovedOptionModule/mkChangedOptionModule with attrsets isn't possible.
 | |
|       warnings = filter (w: w != "") (mapAttrsToList (cert: data: if data.extraDomains != "_mkMergedOptionModule" then ''
 | |
|         The option definition `security.acme.certs.${cert}.extraDomains` has changed
 | |
|         to `security.acme.certs.${cert}.extraDomainNames` and is now a list of strings.
 | |
|         Setting a custom webroot for extra domains is not possible, instead use separate certs.
 | |
|       '' else "") cfg.certs);
 | |
| 
 | |
|       assertions = let
 | |
|         certs = attrValues cfg.certs;
 | |
|       in [
 | |
|         {
 | |
|           assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs;
 | |
|           message = ''
 | |
|             You must define `security.acme.certs.<name>.email` or
 | |
|             `security.acme.email` to register with the CA. Note that using
 | |
|             many different addresses for certs may trigger account rate limits.
 | |
|           '';
 | |
|         }
 | |
|         {
 | |
|           assertion = cfg.acceptTerms;
 | |
|           message = ''
 | |
|             You must accept the CA's terms of service before using
 | |
|             the ACME module by setting `security.acme.acceptTerms`
 | |
|             to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/
 | |
|           '';
 | |
|         }
 | |
|       ] ++ (builtins.concatLists (mapAttrsToList (cert: data: [
 | |
|         {
 | |
|           assertion = data.user == "_mkRemovedOptionModule";
 | |
|           message = ''
 | |
|             The option definition `security.acme.certs.${cert}.user' no longer has any effect; Please remove it.
 | |
|             Certificate user is now hard coded to the "acme" user. If you would
 | |
|             like another user to have access, consider adding them to the
 | |
|             "acme" group or changing security.acme.certs.${cert}.group.
 | |
|           '';
 | |
|         }
 | |
|         {
 | |
|           assertion = data.allowKeysForGroup == "_mkRemovedOptionModule";
 | |
|           message = ''
 | |
|             The option definition `security.acme.certs.${cert}.allowKeysForGroup' no longer has any effect; Please remove it.
 | |
|             All certs are readable by the configured group. If this is undesired,
 | |
|             consider changing security.acme.certs.${cert}.group to an unused group.
 | |
|           '';
 | |
|         }
 | |
|         # * in the cert value breaks building of systemd services, and makes
 | |
|         # referencing them as a user quite weird too. Best practice is to use
 | |
|         # the domain option.
 | |
|         {
 | |
|           assertion = ! hasInfix "*" cert;
 | |
|           message = ''
 | |
|             The cert option path `security.acme.certs.${cert}.dnsProvider`
 | |
|             cannot contain a * character.
 | |
|             Instead, set `security.acme.certs.${cert}.domain = "${cert}";`
 | |
|             and remove the wildcard from the path.
 | |
|           '';
 | |
|         }
 | |
|         {
 | |
|           assertion = data.dnsProvider == null || data.webroot == null;
 | |
|           message = ''
 | |
|             Options `security.acme.certs.${cert}.dnsProvider` and
 | |
|             `security.acme.certs.${cert}.webroot` are mutually exclusive.
 | |
|           '';
 | |
|         }
 | |
|       ]) cfg.certs));
 | |
| 
 | |
|       users.users.acme = {
 | |
|         home = "/var/lib/acme";
 | |
|         group = "acme";
 | |
|         isSystemUser = true;
 | |
|       };
 | |
| 
 | |
|       users.groups.acme = {};
 | |
| 
 | |
|       systemd.services = {
 | |
|         "acme-fixperms" = userMigrationService;
 | |
|       } // (mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewService) certConfigs)
 | |
|         // (optionalAttrs (cfg.preliminarySelfsigned) ({
 | |
|         "acme-selfsigned-ca" = selfsignCAService;
 | |
|       } // (mapAttrs' (cert: conf: nameValuePair "acme-selfsigned-${cert}" conf.selfsignService) certConfigs)));
 | |
| 
 | |
|       systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;
 | |
| 
 | |
|       # .lego and .lego/accounts specified to fix any incorrect permissions
 | |
|       systemd.tmpfiles.rules = [
 | |
|         "d /var/lib/acme/.lego - acme acme"
 | |
|         "d /var/lib/acme/.lego/accounts - acme acme"
 | |
|       ] ++ (unique (concatMap (conf: [
 | |
|           "d ${conf.accountDir} - acme acme"
 | |
|         ] ++ (optional (conf.webroot != null) "d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}")
 | |
|       ) (attrValues certConfigs)));
 | |
| 
 | |
|       # Create some targets which can be depended on to be "active" after cert renewals
 | |
|       systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
 | |
|         wantedBy = [ "default.target" ];
 | |
|         requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
 | |
|         after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
 | |
|       }) certConfigs;
 | |
|     })
 | |
|   ];
 | |
| 
 | |
|   meta = {
 | |
|     maintainers = lib.teams.acme.members;
 | |
|     doc = ./acme.xml;
 | |
|   };
 | |
| }
 | 
