diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 111bdc03..d5eaf15b 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -41,6 +41,11 @@ let # intermittent one. secretPath = "/var/lib/grafana-oidc/${cfg.oidc.clientId}.secret"; + # Grafana's own datasource-encryption key. Generated in-container (see the + # unit below) because nothing outside the container ever reads it — unlike + # the OIDC secret above, whose other reader is authelia's container. + secretKeyPath = "/var/lib/grafana-secret/secret_key"; + # Format-locked by Grafana: the generic OAuth callback is always # `/login/generic_oauth`. Declared once here and read by both # the authelia client and Grafana itself. @@ -344,6 +349,52 @@ in systemd.services.grafana.environment.SSL_CERT_FILE = lib.mkIf useSelfSigned grafanaCaBundle; + # Grafana's `secret_key` has **no default in nixpkgs** and an + # assertion refuses the build without one — which is how the first + # version of this module broke a deploy. It signs the datasource + # secrets in Grafana's own database. + # + # Generated in-container on first boot, like authelia's own keys and + # for the same reason: nothing outside this container ever reads it, + # which makes in-container generation right rather than merely + # easier. (The OIDC client secret is delivered host-side precisely + # because it has a second reader.) + # + # ⚠️ Generated ONCE and kept — the `-s` guard is load-bearing. + # Rotating this key does not re-encrypt what it already encrypted, so + # a fresh key on every boot would leave Grafana unable to decrypt its + # own stored datasource secrets. Under /var/lib, never /run. + systemd.services.swarm-grafana-secret-key = { + description = "generate Grafana's datasource encryption key on first boot"; + wantedBy = [ "grafana.service" ]; + before = [ "grafana.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Same User/Group/StateDirectory as grafana itself, so systemd + # creates the directory owned by the account that has to read + # the file — no chown, no mode juggling. + User = "grafana"; + Group = "grafana"; + StateDirectory = "grafana-secret"; + StateDirectoryMode = "0700"; + SyslogIdentifier = "swarm-grafana-secret-key"; + }; + path = [ + pkgs.openssl + pkgs.coreutils + ]; + script = '' + set -euo pipefail + key=${lib.escapeShellArg secretKeyPath} + if [ ! -s "$key" ]; then + umask 077 + openssl rand -hex 32 > "$key" + echo "generated Grafana's datasource encryption key" + fi + ''; + }; + services.grafana = { enable = true; package = cfg.package; @@ -371,6 +422,11 @@ in users.auto_assign_org_role = cfg.oidc.role; + # ⚠️ `$__file{}`, and required rather than optional: nixpkgs + # dropped the default and asserts on null, so without this the + # whole host fails to build. The unit below generates it. + security.secret_key = "$__file{${secretKeyPath}}"; + # No local password path at all when SSO is configured. This # is not tidiness: Grafana ships an `admin`/`admin` account, # and this vhost is on the public gateway.