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');
+ '';
+ };
+
+}