From b719181dfea7473cef4f1a4c0aad7a82a3af1c44 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Thu, 17 Mar 2022 23:31:55 +0000 Subject: [PATCH] nixos: migrate to secretsmgr for sshd and ACME --- go/secretsmgr/secretsmgr.go | 44 ++++- ops/nixos/blade-tuvok/default.nix | 2 +- ops/nixos/bvm-matrix/default.nix | 2 +- ops/nixos/bvm-prosody/default.nix | 5 +- ops/nixos/bvm-radius/default.nix | 1 + ops/nixos/clouvider-lon01/default.nix | 2 +- ops/nixos/etheroute-lon01/default.nix | 2 +- ops/nixos/lib/as205479-web.nix | 2 +- ops/nixos/lib/baserow.nix | 2 +- ops/nixos/lib/common.nix | 3 +- ops/nixos/lib/fup.nix | 1 + ops/nixos/lib/quotes.bfob.gg.nix | 4 +- ops/nixos/lib/secretsmgr-acme.nix | 93 +++++++++++ ops/nixos/lib/secretsmgr.nix | 155 ++++++++++++++++++ ops/nixos/lib/ssh-ca-vault.nix | 26 +-- ops/nixos/lib/vault-agent-acme.nix | 224 -------------------------- ops/nixos/totoro/default.nix | 4 +- ops/nixos/totoro/home-assistant.nix | 1 + 18 files changed, 309 insertions(+), 264 deletions(-) create mode 100644 ops/nixos/lib/secretsmgr-acme.nix create mode 100644 ops/nixos/lib/secretsmgr.nix delete mode 100644 ops/nixos/lib/vault-agent-acme.nix diff --git a/go/secretsmgr/secretsmgr.go b/go/secretsmgr/secretsmgr.go index 4f210826c0..d2292515da 100644 --- a/go/secretsmgr/secretsmgr.go +++ b/go/secretsmgr/secretsmgr.go @@ -37,6 +37,7 @@ var ( 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") 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") 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) { - 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 { return nil, err } @@ -69,7 +74,11 @@ func sshHostKeyPaths(ctx context.Context) ([]string, error) { for scanner.Scan() { t := scanner.Text() 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 { @@ -226,7 +235,7 @@ func shouldRenewSSHCert(certPath string, publicKey []byte, principals string) (b } func reloadOrTryRestartServices(ctx context.Context, services []string) error { - sd, err := dbus.NewSystemdConnection() + sd, err := dbus.NewWithContext(ctx) if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } @@ -352,7 +361,9 @@ func setGroup(groupName string) (func(), error) { } origGid := unix.Getegid() - unix.Setregid(-1, gid) + if err := unix.Setregid(-1, gid); err != nil { + return nil, err + } return func() { unix.Setregid(-1, origGid) }, nil @@ -514,6 +525,18 @@ func writeCertificate(certDef acmeCertificate, cert *vapi.Secret) error { 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 { certDefs, err := parseACMECertificates() if err != nil { @@ -523,6 +546,7 @@ func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool { log.Infof("acme certificate defs: %#v", certDefs) var hadErr bool + var unitsToReloadOrRestart []string for _, certDef := range certDefs { 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 { - log.Errorf("creating/fixing dir %q: %w", certDef.OutputDir(), err) + log.Errorf("creating/fixing dir %q: %v", certDef.OutputDir(), err) hadErr = true continue } @@ -565,6 +589,16 @@ func checkAndRenewACMECertificates(ctx context.Context, c *vapi.Client) bool { hadErr = true 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 diff --git a/ops/nixos/blade-tuvok/default.nix b/ops/nixos/blade-tuvok/default.nix index a1e2be3c7b..96cb392ea1 100644 --- a/ops/nixos/blade-tuvok/default.nix +++ b/ops/nixos/blade-tuvok/default.nix @@ -82,7 +82,7 @@ in { }; }; my.vault.acmeCertificates."objdump.zxcvbnm.ninja" = { - extraNames = [ "*.objdump.zxcvbnm.ninja" ]; + hostnames = [ "objdump.zxcvbnm.ninja" "*.objdump.zxcvbnm.ninja" ]; nginxVirtualHosts = [ "objdump.zxcvbnm.ninja" ]; }; my.fup.listen = [ diff --git a/ops/nixos/bvm-matrix/default.nix b/ops/nixos/bvm-matrix/default.nix index 9f0712fde1..d604ae1856 100644 --- a/ops/nixos/bvm-matrix/default.nix +++ b/ops/nixos/bvm-matrix/default.nix @@ -167,7 +167,7 @@ in { my.vault.acmeCertificates."matrix.zxcvbnm.ninja" = { 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" ]; }; diff --git a/ops/nixos/bvm-prosody/default.nix b/ops/nixos/bvm-prosody/default.nix index e9d693fcbf..6aa6de10a1 100644 --- a/ops/nixos/bvm-prosody/default.nix +++ b/ops/nixos/bvm-prosody/default.nix @@ -93,10 +93,13 @@ in { my.vault.acmeCertificates = { "xmpp.lukegb.com" = { group = "prosody"; - extraNames = [ "*.xmpp.lukegb.com" "lukegb.com" ]; + hostnames = [ "xmpp.lukegb.com" "*.xmpp.lukegb.com" "lukegb.com" ]; + reloadOrRestartUnits = [ "prosody.service" ]; }; "turn.lukegb.com" = { group = "turnserver"; + hostnames = [ "turn.lukegb.com" ]; + reloadOrRestartUnits = [ "coturn.service" ]; }; }; diff --git a/ops/nixos/bvm-radius/default.nix b/ops/nixos/bvm-radius/default.nix index c68a2d29d0..5da38c6f08 100644 --- a/ops/nixos/bvm-radius/default.nix +++ b/ops/nixos/bvm-radius/default.nix @@ -56,6 +56,7 @@ in { my.vault.acmeCertificates."as205479.net" = { group = "acme"; + hostnames = [ "as205479.net" ]; reloadOrRestartUnits = [ "freeradius.service" ]; }; users.users.nginx.extraGroups = lib.mkAfter [ "acme" ]; diff --git a/ops/nixos/clouvider-lon01/default.nix b/ops/nixos/clouvider-lon01/default.nix index 4f6d38913f..1e07c03098 100644 --- a/ops/nixos/clouvider-lon01/default.nix +++ b/ops/nixos/clouvider-lon01/default.nix @@ -193,7 +193,7 @@ in { useLegacyConfig = false; }; my.vault.acmeCertificates."znc.lukegb.com" = { - extraNames = [ "akiichiro.lukegb.com" ]; + hostnames = [ "znc.lukegb.com" "akiichiro.lukegb.com" ]; group = "znc-acme"; }; services.nginx = { diff --git a/ops/nixos/etheroute-lon01/default.nix b/ops/nixos/etheroute-lon01/default.nix index 40efd25191..17a7ac47af 100644 --- a/ops/nixos/etheroute-lon01/default.nix +++ b/ops/nixos/etheroute-lon01/default.nix @@ -354,7 +354,7 @@ in { }; }; my.vault.acmeCertificates."lukegb.com" = { - extraNames = [ + hostnames = [ "lukegb.com" "*.lukegb.com" "*.int.lukegb.com" diff --git a/ops/nixos/lib/as205479-web.nix b/ops/nixos/lib/as205479-web.nix index 733eb9a44b..4aca3cdba1 100644 --- a/ops/nixos/lib/as205479-web.nix +++ b/ops/nixos/lib/as205479-web.nix @@ -10,7 +10,7 @@ }; my.vault.acmeCertificates."as205479.net" = { role = "letsencrypt-gcloud-as205479"; - extraNames = [ "www.as205479.net" ]; + hostnames = [ "as205479.net" "www.as205479.net" ]; nginxVirtualHosts = [ "as205479.net" ]; }; } diff --git a/ops/nixos/lib/baserow.nix b/ops/nixos/lib/baserow.nix index d2d7d6b1dc..712c15e9e9 100644 --- a/ops/nixos/lib/baserow.nix +++ b/ops/nixos/lib/baserow.nix @@ -189,7 +189,7 @@ in }; my.vault.acmeCertificates = { "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" ]; }; }; diff --git a/ops/nixos/lib/common.nix b/ops/nixos/lib/common.nix index c0ee81197e..7fec64b8c8 100644 --- a/ops/nixos/lib/common.nix +++ b/ops/nixos/lib/common.nix @@ -18,8 +18,9 @@ in imports = [ ../../../third_party/home-manager/nixos ./vault-agent.nix - ./vault-agent-acme.nix ./vault-agent-secrets.nix + ./secretsmgr.nix + ./secretsmgr-acme.nix ./ssh-ca-vault.nix ]; diff --git a/ops/nixos/lib/fup.nix b/ops/nixos/lib/fup.nix index ac0ad2987e..0363126248 100644 --- a/ops/nixos/lib/fup.nix +++ b/ops/nixos/lib/fup.nix @@ -28,6 +28,7 @@ in }) config.my.fup.listen); in { my.vault.acmeCertificates."p.lukegb.com" = { + hostnames = [ "p.lukegb.com" ]; nginxVirtualHosts = [ "p.lukegb.com" ]; }; services.nginx = { diff --git a/ops/nixos/lib/quotes.bfob.gg.nix b/ops/nixos/lib/quotes.bfob.gg.nix index 0cb390e093..8df0990e72 100644 --- a/ops/nixos/lib/quotes.bfob.gg.nix +++ b/ops/nixos/lib/quotes.bfob.gg.nix @@ -25,8 +25,8 @@ in ssl = true; }) config.my.quotesdb.listen); in { - my.vault.acmeCertificates."bfob.gg" = { - extraNames = [ "*.bfob.gg" ]; + my.vault.acmeCertificates.bfob = { + hostnames = [ "bfob.gg" "*.bfob.gg" ]; nginxVirtualHosts = [ "qdb.bfob.gg" "quotes.bfob.gg" "dev-quotes.bfob.gg" ]; }; services.nginx = { diff --git a/ops/nixos/lib/secretsmgr-acme.nix b/ops/nixos/lib/secretsmgr-acme.nix new file mode 100644 index 0000000000..da69d275c5 --- /dev/null +++ b/ops/nixos/lib/secretsmgr-acme.nix @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2022 Luke Granger-Brown +# +# 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; + }; +} diff --git a/ops/nixos/lib/secretsmgr.nix b/ops/nixos/lib/secretsmgr.nix new file mode 100644 index 0000000000..9a8decc3a1 --- /dev/null +++ b/ops/nixos/lib/secretsmgr.nix @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: 2022 Luke Granger-Brown +# +# 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; + }); + ''; + + }; + +} diff --git a/ops/nixos/lib/ssh-ca-vault.nix b/ops/nixos/lib/ssh-ca-vault.nix index 314515e198..8447401f01 100644 --- a/ops/nixos/lib/ssh-ca-vault.nix +++ b/ops/nixos/lib/ssh-ca-vault.nix @@ -5,31 +5,11 @@ { config, lib, pkgs, ... }: let 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 { config = { - my.vault.secrets = let - hostname = config.networking.hostName; - fromKey = keyType: { - 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 + '' + services.openssh.extraConfig = '' + HostCertificate /var/lib/secretsmgr/ssh/ssh_host_ed25519_key-cert.pub + HostCertificate /var/lib/secretsmgr/ssh/ssh_host_rsa_key-cert.pub TrustedUserCAKeys ${../../secrets/client-ca.pub} AuthorizedPrincipalsCommand /etc/ssh/authorized_principals_cmd %u AuthorizedPrincipalsCommandUser sshd diff --git a/ops/nixos/lib/vault-agent-acme.nix b/ops/nixos/lib/vault-agent-acme.nix deleted file mode 100644 index 746b9fe74c..0000000000 --- a/ops/nixos/lib/vault-agent-acme.nix +++ /dev/null @@ -1,224 +0,0 @@ -# SPDX-FileCopyrightText: 2020 Luke Granger-Brown -# -# 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; - }); - ''; - }; -} diff --git a/ops/nixos/totoro/default.nix b/ops/nixos/totoro/default.nix index de28caefdb..76bef43f17 100644 --- a/ops/nixos/totoro/default.nix +++ b/ops/nixos/totoro/default.nix @@ -551,8 +551,8 @@ in { }; my.vault.acmeCertificates = { - "plex-totoro.lukegb.com" = { nginxVirtualHosts = [ "plex-totoro.lukegb.com" ]; }; - "invoices.lukegb.com" = { nginxVirtualHosts = [ "invoices.lukegb.com" ]; }; + "plex-totoro.lukegb.com" = { hostnames = [ "plex-totoro.lukegb.com" ]; nginxVirtualHosts = [ "plex-totoro.lukegb.com" ]; }; + "invoices.lukegb.com" = { hostnames = [ "invoices.lukegb.com" ]; nginxVirtualHosts = [ "invoices.lukegb.com" ]; }; }; system.stateVersion = "20.03"; diff --git a/ops/nixos/totoro/home-assistant.nix b/ops/nixos/totoro/home-assistant.nix index b2c7c2bd33..c742c08691 100644 --- a/ops/nixos/totoro/home-assistant.nix +++ b/ops/nixos/totoro/home-assistant.nix @@ -141,6 +141,7 @@ in { }; }; my.vault.acmeCertificates."ha.lukegb.com" = { + hostnames = [ "ha.lukegb.com" ]; nginxVirtualHosts = [ "ha.lukegb.com" ]; }; }