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'';
|
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 {
|
writer = mkOption {
|
||||||
default = ''
|
default = ''
|
||||||
echo "$secret" > "$destination"
|
echo "$secret" > "$destination"
|
||||||
|
@ -149,6 +171,13 @@ let shq = escapeShellArg;
|
||||||
fi
|
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) {
|
secretToPath = n: {enabled, destination, group, user, mode, action, ...}@opts: mkIf (enabled) {
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
pathConfig = {
|
pathConfig = {
|
||||||
|
@ -161,10 +190,10 @@ let shq = escapeShellArg;
|
||||||
before = before;
|
before = before;
|
||||||
script = makeUpdateScript n opts;
|
script = makeUpdateScript n opts;
|
||||||
};
|
};
|
||||||
secretToAfterPath = n: {enabled, ...}@opts: mkIf (enabled) {
|
secretToAfterPath = n: {enabled, destination, ...}@opts: mkIf (enabled) {
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
pathConfig = {
|
pathConfig = {
|
||||||
PathChanged = "/etc/cascade/deterministic-passwords/namespaces/${ns opts}";
|
PathChanged = destination;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
secretToAfterService = n: {action, before, enabled, ...}@opts: mkIf (enabled) {
|
secretToAfterService = n: {action, before, enabled, ...}@opts: mkIf (enabled) {
|
||||||
|
@ -178,6 +207,7 @@ let shq = escapeShellArg;
|
||||||
secretToActivationScript = n: {enabled, destination, ...}@opts: {
|
secretToActivationScript = n: {enabled, destination, ...}@opts: {
|
||||||
text = mkMerge [
|
text = mkMerge [
|
||||||
(mkIf (!enabled) ''rm -f ${shq destination}'')
|
(mkIf (!enabled) ''rm -f ${shq destination}'')
|
||||||
|
(mkIf (enabled) ''systemctl start deterministic-password-setup-${n}.service'')
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
secretToGenerateNamespace = n: opts: let namespace = ns opts; in ''
|
secretToGenerateNamespace = n: opts: let namespace = ns opts; in ''
|
||||||
|
@ -186,6 +216,9 @@ let shq = escapeShellArg;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
systemd.targets."deterministic-passwords".wants = [ "paths.target" ];
|
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 = (
|
systemd.paths = (
|
||||||
(mapAttrs' (n: v: nameValuePair "deterministic-password-setup-${n}" (secretToPath n v)) config.environment.deterministic-passwords.secrets)
|
(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