Merge pull request #91121 from m1cr0man/master

Restructure acme module
This commit is contained in:
Florian Klink 2020-09-06 18:26:22 +02:00 committed by GitHub
commit d7046947e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 897 additions and 733 deletions

View File

@ -394,6 +394,20 @@ php.override {
</para>
</listitem>
<listitem>
<para>
The ACME module has been overhauled for simplicity and maintainability.
Cert generation now implicitly uses the <literal>acme</literal>
user, and the <literal>security.acme.certs._name_.user</literal> option
has been removed. Instead, certificate access from other services is now
managed through group permissions. The module no longer runs lego
twice under certain conditions, and will correctly renew certificates if
their configuration is changed. Services which reload nginx and httpd after
certificate renewal are now properly configured too so you no longer have
to do this manually if you are using HTTPS enabled virtual hosts. A mechanism
for regenerating certs on demand has also been added and documented.
</para>
</listitem>
<listitem>
<para>
Gollum received a major update to version 5.x and you may have to change

View File

@ -1,11 +1,314 @@
{ config, lib, pkgs, ... }:
{ 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 750 "$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}";
accountDir = "/var/lib/acme/.lego/accounts/" + othersHash;
protocolOpts = if useDns then (
[ "--dns" data.dnsProvider ]
++ optionals (!data.dnsPropagationCheck) [ "--dns.disable-cp" ]
) 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 data.ocspMustStaple [ "--must-staple" ]
++ optionals (acmeServer != null) [ "--server" acmeServer ]
++ concatMap (name: [ "-d" name ]) extraDomains
++ data.extraLegoFlags;
runOpts = escapeShellArgs (
commonOpts
++ [ "run" ]
++ data.extraLegoRunFlags
);
renewOpts = escapeShellArgs (
commonOpts
++ [ "renew" "--reuse-key" ]
++ 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" ] ++ 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
if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' ]; 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 700 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;
@ -41,35 +344,19 @@ let
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";
default = "acme";
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.&lt;name&gt;.group</option>) to read SSL private certificates.
'';
};
postRun = mkOption {
type = types.lines;
default = "";
example = "systemctl reload nginx.service";
example = "cp full.pem backup.pem";
description = ''
Commands to run after new certificates go live. Typically
the web server and other servers using certificates need to
be reloaded.
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.
'';
@ -82,18 +369,17 @@ let
description = "Directory where certificate and other state is stored.";
};
extraDomains = mkOption {
type = types.attrsOf (types.nullOr types.str);
default = {};
extraDomainNames = mkOption {
type = types.listOf types.str;
default = [];
example = literalExample ''
{
"example.org" = null;
"mydomain.org" = null;
}
[
"example.org"
"mydomain.org"
]
'';
description = ''
A list of extra domain names, which are included in the one certificate to be issued.
Setting a distinct server root is deprecated and not functional in 20.03+
'';
};
@ -176,24 +462,8 @@ let
};
};
in
in {
{
###### interface
imports = [
(mkRemovedOptionModule [ "security" "acme" "production" ] ''
Use security.acme.server to define your staging ACME server URL instead.
To use Let's Encrypt's 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)))
];
options = {
security.acme = {
@ -266,7 +536,7 @@ in
"example.com" = {
webroot = "/var/www/challenges/";
email = "foo@example.com";
extraDomains = { "www.example.com" = null; "foo.example.com" = null; };
extraDomainNames = [ "www.example.com" "foo.example.com" ];
};
"bar.example.com" = {
webroot = "/var/www/challenges/";
@ -278,25 +548,40 @@ in
};
};
###### implementation
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 = (mapAttrsToList (k: v: v) cfg.certs);
certs = attrValues cfg.certs;
in [
{
assertion = all (certOpts: certOpts.dnsProvider == null || certOpts.webroot == null) certs;
message = ''
Options `security.acme.certs.<name>.dnsProvider` and
`security.acme.certs.<name>.webroot` are mutually exclusive.
'';
}
{
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.
`security.acme.email` to register with the CA. Note that using
many different addresses for certs may trigger account rate limits.
'';
}
{
@ -307,184 +592,78 @@ in
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));
systemd.services = let
services = concatLists servicesLists;
servicesLists = mapAttrsToList certToServices cfg.certs;
certToServices = cert: data:
let
# StateDirectory must be relative, and will be created under /var/lib by systemd
lpath = "acme/${cert}";
apath = "/var/lib/${lpath}";
spath = "/var/lib/acme/.lego/${cert}";
keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
requestedDomains = pipe ([ data.domain ] ++ (attrNames data.extraDomains)) [
(domains: sort builtins.lessThan domains)
(domains: concatStringsSep "," domains)
];
fileMode = if data.allowKeysForGroup then "640" else "600";
globalOpts = [ "-d" data.domain "--email" data.email "--path" "." "--key-type" data.keyType ]
++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
++ optionals (data.dnsProvider != null && !data.dnsPropagationCheck) [ "--dns.disable-cp" ]
++ concatLists (mapAttrsToList (name: root: [ "-d" name ]) data.extraDomains)
++ (if data.dnsProvider != null then [ "--dns" data.dnsProvider ] else [ "--http" "--http.webroot" data.webroot ])
++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)]
++ data.extraLegoFlags;
certOpts = optionals data.ocspMustStaple [ "--must-staple" ];
runOpts = escapeShellArgs (globalOpts ++ [ "run" ] ++ certOpts ++ data.extraLegoRunFlags);
renewOpts = escapeShellArgs (globalOpts ++
[ "renew" "--days" (toString cfg.validMinDays) ] ++
certOpts ++ data.extraLegoRenewFlags);
acmeService = {
description = "Renew ACME Certificate for ${cert}";
path = with pkgs; [ openssl ];
after = [ "network.target" "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = data.user;
Group = data.group;
PrivateTmp = true;
StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}";
StateDirectoryMode = if data.allowKeysForGroup then "750" else "700";
WorkingDirectory = spath;
# Only try loading the credentialsFile if the dns challenge is enabled
EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null;
ExecStart = pkgs.writeScript "acme-start" ''
#!${pkgs.runtimeShell} -e
test -L ${spath}/accounts -o -d ${spath}/accounts || ln -s ../accounts ${spath}/accounts
LEGO_ARGS=(${runOpts})
if [ -e ${spath}/certificates/${keyName}.crt ]; then
REQUESTED_DOMAINS="${requestedDomains}"
EXISTING_DOMAINS="$(openssl x509 -in ${spath}/certificates/${keyName}.crt -noout -ext subjectAltName | tail -n1 | sed -e 's/ *DNS://g')"
if [ "''${REQUESTED_DOMAINS}" == "''${EXISTING_DOMAINS}" ]; then
LEGO_ARGS=(${renewOpts})
fi
fi
${pkgs.lego}/bin/lego ''${LEGO_ARGS[@]}
'';
ExecStartPost =
let
script = pkgs.writeScript "acme-post-start" ''
#!${pkgs.runtimeShell} -e
cd ${apath}
users.users.acme = {
home = "/var/lib/acme";
group = "acme";
isSystemUser = true;
};
# Test that existing cert is older than new cert
KEY=${spath}/certificates/${keyName}.key
KEY_CHANGED=no
if [ -e $KEY -a $KEY -nt key.pem ]; then
KEY_CHANGED=yes
cp -p ${spath}/certificates/${keyName}.key key.pem
cp -p ${spath}/certificates/${keyName}.crt fullchain.pem
cp -p ${spath}/certificates/${keyName}.issuer.crt chain.pem
ln -sf fullchain.pem cert.pem
cat key.pem fullchain.pem > full.pem
fi
users.groups.acme = {};
chmod ${fileMode} *.pem
chown '${data.user}:${data.group}' *.pem
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)));
if [ "$KEY_CHANGED" = "yes" ]; then
: # noop in case postRun is empty
${data.postRun}
fi
'';
in
"+${script}";
};
systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;
};
selfsignedService = {
description = "Create preliminary self-signed certificate for ${cert}";
path = [ pkgs.openssl ];
script =
''
workdir="$(mktemp -d)"
# .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 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 ${apath}/key.pem
# Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
cat $workdir/{server.crt,ca.crt} > "${apath}/fullchain.pem"
# Create full.pem for e.g. lighttpd
cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem"
# Give key acme permissions
chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem
chmod ${fileMode} "${apath}/"{key,fullchain,full}.pem
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
StateDirectory = lpath;
User = data.user;
Group = data.group;
};
unitConfig = {
# Do not create self-signed key when key already exists
ConditionPathExists = "!${apath}/key.pem";
};
};
in (
[ { name = "acme-${cert}"; value = acmeService; } ]
++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; }
);
servicesAttr = listToAttrs services;
in
servicesAttr;
systemd.tmpfiles.rules =
map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs));
systemd.timers = let
# 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.
numCerts = length (attrNames cfg.certs);
_24hSecs = 60 * 60 * 24;
AccuracySec = "${toString (_24hSecs / numCerts)}s";
in 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";
inherit AccuracySec;
# Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
RandomizedDelaySec = "24h";
};
})
);
systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {};
systemd.targets.acme-certificates = {};
# 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 = {

