diff options
Diffstat (limited to 'nixpkgs/nixos/modules/security/acme.nix')
-rw-r--r-- | nixpkgs/nixos/modules/security/acme.nix | 391 |
1 files changed, 391 insertions, 0 deletions
diff --git a/nixpkgs/nixos/modules/security/acme.nix b/nixpkgs/nixos/modules/security/acme.nix new file mode 100644 index 000000000000..092704c6fc3f --- /dev/null +++ b/nixpkgs/nixos/modules/security/acme.nix @@ -0,0 +1,391 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.acme; + + certOpts = { name, ... }: { + options = { + webroot = mkOption { + type = types.str; + 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). + ''; + }; + + 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 = null; + description = "Contact email address for the CA to be able to reach you."; + }; + + user = mkOption { + type = types.str; + default = "root"; + description = "User running the ACME client."; + }; + + group = mkOption { + type = types.str; + default = "root"; + description = "Group running the ACME client."; + }; + + allowKeysForGroup = mkOption { + type = types.bool; + default = false; + description = '' + Give read permissions to the specified group + (<option>security.acme.cert.<name>.group</option>) to read SSL private certificates. + ''; + }; + + postRun = mkOption { + type = types.lines; + default = ""; + example = "systemctl reload nginx.service"; + description = '' + Commands to run after new certificates go live. Typically + the web server and other servers using certificates need to + be reloaded. + + Executed in the same directory with the new certificate. + ''; + }; + + plugins = mkOption { + type = types.listOf (types.enum [ + "cert.der" "cert.pem" "chain.pem" "external.sh" + "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json" + ]); + default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" ]; + description = '' + Plugins to enable. With default settings simp_le will + store public certificate bundle in <filename>fullchain.pem</filename>, + private key in <filename>key.pem</filename> and those two previous + files combined in <filename>full.pem</filename> in its state directory. + ''; + }; + + activationDelay = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Systemd time span expression to delay copying new certificates to main + state directory. See <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry>. + ''; + }; + + preDelay = mkOption { + type = types.lines; + default = ""; + description = '' + Commands to run after certificates are re-issued but before they are + activated. Typically the new certificate is published to DNS. + + Executed in the same directory with the new certificate. + ''; + }; + + extraDomains = mkOption { + type = types.attrsOf (types.nullOr types.str); + default = {}; + example = literalExample '' + { + "example.org" = "/srv/http/nginx"; + "mydomain.org" = null; + } + ''; + description = '' + A list of extra domain names, which are included in the one certificate to be issued, with their + own server roots if needed. + ''; + }; + }; + }; + +in + +{ + + ###### interface + + options = { + security.acme = { + directory = mkOption { + default = "/var/lib/acme"; + type = types.str; + description = '' + Directory where certs and other state will be stored by default. + ''; + }; + + validMin = mkOption { + type = types.int; + default = 30 * 24 * 3600; + description = "Minimum remaining validity before renewal in seconds."; + }; + + renewInterval = mkOption { + type = types.str; + default = "weekly"; + description = '' + Systemd calendar expression when to check for renewal. See + <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry>. + ''; + }; + + 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. + ''; + }; + + production = mkOption { + type = types.bool; + default = true; + description = '' + If set to true, use Let's Encrypt's production environment + instead of the staging environment. The main benefit of the + staging environment is to get much higher rate limits. + + See + <literal>https://letsencrypt.org/docs/staging-environment</literal> + for more detail. + ''; + }; + + certs = mkOption { + default = { }; + type = with types; attrsOf (submodule certOpts); + description = '' + Attribute set of certificates to get signed and renewed. + ''; + example = literalExample '' + { + "example.com" = { + webroot = "/var/www/challenges/"; + email = "foo@example.com"; + extraDomains = { "www.example.com" = null; "foo.example.com" = "/var/www/foo/"; }; + }; + "bar.example.com" = { + webroot = "/var/www/challenges/"; + email = "bar@example.com"; + }; + } + ''; + }; + }; + }; + + ###### implementation + config = mkMerge [ + (mkIf (cfg.certs != { }) { + + systemd.services = let + services = concatLists servicesLists; + servicesLists = mapAttrsToList certToServices cfg.certs; + certToServices = cert: data: + let + cpath = lpath + optionalString (data.activationDelay != null) ".staging"; + lpath = "${cfg.directory}/${cert}"; + rights = if data.allowKeysForGroup then "750" else "700"; + cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ] + ++ optionals (data.email != null) [ "--email" data.email ] + ++ concatMap (p: [ "-f" p ]) data.plugins + ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains) + ++ optionals (!cfg.production) ["--server" "https://acme-staging.api.letsencrypt.org/directory"]; + acmeService = { + description = "Renew ACME Certificate for ${cert}"; + after = [ "network.target" "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + SuccessExitStatus = [ "0" "1" ]; + PermissionsStartOnly = true; + User = data.user; + Group = data.group; + PrivateTmp = true; + }; + path = with pkgs; [ simp_le systemd ]; + preStart = '' + mkdir -p '${cfg.directory}' + chown 'root:root' '${cfg.directory}' + chmod 755 '${cfg.directory}' + if [ ! -d '${cpath}' ]; then + mkdir '${cpath}' + fi + chmod ${rights} '${cpath}' + chown -R '${data.user}:${data.group}' '${cpath}' + mkdir -p '${data.webroot}/.well-known/acme-challenge' + chown -R '${data.user}:${data.group}' '${data.webroot}/.well-known/acme-challenge' + ''; + script = '' + cd '${cpath}' + set +e + simp_le ${escapeShellArgs cmdline} + EXITCODE=$? + set -e + echo "$EXITCODE" > /tmp/lastExitCode + exit "$EXITCODE" + ''; + postStop = '' + cd '${cpath}' + + if [ -e /tmp/lastExitCode ] && [ "$(cat /tmp/lastExitCode)" = "0" ]; then + ${if data.activationDelay != null then '' + + ${data.preDelay} + + if [ -d '${lpath}' ]; then + systemd-run --no-block --on-active='${data.activationDelay}' --unit acme-setlive-${cert}.service + else + systemctl --wait start acme-setlive-${cert}.service + fi + '' else data.postRun} + + # noop ensuring that the "if" block is non-empty even if + # activationDelay == null and postRun == "" + true + fi + ''; + + before = [ "acme-certificates.target" ]; + wantedBy = [ "acme-certificates.target" ]; + }; + delayService = { + description = "Set certificate for ${cert} live"; + path = with pkgs; [ rsync ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + rsync -a --delete-after '${cpath}/' '${lpath}' + ''; + postStop = data.postRun; + }; + selfsignedService = { + description = "Create preliminary self-signed certificate for ${cert}"; + path = [ pkgs.openssl ]; + preStart = '' + if [ ! -d '${cpath}' ] + then + mkdir -p '${cpath}' + chmod ${rights} '${cpath}' + chown '${data.user}:${data.group}' '${cpath}' + fi + ''; + script = + '' + workdir="$(mktemp -d)" + + # Create CA + openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048 + openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key + openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \ + -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com" + openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt + + # Create key + openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048 + openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key + openssl req -new -key $workdir/server.key -out $workdir/server.csr \ + -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com" + openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \ + -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \ + -out $workdir/server.crt + + # Copy key to destination + cp $workdir/server.key ${cpath}/key.pem + + # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates) + cat $workdir/{server.crt,ca.crt} > "${cpath}/fullchain.pem" + + # Create full.pem for e.g. lighttpd + cat $workdir/{server.key,server.crt,ca.crt} > "${cpath}/full.pem" + + # Give key acme permissions + chown '${data.user}:${data.group}' "${cpath}/"{key,fullchain,full}.pem + chmod ${rights} "${cpath}/"{key,fullchain,full}.pem + ''; + serviceConfig = { + Type = "oneshot"; + PermissionsStartOnly = true; + PrivateTmp = true; + User = data.user; + Group = data.group; + }; + unitConfig = { + # Do not create self-signed key when key already exists + ConditionPathExists = "!${cpath}/key.pem"; + }; + before = [ + "acme-selfsigned-certificates.target" + ]; + wantedBy = [ + "acme-selfsigned-certificates.target" + ]; + }; + in ( + [ { name = "acme-${cert}"; value = acmeService; } ] + ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; } + ++ optional (data.activationDelay != null) { name = "acme-setlive-${cert}"; value = delayService; } + ); + servicesAttr = listToAttrs services; + injectServiceDep = { + after = [ "acme-selfsigned-certificates.target" ]; + wants = [ "acme-selfsigned-certificates.target" "acme-certificates.target" ]; + }; + in + servicesAttr // + (if config.services.nginx.enable then { nginx = injectServiceDep; } else {}) // + (if config.services.lighttpd.enable then { lighttpd = injectServiceDep; } else {}); + + systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair + ("acme-${cert}") + ({ + description = "Renew ACME Certificate for ${cert}"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.renewInterval; + Unit = "acme-${cert}.service"; + Persistent = "yes"; + AccuracySec = "5m"; + RandomizedDelaySec = "1h"; + }; + }) + ); + + systemd.targets."acme-selfsigned-certificates" = mkIf cfg.preliminarySelfsigned {}; + systemd.targets."acme-certificates" = {}; + }) + + ]; + + meta = { + maintainers = with lib.maintainers; [ abbradar fpletz globin ]; + doc = ./acme.xml; + }; +} |