Files
dendritic/modules/features/mtls.nix
T
John Lancaster cf1174d36b consolidation
2026-04-30 22:39:27 -05:00

434 lines
14 KiB
Nix

{ self, inputs, lib, ... }:
let
# Options that will be in common between the nixos module and the home-manager module.
mkOpts = config: let cfg = config.mtls; in {
enable = lib.mkEnableOption "Enable mTLS";
subject = lib.mkOption {
description = "The Common Name, DNS Name, or IP address that will be set as the Subject Common Name for the certificate. If no Subject Alternative Names (SANs) are configured (via the --san flag) then the subject will be set as the only SAN.";
type = lib.types.str;
};
certDir = lib.mkOption {
description = "String path to the directory where the certs will be stored";
type = lib.types.str;
};
caFile = lib.mkOption {
description = "String path for the root CA file";
type = lib.types.str;
default = "${cfg.certDir}/root_ca.crt";
};
keyFile = lib.mkOption {
description = "String path for the private key";
type = lib.types.str;
default = "${cfg.certDir}/key.pem";
};
certFile = lib.mkOption {
description = "String path for the public cert";
type = lib.types.str;
default = "${cfg.certDir}/cert.pem";
};
bundleFile = lib.mkOption {
description = "String path for the mTLS key bundle";
type = lib.types.str;
default = "${cfg.certDir}/mtls.pem";
};
san = lib.mkOption {
description = "List of SAN to give the mTLS cert";
type = lib.types.listOf lib.types.str;
default = [ ];
};
provisioner = lib.mkOption {
type = lib.types.str;
default = "admin";
};
lifetime = lib.mkOption {
type = lib.types.str;
default = "24h";
};
renew = {
enable = lib.mkOption {
description = "Enable automatic mTLS certificate renewal using a systemd timer.";
type = lib.types.bool;
default = true;
};
onCalendar = lib.mkOption {
description = "systemd OnCalendar schedule for mTLS certificate renewal checks.";
type = lib.types.str;
default = "*:1/15";
};
randomizedDelaySec = lib.mkOption {
description = "Randomized delay added to renewal timer runs to avoid synchronized renewals.";
type = lib.types.str;
default = "5m";
};
user = lib.mkOption {
description = "User account to run the mTLS renewal service as.";
type = lib.types.str;
default = "root";
};
group = lib.mkOption {
description = "Group to run the mTLS renewal service as. Defaults to the configured renewal user.";
type = lib.types.nullOr lib.types.str;
default = "mtls";
};
reloadUnits = lib.mkOption {
description = "systemd units to try-reload-or-restart after a successful certificate renewal.";
type = lib.types.listOf lib.types.str;
default = [ ];
};
postCommands = lib.mkOption {
description = "Shell commands to run after a successful certificate renewal.";
type = lib.types.listOf lib.types.lines;
default = [ ];
};
};
};
mkMtlsGenerateScript = {
pkgs,
subject,
provisioner,
san,
certFile,
keyFile,
bundleFile,
lifetime,
user,
group,
}:
let
sanArgs = lib.concatMapStringsSep " " (s: "--san \"${s}\"") san;
in
pkgs.writeShellApplication {
name = "mtls-generate";
runtimeInputs = with pkgs; [ coreutils step-cli ];
text = ''
set -euo pipefail
step ca certificate ${subject} ${certFile} ${keyFile} \
--provisioner ${provisioner} \
--not-before=-5m --not-after=${lifetime} \
${sanArgs} \
"$@"
(umask 077; cat ${certFile} ${keyFile} > ${bundleFile})
chown ${user}:${group} ${certFile} ${keyFile} ${bundleFile}
chmod 640 ${certFile} ${keyFile} ${bundleFile}
printf '\033[32m\033[0m \033[1mmTLS Bundle:\033[0m %s\n' ${lib.escapeShellArg bundleFile}
'';
};
in
{
flake.modules.nixos.mtls = { config, lib, pkgs, ... }:
let
cfg = config.mtls;
mtlsRenewWrapper = inputs.self.wrappers.mtlsRenew.apply {
inherit pkgs;
inherit (cfg) certDir keyFile certFile bundleFile;
inherit (cfg.renew) user group reloadUnits postCommands;
systemd = {
description = "Renew the mTLS certificate when Smallstep marks it ready";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = cfg.renew.user;
Group = cfg.renew.group;
};
} // lib.optionalAttrs cfg.renew.enable {
startAt = cfg.renew.onCalendar;
};
};
in
{
options.mtls = (mkOpts config) // {
certDir = lib.mkOption {
description = "String path to where the mtls certs will be stored.";
type = lib.types.str;
default = "/etc/step-ca/certs";
};
certReaders = lib.mkOption {
description = "";
type = lib.types.listOf lib.types.str;
default = [ ];
};
};
config = lib.mkIf cfg.enable {
users.groups.certReaders = {
name = cfg.renew.group;
members = cfg.certReaders;
};
environment.systemPackages = with pkgs; lib.optionals cfg.enable [
# step-cli
(mkMtlsGenerateScript {
inherit pkgs;
inherit (cfg) subject provisioner san certFile keyFile bundleFile lifetime;
inherit (cfg.renew) user group;
})
(inputs.self.wrappers.mtlsCheck.apply {
inherit pkgs;
inherit (cfg) bundleFile;
}).wrapper
mtlsRenewWrapper.wrapper
];
systemd.tmpfiles.rules = [
"d ${cfg.certDir} 0750 ${cfg.renew.user} ${cfg.renew.group} -"
];
systemd.packages = lib.mkIf cfg.renew.enable [
mtlsRenewWrapper.outputs.systemd-system
];
systemd.timers.mtls-renew = lib.mkIf cfg.renew.enable {
wantedBy = [ "timers.target" ];
timerConfig = {
Persistent = true;
AccuracySec = "1us";
RandomizedDelaySec = cfg.renew.randomizedDelaySec;
};
};
};
};
flake.modules.homeManager.mtls = { config, lib, pkgs, ... }:
let
cfg = config.mtls;
mtlsRenewWrapper = inputs.self.wrappers.mtlsRenew.apply {
inherit pkgs;
inherit (cfg) certDir keyFile certFile bundleFile;
inherit (cfg.renew) reloadUnits postCommands;
systemctlArgs = [ "--user" ];
systemd = {
description = "Renew the mTLS certificate when Smallstep marks it ready";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
} // lib.optionalAttrs cfg.renew.enable {
startAt = cfg.renew.onCalendar;
};
};
in
{
options.mtls = (mkOpts config) // {
certDir = lib.mkOption {
description = "String path to where the mtls certs will be stored.";
type = lib.types.str;
default = "${config.home.homeDirectory}/.step/certs";
};
};
config = {
home.packages = with pkgs; lib.optionals cfg.enable [
# step-cli
(mkMtlsGenerateScript {
inherit pkgs;
inherit (cfg) keyFile certFile bundleFile;
inherit (cfg) subject provisioner san lifetime;
inherit (cfg.renew) user group;
})
(inputs.self.wrappers.mtlsCheck.apply {
inherit pkgs;
inherit (cfg) bundleFile;
}).wrapper
mtlsRenewWrapper.wrapper
];
systemd.user.tmpfiles.rules = lib.mkIf cfg.enable [
"d ${cfg.certDir} 0700 - - -"
];
xdg.dataFile = lib.mkIf (cfg.enable && cfg.renew.enable) {
"systemd/user/mtls-renew.service".source =
"${mtlsRenewWrapper.outputs.systemd-user}/systemd/user/mtls-renew.service";
"systemd/user/mtls-renew.timer".source =
"${mtlsRenewWrapper.outputs.systemd-user}/systemd/user/mtls-renew.timer";
"systemd/user/mtls-renew.timer.d/override.conf".text = ''
[Timer]
Persistent=true
AccuracySec=1us
RandomizedDelaySec=${cfg.renew.randomizedDelaySec}
'';
};
home.activation.mtlsRenewTimer = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
if [ -n "$XDG_RUNTIME_DIR" ] && [ -S "$XDG_RUNTIME_DIR/systemd/private" ]; then
if [ "${lib.boolToString (cfg.enable && cfg.renew.enable)}" = "true" ]; then
run ${pkgs.systemd}/bin/systemctl --user daemon-reload
run ${pkgs.systemd}/bin/systemctl --user enable --now mtls-renew.timer
else
run ${pkgs.systemd}/bin/systemctl --user disable --now mtls-renew.timer || true
run ${pkgs.systemd}/bin/systemctl --user daemon-reload || true
fi
fi
'';
};
};
flake.wrappers = {
mtlsCheck = inputs.wrappers.lib.wrapModule ({ config, lib, wlib, ... }: {
options = {
bundleFile = lib.mkOption {
description = "String path for the mTLS key bundle";
type = lib.types.str;
};
};
config = {
binName = "mtls-check";
# This pattern is necessary to wrap packages like openssl that provide more than one binary
package = config.pkgs.symlinkJoin {
name = "openssl";
paths = [ config.pkgs.openssl.bin config.pkgs.openssl.man ];
meta.mainProgram = "openssl";
};
args = [
"x509"
"-noout"
"-in" config.bundleFile
"-subject"
"-issuer"
"-ext" "subjectAltName,extendedKeyUsage"
"-enddate"
];
};
});
mtlsNeedsRenewal = inputs.wrappers.lib.wrapModule ({ config, lib, wlib, ... }: {
options = {
certFile = lib.mkOption {
description = "String path for the public cert";
type = lib.types.str;
};
};
config = {
binName = "mtls-needs-renewal";
package = config.pkgs.step-cli;
preHook = ''
echo "Checking renewal status..."
'';
args = [ "certificate" "needs-renewal" "${config.certFile}" ];
};
});
mtlsRenew = inputs.wrappers.lib.wrapModule ({ config, lib, wlib, ... }: {
imports = [ wlib.modules.systemd ];
options = {
certDir = lib.mkOption {
description = "String path to the directory where the certs will be stored";
type = lib.types.str;
};
keyFile = lib.mkOption {
description = "String path for the private key";
type = lib.types.str;
default = "${config.certDir}/key.pem";
};
certFile = lib.mkOption {
description = "String path for the public cert";
type = lib.types.str;
default = "${config.certDir}/cert.pem";
};
bundleFile = lib.mkOption {
description = "String path for the mTLS key bundle";
type = lib.types.str;
default = "${config.certDir}/mtls.pem";
};
user = lib.mkOption {
description = "User that should own the renewed certificate files.";
type = lib.types.nullOr lib.types.str;
default = null;
};
group = lib.mkOption {
description = "Group that should own the renewed certificate files.";
type = lib.types.nullOr lib.types.str;
default = null;
};
reloadUnits = lib.mkOption {
description = "systemd units to try-reload-or-restart after a successful renewal.";
type = lib.types.listOf lib.types.str;
default = [ ];
};
postCommands = lib.mkOption {
description = "Shell commands to run after a successful renewal.";
type = lib.types.listOf lib.types.lines;
default = [ ];
};
systemctlArgs = lib.mkOption {
description = "Additional arguments to pass to systemctl when reloading units.";
type = lib.types.listOf lib.types.str;
default = [ ];
};
};
config = {
binName = "mtls-renew";
package = let
systemctlCmd = "systemctl ${lib.escapeShellArgs config.systemctlArgs}";
hasReloadUnits = config.reloadUnits != [ ];
renewReloadScript = lib.concatMapStringsSep "\n" (unit: ''
if ${systemctlCmd} --quiet is-active "${unit}"; then
${systemctlCmd} try-reload-or-restart "${unit}"
fi
'') config.reloadUnits;
hasPostCommands = config.postCommands != [ ];
renewPostCommands = lib.concatStringsSep "\n" config.postCommands;
hasOwnership = config.user != null && config.group != null;
in
config.pkgs.writeShellApplication {
name = "mtls-renew";
runtimeInputs = with config.pkgs; [ coreutils step-cli systemd ];
text = ''
set -euo pipefail
YELLOW_BANG="\e[33m!\e[0m"
force=0
while [[ $# -gt 0 ]]; do
case $1 in
--force)
force=1
shift
;;
*)
echo -e "$YELLOW_BANG Warning: ignoring unrecognized argument '$1'"
exit 1
;;
esac
done
if [[ $force -eq 0 ]] && ! step certificate needs-renewal "${config.certFile}"; then
echo "Skipping renew"
exit 0
fi
echo "Renewing mTLS certificate"
step ca renew --force "${config.certFile}" "${config.keyFile}"
(umask 077; cat "${config.certFile}" "${config.keyFile}" > "${config.bundleFile}")
${lib.optionalString hasOwnership ''
chown ${config.user}:${config.group} ${config.certFile} ${config.keyFile} ${config.bundleFile}
chmod 640 ${config.certFile} ${config.keyFile} ${config.bundleFile}
''}
${lib.optionalString hasReloadUnits ''
echo "Reloading units: ${lib.concatStringsSep ", " config.reloadUnits}"
${renewReloadScript}
''}
${lib.optionalString hasPostCommands ''
echo "Running post commands"
${renewPostCommands}
''}
'';
};
systemd = {
serviceConfig.Type = lib.mkDefault "oneshot";
};
};
});
};
}