View File

@ -72,7 +72,7 @@ services.nginx = {
"foo.example.com" = {
<link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
<link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true;
# All serverAliases will be added as <link linkend="opt-security.acme.certs._name_.extraDomains">extra domains</link> on the certificate.
# All serverAliases will be added as <link linkend="opt-security.acme.certs._name_.extraDomainNames">extra domain names</link> on the certificate.
<link linkend="opt-services.nginx.virtualHosts._name_.serverAliases">serverAliases</link> = [ "bar.example.com" ];
locations."/" = {
<link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.root">root</link> = "/var/www";
@ -80,8 +80,8 @@ services.nginx = {
};
# We can also add a different vhost and reuse the same certificate
# but we have to append extraDomains manually.
<link linkend="opt-security.acme.certs._name_.extraDomains">security.acme.certs."foo.example.com".extraDomains."baz.example.com"</link> = null;
# but we have to append extraDomainNames manually.
<link linkend="opt-security.acme.certs._name_.extraDomainNames">security.acme.certs."foo.example.com".extraDomainNames</link> = [ "baz.example.com" ];
"baz.example.com" = {
<link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
<link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">useACMEHost</link> = "foo.example.com";
@ -165,7 +165,7 @@ services.httpd = {
# Since we have a wildcard vhost to handle port 80,
# we can generate certs for anything!
# Just make sure your DNS resolves them.
<link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains</link> = [ "mail.example.com" ];
<link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> = [ "mail.example.com" ];
};
</programlisting>
@ -251,4 +251,16 @@ chmod 400 /var/lib/secrets/certs.secret
journalctl -fu acme-example.com.service</literal> and watching its log output.
</para>
</section>
<section xml:id="module-security-acme-regenerate">
<title>Regenerating certificates</title>
<para>
Should you need to regenerate a particular certificate in a hurry, such
as when a vulnerability is found in Let's Encrypt, there is now a convenient
mechanism for doing so. Running <literal>systemctl clean acme-example.com.service</literal>
will remove all certificate files for the given domain, allowing you to then
<literal>systemctl start acme-example.com.service</literal> to generate fresh
ones.
</para>
</section>
</chapter>

View File

@ -65,7 +65,7 @@ services.prosody = {
you'll need a single TLS certificate covering your main endpoint,
the MUC one as well as the HTTP Upload one. We can generate such a
certificate by leveraging the ACME
<link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains</link> module option.
<link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> module option.
</para>
<para>
Provided the setup detailed in the previous section, you'll need the following acme configuration to generate
@ -78,8 +78,7 @@ security.acme = {
"example.org" = {
<link linkend="opt-security.acme.certs._name_.webroot">webroot</link> = "/var/www/example.org";
<link linkend="opt-security.acme.certs._name_.email">email</link> = "root@example.org";
<link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains."conference.example.org"</link> = null;
<link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains."upload.example.org"</link> = null;
<link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> = [ "conference.example.org" "upload.example.org" ];
};
};
};</programlisting>

View File

@ -6,6 +6,8 @@ let
cfg = config.services.httpd;
certs = config.security.acme.certs;
runtimeDir = "/run/httpd";
pkg = cfg.package.out;
@ -26,6 +28,13 @@ let
vhosts = attrValues cfg.virtualHosts;
# certName is used later on to determine systemd service names.
acmeEnabledVhosts = map (hostOpts: hostOpts // {
certName = if hostOpts.useACMEHost != null then hostOpts.useACMEHost else hostOpts.hostName;
}) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts);
dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
mkListenInfo = hostOpts:
if hostOpts.listen != [] then hostOpts.listen
else (
@ -125,13 +134,13 @@ let
useACME = hostOpts.enableACME || hostOpts.useACMEHost != null;
sslCertDir =
if hostOpts.enableACME then config.security.acme.certs.${hostOpts.hostName}.directory
else if hostOpts.useACMEHost != null then config.security.acme.certs.${hostOpts.useACMEHost}.directory
if hostOpts.enableACME then certs.${hostOpts.hostName}.directory
else if hostOpts.useACMEHost != null then certs.${hostOpts.useACMEHost}.directory
else abort "This case should never happen.";
sslServerCert = if useACME then "${sslCertDir}/full.pem" else hostOpts.sslServerCert;
sslServerCert = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerCert;
sslServerKey = if useACME then "${sslCertDir}/key.pem" else hostOpts.sslServerKey;
sslServerChain = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerChain;
sslServerChain = if useACME then "${sslCertDir}/chain.pem" else hostOpts.sslServerChain;
acmeChallenge = optionalString useACME ''
Alias /.well-known/acme-challenge/ "${hostOpts.acmeRoot}/.well-known/acme-challenge/"
@ -347,7 +356,6 @@ let
cat ${php.phpIni} > $out
echo "$options" >> $out
'';
in
@ -647,14 +655,17 @@ in
wwwrun.gid = config.ids.gids.wwwrun;
};
security.acme.certs = mapAttrs (name: hostOpts: {
user = cfg.user;
group = mkDefault cfg.group;
email = if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr;
webroot = hostOpts.acmeRoot;
extraDomains = genAttrs hostOpts.serverAliases (alias: null);
postRun = "systemctl reload httpd.service";
}) (filterAttrs (name: hostOpts: hostOpts.enableACME) cfg.virtualHosts);
security.acme.certs = let
acmePairs = map (hostOpts: nameValuePair hostOpts.hostName {
group = mkDefault cfg.group;
webroot = hostOpts.acmeRoot;
extraDomainNames = hostOpts.serverAliases;
# Use the vhost-specific email address if provided, otherwise let
# security.acme.email or security.acme.certs.<cert>.email be used.
email = mkOverride 2000 (if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr);
# Filter for enableACME-only vhosts. Don't want to create dud certs
}) (filter (hostOpts: hostOpts.useACMEHost == null) acmeEnabledVhosts);
in listToAttrs acmePairs;
environment.systemPackages = [
apachectl
@ -724,16 +735,12 @@ in
"Z '${cfg.logDir}' - ${svc.User} ${svc.Group}"
];
systemd.services.httpd =
let
vhostsACME = filter (hostOpts: hostOpts.enableACME) vhosts;
in
{ description = "Apache HTTPD";
systemd.services.httpd = {
description = "Apache HTTPD";
wantedBy = [ "multi-user.target" ];
wants = concatLists (map (hostOpts: [ "acme-${hostOpts.hostName}.service" "acme-selfsigned-${hostOpts.hostName}.service" ]) vhostsACME);
after = [ "network.target" "fs.target" ] ++ map (hostOpts: "acme-selfsigned-${hostOpts.hostName}.service") vhostsACME;
before = map (hostOpts: "acme-${hostOpts.hostName}.service") vhostsACME;
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames);
after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames;
before = map (certName: "acme-${certName}.service") dependentCertNames;
path = [ pkg pkgs.coreutils pkgs.gnugrep ];
@ -767,5 +774,31 @@ in
};
};
# postRun hooks on cert renew can't be used to restart Apache since renewal
# runs as the unprivileged acme user. sslTargets are added to wantedBy + before
# which allows the acme-finished-$cert.target to signify the successful updating
# of certs end-to-end.
systemd.services.httpd-config-reload = let
sslServices = map (certName: "acme-${certName}.service") dependentCertNames;
sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames;
in mkIf (sslServices != []) {
wantedBy = sslServices ++ [ "multi-user.target" ];
# Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded.
before = sslTargets;
after = sslServices;
# Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs.
unitConfig.ConditionPathExists = map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames;
serviceConfig = {
Type = "oneshot";
TimeoutSec = 60;
ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active httpd.service";
ExecStartPre = "${pkg}/bin/httpd -f ${httpdConf} -t";
ExecStart = "/run/current-system/systemd/bin/systemctl reload httpd.service";
};
};
};
}

