From 33505524b4c27e573ba7c816f225d5736776511f Mon Sep 17 00:00:00 2001
From: ibizaman <ibizapeanut@gmail.com>
Date: Wed, 24 Jan 2024 22:41:18 -0800
Subject: [PATCH] add group and reloadServices options to ssl block

---
 modules/blocks/ssl.nix             | 52 ++++++++++++++++++++++++--
 modules/blocks/ssl/docs/default.md | 11 ++++++
 test/vm/ssl.nix                    | 59 ++++++++++++++++++++++++++++++
 3 files changed, 119 insertions(+), 3 deletions(-)

diff --git a/modules/blocks/ssl.nix b/modules/blocks/ssl.nix
index 5cc1ad5..b667138 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 a241840..1e9784c 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 619e882..e66104a 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")