add the cascade cert client

This commit is contained in:
James Andariese 2022-10-15 16:50:35 -05:00
parent 6385d27f2a
commit 264dde6b1f
4 changed files with 338 additions and 2 deletions

View File

@ -0,0 +1,301 @@
{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;
};
}

View File

@ -88,6 +88,28 @@ options = with types; {
description = ''Where to save the secret'';
};
renew = mkOption {
type = nullOr str;
description = ''
a systemd time interval (systemd.time(7)) or null
specifies the interval after which the secret will be renewed
by recomputing it from its namespace, running the writer, and
running the after action. this occurs after the first time
the secret is written and on every boot.
if set to null, the default, this will only take place when the
namespace secret is refreshed.
prod sniff test! the default is the _safest_, not the _best_.
if the writer and action are cheap, consider putting this on a
loop to ensure there is no issue caused by accidental usage of
NFS or similar issues which can disrupt the async notification
mechanisms in the kernel.
'';
default = null;
};
writer = mkOption {
default = ''
echo "$secret" > "$destination"
@ -149,6 +171,13 @@ let shq = escapeShellArg;
fi
)
'';
secretToTimer = n: {enabled, renew, ...}@opts: mkIf (enabled) {
wantedBy = [ "multi-user.target" "timers.target" ];
timerConfig.OnUnitActiveSec = renew;
timerConfig.OnStartupSec = "3s";
timerConfig.AccuracySec = mkIf (isSecondsTimeUnit renew) "1s";
partOf = [ "deterministic-password-setup-${n}.path" ];
};
secretToPath = n: {enabled, destination, group, user, mode, action, ...}@opts: mkIf (enabled) {
wantedBy = [ "multi-user.target" ];
pathConfig = {
@ -161,10 +190,10 @@ let shq = escapeShellArg;
before = before;
script = makeUpdateScript n opts;
};
secretToAfterPath = n: {enabled, ...}@opts: mkIf (enabled) {
secretToAfterPath = n: {enabled, destination, ...}@opts: mkIf (enabled) {
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = "/etc/cascade/deterministic-passwords/namespaces/${ns opts}";
PathChanged = destination;
};
};
secretToAfterService = n: {action, before, enabled, ...}@opts: mkIf (enabled) {
@ -178,6 +207,7 @@ let shq = escapeShellArg;
secretToActivationScript = n: {enabled, destination, ...}@opts: {
text = mkMerge [
(mkIf (!enabled) ''rm -f ${shq destination}'')
(mkIf (enabled) ''systemctl start deterministic-password-setup-${n}.service'')
];
};
secretToGenerateNamespace = n: opts: let namespace = ns opts; in ''
@ -186,6 +216,9 @@ let shq = escapeShellArg;
in
{
systemd.targets."deterministic-passwords".wants = [ "paths.target" ];
systemd.timers = (
(mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToTimer n v)) config.environment.deterministic-passwords.secrets)
);
systemd.paths = (
(mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToPath n v)) config.environment.deterministic-passwords.secrets)
//

View File

@ -0,0 +1 @@
n: if n == null then false else builtins.match "[0-9] *m(in)?" n != null

View File

@ -0,0 +1 @@
n: if n == null then false else builtins.match "[0-9] *(s(ec)?|$)" n != null