diff --git a/ops/nixos/lib/common.nix b/ops/nixos/lib/common.nix index 3e8f8fe568..534a2927fe 100644 --- a/ops/nixos/lib/common.nix +++ b/ops/nixos/lib/common.nix @@ -18,6 +18,7 @@ in imports = [ ../../../third_party/home-manager/nixos ./vault-agent.nix + ./vault-agent-acme.nix ]; options.my.specialisationName = lib.mkOption { diff --git a/ops/nixos/lib/vault-agent-acme.nix b/ops/nixos/lib/vault-agent-acme.nix new file mode 100644 index 0000000000..3233859bbe --- /dev/null +++ b/ops/nixos/lib/vault-agent-acme.nix @@ -0,0 +1,186 @@ +# SPDX-FileCopyrightText: 2020 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ pkgs, config, depot, lib, ... }: +let + inherit (lib) mkOption types mkBefore optionalAttrs; + + # Work out where we're being asked to write things, and which groups, so we can correctly get permissions. + certPath = c: pathFor c.certificate c "cert.pem"; + keyPath = c: pathFor c.certificate c "privkey.pem"; + pathFor = p: c: suffix: if isNull p.path then "/var/lib/acme/${c.name}/${suffix}" else p.path; + + acmeCertificatesGroups = lib.unique (lib.filter (x: x != "") (builtins.concatMap (c: [ c.certificate.group c.key.group ]) config.my.vault.acmeCertificates)); + + acmeCertificatesTemplate = builtins.concatMap (c: let + secretStanza = '' + secret "acme/certs/${c.role}" "common_name=${c.name}" "alternative_names=${builtins.concatStringsSep "," (builtins.sort builtins.lessThan c.extraNames)}" + ''; + in [ + { + # Certificate + contents = '' + {{with ${secretStanza}}} + {{ .Data.cert }}{{ end }} + ''; + destination = certPath c; + perms = c.certificate.mode; + command = pkgs.writeShellScript "post-${c.name}-crt" '' + ${lib.optionalString (c.certificate.group != "") '' + chgrp "${c.certificate.group}" "${certPath c}" + ''} + ${lib.concatMapStringsSep "\n" (x: '' + /run/current-system/sw/bin/systemctl reload-or-restart ${x} + '') c.reloadOrRestartUnits} + ${lib.concatMapStringsSep "\n" (x: '' + /run/current-system/sw/bin/systemctl restart ${x} + '') c.restartUnits} + ${lib.optionalString (c.command != "") c.command} + ''; + } { + # Key + contents = '' + {{with ${secretStanza}}} + {{ .Data.private_key }}{{ end }} + ''; + destination = keyPath c; + perms = c.key.mode; + command = pkgs.writeShellScript "post-${c.name}-key" '' + ${lib.optionalString (c.key.group != "") '' + chgrp "${c.key.group}" "${keyPath c}" + ''} + ''; + } + ]) config.my.vault.acmeCertificates; + + acmeCertificatesTmpdirs = lib.unique (builtins.concatMap (c: + let + certDir = dirOf (certPath c); + keyDir = dirOf (keyPath c); + + dirGroup = if certDir == keyDir && c.certificate.makeDir && c.key.makeDir then if c.certificate.group == c.key.group then c.certificate.group else "-" else null; + + certDirGroup = if isNull dirGroup then c.certificate.group else dirGroup; + keyDirGroup = if isNull dirGroup then c.certificate.group else dirGroup; + in lib.optional c.certificate.makeDir "d ${certDir} 0750 vault-agent ${certDirGroup} - -" + ++ lib.optional c.key.makeDir "d ${keyDir} 0750 vault-agent ${keyDirGroup} - -" + ) config.my.vault.acmeCertificates); + + allRestartableUnits = lib.unique (builtins.concatMap (c: c.reloadOrRestartUnits ++ c.restartUnits) config.my.vault.acmeCertificates); +in +{ + options.my.vault.acmeCertificates = mkOption { + type = with types; listOf (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 = str; + default = "acme"; + description = "Owner group to set for the ${what}."; + }; + + makeDir = mkOption { + type = bool; + default = true; + description = "If true, creates the parent directory."; + }; + }; + }; + in { + role = mkOption { + type = str; + default = "letsencrypt-prod-cloudflare"; + description = "Which role to use for certificate issuance."; + }; + name = mkOption { + type = str; + description = "First hostname for the certificate."; + }; + 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."; + }; + + certificate = mkOption { + type = fileType "certificate" "0644"; + default = {}; + }; + key = mkOption { + type = fileType "certificate's key" "0640"; + default = {}; + }; + }; + }); + default = []; + }; + + config = { + my.vault.settings = { + template = mkBefore acmeCertificatesTemplate; + }; + + systemd = optionalAttrs config.my.vault.enable { + services.vault-agent = { + serviceConfig = { + SupplementaryGroups = mkBefore acmeCertificatesGroups; + }; + }; + + tmpfiles.rules = acmeCertificatesTmpdirs; + }; + + 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/lib/vault-agent.nix b/ops/nixos/lib/vault-agent.nix index a96e983e24..c092b03c85 100644 --- a/ops/nixos/lib/vault-agent.nix +++ b/ops/nixos/lib/vault-agent.nix @@ -4,10 +4,12 @@ { pkgs, config, depot, lib, ... }: let - inherit (lib) mkEnableOption mkOption types; + inherit (lib) mkEnableOption mkOption types mkBefore; mkDefault = lib.mkOverride 900; format = pkgs.formats.json {}; + + templatePathDirectories = lib.unique (map (t: dirOf t.destination) config.my.vault.settings.template); in { options.my.vault = { @@ -32,10 +34,10 @@ in config = { my.vault.enable = mkDefault true; - my.vault.settings = mkDefault { - pid_file = "/run/vault-agent/pid"; - vault.address = "https://vault.int.lukegb.com"; - auto_auth.method = [{ + my.vault.settings = { + pid_file = mkDefault "/run/vault-agent/pid"; + vault.address = mkDefault "https://vault.int.lukegb.com"; + auto_auth.method = mkDefault [{ type = "approle"; config = { role_id_file_path = pkgs.writeText "${config.my.vault.roleID}-role-id" config.my.vault.roleID; @@ -43,14 +45,17 @@ in remove_secret_id_file_after_reading = false; }; }]; - cache.use_auto_auth_token = true; + cache.use_auto_auth_token = mkDefault true; listener.tcp = { - address = "127.0.0.1:8200"; - tls_disable = true; + address = mkDefault "127.0.0.1:8200"; + tls_disable = mkDefault true; }; }; + users.groups.vault-agent = {}; + users.users.vault-agent = { isSystemUser = true; group = "vault-agent"; }; + systemd = lib.optionalAttrs config.my.vault.enable { services.vault-agent = { description = "Hashicorp Vault Agent"; @@ -64,12 +69,14 @@ in StateDirectory = "vault-agent"; StateDirectoryMode = "0700"; - DynamicUser = true; User = "vault-agent"; + NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = "yes"; + ReadWritePaths = templatePathDirectories; + ExecStart = "${pkgs.vault}/bin/vault agent -config=${format.generate "vault-agent.json" config.my.vault.settings}"; }; }; diff --git a/ops/nixos/totoro/default.nix b/ops/nixos/totoro/default.nix index 6b7eaa6dbe..692a8a2ad9 100644 --- a/ops/nixos/totoro/default.nix +++ b/ops/nixos/totoro/default.nix @@ -557,5 +557,17 @@ in { }; }; + my.vault.acmeCertificates = [ + { + name = "lukegb.com"; + role = "lukegb.com-staging"; + extraNames = [ "*.lukegb.com" "*.int.lukegb.com" ]; + restartUnits = [ "nginx.service" ]; + #certificate.path = "/tmp/lukegb.com.crt"; + #key.path = "/tmp/lukegb.com.key"; + key.group = "acme"; + } + ]; + system.stateVersion = "20.03"; }