View File

@ -6,23 +6,23 @@ let
cfg = config.services.nginx;
certs = config.security.acme.certs;
vhostsConfigs = mapAttrsToList (vhostName: vhostConfig: vhostConfig) virtualHosts;
acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME && vhostConfig.useACMEHost == null) vhostsConfigs;
acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null) vhostsConfigs;
dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
virtualHosts = mapAttrs (vhostName: vhostConfig:
let
serverName = if vhostConfig.serverName != null
then vhostConfig.serverName
else vhostName;
certName = if vhostConfig.useACMEHost != null
then vhostConfig.useACMEHost
else serverName;
in
vhostConfig // {
inherit serverName;
} // (optionalAttrs vhostConfig.enableACME {
sslCertificate = "${certs.${serverName}.directory}/fullchain.pem";
sslCertificateKey = "${certs.${serverName}.directory}/key.pem";
sslTrustedCertificate = "${certs.${serverName}.directory}/full.pem";
}) // (optionalAttrs (vhostConfig.useACMEHost != null) {
sslCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem";
sslCertificateKey = "${certs.${vhostConfig.useACMEHost}.directory}/key.pem";
sslTrustedCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem";
inherit serverName certName;
} // (optionalAttrs (vhostConfig.enableACME || vhostConfig.useACMEHost != null) {
sslCertificate = "${certs.${certName}.directory}/fullchain.pem";
sslCertificateKey = "${certs.${certName}.directory}/key.pem";
sslTrustedCertificate = "${certs.${certName}.directory}/chain.pem";
})
) cfg.virtualHosts;
enableIPv6 = config.networking.enableIPv6;
@ -691,12 +691,12 @@ in
systemd.services.nginx = {
description = "Nginx Web Server";
wantedBy = [ "multi-user.target" ];
wants = concatLists (map (vhostConfig: ["acme-${vhostConfig.serverName}.service" "acme-selfsigned-${vhostConfig.serverName}.service"]) acmeEnabledVhosts);
after = [ "network.target" ] ++ map (vhostConfig: "acme-selfsigned-${vhostConfig.serverName}.service") acmeEnabledVhosts;
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames);
after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames;
# Nginx needs to be started in order to be able to request certificates
# (it's hosting the acme challenge after all)
# This fixes https://github.com/NixOS/nixpkgs/issues/81842
before = map (vhostConfig: "acme-${vhostConfig.serverName}.service") acmeEnabledVhosts;
before = map (certName: "acme-${certName}.service") dependentCertNames;
stopIfChanged = false;
preStart = ''
${cfg.preStart}
@ -753,37 +753,41 @@ in
source = configFile;
};
systemd.services.nginx-config-reload = mkIf cfg.enableReload {
wants = [ "nginx.service" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ configFile ];
# commented, because can cause extra delays during activate for this config:
# services.nginx.virtualHosts."_".locations."/".proxyPass = "http://blabla:3000";
# stopIfChanged = false;
serviceConfig.Type = "oneshot";
serviceConfig.TimeoutSec = 60;
script = ''
if /run/current-system/systemd/bin/systemctl -q is-active nginx.service ; then
/run/current-system/systemd/bin/systemctl reload nginx.service
fi
'';
serviceConfig.RemainAfterExit = true;
# postRun hooks on cert renew can't be used to restart Nginx since renewal
# runs as the unprivileged acme user. sslTargets are added to wantedBy + before
# which allows the acme-finished-$cert.target to signify the successful updating
# of certs end-to-end.
systemd.services.nginx-config-reload = let
sslServices = map (certName: "acme-${certName}.service") dependentCertNames;
sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames;
in mkIf (cfg.enableReload || sslServices != []) {
wants = optionals (cfg.enableReload) [ "nginx.service" ];
wantedBy = sslServices ++ [ "multi-user.target" ];
# Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded.
before = sslTargets;
after = sslServices;
restartTriggers = optionals (cfg.enableReload) [ configFile ];
# Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs.
unitConfig.ConditionPathExists = optionals (sslServices != []) (map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames);
serviceConfig = {
Type = "oneshot";
TimeoutSec = 60;
ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active nginx.service";
ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service";
};
};
security.acme.certs = filterAttrs (n: v: v != {}) (
let
acmePairs = map (vhostConfig: { name = vhostConfig.serverName; value = {
user = cfg.user;
group = lib.mkDefault cfg.group;
webroot = vhostConfig.acmeRoot;
extraDomains = genAttrs vhostConfig.serverAliases (alias: null);
postRun = ''
/run/current-system/systemd/bin/systemctl reload nginx
'';
}; }) acmeEnabledVhosts;
in
listToAttrs acmePairs
);
security.acme.certs = let
acmePairs = map (vhostConfig: nameValuePair vhostConfig.serverName {
group = mkDefault cfg.group;
webroot = vhostConfig.acmeRoot;
extraDomainNames = vhostConfig.serverAliases;
# Filter for enableACME-only vhosts. Don't want to create dud certs
}) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts);
in listToAttrs acmePairs;
users.users = optionalAttrs (cfg.user == "nginx") {
nginx = {

View File

@ -1,29 +1,43 @@
let
commonConfig = ./common/acme/client;
dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" ''
#!${bash}/bin/bash
dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress;
dnsScript = {pkgs, nodes}: let
dnsAddress = dnsServerIP nodes;
in pkgs.writeShellScript "dns-hook.sh" ''
set -euo pipefail
echo '[INFO]' "[$2]" 'dns-hook.sh' $*
if [ "$1" = "present" ]; then
${curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
${pkgs.curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
else
${curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
fi
'';
documentRoot = pkgs: pkgs.runCommand "docroot" {} ''
mkdir -p "$out"
echo hello world > "$out/index.html"
'';
vhostBase = pkgs: {
forceSSL = true;
locations."/".root = documentRoot pkgs;
};
in import ./make-test-python.nix ({ lib, ... }: {
name = "acme";
meta.maintainers = lib.teams.acme.members;
nodes = rec {
nodes = {
# The fake ACME server which will respond to client requests
acme = { nodes, lib, ... }: {
imports = [ ./common/acme/server ];
networking.nameservers = lib.mkForce [
nodes.dnsserver.config.networking.primaryIPAddress
];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
};
# A fake DNS server which can be configured with records as desired
# Used to test DNS-01 challenge
dnsserver = { nodes, pkgs, ... }: {
networking.firewall.allowedTCPPorts = [ 8055 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
@ -39,112 +53,97 @@ in import ./make-test-python.nix ({ lib, ... }: {
};
};
acmeStandalone = { nodes, lib, config, pkgs, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [
nodes.dnsserver.config.networking.primaryIPAddress
];
networking.firewall.allowedTCPPorts = [ 80 ];
security.acme.certs."standalone.test" = {
webroot = "/var/lib/acme/acme-challenges";
};
systemd.targets."acme-finished-standalone.test" = {
after = [ "acme-standalone.test.service" ];
wantedBy = [ "acme-standalone.test.service" ];
};
services.nginx.enable = true;
services.nginx.virtualHosts."standalone.test" = {
locations."/.well-known/acme-challenge".root = "/var/lib/acme/acme-challenges";
};
};
webserver = { nodes, config, pkgs, lib, ... }: {
# A web server which will be the node requesting certs
webserver = { pkgs, nodes, lib, config, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
networking.firewall.allowedTCPPorts = [ 80 443 ];
networking.nameservers = lib.mkForce [
nodes.dnsserver.config.networking.primaryIPAddress
];
# A target remains active. Use this to probe the fact that
# a service fired eventhough it is not RemainAfterExit
systemd.targets."acme-finished-a.example.test" = {
after = [ "acme-a.example.test.service" ];
wantedBy = [ "acme-a.example.test.service" ];
# OpenSSL will be used for more thorough certificate validation
environment.systemPackages = [ pkgs.openssl ];
# Set log level to info so that we can see when the service is reloaded
services.nginx.enable = true;
services.nginx.logError = "stderr info";
# First tests configure a basic cert and run a bunch of openssl checks
services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
enableACME = true;
};
services.nginx.enable = true;
# Used to determine if service reload was triggered
systemd.targets.test-renew-nginx = {
wants = [ "acme-a.example.test.service" ];
after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
};
services.nginx.virtualHosts."a.example.test" = {
enableACME = true;
forceSSL = true;
locations."/".root = pkgs.runCommand "docroot" {} ''
mkdir -p "$out"
echo hello world > "$out/index.html"
# Cert config changes will not cause the nginx configuration to change.
# This tests that the reload service is correctly triggered.
# It also tests that postRun is exec'd as root
specialisation.cert-change.configuration = { pkgs, ... }: {
security.acme.certs."a.example.test".keyType = "ec384";
security.acme.certs."a.example.test".postRun = ''
set -euo pipefail
touch test
chown root:root test
echo testing > test
'';
};
security.acme.server = "https://acme.test/dir";
specialisation.second-cert.configuration = {pkgs, ...}: {
systemd.targets."acme-finished-b.example.test" = {
after = [ "acme-b.example.test.service" ];
wantedBy = [ "acme-b.example.test.service" ];
};
services.nginx.virtualHosts."b.example.test" = {
enableACME = true;
forceSSL = true;
locations."/".root = pkgs.runCommand "docroot" {} ''
mkdir -p "$out"
echo hello world > "$out/index.html"
'';
# Now adding an alias to ensure that the certs are updated
specialisation.nginx-aliases.configuration = { pkgs, ... }: {
services.nginx.virtualHosts."a.example.test" = {
serverAliases = [ "b.example.test" ];
};
};
specialisation.dns-01.configuration = {pkgs, config, nodes, lib, ...}: {
# Test using Apache HTTPD
specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: {
services.nginx.enable = lib.mkForce false;
services.httpd.enable = true;
services.httpd.adminAddr = config.security.acme.email;
services.httpd.virtualHosts."c.example.test" = {
serverAliases = [ "d.example.test" ];
forceSSL = true;
enableACME = true;
documentRoot = documentRoot pkgs;
};
# Used to determine if service reload was triggered
systemd.targets.test-renew-httpd = {
wants = [ "acme-c.example.test.service" ];
after = [ "acme-c.example.test.service" "httpd-config-reload.service" ];
};
};
# Validation via DNS-01 challenge
specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: {
security.acme.certs."example.test" = {
domain = "*.example.test";
group = config.services.nginx.group;
dnsProvider = "exec";
dnsPropagationCheck = false;
credentialsFile = with pkgs; writeText "wildcard.env" ''
EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }}
credentialsFile = pkgs.writeText "wildcard.env" ''
EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
'';
user = config.services.nginx.user;
group = config.services.nginx.group;
};
systemd.targets."acme-finished-example.test" = {
after = [ "acme-example.test.service" ];
wantedBy = [ "acme-example.test.service" ];
};
systemd.services."acme-example.test" = {
before = [ "nginx.service" ];
wantedBy = [ "nginx.service" ];
};
services.nginx.virtualHosts."c.example.test" = {
forceSSL = true;
sslCertificate = config.security.acme.certs."example.test".directory + "/cert.pem";
sslTrustedCertificate = config.security.acme.certs."example.test".directory + "/full.pem";
sslCertificateKey = config.security.acme.certs."example.test".directory + "/key.pem";
locations."/".root = pkgs.runCommand "docroot" {} ''
mkdir -p "$out"
echo hello world > "$out/index.html"
'';
services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
useACMEHost = "example.test";
};
};
# When nginx depends on a service that is slow to start up, requesting used to fail
# certificates fail. Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ...}: {
# Validate service relationships by adding a slow start service to nginx' wants.
# Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: {
systemd.services.my-slow-service = {
wantedBy = [ "multi-user.target" "nginx.service" ];
before = [ "nginx.service" ];
preStart = "sleep 5";
script = "${pkgs.python3}/bin/python -m http.server";
};
systemd.targets."acme-finished-d.example.com" = {
after = [ "acme-d.example.com.service" ];
wantedBy = [ "acme-d.example.com.service" ];
};
services.nginx.virtualHosts."d.example.com" = {
services.nginx.virtualHosts."slow.example.com" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:8000";
@ -152,11 +151,13 @@ in import ./make-test-python.nix ({ lib, ... }: {
};
};
client = {nodes, lib, ...}: {
# The client will be used to curl the webserver to validate configuration
client = {nodes, lib, pkgs, ...}: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [
nodes.dnsserver.config.networking.primaryIPAddress
];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
# OpenSSL will be used for more thorough certificate validation
environment.systemPackages = [ pkgs.openssl ];
};
};
@ -167,73 +168,168 @@ in import ./make-test-python.nix ({ lib, ... }: {
in
# Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
# this is because a oneshot goes from inactive => activating => inactive, and never
# reaches the active state. To work around this, we create some mock target units which
# get pulled in by the oneshot units. The target units linger after activation, and hence we
# can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do
# reaches the active state. Targets do not have this issue.
''
import time
has_switched = False
def switch_to(node, name):
global has_switched
if has_switched:
node.succeed(
"${switchToNewServer}"
)
has_switched = True
node.succeed(
f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test"
)
# Ensures the issuer of our cert matches the chain
# and matches the issuer we expect it to be.
# It's a good validation to ensure the cert.pem and fullchain.pem
# are not still selfsigned afer verification
def check_issuer(node, cert_name, issuer):
for fname in ("cert.pem", "fullchain.pem"):
actual_issuer = node.succeed(
f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}"
).partition("=")[2]
print(f"{fname} issuer: {actual_issuer}")
assert issuer.lower() in actual_issuer.lower()
# Ensure cert comes before chain in fullchain.pem
def check_fullchain(node, cert_name):
subject_data = node.succeed(
f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem"
" | openssl pkcs7 -print_certs -noout"
)
for line in subject_data.lower().split("\n"):
if "subject" in line:
print(f"First subject in fullchain.pem: ", line)
assert cert_name.lower() in line
return
assert False
def check_connection(node, domain, retries=3):
assert retries >= 0
result = node.succeed(
"openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt"
f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1"
)
for line in result.lower().split("\n"):
if "verification" in line and "error" in line:
time.sleep(1)
return check_connection(node, domain, retries - 1)
def check_connection_key_bits(node, domain, bits, retries=3):
assert retries >= 0
result = node.succeed(
"openssl s_client -CAfile /tmp/ca.crt"
f" -servername {domain} -connect {domain}:443 < /dev/null"
" | openssl x509 -noout -text | grep -i Public-Key"
)
print("Key type:", result)
if bits not in result:
time.sleep(1)
return check_connection_key_bits(node, domain, bits, retries - 1)
client.start()
dnsserver.start()
acme.wait_for_unit("default.target")
dnsserver.wait_for_unit("pebble-challtestsrv.service")
client.wait_for_unit("default.target")
client.succeed(
'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
)
client.succeed(
'curl --data \'{"host": "standalone.test", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
)
acme.start()
acmeStandalone.start()
webserver.start()
acme.wait_for_unit("default.target")
acme.wait_for_unit("pebble.service")
with subtest("can request certificate with HTTPS-01 challenge"):
acmeStandalone.wait_for_unit("default.target")
acmeStandalone.succeed("systemctl start acme-standalone.test.service")
acmeStandalone.wait_for_unit("acme-finished-standalone.test.target")
client.wait_for_unit("default.target")
client.succeed("curl https://acme.test:15000/roots/0 > /tmp/ca.crt")
client.succeed("curl https://acme.test:15000/intermediate-keys/0 >> /tmp/ca.crt")
with subtest("Can request certificate for nginx service"):
with subtest("Can request certificate with HTTPS-01 challenge"):
webserver.wait_for_unit("acme-finished-a.example.test.target")
client.succeed(
"curl --cacert /tmp/ca.crt https://a.example.test/ | grep -qF 'hello world'"
)
check_fullchain(webserver, "a.example.test")
check_issuer(webserver, "a.example.test", "pebble")
check_connection(client, "a.example.test")
with subtest("Can add another certificate for nginx service"):
webserver.succeed(
"/run/current-system/specialisation/second-cert/bin/switch-to-configuration test"
)
webserver.wait_for_unit("acme-finished-b.example.test.target")
client.succeed(
"curl --cacert /tmp/ca.crt https://b.example.test/ | grep -qF 'hello world'"
)
with subtest("Can generate valid selfsigned certs"):
webserver.succeed("systemctl clean acme-a.example.test.service --what=state")
webserver.succeed("systemctl start acme-selfsigned-a.example.test.service")
check_fullchain(webserver, "a.example.test")
check_issuer(webserver, "a.example.test", "minica")
# Will succeed if nginx can load the certs
webserver.succeed("systemctl start nginx-config-reload.service")
with subtest("Can reload nginx when timer triggers renewal"):
webserver.succeed("systemctl start test-renew-nginx.target")
check_issuer(webserver, "a.example.test", "pebble")
check_connection(client, "a.example.test")
with subtest("Can reload web server when cert configuration changes"):
switch_to(webserver, "cert-change")
webserver.wait_for_unit("acme-finished-a.example.test.target")
check_connection_key_bits(client, "a.example.test", "384")
webserver.succeed("grep testing /var/lib/acme/a.example.test/test")
with subtest("Can request certificate with HTTPS-01 when nginx startup is delayed"):
switch_to(webserver, "slow-startup")
webserver.wait_for_unit("acme-finished-slow.example.com.target")
check_issuer(webserver, "slow.example.com", "pebble")
check_connection(client, "slow.example.com")
with subtest("Can request certificate for vhost + aliases (nginx)"):
# Check the key hash before and after adding an alias. It should not change.
# The previous test reverts the ed384 change
webserver.wait_for_unit("acme-finished-a.example.test.target")
keyhash_old = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem")
switch_to(webserver, "nginx-aliases")
webserver.wait_for_unit("acme-finished-a.example.test.target")
check_issuer(webserver, "a.example.test", "pebble")
check_connection(client, "a.example.test")
check_connection(client, "b.example.test")
keyhash_new = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem")
assert keyhash_old == keyhash_new
with subtest("Can request certificates for vhost + aliases (apache-httpd)"):
switch_to(webserver, "httpd-aliases")
webserver.wait_for_unit("acme-finished-c.example.test.target")
check_issuer(webserver, "c.example.test", "pebble")
check_connection(client, "c.example.test")
check_connection(client, "d.example.test")
with subtest("Can reload httpd when timer triggers renewal"):
# Switch to selfsigned first
webserver.succeed("systemctl clean acme-c.example.test.service --what=state")
webserver.succeed("systemctl start acme-selfsigned-c.example.test.service")
check_issuer(webserver, "c.example.test", "minica")
webserver.succeed("systemctl start httpd-config-reload.service")
webserver.succeed("systemctl start test-renew-httpd.target")
check_issuer(webserver, "c.example.test", "pebble")
check_connection(client, "c.example.test")
with subtest("Can request wildcard certificates using DNS-01 challenge"):
webserver.succeed(
"${switchToNewServer}"
)
webserver.succeed(
"/run/current-system/specialisation/dns-01/bin/switch-to-configuration test"
)
switch_to(webserver, "dns-01")
webserver.wait_for_unit("acme-finished-example.test.target")
client.succeed(
"curl --cacert /tmp/ca.crt https://c.example.test/ | grep -qF 'hello world'"
)
with subtest("Can request certificate of nginx when startup is delayed"):
webserver.succeed(
"${switchToNewServer}"
)
webserver.succeed(
"/run/current-system/specialisation/slow-startup/bin/switch-to-configuration test"
)
webserver.wait_for_unit("acme-finished-d.example.com.target")
client.succeed("curl --cacert /tmp/ca.crt https://d.example.com/")
check_issuer(webserver, "example.test", "pebble")
check_connection(client, "dns.example.test")
'';
})

View File

@ -1,15 +1,14 @@
{ lib, nodes, pkgs, ... }:
let
acme-ca = nodes.acme.config.test-support.acme.caCert;
in
caCert = nodes.acme.config.test-support.acme.caCert;
caDomain = nodes.acme.config.test-support.acme.caDomain;
{
in {
security.acme = {
server = "https://acme.test/dir";
server = "https://${caDomain}/dir";
email = "hostmaster@example.test";
acceptTerms = true;
};
security.pki.certificateFiles = [ acme-ca ];
security.pki.certificateFiles = [ caCert ];
}

View File

@ -3,7 +3,7 @@
# config.test-support.acme.caCert
#
# This value can be used inside the configuration of other test nodes to inject
# the snakeoil certificate into security.pki.certificateFiles or into package
# the test certificate into security.pki.certificateFiles or into package
# overlays.
#
# Another value that's needed if you don't use a custom resolver (see below for
@ -50,19 +50,13 @@
# Also make sure that whenever you use a resolver from a different test node
# that it has to be started _before_ the ACME service.
{ config, pkgs, lib, ... }:
let
snakeOilCerts = import ./snakeoil-certs.nix;
testCerts = import ./snakeoil-certs.nix {
minica = pkgs.minica;
mkDerivation = pkgs.stdenv.mkDerivation;
};
domain = testCerts.domain;
wfeDomain = "acme.test";
wfeCertFile = snakeOilCerts.${wfeDomain}.cert;
wfeKeyFile = snakeOilCerts.${wfeDomain}.key;
siteDomain = "acme.test";
siteCertFile = snakeOilCerts.${siteDomain}.cert;
siteKeyFile = snakeOilCerts.${siteDomain}.key;
pebble = pkgs.pebble;
resolver = let
message = "You need to define a resolver for the acme test module.";
firstNS = lib.head config.networking.nameservers;
@ -71,8 +65,9 @@ let
pebbleConf.pebble = {
listenAddress = "0.0.0.0:443";
managementListenAddress = "0.0.0.0:15000";
certificate = snakeOilCerts.${wfeDomain}.cert;
privateKey = snakeOilCerts.${wfeDomain}.key;
# These certs and keys are used for the Web Front End (WFE)
certificate = testCerts.${domain}.cert;
privateKey = testCerts.${domain}.key;
httpPort = 80;
tlsPort = 443;
ocspResponderURL = "http://0.0.0.0:4002";
@ -80,18 +75,30 @@ let
};
pebbleConfFile = pkgs.writeText "pebble.conf" (builtins.toJSON pebbleConf);
pebbleDataDir = "/root/pebble";
in {
imports = [ ../../resolver.nix ];
options.test-support.acme.caCert = lib.mkOption {
type = lib.types.path;
description = ''
A certificate file to use with the <literal>nodes</literal> attribute to
inject the snakeoil CA certificate used in the ACME server into
<option>security.pki.certificateFiles</option>.
'';
options.test-support.acme = with lib; {
caDomain = mkOption {
type = types.str;
readOnly = true;
default = domain;
description = ''
A domain name to use with the <literal>nodes</literal> attribute to
identify the CA server.
'';
};
caCert = mkOption {
type = types.path;
readOnly = true;
default = testCerts.ca.cert;
description = ''
A certificate file to use with the <literal>nodes</literal> attribute to
inject the test CA certificate used in the ACME server into
<option>security.pki.certificateFiles</option>.
'';
};
};
config = {
@ -99,35 +106,32 @@ in {
resolver.enable = let
isLocalResolver = config.networking.nameservers == [ "127.0.0.1" ];
in lib.mkOverride 900 isLocalResolver;
acme.caCert = snakeOilCerts.ca.cert;
};
# This has priority 140, because modules/testing/test-instrumentation.nix
# already overrides this with priority 150.
networking.nameservers = lib.mkOverride 140 [ "127.0.0.1" ];
networking.firewall.enable = false;
networking.firewall.allowedTCPPorts = [ 80 443 15000 4002 ];
networking.extraHosts = ''
127.0.0.1 ${wfeDomain}
${config.networking.primaryIPAddress} ${wfeDomain} ${siteDomain}
127.0.0.1 ${domain}
${config.networking.primaryIPAddress} ${domain}
'';
systemd.services = {
pebble = {
enable = true;
description = "Pebble ACME server";
requires = [ ];
wantedBy = [ "network.target" ];
preStart = ''
mkdir ${pebbleDataDir}
'';
script = ''
cd ${pebbleDataDir}
${pebble}/bin/pebble -config ${pebbleConfFile}
'';
serviceConfig = {
RuntimeDirectory = "pebble";
WorkingDirectory = "/run/pebble";
# Required to bind on privileged ports.
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
ExecStart = "${pkgs.pebble}/bin/pebble -config ${pebbleConfFile}";
};
};
};

View File

@ -1,69 +0,0 @@
{ pkgs ? import <nixpkgs> {}
, lib ? pkgs.lib
, domains ? [ "acme.test" ]
}:
pkgs.runCommand "acme-snakeoil-ca" {
nativeBuildInputs = [ pkgs.openssl ];
} ''
addpem() {
local file="$1"; shift
local storeFileName="$(IFS=.; echo "$*")"
echo -n " " >> "$out"
# Every following argument is an attribute, so let's recurse and check
# every attribute whether it must be quoted and write it into $out.
while [ -n "$1" ]; do
if expr match "$1" '^[a-zA-Z][a-zA-Z0-9]*$' > /dev/null; then
echo -n "$1" >> "$out"
else
echo -n '"' >> "$out"
echo -n "$1" | sed -e 's/["$]/\\&/g' >> "$out"
echo -n '"' >> "$out"
fi
shift
[ -z "$1" ] || echo -n . >> "$out"
done
echo " = builtins.toFile \"$storeFileName\" '''" >> "$out"
sed -e 's/^/ /' "$file" >> "$out"
echo " ''';" >> "$out"
}
echo '# Generated via mkcert.sh in the same directory.' > "$out"
echo '{' >> "$out"
openssl req -newkey rsa:4096 -x509 -sha256 -days 36500 \
-subj '/CN=Snakeoil CA' -nodes -out ca.pem -keyout ca.key
addpem ca.key ca key
addpem ca.pem ca cert
${lib.concatMapStrings (fqdn: let
opensslConfig = pkgs.writeText "snakeoil.cnf" ''
[req]
default_bits = 4096
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${fqdn}
[req_ext]
subjectAltName = DNS:${fqdn}
'';
in ''
export OPENSSL_CONF=${lib.escapeShellArg opensslConfig}
openssl genrsa -out snakeoil.key 4096
openssl req -new -key snakeoil.key -out snakeoil.csr
openssl x509 -req -in snakeoil.csr -sha256 -set_serial 666 \
-CA ca.pem -CAkey ca.key -out snakeoil.pem -days 36500 \
-extfile "$OPENSSL_CONF" -extensions req_ext
addpem snakeoil.key ${lib.escapeShellArg fqdn} key
addpem snakeoil.pem ${lib.escapeShellArg fqdn} cert
'') domains}
echo '}' >> "$out"
''

View File

@ -1,6 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -p nix bash -i bash
set -e
cd "$(dirname "$0")"
storepath="$(nix-build --no-out-link mkcerts.nix)"
cat "$storepath" > snakeoil-certs.nix

View File

@ -1,172 +1,37 @@
# Generated via mkcert.sh in the same directory.
{
ca.key = builtins.toFile "ca.key" ''
-----BEGIN PRIVATE KEY-----
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
s9o9Sr49cibZ3CbMjCSNE3AOeVE1/Q==
-----END PRIVATE KEY-----
'';
ca.cert = builtins.toFile "ca.cert" ''
-----BEGIN CERTIFICATE-----
MIIFDzCCAvegAwIBAgIUX0P6NfX4gRUpFz+TNV/f26GHokgwDQYJKoZIhvcNAQEL
BQAwFjEUMBIGA1UEAwwLU25ha2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEyWhgPMjEy
MDA3MzEwNzQyMTJaMBYxFDASBgNVBAMMC1NuYWtlb2lsIENBMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEA3dRuzhaVyKB5668XcPoYiVQ2Op0AwyhbfRQS
sJb+8doQr1+258rx2i+x+tXPvNDHBjyEq0J9/jI7kWbqsGsePQ3XTV2xreFqhpce
CwHiN+zoDVFM/X6Da9qww1e+OL8KdePzw9X956qVnzoM87IfK8WnNJmeUnvR8TiK
SOzUTbhyY4+x1meGMXjr4tvPa5qbtCT3KduZ48O4ehTXx7vBhqd+FI+sAMbm3Ucn
1YlfM/RTH24JS7gPYtTokstzUKavFcARvrv9Iguz0LEI8Uq2oELtubHwE2CKxSUO
GQr4dJ8aijHsjzwYunQHDKWtgGQKrar/gsFnaN+gbUuV4gu6wKYGJ/srXikB0m5w
ohtlv26Em34PZUZN8ONXREhDQgA1Woy29xKL76dLQTvwQC7nb1o8wD26V5XlTa+p
4FrRpbGUwuy1w4AanVtipc6WRnM/j1twE5cIqf2RUbewNebuXVZFj1aD7kNMNU8P
oPXcVhUzZN4ftneyvOyMUG3L6sXiA0hFRTK8aNHuAhtrnTHHpWcAEZeojUwBE2ts
yVtF7QHRlTPh2NUv5FOlPSd57mw7bQJADAAYz8Moyxu4X8lqlB3/cj7OPRrH7QFb
yMvFyHrtLIQ9QRtdf1c78KVAokXPm7blotnq2eGXz6EOWKEvYLeEviKc+0zxgZfg
FJPRztkCAwEAAaNTMFEwHQYDVR0OBBYEFNhBZxryvykCjfPO85xB3wof2enAMB8G
A1UdIwQYMBaAFNhBZxryvykCjfPO85xB3wof2enAMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggIBAEZwlsQ+3yd1MVxLRy9RjoA8hI7iWBNmvPUyNjlb
l/L9N+dZgdx9G5h/KPRUyzvUc/uk/ZxTWVPIOp13WI65qwsBKrwvYKiXiwzjt+9V
CKDRc1sOghTSXk4FD3L5UcKvTQ2lRcFsqxbkopEwQWhoCuhe4vFyt3Nx8ZGLCBUD
3I5zMHtO8FtpZWKJPw46Yc1kasv0nlfly/vUbnErYfgjWX1hgWUcRgYdKwO4sOZ7
KbNma0WUsX5mWhXo4Kk7D15wATHO+j9s+j8m86duBL3A4HzpTo1DhHvBi0dkg0CO
XuSdByIzVLIPh3yhCHN1loRCP2rbzKM8IQeU/X5Q4UJeC/x9ew8Kk+RKXoHc8Y2C
JQO1DxuidyDJRhbb98wZo2YfIsdWQGjYZBe1XQRwBD28JnB+Rb9shml6lORWQn9y
P/STo9uWm5zvOCfqwbnCoetljDweItINx622G9SafBwPZc3o79oL7QSl8DgCtN6g
p0wGIlIBx+25w/96PqZcrYb8B7/uBHJviiKjBXDoIJWNiNRhW5HaFjeJdSKq7KIL
I/PO9KuHafif36ksG69X02Rio2/cTjgjEW1hGHcDRyyJWWaj7bd2eWuouh6FF22b
PA6FGY4vewDPnbLKLaix2ZIKxtedUDOH/qru3Mv58IFXmQ4iyM8oC8aOxYSQLZDn
1yJD
-----END CERTIFICATE-----
'';
"acme.test".key = builtins.toFile "acme.test.key" ''
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
'';
"acme.test".cert = builtins.toFile "acme.test.cert" ''
-----BEGIN CERTIFICATE-----
MIIEwDCCAqigAwIBAgICApowDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLU25h
a2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEzWhgPMjEyMDA3MzEwNzQyMTNaMBQxEjAQ
BgNVBAMMCWFjbWUudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
AN3SZeAchx0XKm2c25bMxyEuXhFt03F075ZagtZ3nRixlPXZwGG3aQ1GIb1s/8vQ
H0WdXfI2M9NY2bbecVmBtEKEFHZAnPUDz4dAMa5d/HBpYgeiGSGNKn7Vvc6y0oAj
lJ1y/+OSakqQ+ewxzEkoiZd4aDqOJHgWjETf1Cuhdw8Q/52RLIfsgQYdo7N6sZXh
MapF/CJ5oyvaRvI+6muAXuS4eIiCwR2xgDGsTO1YjQIOCoFsoHefuKgfOiRbRP3C
fq9w+tgEY1WsVOnjzi9AMEv78eZbOgxUAu/tQqRgjXpoCz83HO9LNFdEgJxe98R2
KkVoyRbbpsr8k5IkFi4NMw0DroFndwfQA7miBMdp74j8qYQLYzN2Sqy9szdWiH6s
28Sfy/E0BWtlj7D1Nvos2v5vZDHy12eFzxf0icUPuclR/q8ceTlq12dd5wjMnlaG
l0KH5hu6GbzkG7bk/a+Q0GXfVjdD9xt7MtP3TZ5TVZ/Lggb1yU0IUSrMtRREmXYH
BTbaSRBuwURvRnOotFd/Wkkc0oWciiUgUIZXNBTE5V+OORGLITyoxAv/krqvcMvy
7i4yJJKgW1YOOteyJYmy89Hw2P8q2apH0qoQlbFp62ttU8tzKbw/sJ7JE87hq7wl
U3tynFqfsJHh9Mb4a/BV1+p5+PdmAn72UiEztxRfqlrfAgMBAAGjGDAWMBQGA1Ud
EQQNMAuCCWFjbWUudGVzdDANBgkqhkiG9w0BAQsFAAOCAgEAM5WrCpBOmLrZ1QX8
l6vxVXwoI8pnqyy3cbAm3aLRPbw4gb0Ot90Pv/LoMhP0fkrNOKwH/FGRjSXyti0X
TheKrP7aEf6XL2/Xnb8rK2jYMQo6YJU9T+wBJA6Q+GBrc8SE75KfOi5NWJr8T4Ju
Etb+G05hXClrN19VFzIoz3L4kRV+xNMialcOT3xQfHtXCQUgwAWpPlwcJA/Jf60m
XsfwQwk2Ir16wq+Lc3y+mQ7d/dbG+FVrngFk4qN2B9M/Zyv4N9ZBbqeDUn3mYtJE
FeJrwHgmwH6slf1gBN3gxUKRW7Bvzxk548NdmLOyN+Y4StsqbOaYGtShUJA7f1Ng
qQqdgvxZ9MNwwMv9QVDZEnaaew3/oWOSmQGAai4hrc7gLMLJmIxzgfd5P6Dr06e4
2zwsMuI8Qh/IDqu/CfmFYvaua0FEeyAtpoID9Y/KPM7fu9bJuxjZ6kqLVFkEi9nF
/rCMchcSA8N2z/vLPabpNotO7OYH3VD7aQGTfCL82dMlp1vwZ39S3Z1TFLLh3MZ+
BYcAv8kUvCV6kIdPAXvJRSQOJUlJRV7XiI2mwugdDzMx69wQ0Zc1e4WyGfiSiVYm
ckSJ/EkxuwT/ZYLqCAKSFGMlFhad9g1Zyvd67XgfZq5p0pJTtGxtn5j8QHy6PM6m
NbjvWnP8lDU8j2l3eSG58S14iGs=
-----END CERTIFICATE-----
'';
# Minica can provide a CA key and cert, plus a key
# and cert for our fake CA server's Web Front End (WFE).
{ minica, mkDerivation }:
let
domain = "acme.test";
selfSignedCertData = mkDerivation {
name = "test-certs";
buildInputs = [ minica ];
phases = [ "buildPhase" "installPhase" ];
buildPhase = ''
mkdir ca
minica \
--ca-key ca/key.pem \
--ca-cert ca/cert.pem \
--domains ${domain}
chmod 600 ca/*
chmod 640 ${domain}/*.pem
'';
installPhase = ''
mkdir -p $out
mv ${domain} ca $out/
'';
};
in {
inherit domain;
ca = {
cert = "${selfSignedCertData}/ca/cert.pem";
key = "${selfSignedCertData}/ca/key.pem";
};
"${domain}" = {
cert = "${selfSignedCertData}/${domain}/cert.pem";
key = "${selfSignedCertData}/${domain}/key.pem";
};
}

View File

@ -1,6 +1,3 @@
let
certs = import ./common/acme/server/snakeoil-certs.nix;
in
import ./make-test-python.nix {
name = "postfix";

View File

@ -1,5 +1,6 @@
let
certs = import ./common/acme/server/snakeoil-certs.nix;
domain = certs.domain;
in
import ./make-test-python.nix {
name = "postfix";
@ -11,8 +12,8 @@ import ./make-test-python.nix {
enableSubmission = true;
enableSubmissions = true;
sslCACert = certs.ca.cert;
sslCert = certs."acme.test".cert;
sslKey = certs."acme.test".key;
sslCert = certs.${domain}.cert;
sslKey = certs.${domain}.key;
submissionsOptions = {
smtpd_sasl_auth_enable = "yes";
smtpd_client_restrictions = "permit";
@ -25,7 +26,7 @@ import ./make-test-python.nix {
];
networking.extraHosts = ''
127.0.0.1 acme.test
127.0.0.1 ${domain}
'';
environment.systemPackages = let
@ -33,7 +34,7 @@ import ./make-test-python.nix {
#!${pkgs.python3.interpreter}
import smtplib
with smtplib.SMTP('acme.test') as smtp:
with smtplib.SMTP('${domain}') as smtp:
smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test\n\nTest data.')
smtp.quit()
'';
@ -45,7 +46,7 @@ import ./make-test-python.nix {
ctx = ssl.create_default_context()
with smtplib.SMTP('acme.test') as smtp:
with smtplib.SMTP('${domain}') as smtp:
smtp.ehlo()
smtp.starttls(context=ctx)
smtp.ehlo()
@ -60,7 +61,7 @@ import ./make-test-python.nix {
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(host='acme.test', context=ctx) as smtp:
with smtplib.SMTP_SSL(host='${domain}', context=ctx) as smtp:
smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test SMTPS\n\nTest data.')
smtp.quit()
'';

View File

@ -0,0 +1,34 @@
{ lib, buildGoPackage, fetchFromGitHub }:
buildGoPackage rec {
pname = "minica";
version = "1.0.2";
goPackagePath = "github.com/jsha/minica";
src = fetchFromGitHub {
owner = "jsha";
repo = "minica";
rev = "v${version}";
sha256 = "18518wp3dcjhf3mdkg5iwxqr3326n6jwcnqhyibphnb2a58ap7ny";
};
buildFlagsArray = ''
-ldflags=
-X main.BuildVersion=${version}
'';
meta = with lib; {
description = "A simple tool for generating self signed certificates.";
longDescription = ''
Minica is a simple CA intended for use in situations where the CA
operator also operates each host where a certificate will be used. It
automatically generates both a key and a certificate when asked to
produce a certificate.
'';
homepage = "https://github.com/jsha/minica/";
license = licenses.mit;
maintainers = with maintainers; [ m1cr0man ];
platforms = platforms.linux ++ platforms.darwin;
};
}

View File

@ -4959,6 +4959,8 @@ in
minergate-cli = callPackage ../applications/misc/minergate-cli { };
minica = callPackage ../tools/security/minica { };
minidlna = callPackage ../tools/networking/minidlna { };
minisign = callPackage ../tools/security/minisign { };