diff --git a/modules/blocks/ssl.nix b/modules/blocks/ssl.nix index 5cc1ad53..b667138f 100644 --- a/modules/blocks/ssl.nix +++ b/modules/blocks/ssl.nix @@ -87,6 +87,15 @@ in ''; }; + group = lib.mkOption { + type = lib.types.str; + description = '' + Unix group owning this certificate. + ''; + default = "root"; + example = "nginx"; + }; + paths = lib.mkOption { description = '' Paths where certs will be located. @@ -105,6 +114,15 @@ in type = lib.types.str; default = "shb-certs-cert-selfsigned-${config._module.args.name}.service"; }; + + reloadServices = lib.mkOption { + description = '' + The list of systemd services to call `systemctl try-reload-or-restart` on. + ''; + type = lib.types.listOf lib.types.str; + default = []; + example = [ "nginx.service" ]; + }; }; })); }; @@ -150,12 +168,30 @@ in }; }; + group = lib.mkOption { + type = lib.types.nullOr lib.types.str; + description = '' + Unix group owning this certificate. + ''; + default = "acme"; + example = "nginx"; + }; + systemdService = lib.mkOption { description = "Systemd oneshot service used to generate the certs."; type = lib.types.str; default = "shb-certs-cert-letsencrypt-${config._module.args.name}.service"; }; + reloadServices = lib.mkOption { + description = '' + The list of systemd services to call `systemctl try-reload-or-restart` on. + ''; + type = lib.types.listOf lib.types.str; + default = []; + example = [ "nginx.service" ]; + }; + dnsProvider = lib.mkOption { description = "DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers."; type = lib.types.nullOr lib.types.str; @@ -245,7 +281,6 @@ in before = [ config.shb.certs.systemdService ]; serviceConfig.Type = "oneshot"; serviceConfig.RuntimeDirectory = serviceName caCfg.systemdService; - # serviceConfig.User = "nextcloud"; # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix script = '' cd $RUNTIME_DIRECTORY @@ -278,6 +313,7 @@ in } ) cfg.cas.selfsigned; } + # Config for self-signed CA bundle. { systemd.services.${serviceName config.shb.certs.systemdService} = (lib.mkIf (cfg.cas.selfsigned != {}) { wantedBy = [ "multi-user.target" ]; @@ -309,6 +345,11 @@ in script = let extraDnsNames = lib.strings.concatStringsSep "\n" (map (n: "dns_name = ${n}") certCfg.extraDomains); + chmod = cert: + '' + chown root:${certCfg.group} ${cert} + chmod 640 ${cert} + ''; in '' cd $RUNTIME_DIRECTORY @@ -330,7 +371,7 @@ in --key-type rsa \ --sec-param High \ --outfile ${certCfg.paths.key} - chmod 666 ${certCfg.paths.key} + ${chmod certCfg.paths.key} mkdir -p "$(dirname -- "${certCfg.paths.cert}")" ${pkgs.gnutls}/bin/certtool \ @@ -340,7 +381,11 @@ in --load-ca-certificate ${certCfg.ca.paths.cert} \ --template server.template \ --outfile ${certCfg.paths.cert} - chmod 666 ${certCfg.paths.cert} + ${chmod certCfg.paths.cert} + ''; + + postStart = lib.optionalString (certCfg.reloadServices != []) '' + systemctl --no-block try-reload-or-restart ${lib.escapeShellArgs certCfg.reloadServices} ''; serviceConfig.Type = "oneshot"; @@ -363,6 +408,7 @@ in extraDomainNames = [ certCfg.domain ] ++ certCfg.extraDomains; email = certCfg.adminEmail; inherit (certCfg) dnsProvider dnsResolver; + inherit (certCfg) group reloadServices; credentialsFile = certCfg.credentialsFile; enableDebugLogs = certCfg.debug; }; diff --git a/modules/blocks/ssl/docs/default.md b/modules/blocks/ssl/docs/default.md index a2418401..1e9784cd 100644 --- a/modules/blocks/ssl/docs/default.md +++ b/modules/blocks/ssl/docs/default.md @@ -20,6 +20,10 @@ The contract for this block is defined in [`/modules/contracts/ssl.nix`](@REPO@/ Every module implementing this contract provides the following options: +- `domain`: Domain to generate the certificate for. +- `extraDomains`: Other domains the certificate should be generated for. +- `group`: The unix group owning this certificate. +- `reloadServices`: Systemd services to reload when the certificate gets renewed. - `paths.cert`: Path to the cert file. - `paths.key`: Path to the key file. - `systemdService`: Systemd oneshot service used to generate the certificate. @@ -53,15 +57,21 @@ shb.certs.certs.selfsigned = { ca = config.shb.certs.cas.selfsigned.myca; domain = "example.com"; + group = "nginx"; + reloadServices = [ "nginx.service" ]; }; "www.example.com" = { ca = config.shb.certs.cas.selfsigned.myca; domain = "www.example.com"; + group = "nginx"; }; }; ``` +The group has been chosen to be `nginx` to be consistent with the examples further down in this +document. + ### Let's Encrypt {#ssl-block-impl-lets-encrypt} Defined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix). @@ -71,6 +81,7 @@ We can ask Let's Encrypt to generate a certificate with: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; + group = "nginx"; dnsProvider = "linode"; adminEmail = "admin@example.com"; credentialsFile = /path/to/secret/file; diff --git a/test/vm/ssl.nix b/test/vm/ssl.nix index 619e882a..e66104ab 100644 --- a/test/vm/ssl.nix +++ b/test/vm/ssl.nix @@ -8,6 +8,21 @@ ../../modules/blocks/ssl.nix ]; + users.users = { + user1 = { + group = "group1"; + isSystemUser = true; + }; + user2 = { + group = "group2"; + isSystemUser = true; + }; + }; + users.groups = { + group1 = {}; + group2 = {}; + }; + shb.certs = { cas.selfsigned = { myca = { @@ -22,17 +37,32 @@ ca = config.shb.certs.cas.selfsigned.myca; domain = "example.com"; + group = "nginx"; }; subdomain = { ca = config.shb.certs.cas.selfsigned.myca; domain = "subdomain.example.com"; + group = "nginx"; }; multi = { ca = config.shb.certs.cas.selfsigned.myca; domain = "multi1.example.com"; extraDomains = [ "multi2.example.com" "multi3.example.com" ]; + group = "nginx"; + }; + + cert1 = { + ca = config.shb.certs.cas.selfsigned.myca; + + domain = "cert1.example.com"; + }; + cert2 = { + ca = config.shb.certs.cas.selfsigned.myca; + + domain = "cert2.example.com"; + group = "group2"; }; }; }; @@ -81,6 +111,9 @@ top = nodes.server.shb.certs.certs.selfsigned.top; subdomain = nodes.server.shb.certs.certs.selfsigned.subdomain; multi = nodes.server.shb.certs.certs.selfsigned.multi; + cert1 = nodes.server.shb.certs.certs.selfsigned.cert1; + cert2 = nodes.server.shb.certs.certs.selfsigned.cert2; + cert3 = nodes.server.shb.certs.certs.selfsigned.cert3; in '' start_all() @@ -96,12 +129,27 @@ server.wait_for_file("${subdomain.paths.cert}") server.wait_for_file("${multi.paths.key}") server.wait_for_file("${multi.paths.cert}") + server.wait_for_file("${cert1.paths.key}") + server.wait_for_file("${cert1.paths.cert}") + server.wait_for_file("${cert2.paths.key}") + server.wait_for_file("${cert2.paths.cert}") server.require_unit_state("${nodes.server.shb.certs.systemdService}", "inactive") server.wait_for_unit("nginx") server.wait_for_open_port(443) + def assert_owner(path, user, group): + owner = server.succeed("stat --format '%U:%G' {}".format(path)).strip(); + want_owner = user + ":" + group + if owner != want_owner: + raise Exception('Unexpected owner for {}: wanted "{}", got: "{}"'.format(path, want_owner, owner)) + + def assert_perm(path, want_perm): + perm = server.succeed("stat --format '%a' {}".format(path)).strip(); + if perm != want_perm: + raise Exception('Unexpected perm for {}: wanted "{}", got: "{}"'.format(path, want_perm, perm)) + with subtest("Certificate is trusted in curl"): resp = server.succeed("curl --fail-with-body -v https://example.com") if resp != "Top domain": @@ -123,6 +171,17 @@ if resp != "multi3": raise Exception('Unexpected response, got: {}'.format(resp)) + with subtest("Certificate has correct permission"): + assert_owner("${cert1.paths.key}", "root", "root") + assert_owner("${cert1.paths.cert}", "root", "root") + assert_perm("${cert1.paths.key}", "640") + assert_perm("${cert1.paths.cert}", "640") + + assert_owner("${cert2.paths.key}", "root", "group2") + assert_owner("${cert2.paths.cert}", "root", "group2") + assert_perm("${cert2.paths.key}", "640") + assert_perm("${cert2.paths.cert}", "640") + with subtest("Fail if certificate is not in CA bundle"): server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://subdomain.example.com")