nixos: migrate to secretsmgr for sshd and ACME

This commit is contained in:
Luke Granger-Brown 2022-03-17 23:31:55 +00:00
parent 702cd972ab
commit b719181dfe
18 changed files with 309 additions and 264 deletions

View file

@ -37,6 +37,7 @@ var (
sshHostKeyPrincipals = flag.String("ssh_host_key_principals", defaultPrincipals(hostname()), "Principals to use for SSH host key certificate") sshHostKeyPrincipals = flag.String("ssh_host_key_principals", defaultPrincipals(hostname()), "Principals to use for SSH host key certificate")
sshHostKeyExpiryThreshold = flag.Duration("ssh_host_key_expiry_threshold", 3*24*time.Hour, "Expiry threshold for SSH host key") sshHostKeyExpiryThreshold = flag.Duration("ssh_host_key_expiry_threshold", 3*24*time.Hour, "Expiry threshold for SSH host key")
sshHostKeyOutputDir = flag.String("ssh_host_key_output_dir", "/var/lib/secretsmgr/ssh", "Path to SSH host key output dir") sshHostKeyOutputDir = flag.String("ssh_host_key_output_dir", "/var/lib/secretsmgr/ssh", "Path to SSH host key output dir")
sshDummyHostKey = flag.String("ssh_dummy_host_key", "", "Path to dummy SSH hostkey")
sshd = flag.String("sshd", "sshd", "Path to sshd") sshd = flag.String("sshd", "sshd", "Path to sshd")
acmeCertificatesConfig = flag.String("acme_certificates_config", "", "Path to ACME certificates config") acmeCertificatesConfig = flag.String("acme_certificates_config", "", "Path to ACME certificates config")
@ -58,7 +59,11 @@ func defaultPrincipals(hostname string) string {
} }
func sshHostKeyPaths(ctx context.Context) ([]string, error) { func sshHostKeyPaths(ctx context.Context) ([]string, error) {
out, err := exec.CommandContext(ctx, *sshd, "-T").Output() args := []string{"-T"}
if *sshDummyHostKey != "" {
args = append(args, "-h", *sshDummyHostKey)
}
out, err := exec.CommandContext(ctx, *sshd, args...).Output()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -69,7 +74,11 @@ func sshHostKeyPaths(ctx context.Context) ([]string, error) {
for scanner.Scan() { for scanner.Scan() {
t := scanner.Text() t := scanner.Text()
if strings.HasPrefix(t, hostkeyPrefix) { if strings.HasPrefix(t, hostkeyPrefix) {
keys = append(keys, fmt.Sprintf("%s.pub", strings.TrimPrefix(t, hostkeyPrefix))) key := strings.TrimPrefix(t, hostkeyPrefix)
if key == *sshDummyHostKey {
continue
}
keys = append(keys, fmt.Sprintf("%s.pub", key))
} }
} }
if scanner.Err() != nil { if scanner.Err() != nil {
@ -226,7 +235,7 @@ func shouldRenewSSHCert(certPath string, publicKey []byte, principals string) (b
} }
func reloadOrTryRestartServices(ctx context.Context, services []string) error { func reloadOrTryRestartServices(ctx context.Context, services []string) error {
sd, err := dbus.NewSystemdConnection() sd, err := dbus.NewWithContext(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to systemd: %w", err) return fmt.Errorf("failed to connect to systemd: %w", err)
} }
@ -352,7 +361,9 @@ func setGroup(groupName string) (func(), error) {
} }
origGid := unix.Getegid() origGid := unix.Getegid()
unix.Setregid(-1, gid) if err := unix.Setregid(-1, gid); err != nil {
return nil, err
}
return func() { return func() {
unix.Setregid(-1, origGid) unix.Setregid(-1, origGid)
}, nil }, nil
@ -514,6 +525,18 @@ func writeCertificate(certDef acmeCertificate, cert *vapi.Secret) error {
return nil return nil
} }
func uniqueStrings(vs []string) []string {
m := map[string]bool{}
for _, v := range vs {
m[v] = true
}
x := make([]string, 0, len(m))
for v := range m {
x = append(x, v)
}
return x
}
func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool { func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool {
certDefs, err := parseACMECertificates() certDefs, err := parseACMECertificates()
if err != nil { if err != nil {
@ -523,6 +546,7 @@ func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool {
log.Infof("acme certificate defs: %#v", certDefs) log.Infof("acme certificate defs: %#v", certDefs)
var hadErr bool var hadErr bool
var unitsToReloadOrRestart []string
for _, certDef := range certDefs { for _, certDef := range certDefs {
log.Infof("maybe issuing %v (role: %v) with hostnames %v into %v", certDef.OutputName, certDef.Role, certDef.Hostnames, certDef.OutputDir()) log.Infof("maybe issuing %v (role: %v) with hostnames %v into %v", certDef.OutputName, certDef.Role, certDef.Hostnames, certDef.OutputDir())
@ -540,7 +564,7 @@ func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool {
} }
if err := makeOrFixPermissionsOnDir(certDef.OutputDir(), 0750, certDef.Group); err != nil { if err := makeOrFixPermissionsOnDir(certDef.OutputDir(), 0750, certDef.Group); err != nil {
log.Errorf("creating/fixing dir %q: %w", certDef.OutputDir(), err) log.Errorf("creating/fixing dir %q: %v", certDef.OutputDir(), err)
hadErr = true hadErr = true
continue continue
} }
@ -565,6 +589,16 @@ func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool {
hadErr = true hadErr = true
continue continue
} }
unitsToReloadOrRestart = append(unitsToReloadOrRestart, certDef.UnitsToReloadOrRestart...)
}
unitsToReloadOrRestart = uniqueStrings(unitsToReloadOrRestart)
if len(unitsToReloadOrRestart) > 0 {
log.Infof("reloading/restarting %d units: %v", len(unitsToReloadOrRestart), unitsToReloadOrRestart)
if err := reloadOrTryRestartServices(ctx, unitsToReloadOrRestart); err != nil {
log.Errorf("failed to restart units: %v", err)
hadErr = true
}
} }
return hadErr return hadErr

View file

@ -82,7 +82,7 @@ in {
}; };
}; };
my.vault.acmeCertificates."objdump.zxcvbnm.ninja" = { my.vault.acmeCertificates."objdump.zxcvbnm.ninja" = {
extraNames = [ "*.objdump.zxcvbnm.ninja" ]; hostnames = [ "objdump.zxcvbnm.ninja" "*.objdump.zxcvbnm.ninja" ];
nginxVirtualHosts = [ "objdump.zxcvbnm.ninja" ]; nginxVirtualHosts = [ "objdump.zxcvbnm.ninja" ];
}; };
my.fup.listen = [ my.fup.listen = [

View file

@ -167,7 +167,7 @@ in {
my.vault.acmeCertificates."matrix.zxcvbnm.ninja" = { my.vault.acmeCertificates."matrix.zxcvbnm.ninja" = {
group = "matrixcert"; group = "matrixcert";
extraNames = [ "element.zxcvbnm.ninja" "zxcvbnm.ninja" ]; hostnames = [ "matrix.zxcvbnm.ninja" "element.zxcvbnm.ninja" "zxcvbnm.ninja" ];
nginxVirtualHosts = [ "zxcvbnm.ninja" "element.zxcvbnm.ninja" "matrix.zxcvbnm.ninja" ]; nginxVirtualHosts = [ "zxcvbnm.ninja" "element.zxcvbnm.ninja" "matrix.zxcvbnm.ninja" ];
}; };

View file

@ -93,10 +93,13 @@ in {
my.vault.acmeCertificates = { my.vault.acmeCertificates = {
"xmpp.lukegb.com" = { "xmpp.lukegb.com" = {
group = "prosody"; group = "prosody";
extraNames = [ "*.xmpp.lukegb.com" "lukegb.com" ]; hostnames = [ "xmpp.lukegb.com" "*.xmpp.lukegb.com" "lukegb.com" ];
reloadOrRestartUnits = [ "prosody.service" ];
}; };
"turn.lukegb.com" = { "turn.lukegb.com" = {
group = "turnserver"; group = "turnserver";
hostnames = [ "turn.lukegb.com" ];
reloadOrRestartUnits = [ "coturn.service" ];
}; };
}; };

View file

@ -56,6 +56,7 @@ in {
my.vault.acmeCertificates."as205479.net" = { my.vault.acmeCertificates."as205479.net" = {
group = "acme"; group = "acme";
hostnames = [ "as205479.net" ];
reloadOrRestartUnits = [ "freeradius.service" ]; reloadOrRestartUnits = [ "freeradius.service" ];
}; };
users.users.nginx.extraGroups = lib.mkAfter [ "acme" ]; users.users.nginx.extraGroups = lib.mkAfter [ "acme" ];

View file

@ -193,7 +193,7 @@ in {
useLegacyConfig = false; useLegacyConfig = false;
}; };
my.vault.acmeCertificates."znc.lukegb.com" = { my.vault.acmeCertificates."znc.lukegb.com" = {
extraNames = [ "akiichiro.lukegb.com" ]; hostnames = [ "znc.lukegb.com" "akiichiro.lukegb.com" ];
group = "znc-acme"; group = "znc-acme";
}; };
services.nginx = { services.nginx = {

View file

@ -354,7 +354,7 @@ in {
}; };
}; };
my.vault.acmeCertificates."lukegb.com" = { my.vault.acmeCertificates."lukegb.com" = {
extraNames = [ hostnames = [
"lukegb.com" "lukegb.com"
"*.lukegb.com" "*.lukegb.com"
"*.int.lukegb.com" "*.int.lukegb.com"

View file

@ -10,7 +10,7 @@
}; };
my.vault.acmeCertificates."as205479.net" = { my.vault.acmeCertificates."as205479.net" = {
role = "letsencrypt-gcloud-as205479"; role = "letsencrypt-gcloud-as205479";
extraNames = [ "www.as205479.net" ]; hostnames = [ "as205479.net" "www.as205479.net" ];
nginxVirtualHosts = [ "as205479.net" ]; nginxVirtualHosts = [ "as205479.net" ];
}; };
} }

View file

@ -189,7 +189,7 @@ in
}; };
my.vault.acmeCertificates = { my.vault.acmeCertificates = {
"baserow.lukegb.com" = { "baserow.lukegb.com" = {
extraNames = [ "api.baserow.lukegb.com" "baserow-media.zxcvbnm.ninja" ]; hostnames = [ "baserow.lukegb.com" "api.baserow.lukegb.com" "baserow-media.zxcvbnm.ninja" ];
nginxVirtualHosts = [ "baserow.lukegb.com" "api.baserow.lukegb.com" "baserow-media.zxcvbnm.ninja" ]; nginxVirtualHosts = [ "baserow.lukegb.com" "api.baserow.lukegb.com" "baserow-media.zxcvbnm.ninja" ];
}; };
}; };

View file

@ -18,8 +18,9 @@ in
imports = [ imports = [
../../../third_party/home-manager/nixos ../../../third_party/home-manager/nixos
./vault-agent.nix ./vault-agent.nix
./vault-agent-acme.nix
./vault-agent-secrets.nix ./vault-agent-secrets.nix
./secretsmgr.nix
./secretsmgr-acme.nix
./ssh-ca-vault.nix ./ssh-ca-vault.nix
]; ];

View file

@ -28,6 +28,7 @@ in
}) config.my.fup.listen); }) config.my.fup.listen);
in { in {
my.vault.acmeCertificates."p.lukegb.com" = { my.vault.acmeCertificates."p.lukegb.com" = {
hostnames = [ "p.lukegb.com" ];
nginxVirtualHosts = [ "p.lukegb.com" ]; nginxVirtualHosts = [ "p.lukegb.com" ];
}; };
services.nginx = { services.nginx = {

View file

@ -25,8 +25,8 @@ in
ssl = true; ssl = true;
}) config.my.quotesdb.listen); }) config.my.quotesdb.listen);
in { in {
my.vault.acmeCertificates."bfob.gg" = { my.vault.acmeCertificates.bfob = {
extraNames = [ "*.bfob.gg" ]; hostnames = [ "bfob.gg" "*.bfob.gg" ];
nginxVirtualHosts = [ "qdb.bfob.gg" "quotes.bfob.gg" "dev-quotes.bfob.gg" ]; nginxVirtualHosts = [ "qdb.bfob.gg" "quotes.bfob.gg" "dev-quotes.bfob.gg" ];
}; };
services.nginx = { services.nginx = {

View file

@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
{ pkgs, config, depot, lib, ... }:
let
inherit (lib) mkOption types mkBefore optionalAttrs mkDefault mkIf mapAttrsToList mkAfter;
acmeCertificates = builtins.attrValues config.my.vault.acmeCertificates;
# Work out where we're being asked to write things, and which groups, so we can correctly get permissions.
fullchainPath = c: pathFor c "fullchain.pem";
chainPath = c: pathFor c "chain.pem";
keyPath = c: pathFor c "privkey.pem";
pathFor = c: suffix: "/var/lib/acme/${c.name}/${suffix}";
isNginx = c: builtins.length c.nginxVirtualHosts > 0;
defaultGroup = c: if isNull c.group then if isNginx c then "nginx" else "acme" else c.group;
groupOrDefault = p: c: if isNull p then defaultGroup c else p;
allRestartableUnits = builtins.concatMap (c: c.reloadOrRestartUnits) acmeCertificates;
allGroups = lib.unique (map (c: c.group) acmeCertificates);
in
{
imports = [
./secretsmgr.nix
];
options.my.vault.acmeCertificates = mkOption {
default = {};
type = with types; attrsOf (submodule ({ name, config, ... }: {
options = let
isNginx = builtins.length config.nginxVirtualHosts > 0;
in {
name = mkOption {
type = str;
default = name;
description = "Path to put the certificate.";
};
nginxVirtualHosts = mkOption {
type = listOf str;
default = [];
description = "List of nginx virtual hosts to apply SSL to.";
};
group = mkOption {
type = str;
default = if isNginx then "nginx" else "acme";
description = "Owner group to set for the ${what}. If null, taken from parent.";
};
role = mkOption {
type = str;
default = "letsencrypt-cloudflare";
description = "Which role to use for certificate issuance.";
};
hostnames = mkOption {
type = listOf str;
description = "List of hostnames to issue certificate for.";
};
reloadOrRestartUnits = mkOption {
type = listOf str;
default = lib.optional isNginx "nginx.service";
description = "List of systemd units to reload/restart after obtaining a new certificate.";
};
};
}));
};
config = mkIf config.my.vault.secretsmgr.acmeCertificates.enable {
services.nginx = optionalAttrs config.my.vault.enable {
virtualHosts = builtins.listToAttrs (builtins.concatMap (certData: let
fullchain = fullchainPath certData;
chain = chainPath certData;
key = keyPath certData;
in map (hostName: lib.nameValuePair hostName {
sslCertificate = mkDefault (fullchainPath certData);
sslCertificateKey = mkDefault (keyPath certData);
sslTrustedCertificate = mkDefault (chainPath certData);
}) certData.nginxVirtualHosts) acmeCertificates);
};
my.vault.secretsmgr.groups = mkAfter allGroups;
my.vault.secretsmgr.restartableUnits = mkAfter allRestartableUnits;
my.vault.secretsmgr.acmeCertificates.config = mapAttrsToList (_: c: {
inherit (c) group hostnames role;
output_name = c.name;
units_to_reload_or_restart = c.reloadOrRestartUnits;
}) config.my.vault.acmeCertificates;
};
}

View file

@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
{ depot, pkgs, config, lib, ... }:
let
inherit (lib) mkIf mkOption types concatStringsSep unique optional mkAfter;
pkg = depot.go.secretsmgr;
cfg = config.my.vault.secretsmgr;
jsonFormat = pkgs.formats.json { };
dummyHostKey = pkgs.runCommandLocal "dummy-host-key" {
nativeBuildInputs = [ config.programs.ssh.package ];
} ''
ssh-keygen -q -f $out -N ""
'';
in
{
options.my.vault.secretsmgr = {
enable = mkOption {
type = types.bool;
default = config.my.vault.enable;
};
groups = mkOption {
type = with types; listOf str;
default = [];
};
restartableUnits = mkOption {
type = with types; listOf str;
default = [];
};
acmeCertificates.enable = mkOption {
type = types.bool;
default = true;
};
acmeCertificates.expiryThreshold = mkOption {
type = types.str;
default = "744h";
};
acmeCertificates.config = mkOption {
type = jsonFormat.type;
default = {};
};
acmeCertificates.mount = mkOption {
type = types.str;
default = "acme";
};
sshCertificates.enable = mkOption {
type = types.bool;
default = true;
};
sshCertificates.mount = mkOption {
type = types.str;
default = "ssh-host";
};
sshCertificates.expiryThreshold = mkOption {
type = types.str;
default = "72h";
};
sshCertificates.outputDir = mkOption {
type = types.str;
default = "/var/lib/secretsmgr/ssh";
};
sshCertificates.principals = mkOption {
type = with types; listOf str;
default = let inherit (config.networking) hostName; in [
"${hostName}.as205479.net"
"${hostName}.int.as205479.net"
];
};
sshCertificates.role = mkOption {
type = types.str;
default = config.networking.hostName;
};
};
config = mkIf cfg.enable {
my.vault.secretsmgr.restartableUnits = mkIf cfg.sshCertificates.enable (mkAfter ["sshd.service"]);
users.groups.secretsmgr = {};
users.users.secretsmgr = {
isSystemUser = true;
group = "secretsmgr";
};
systemd.services.secretsmgr = {
requires = ["vault-agent.service"];
after = ["vault-agent.service"];
restartIfChanged = true;
serviceConfig = {
User = "secretsmgr";
Group = "secretsmgr";
SupplementaryGroups = cfg.groups;
AmbientCapabilities = [ "CAP_SETGID" ];
Type = "oneshot";
ExecStart = concatStringsSep " " ([
"${pkg}/bin/secretsmgr"
"--logtostderr"
] ++ lib.optionals cfg.acmeCertificates.enable [
"--acme_certificates_config=${jsonFormat.generate "secretsmgr-acme-certificates-config.json" cfg.acmeCertificates.config}"
"--acme_certificates_expiry_threshold=${cfg.acmeCertificates.expiryThreshold}"
"--acme_certificates_mount=${cfg.acmeCertificates.mount}"
] ++ [
"--sign_ssh_host_keys=${toString cfg.sshCertificates.enable}"
] ++ lib.optionals cfg.sshCertificates.enable [
"--ssh_host_key_ca_path=${cfg.sshCertificates.mount}"
"--ssh_host_key_expiry_threshold=${cfg.sshCertificates.expiryThreshold}"
"--ssh_host_key_output_dir=${cfg.sshCertificates.outputDir}"
"--ssh_host_key_principals=${concatStringsSep "," cfg.sshCertificates.principals}"
"--ssh_host_key_role=${cfg.sshCertificates.role}"
"--ssh_dummy_host_key=${dummyHostKey}"
"--sshd=${config.programs.ssh.package}/bin/sshd"
]);
};
};
systemd.tmpfiles.rules = [
"d /var/lib/acme 0711 secretsmgr secretsmgr - -"
"d /var/lib/secretsmgr 0711 secretsmgr secretsmgr - -"
"d /var/lib/secretsmgr/ssh 0711 secretsmgr secretsmgr - -"
];
security.polkit.extraConfig = lib.mkAfter ''
// NixOS module: depot/lib/secretsmgr.nix
polkit.addRule(function(action, subject) {
if (action.id !== "org.freedesktop.systemd1.manage-units" ||
subject.user !== "secretsmgr") {
return polkit.Result.NOT_HANDLED;
}
var verb = action.lookup("verb");
if (verb !== "restart" && verb !== "reload-or-restart" && verb != "reload-or-try-restart") {
return polkit.Result.NOT_HANDLED;
}
var allowedUnits = ${builtins.toJSON (unique cfg.restartableUnits)};
var unit = action.lookup("unit");
for (var i = 0; i < allowedUnits.length; i++) {
if (allowedUnits[i] === unit) {
return polkit.Result.YES;
}
}
return polkit.Result.NOT_HANDLED;
});
'';
};
}

View file

@ -5,31 +5,11 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
inherit (lib) listToAttrs nameValuePair mkAfter concatMapStrings; inherit (lib) listToAttrs nameValuePair mkAfter concatMapStrings;
#keyTypes = [ "ed25519" "rsa" ];
keyTypes = [ ];
hostKeyForKeyType = keyType: "/etc/ssh/ssh_host_${keyType}_key.pub";
secretNameForKeyType = keyType: "openssh-cert-${keyType}";
signedPaths = map (keyType: config.my.vault.secrets.${secretNameForKeyType keyType}.path) keyTypes;
in { in {
config = { config = {
my.vault.secrets = let services.openssh.extraConfig = ''
hostname = config.networking.hostName; HostCertificate /var/lib/secretsmgr/ssh/ssh_host_ed25519_key-cert.pub
fromKey = keyType: { HostCertificate /var/lib/secretsmgr/ssh/ssh_host_rsa_key-cert.pub
template = ''
{{ with file "${hostKeyForKeyType keyType}" | printf "public_key=%s" | secret "ssh-host/sign/${hostname}" "cert_type=host" "valid_principals=${hostname}.as205479.net,${hostname}.int.as205479.net" }}
{{ .Data.signed_key }}
{{ end }}
'';
group = "root";
reloadOrRestartUnits = [ "sshd.service" ];
};
in listToAttrs (map (keyType: nameValuePair (secretNameForKeyType keyType) (fromKey keyType)) keyTypes);
systemd.services.vault-agent.serviceConfig.ReadOnlyPaths = mkAfter (map hostKeyForKeyType keyTypes);
services.openssh.extraConfig = concatMapStrings (c: "HostCertificate ${c}\n") signedPaths + ''
TrustedUserCAKeys ${../../secrets/client-ca.pub} TrustedUserCAKeys ${../../secrets/client-ca.pub}
AuthorizedPrincipalsCommand /etc/ssh/authorized_principals_cmd %u AuthorizedPrincipalsCommand /etc/ssh/authorized_principals_cmd %u
AuthorizedPrincipalsCommandUser sshd AuthorizedPrincipalsCommandUser sshd

View file

@ -1,224 +0,0 @@
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
{ pkgs, config, depot, lib, ... }:
let
inherit (lib) mkOption types mkBefore optionalAttrs mkDefault;
acmeCertificates = lib.mapAttrsToList (name: cOrig: cOrig // { inherit name; }) config.my.vault.acmeCertificates;
# Work out where we're being asked to write things, and which groups, so we can correctly get permissions.
fullchainPath = c: pathFor c.fullchain c "fullchain.pem";
chainPath = c: pathFor c.chain c "chain.pem";
keyPath = c: pathFor c.key c "privkey.pem";
pathFor = p: c: suffix: if isNull p.path then "/var/lib/acme/${c.name}/${suffix}" else p.path;
isNginx = c: builtins.length c.nginxVirtualHosts > 0;
defaultGroup = c: if isNull c.group then if isNginx c then "nginx" else "acme" else c.group;
groupOrDefault = p: c: if isNull p then defaultGroup c else p;
reloadOrRestartUnits = c: (lib.optional (isNginx c) "nginx.service") ++ c.reloadOrRestartUnits;
acmeCertificatesGroups = lib.unique (lib.filter (x: x != "") (builtins.concatMap (c: [
(groupOrDefault c.fullchain.group c)
(groupOrDefault c.chain.group c)
(groupOrDefault c.key.group c)
]) acmeCertificates));
acmeCertificatesTemplate = map (c: {
contents = ''
{{with secret "acme/certs/${c.role}" "common_name=${c.name}" "alternative_names=${builtins.concatStringsSep "," (builtins.sort builtins.lessThan c.extraNames)}"}}
{{ .Data.cert | writeToFile "${fullchainPath c}" "vault-agent" "${groupOrDefault c.fullchain.group c}" "${c.fullchain.mode}" "newline" }}
{{ .Data.issuer_cert | writeToFile "${chainPath c}" "vault-agent" "${groupOrDefault c.chain.group c}" "${c.chain.mode}" "newline" }}
{{ .Data.private_key | writeToFile "${keyPath c}" "vault-agent" "${groupOrDefault c.key.group c}" "${c.key.mode}" "newline" }}
{{ end }}
'';
destination = "/var/lib/acme/${c.name}/token";
perms = "0600";
command = let
grp = groupOrDefault c.fullchain.group c;
in pkgs.writeShellScript "post-${c.name}-crt" ''
${lib.concatMapStringsSep "\n" (x: ''
/run/current-system/sw/bin/systemctl reload-or-restart ${x}
'') (reloadOrRestartUnits c)}
${lib.concatMapStringsSep "\n" (x: ''
/run/current-system/sw/bin/systemctl restart ${x}
'') c.restartUnits}
${lib.optionalString (c.command != "") c.command}
'';
}) acmeCertificates;
extraWritableDirs = lib.unique (builtins.concatMap (c: [
(dirOf (fullchainPath c))
(dirOf (chainPath c))
(dirOf (keyPath c))
]) acmeCertificates);
acmeCertificatesTmpdirs = lib.unique (builtins.concatMap (c:
let
fullchainDir = dirOf (fullchainPath c);
chainDir = dirOf (chainPath c);
keyDir = dirOf (keyPath c);
fullchainGroup = groupOrDefault c.fullchain.group c;
chainGroup = groupOrDefault c.chain.group c;
keyGroup = groupOrDefault c.key.group c;
dirGroup = if fullchainDir == keyDir && chainDir == keyDir && c.fullchain.makeDir && c.chain.makeDir && c.key.makeDir then if fullchainGroup == keyGroup && fullchainGroup == chainGroup then fullchainGroup else "-" else null;
fullchainDirGroup = if isNull dirGroup then fullchainGroup else dirGroup;
chainDirGroup = if isNull dirGroup then chainGroup else dirGroup;
keyDirGroup = if isNull dirGroup then keyGroup else dirGroup;
in lib.optional c.fullchain.makeDir "d ${fullchainDir} 0750 vault-agent ${fullchainDirGroup} - -"
++ lib.optional c.chain.makeDir "d ${chainDir} 0750 vault-agent ${chainDirGroup} - -"
++ lib.optional c.key.makeDir "d ${keyDir} 0750 vault-agent ${keyDirGroup} - -"
++ [ "d /var/lib/acme/${c.name} 0750 vault-agent - -" ]
) acmeCertificates);
allRestartableUnits = lib.unique (builtins.concatMap (c: (reloadOrRestartUnits c) ++ c.restartUnits) acmeCertificates);
in
{
imports = [
./vault-agent.nix
];
options.my.vault.acmeCertificates = mkOption {
type = with types; attrsOf (submodule {
options = let
fileType = what: defaultMode: submodule {
options = {
path = mkOption {
type = nullOr path;
default = null;
description = "Path to put the ${what}.";
};
mode = mkOption {
type = str;
default = defaultMode;
description = "Mode to set for the ${what}.";
};
group = mkOption {
type = nullOr str;
default = null;
description = "Owner group to set for the ${what}. If null, taken from parent.";
};
makeDir = mkOption {
type = bool;
default = true;
description = "If true, creates the parent directory.";
};
};
};
in {
role = mkOption {
type = str;
default = "letsencrypt-cloudflare";
description = "Which role to use for certificate issuance.";
};
extraNames = mkOption {
type = listOf str;
default = [];
description = "Non-empty list of hostnames to include.";
};
command = mkOption {
type = lines;
default = "";
description = "Command to run after generating the certificate.";
};
reloadOrRestartUnits = mkOption {
type = listOf str;
default = [];
description = "List of systemd units to reload/restart after obtaining a new certificate.";
};
restartUnits = mkOption {
type = listOf str;
default = [];
description = "List of systemd units to restart after obtaining a new certificate.";
};
nginxVirtualHosts = mkOption {
type = listOf str;
default = [];
description = "List of nginx virtual hosts to apply SSL to.";
};
group = mkOption {
type = nullOr str;
default = null;
description = "Owner group to set for the generated files. Defaults to 'acme' unless nginxVirtualHosts is set, in which case it defaults to 'nginx'.";
};
fullchain = mkOption {
type = fileType "certificate's full chain" "0644";
default = {};
};
chain = mkOption {
type = fileType "certificate chain only" "0644";
default = {};
};
key = mkOption {
type = fileType "certificate's key" "0640";
default = {};
};
};
});
default = {};
};
config = {
my.vault.settings = {
# TODO: lukegb: figure out how to not get this to DoS Let's Encrypt.
#template = mkBefore acmeCertificatesTemplate;
};
systemd = optionalAttrs config.my.vault.enable {
services.vault-agent = {
serviceConfig = {
SupplementaryGroups = mkBefore acmeCertificatesGroups;
ReadWritePaths = mkBefore extraWritableDirs;
};
};
tmpfiles.rules = acmeCertificatesTmpdirs;
};
services.nginx = optionalAttrs config.my.vault.enable {
virtualHosts = builtins.listToAttrs (builtins.concatMap (certData: let
fullchain = fullchainPath certData;
chain = chainPath certData;
key = keyPath certData;
in map (hostName: lib.nameValuePair hostName {
sslCertificate = mkDefault (fullchainPath certData);
sslCertificateKey = mkDefault (keyPath certData);
sslTrustedCertificate = mkDefault (chainPath certData);
}) certData.nginxVirtualHosts) acmeCertificates);
};
security.polkit.extraConfig = lib.mkAfter ''
// NixOS module: depot/lib/vault-agent-acme.nix
polkit.addRule(function(action, subject) {
if (action.id !== "org.freedesktop.systemd1.manage-units" ||
subject.user !== "vault-agent") {
return polkit.Result.NOT_HANDLED;
}
var verb = action.lookup("verb");
if (verb !== "restart" && verb !== "reload-or-restart") {
return polkit.Result.NOT_HANDLED;
}
var allowedUnits = ${builtins.toJSON allRestartableUnits};
var unit = action.lookup("unit");
for (var i = 0; i < allowedUnits.length; i++) {
if (allowedUnits[i] === unit) {
return polkit.Result.YES;
}
}
return polkit.Result.NOT_HANDLED;
});
'';
};
}

View file

@ -551,8 +551,8 @@ in {
}; };
my.vault.acmeCertificates = { my.vault.acmeCertificates = {
"plex-totoro.lukegb.com" = { nginxVirtualHosts = [ "plex-totoro.lukegb.com" ]; }; "plex-totoro.lukegb.com" = { hostnames = [ "plex-totoro.lukegb.com" ]; nginxVirtualHosts = [ "plex-totoro.lukegb.com" ]; };
"invoices.lukegb.com" = { nginxVirtualHosts = [ "invoices.lukegb.com" ]; }; "invoices.lukegb.com" = { hostnames = [ "invoices.lukegb.com" ]; nginxVirtualHosts = [ "invoices.lukegb.com" ]; };
}; };
system.stateVersion = "20.03"; system.stateVersion = "20.03";

View file

@ -141,6 +141,7 @@ in {
}; };
}; };
my.vault.acmeCertificates."ha.lukegb.com" = { my.vault.acmeCertificates."ha.lukegb.com" = {
hostnames = [ "ha.lukegb.com" ];
nginxVirtualHosts = [ "ha.lukegb.com" ]; nginxVirtualHosts = [ "ha.lukegb.com" ];
}; };
} }