diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 5f96336de672..cd087c56398c 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -622,6 +622,7 @@ ./services/search/hound.nix ./services/search/kibana.nix ./services/search/solr.nix + ./services/security/certmgr.nix ./services/security/cfssl.nix ./services/security/clamav.nix ./services/security/fail2ban.nix diff --git a/nixos/modules/services/security/certmgr.nix b/nixos/modules/services/security/certmgr.nix new file mode 100644 index 000000000000..22d5817ec4f0 --- /dev/null +++ b/nixos/modules/services/security/certmgr.nix @@ -0,0 +1,194 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.certmgr; + + specs = mapAttrsToList (n: v: rec { + name = n + ".json"; + path = if isAttrs v then pkgs.writeText name (builtins.toJSON v) else v; + }) cfg.specs; + + allSpecs = pkgs.linkFarm "certmgr.d" specs; + + certmgrYaml = pkgs.writeText "certmgr.yaml" (builtins.toJSON { + dir = allSpecs; + default_remote = cfg.defaultRemote; + svcmgr = cfg.svcManager; + before = cfg.validMin; + interval = cfg.renewInterval; + inherit (cfg) metricsPort metricsAddress; + }); + + specPaths = map dirOf (concatMap (spec: + if isAttrs spec then + collect isString (filterAttrsRecursive (n: v: isAttrs v || n == "path") spec) + else + [ spec ] + ) (attrValues cfg.specs)); + + preStart = '' + ${concatStringsSep " \\\n" (["mkdir -p"] ++ map escapeShellArg specPaths)} + ${pkgs.certmgr}/bin/certmgr -f ${certmgrYaml} check + ''; +in +{ + options.services.certmgr = { + enable = mkEnableOption "certmgr"; + + defaultRemote = mkOption { + type = types.str; + default = "127.0.0.1:8888"; + description = "The default CA host:port to use."; + }; + + validMin = mkOption { + default = "72h"; + type = types.str; + description = "The interval before a certificate expires to start attempting to renew it."; + }; + + renewInterval = mkOption { + default = "30m"; + type = types.str; + description = "How often to check certificate expirations and how often to update the cert_next_expires metric."; + }; + + metricsAddress = mkOption { + default = "127.0.0.1"; + type = types.str; + description = "The address for the Prometheus HTTP endpoint."; + }; + + metricsPort = mkOption { + default = 9488; + type = types.ints.u16; + description = "The port for the Prometheus HTTP endpoint."; + }; + + specs = mkOption { + default = {}; + example = literalExample '' + { + exampleCert = + let + domain = "example.com"; + secret = name: "/var/lib/secrets/''${name}.pem"; + in { + service = "nginx"; + action = "reload"; + authority = { + file.path = secret "ca"; + }; + certificate = { + path = secret domain; + }; + private_key = { + owner = "root"; + group = "root"; + mode = "0600"; + path = secret "''${domain}-key"; + }; + request = { + CN = domain; + hosts = [ "mail.''${domain}" "www.''${domain}" ]; + key = { + algo = "rsa"; + size = 2048; + }; + names = { + O = "Example Organization"; + C = "USA"; + }; + }; + }; + otherCert = "/var/certmgr/specs/other-cert.json"; + } + ''; + type = with types; attrsOf (either (submodule { + options = { + service = mkOption { + type = nullOr str; + default = null; + description = "The service on which to perform <action> after fetching."; + }; + + action = mkOption { + type = addCheck str (x: cfg.svcManager == "command" || elem x ["restart" "reload" "nop"]); + default = "nop"; + description = "The action to take after fetching."; + }; + + # These ought all to be specified according to certmgr spec def. + authority = mkOption { + type = attrs; + description = "certmgr spec authority object."; + }; + + certificate = mkOption { + type = nullOr attrs; + description = "certmgr spec certificate object."; + }; + + private_key = mkOption { + type = nullOr attrs; + description = "certmgr spec private_key object."; + }; + + request = mkOption { + type = nullOr attrs; + description = "certmgr spec request object."; + }; + }; + }) path); + description = '' + Certificate specs as described by: + + These will be added to the Nix store, so they will be world readable. + ''; + }; + + svcManager = mkOption { + default = "systemd"; + type = types.enum [ "circus" "command" "dummy" "openrc" "systemd" "sysv" ]; + description = '' + This specifies the service manager to use for restarting or reloading services. + See: . + For how to use the "command" service manager in particular, + see: . + ''; + }; + + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.specs != {}; + message = "Certmgr specs cannot be empty."; + } + { + assertion = !any (hasAttrByPath [ "authority" "auth_key" ]) (attrValues cfg.specs); + message = '' + Inline services.certmgr.specs are added to the Nix store rendering them world readable. + Specify paths as specs, if you want to use include auth_key - or use the auth_key_file option." + ''; + } + ]; + + systemd.services.certmgr = { + description = "certmgr"; + path = mkIf (cfg.svcManager == "command") [ pkgs.bash ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + inherit preStart; + + serviceConfig = { + Restart = "always"; + RestartSec = "10s"; + ExecStart = "${pkgs.certmgr}/bin/certmgr -f ${certmgrYaml}"; + }; + }; + }; +} diff --git a/nixos/release.nix b/nixos/release.nix index 007859259b17..500a33cd303d 100644 --- a/nixos/release.nix +++ b/nixos/release.nix @@ -256,6 +256,7 @@ in rec { tests.buildbot = callTest tests/buildbot.nix {}; tests.cadvisor = callTestOnMatchingSystems ["x86_64-linux"] tests/cadvisor.nix {}; tests.ceph = callTestOnMatchingSystems ["x86_64-linux"] tests/ceph.nix {}; + tests.certmgr = callSubTests tests/certmgr.nix {}; tests.cfssl = callTestOnMatchingSystems ["x86_64-linux"] tests/cfssl.nix {}; tests.chromium = (callSubTestsOnMatchingSystems ["x86_64-linux"] tests/chromium.nix {}).stable or {}; tests.cjdns = callTest tests/cjdns.nix {}; diff --git a/nixos/tests/certmgr.nix b/nixos/tests/certmgr.nix new file mode 100644 index 000000000000..8354c46b85f7 --- /dev/null +++ b/nixos/tests/certmgr.nix @@ -0,0 +1,148 @@ +{ system ? builtins.currentSystem }: + +with import ../lib/testing.nix { inherit system; }; +let + mkSpec = { host, service ? null, action }: { + inherit action; + authority = { + file = { + group = "nobody"; + owner = "nobody"; + path = "/tmp/${host}-ca.pem"; + }; + label = "www_ca"; + profile = "three-month"; + remote = "localhost:8888"; + }; + certificate = { + group = "nobody"; + owner = "nobody"; + path = "/tmp/${host}-cert.pem"; + }; + private_key = { + group = "nobody"; + mode = "0600"; + owner = "nobody"; + path = "/tmp/${host}-key.pem"; + }; + request = { + CN = host; + hosts = [ host "www.${host}" ]; + key = { + algo = "rsa"; + size = 2048; + }; + names = [ + { + C = "US"; + L = "San Francisco"; + O = "Example, LLC"; + ST = "CA"; + } + ]; + }; + inherit service; + }; + + mkCertmgrTest = { svcManager, specs, testScript }: makeTest { + name = "certmgr-" + svcManager; + nodes = { + machine = { config, lib, pkgs, ... }: { + networking.firewall.allowedTCPPorts = with config.services; [ cfssl.port certmgr.metricsPort ]; + networking.extraHosts = "127.0.0.1 imp.example.org decl.example.org"; + + services.cfssl.enable = true; + systemd.services.cfssl.after = [ "cfssl-init.service" "networking.target" ]; + + systemd.services.cfssl-init = { + description = "Initialize the cfssl CA"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = "cfssl"; + Type = "oneshot"; + WorkingDirectory = config.services.cfssl.dataDir; + }; + script = '' + ${pkgs.cfssl}/bin/cfssl genkey -initca ${pkgs.writeText "ca.json" (builtins.toJSON { + hosts = [ "ca.example.com" ]; + key = { + algo = "rsa"; size = 4096; }; + names = [ + { + C = "US"; + L = "San Francisco"; + O = "Internet Widgets, LLC"; + OU = "Certificate Authority"; + ST = "California"; + } + ]; + })} | ${pkgs.cfssl}/bin/cfssljson -bare ca + ''; + }; + + services.nginx = { + enable = true; + virtualHosts = lib.mkMerge (map (host: { + ${host} = { + sslCertificate = "/tmp/${host}-cert.pem"; + sslCertificateKey = "/tmp/${host}-key.pem"; + extraConfig = '' + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ''; + onlySSL = true; + serverName = host; + root = pkgs.writeTextDir "index.html" "It works!"; + }; + }) [ "imp.example.org" "decl.example.org" ]); + }; + + systemd.services.nginx.wantedBy = lib.mkForce []; + + systemd.services.certmgr.after = [ "cfssl.service" ]; + services.certmgr = { + enable = true; + inherit svcManager; + inherit specs; + }; + + }; + }; + inherit testScript; + }; +in +{ + systemd = mkCertmgrTest { + svcManager = "systemd"; + specs = { + decl = mkSpec { host = "decl.example.org"; service = "nginx"; action ="restart"; }; + imp = toString (pkgs.writeText "test.json" (builtins.toJSON ( + mkSpec { host = "imp.example.org"; service = "nginx"; action = "restart"; } + ))); + }; + testScript = '' + $machine->waitForUnit('cfssl.service'); + $machine->waitUntilSucceeds('ls /tmp/decl.example.org-ca.pem'); + $machine->waitUntilSucceeds('ls /tmp/decl.example.org-key.pem'); + $machine->waitUntilSucceeds('ls /tmp/decl.example.org-cert.pem'); + $machine->waitUntilSucceeds('ls /tmp/imp.example.org-ca.pem'); + $machine->waitUntilSucceeds('ls /tmp/imp.example.org-key.pem'); + $machine->waitUntilSucceeds('ls /tmp/imp.example.org-cert.pem'); + $machine->waitForUnit('nginx.service'); + $machine->succeed('[ "1" -lt "$(journalctl -u nginx | grep "Starting Nginx" | wc -l)" ]'); + $machine->succeed('curl --cacert /tmp/imp.example.org-ca.pem https://imp.example.org'); + $machine->succeed('curl --cacert /tmp/decl.example.org-ca.pem https://decl.example.org'); + ''; + }; + + command = mkCertmgrTest { + svcManager = "command"; + specs = { + test = mkSpec { host = "command.example.org"; action = "touch /tmp/command.executed"; }; + }; + testScript = '' + $machine->waitForUnit('cfssl.service'); + $machine->waitUntilSucceeds('stat /tmp/command.executed'); + ''; + }; + +}