Skip to content

Instantly share code, notes, and snippets.

@andrew-d
Created May 15, 2019 14:34
Show Gist options
  • Save andrew-d/582a80006d3c1334382dc5a16affb1d4 to your computer and use it in GitHub Desktop.
Save andrew-d/582a80006d3c1334382dc5a16affb1d4 to your computer and use it in GitHub Desktop.
{ config, lib, pkgs, ... }:
let
inherit (lib) concatMap escapeShellArg escapeShellArgs mkIf mkOption optionals types;
cfg = config.roles.lego-letsencrypt;
in
{
options = {
roles.lego-letsencrypt = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
Obtain Let's Encrypt certificates by using lego.
'';
};
staging = mkOption {
default = false;
type = types.bool;
description = ''
Use the staging Let's Encrypt server (useful for testing).
'';
};
directory = mkOption {
default = "/var/lib/lego";
type = types.str;
description = ''
Directory where certs and other state will be stored.
'';
};
group = mkOption {
default = "certs";
type = types.str;
description = ''
Group to create with access to certificates.
'';
};
domain = mkOption {
type = types.str;
description = ''
Domain name to obtain a certificate for.
'';
};
alternateNames = mkOption {
type = types.listOf types.str;
default = [];
description = ''
Alternate names to add to the certificate.
'';
};
email = mkOption {
type = types.nullOr types.str;
default = null;
description = "Contact email address for the CA to be able to reach you.";
};
renewDays = mkOption {
type = types.int;
default = 30;
description = ''
The number of days left on a certificate to renew it.
'';
};
zoneID = mkOption {
type = types.str;
description = ''
Route53 zone ID to update.
'';
};
credentialsFile = mkOption {
type = types.nullOr (types.uniq types.string);
default = null;
description = ''
Shell script that will be evaluated to obtain AWS credentials.
'';
};
};
};
config = (mkIf cfg.enable {
# Create group for certificates so we can allow other things to read them.
users.groups."${cfg.group}".gid = 401;
# Main systemd services
systemd.services =
let
globalOptions =
[ "--accept-tos"
"--dns" "route53"
"--domains" cfg.domain
"--path" cfg.directory
"--pem" ]
++ optionals (cfg.email != null) [ "--email" cfg.email ]
++ optionals cfg.staging [ "--server" "https://acme-staging.api.letsencrypt.org/directory" ]
++ concatMap (d: ["--domains" d]) cfg.alternateNames;
certFile = "${cfg.directory}/certificates/${cfg.domain}.crt";
keyFile = "${cfg.directory}/certificates/${cfg.domain}.key";
hashFile = "${cfg.directory}/certificates/.${cfg.domain}.sha256";
prelude = ''
${if cfg.credentialsFile != null then ''
# Load secrets
source ${cfg.credentialsFile}
'' else ""}
# Export configuration
export AWS_HOSTED_ZONE_ID="${cfg.zoneID}"
export AWS_REGION=us-west-2
'';
postlude = ''
# We only need to do things if we changed the key/cert
if ! sha256sum --quiet -c ${escapeShellArg hashFile} >/dev/null 2>&1 ; then
# Create PKCS#12 file with no password (we mostly care about file permissions)
(
cd '${cfg.directory}/certificates'
umask 027
${pkgs.openssl}/bin/openssl pkcs12 \
-export \
-in ${cfg.domain}.crt \
-inkey ${cfg.domain}.key \
-out ${cfg.domain}.p12 \
-passout pass:x
)
# Ensure it has the right group
${pkgs.coreutils}/bin/chgrp '${cfg.group}' '${cfg.directory}/certificates/${cfg.domain}.p12'
# Hash the input files so we know when things have changed.
${pkgs.coreutils}/bin/sha256sum ${escapeShellArg certFile} ${escapeShellArg keyFile} > ${escapeShellArg hashFile}
fi
'';
in {
# Obtain the certificate if it doesn't exist.
"lego-letsencrypt-initial" = {
description = "Obtain Let's Encrypt certificate";
serviceConfig = {
Type = "oneshot";
User = "root";
PrivateTmp = true;
};
preStart = ''
mkdir -p '${cfg.directory}'
# Certificates directory should be group-readable
mkdir -p '${cfg.directory}/certificates'
chgrp '${cfg.group}' '${cfg.directory}/certificates'
chmod 0775 '${cfg.directory}/certificates'
'';
script = ''
${prelude}
# Obtain the actual certificate
${pkgs.lego}/bin/lego \
${escapeShellArgs globalOptions} \
run
'';
postStop = postlude;
unitConfig = {
# Only obtain the cert if it doesn't exist.
ConditionPathExists = "!${certFile}";
};
before = [ "lego-letsencrypt.target" ];
wantedBy = [ "lego-letsencrypt.target" ];
};
# Renew the certificate if it exists.
"lego-letsencrypt-renew" = {
description = "Renew Let's Encrypt certificates";
serviceConfig = {
Type = "oneshot";
User = "root";
PrivateTmp = true;
};
script = ''
${prelude}
# Renew the certificate
${pkgs.lego}/bin/lego \
${escapeShellArgs globalOptions} \
renew \
--days ${toString cfg.renewDays}
'';
postStop = postlude;
unitConfig = {
# Only renew the cert if it exists.
ConditionPathExists = "${certFile}";
};
};
};
systemd.timers."lego-letsencrypt-renew" = {
description = "Periodically renew certificates";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "weekly";
Persistent = true; # Start immediately after computer is started
AccuracySec = "10m"; # Timer accuracy; better for power if not too granular
RandomizedDelaySec = "1h"; # Delay during the time period so we don't hammer Let's Encrypt
};
};
# Create a target for other things to depend upon.
systemd.targets."lego-letsencrypt" = {};
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment