add the cascade cert client
This commit is contained in:
parent
6385d27f2a
commit
264dde6b1f
301
common/cascade-cert-client.nix
Normal file
301
common/cascade-cert-client.nix
Normal 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;
|
||||
};
|
||||
|
||||
}
|
|
@ -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)
|
||||
//
|
||||
|
|
1
functions/isMinutessTimeUnit.nix
Normal file
1
functions/isMinutessTimeUnit.nix
Normal file
|
@ -0,0 +1 @@
|
|||
n: if n == null then false else builtins.match "[0-9] *m(in)?" n != null
|
1
functions/isSecondsTimeUnit.nix
Normal file
1
functions/isSecondsTimeUnit.nix
Normal file
|
@ -0,0 +1 @@
|
|||
n: if n == null then false else builtins.match "[0-9] *(s(ec)?|$)" n != null
|
Loading…
Reference in New Issue
Block a user