302 lines
9.9 KiB
Nix
302 lines
9.9 KiB
Nix
|
{pkgs, lib, config, ...}:
|
||
|
with lib;
|
||
|
with builtins;
|
||
|
with import <cascade/functions>;
|
||
|
|
||
|
let cfg = config.security.cascade-certs;
|
||
|
shq = lib.escapeShellArg;
|
||
|
hostName = config.networking.hostName;
|
||
|
domain = config.networking.domain;
|
||
|
confToTimer = n: {enabled, minSecondsRemaining, ...}@opts: mkIf enabled {
|
||
|
wantedBy = [ "multi-user.target" "timers.target" ];
|
||
|
timerConfig = {
|
||
|
OnUnitActiveSec = "${toString (minSecondsRemaining / 30)}";
|
||
|
OnStartupSec = "5s";
|
||
|
AccuracySec = mkIf (minSecondsRemaining < 1800) (mkDefault "1s");
|
||
|
};
|
||
|
};
|
||
|
|
||
|
confToService = n: {enabled, readers, minSecondsRemaining, readerGroups, vaultPkiRole, vaultPkiPath, issueOptions, ...}@opts: mkIf enabled {
|
||
|
serviceConfig = {
|
||
|
Type = "oneshot";
|
||
|
};
|
||
|
startLimitIntervalSec = 0;
|
||
|
script = let
|
||
|
certFolder = "${cfg.destination}/${n}";
|
||
|
caFolder = "${cfg.destination}/@trust";
|
||
|
in ''
|
||
|
set -x
|
||
|
PATH="${pkgs.curl}/bin:${pkgs.coreutils}/bin:${pkgs.jq}/bin:${pkgs.openssl}/bin:${pkgs.acl}/bin:$PATH"
|
||
|
|
||
|
if [ -r ${shq certFolder}/cert.pem ] \
|
||
|
&& [ -h ${shq certFolder}/current ] \
|
||
|
&& openssl x509 -checkend ${shq minSecondsRemaining} -in ${shq certFolder}/cert.pem ;then
|
||
|
1>&2 echo "Certificate will not expire in the next "${shq minSecondsRemaining}" seconds. exiting."
|
||
|
exit 0
|
||
|
fi
|
||
|
|
||
|
# we tell curl to also trust our own machine cert if vault presents it.
|
||
|
# this allows the vault server to bootstrap itself
|
||
|
CAOPTS=""
|
||
|
[ -r ${shq certFolder}/cert.pem ] && CAOPTS="--cacert "${shq certFolder}/cert.pem
|
||
|
[ -r ${shq certFolder}/ca.pem ] && CAOPTS="--cacert "${shq certFolder}/ca.pem
|
||
|
JSON="$(
|
||
|
curl \
|
||
|
-X PUT -H "X-Vault-Token: $(cat ${shq cfg.vaultTokenPath} | tr -d '\n')" \
|
||
|
-H "X-Vault-Request: true" \
|
||
|
-d ${shq (toJSON issueOptions)} \
|
||
|
${cfg.vaultAddr}/v1/${vaultPkiPath}/issue/${vaultPkiRole}
|
||
|
)"
|
||
|
ERRORS="$(echo "$JSON" | jq -r '.errors // [] | .[]')"
|
||
|
WARNINGS="$(echo "$JSON" | jq -r '.warnings // [] | .[]')"
|
||
|
echo "$ERRORS" | grep . && exit 1
|
||
|
echo "$WARNINGS"
|
||
|
while true;do
|
||
|
DATE="$(date --iso-8601=seconds)"
|
||
|
NEW_CERTS=${shq certFolder}"/.$DATE"
|
||
|
[ -d "$NEW_CERTS" ] || break # if we found an unused one.
|
||
|
done
|
||
|
|
||
|
CURRENT_CERTS=${shq certFolder}"/current"
|
||
|
VIRTUAL_CERTS=${shq certFolder}
|
||
|
|
||
|
# first, create our cert folder and fix up the rest of the structure if it had to be created.
|
||
|
# we mostly need to ensure it's accessible by the correct users.
|
||
|
mkdir -p "$NEW_CERTS"
|
||
|
|
||
|
# we're gonna do some pointless copying in order _try_ to preserve permissions made on the host
|
||
|
# relax! it's all meaningless anyway.
|
||
|
|
||
|
for f in cert.pem key.pem cert-chain.pem ca.pem;do
|
||
|
# copy the current one to preserve its ACLs
|
||
|
[ -e "$f" ] && cp -a "$f" "$NEW_CERTS/$f"
|
||
|
ln -sf "$CURRENT_CERTS"/"$f" ${shq certFolder}/"$f"
|
||
|
done
|
||
|
|
||
|
# ensure everything is _secure_ though.
|
||
|
# this will reset nearly everything except that it will
|
||
|
# preserve ACLs
|
||
|
chown -R root:root ${shq certFolder}
|
||
|
chown root:root ${shq cfg.destination}
|
||
|
find ${shq certFolder} -type f -exec chmod go=,u=rw {} +
|
||
|
find ${shq certFolder} -type d -exec chmod go=,u=rwx {} +
|
||
|
|
||
|
# then we truncate and append to the newly created files
|
||
|
|
||
|
umask 077
|
||
|
echo "$JSON" | jq -r '.data.certificate' > "$NEW_CERTS"/cert.pem
|
||
|
echo "$JSON" | jq -r '.data.private_key' > "$NEW_CERTS"/key.pem
|
||
|
echo "$JSON" | jq -r '.data.ca_chain[]' > "$NEW_CERTS"/ca.pem
|
||
|
echo "$JSON" | jq -r '.data.certificate, .data.ca_chain[]' > "$NEW_CERTS"/cert-chain.pem
|
||
|
|
||
|
${if (readers == []) then "# " else ""}setfacl -R ${escapeShellArgs (lib.flatten (map (n: ["-m" "u:${n}:rX"]) readers))} ${shq certFolder}
|
||
|
${if (readerGroups == []) then "# " else ""}setfacl -R ${escapeShellArgs (lib.flatten (map (n: ["-m" "g:${n}:rX"]) readerGroups))} ${shq certFolder}
|
||
|
|
||
|
umask 022
|
||
|
mkdir -p ${shq caFolder}
|
||
|
echo "$JSON" | jq -r '.data.certificate, .data.ca_chain[]' > ${shq caFolder}/${shq n}.pem
|
||
|
umask 077
|
||
|
|
||
|
# now we do some symlink dancing to update multiple paths in one atomic write
|
||
|
RRR=$RANDOM$RANDOM$RANDOM
|
||
|
ln -sf "$NEW_CERTS" "$NEW_CERTS.$RRR"
|
||
|
mv -T "$NEW_CERTS.$RRR" "$CURRENT_CERTS"
|
||
|
|
||
|
# now delete any old ones
|
||
|
for f in ${shq certFolder}/.[0-9]*;do
|
||
|
if [ "$f" = "$NEW_CERTS" ];then
|
||
|
continue
|
||
|
fi
|
||
|
rm -rf "$f"
|
||
|
done
|
||
|
'';
|
||
|
};
|
||
|
|
||
|
confToCAFile = n: {enabled, ...}@opts: mkIf enabled "${cfg.destination}/${n}/ca.pem";
|
||
|
in
|
||
|
|
||
|
{
|
||
|
|
||
|
options = with types; {
|
||
|
security.cascade-certs.destination = mkOption {
|
||
|
type = str;
|
||
|
default = "/etc/cascade-certs";
|
||
|
description = ''
|
||
|
Central folder in which to store certs.
|
||
|
|
||
|
This should be world readable and executable.
|
||
|
'';
|
||
|
};
|
||
|
security.cascade-certs.vaultAddr = mkOption {
|
||
|
type = str;
|
||
|
default = "https://vault:8200";
|
||
|
description = ''
|
||
|
the address of the vault server from which certificates will be requested.
|
||
|
'';
|
||
|
};
|
||
|
security.cascade-certs.machineRole = mkOption {
|
||
|
type = str;
|
||
|
default = "cascade-machine";
|
||
|
description = ''
|
||
|
vault pki role from which to request the certificate for a machine.
|
||
|
'';
|
||
|
};
|
||
|
security.cascade-certs.serviceRolePrefix = mkOption {
|
||
|
type = str;
|
||
|
default = "cascade-service--";
|
||
|
description = ''
|
||
|
prefix to apply to a service name to create a service certificate role name.
|
||
|
|
||
|
together, these define the vault pki role from which to request the certificate for a service.
|
||
|
'';
|
||
|
};
|
||
|
security.cascade-certs.vaultTokenPath = mkOption {
|
||
|
type = str;
|
||
|
default = "/root/.vault-token";
|
||
|
description = ''
|
||
|
location of the vault token on disk.
|
||
|
'';
|
||
|
};
|
||
|
security.cascade-certs.clients = mkOption {
|
||
|
default = {};
|
||
|
description = ''
|
||
|
CASCADE certificate clients
|
||
|
|
||
|
Each of these will get a certificate from the authority in the
|
||
|
network and place it in the specified file with the specified
|
||
|
users able to read it. This process makes use of ACLs so these
|
||
|
must be supported.
|
||
|
|
||
|
Each client will output files in ${cfg.destination} under a folder
|
||
|
named the same as the client in security.cascade-certs.clients.
|
||
|
|
||
|
Their names will be cert.pem, key.pem, ca.pem, and cert-chain.pem.
|
||
|
|
||
|
The updates of the files will be atomic.
|
||
|
'';
|
||
|
|
||
|
type = attrsOf (submodule ({config, name, ...}@args: {
|
||
|
config = {
|
||
|
vaultPkiRole = if (config.service == null)
|
||
|
then (mkDefault cfg.machineRole)
|
||
|
else (mkDefault "cfg.serviceRolePrefix${config.service}");
|
||
|
|
||
|
names =
|
||
|
let
|
||
|
namesFromName = n: [ n "${n}.${domain}" ];
|
||
|
in
|
||
|
if (config.service == null)
|
||
|
then (mkDefault (namesFromName hostName))
|
||
|
else (mkDefault (namesFromName config.service));
|
||
|
|
||
|
issueOptions = {
|
||
|
ttl = mkDefault "${toString config.ttlSeconds}s";
|
||
|
alt_names = mkDefault (join-string "," config.names);
|
||
|
common_name = mkDefault (head config.names);
|
||
|
};
|
||
|
minSecondsRemaining = mkDefault (config.ttlSeconds / 10 * 3);
|
||
|
};
|
||
|
options = {
|
||
|
enabled = mkEnableOption "cascade cert client for ${name}" // {default = true;};
|
||
|
addCA = mkEnableOption "adding ${name}'s CA to system roots" // {default = true;};
|
||
|
|
||
|
readers = mkOption {
|
||
|
type = listOf str;
|
||
|
description = ''
|
||
|
The user names who should be able to read this file. This should usually
|
||
|
be the name of the service(s) consuming the certificate.
|
||
|
'';
|
||
|
default = [];
|
||
|
};
|
||
|
|
||
|
readerGroups = mkOption {
|
||
|
type = listOf str;
|
||
|
description = ''
|
||
|
The group names who should be able to read this file. This should usually
|
||
|
be the name of the service(s) consuming the certificate. This need not
|
||
|
and probably should not be specified if the user is already specified
|
||
|
explicitly in readers.
|
||
|
'';
|
||
|
default = [];
|
||
|
};
|
||
|
|
||
|
ttlSeconds = mkOption {
|
||
|
type = int;
|
||
|
description = ''
|
||
|
ttl of the certificate in golang time ParseDuration format.
|
||
|
'';
|
||
|
|
||
|
default = 96*60*60;
|
||
|
};
|
||
|
|
||
|
minSecondsRemaining = mkOption {
|
||
|
type = int;
|
||
|
description = ''
|
||
|
minimum seconds for cert to be valid before creating a new one.
|
||
|
defaults to 3/10 of ttlSeconds or if set to < 1. This will also
|
||
|
inform the frequency of checking the expiration which is usually
|
||
|
polled 1/100 ttlSeconds. This is calculated as 1/30
|
||
|
minSecondsRemaining, however so that the check interval will scale
|
||
|
with the requirement to rotate.
|
||
|
'';
|
||
|
};
|
||
|
baseName = mkOption {
|
||
|
type = str;
|
||
|
description = ''
|
||
|
unit base name
|
||
|
'';
|
||
|
default = "cascade-cert-client-${name}";
|
||
|
};
|
||
|
|
||
|
service = mkOption {
|
||
|
type = nullOr str;
|
||
|
description = ''
|
||
|
the service name to get a certificate for.
|
||
|
|
||
|
if null, this will get a machine certificate instead.
|
||
|
'';
|
||
|
default = null;
|
||
|
};
|
||
|
|
||
|
names = mkOption {
|
||
|
type = listOf str;
|
||
|
description = ''
|
||
|
names to enter in CN and SNI for the certificate.
|
||
|
|
||
|
by default, this will either contain the service name or machine name
|
||
|
as well as that name with the domain appended.
|
||
|
'';
|
||
|
};
|
||
|
vaultPkiPath = mkOption {
|
||
|
type = str;
|
||
|
default = "pki";
|
||
|
description = ''
|
||
|
path of the secret engine in vault to which pki requests will be routed.
|
||
|
'';
|
||
|
};
|
||
|
vaultPkiRole = mkOption {
|
||
|
type = str;
|
||
|
description = ''
|
||
|
the vault role to
|
||
|
'';
|
||
|
};
|
||
|
issueOptions = with types; mkOption {
|
||
|
type = attrsOf str;
|
||
|
description = ''
|
||
|
options to add to the request in addition to common_name, alt_names, ttl.
|
||
|
'';
|
||
|
default = {};
|
||
|
};
|
||
|
};
|
||
|
}));
|
||
|
};
|
||
|
};
|
||
|
|
||
|
config = {
|
||
|
systemd.timers = mapAttrs' (n: v: nameValuePair "${v.baseName}" (confToTimer n v)) cfg.clients;
|
||
|
systemd.services = mapAttrs' (n: v: nameValuePair "${v.baseName}" (confToService n v)) cfg.clients;
|
||
|
security.pki.certificateFiles = mapAttrsToList confToCAFile cfg.clients;
|
||
|
};
|
||
|
|
||
|
}
|