{pkgs, lib, config, ...}: with lib; with builtins; with import ; 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; }; }