diff options
| -rw-r--r-- | README.md | 34 | ||||
| -rw-r--r-- | modules/server.nix | 89 |
2 files changed, 95 insertions, 28 deletions
@@ -76,14 +76,15 @@ nixosConfigurations.poweredge = nixpkgs.lib.nixosSystem { collectors.enable = true; server = { enable = true; - grafana.secretKeyFile = "/run/secrets/grafana-secret-key"; proxy.enable = true; # Grafana on :80 }; }; } ``` -Grafana at `http://localhost`. `collectorHosts` defaults to `[ "localhost" ]`. +Grafana at `http://localhost`. `collectorHosts` defaults to `[ "localhost" ]`, and +Grafana's secret key is generated on first start into `/var/lib/grafana/secret-key` +(mode 0600, owned by `grafana`) — see below if you would rather supply it yourself. ## Example: web services in a container @@ -91,7 +92,7 @@ The host runs the collectors; the container runs everything else. This is the in shape. ```nix -{ config, inputs, ... }: +{ inputs, ... }: { imports = [ inputs.telemetry.nixosModules.collectors ]; @@ -100,23 +101,12 @@ shape. logs.lokiUrl = "http://metrics:3100"; # the container, by name }; - sops.secrets.grafana-secret-key = { - sopsFile = ./secrets/grafana.yaml; - key = "secret_key"; - }; - containers.metrics = { autoStart = true; privateNetwork = true; hostBridge = "br-lan0"; localMacAddress = "02:00:00:00:00:06"; - # The module reads whatever path you point secretKeyFile at. - bindMounts."/run/grafana-secret-key" = { - hostPath = config.sops.secrets.grafana-secret-key.path; - isReadOnly = true; - }; - config = { ... }: { imports = [ inputs.telemetry.nixosModules.server ]; @@ -126,7 +116,6 @@ shape. services.telemetry.server = { enable = true; collectorHosts = [ "poweredge" ]; # the host above - grafana.secretKeyFile = "/run/grafana-secret-key"; proxy.enable = true; }; @@ -262,8 +251,19 @@ inventing placeholder values. render "No data". Don't configure the Grafana datasource by hand. - **The datasource UIDs `prometheus` and `loki` are fixed, not options.** The generated dashboards reference them literally. -- **Grafana's secret key is mandatory** as of nixpkgs 26.05. `openssl rand -base64 32`, - store it somewhere, point `grafana.secretKeyFile` at it. +- **Grafana's secret key is mandatory** as of nixpkgs 26.05, because Grafana's upstream + default is a constant compiled into every release. By default this module generates one + on first start into `/var/lib/grafana/secret-key` (0600, owned by `grafana`), so there + is nothing to wire up. Two things to know: it must stay *stable*, or credentials + encrypted in Grafana's database stop decrypting — so keep the Grafana data directory on + persistent storage; and it sits beside the database it protects, so it defends against + a stolen `grafana.db` but not against filesystem access. Set + `grafana.secretKeyFile` to a path you manage instead once the database holds anything + worth protecting, such as a datasource password: + ```nix + services.telemetry.server.grafana.secretKeyFile = + config.sops.secrets.grafana-secret-key.path; # turns generation off + ``` - **Changing `collectorHosts` changes the label set.** Panels look empty for a minute after first deploy while the recording rules re-evaluate; data from before the change stays under the old labels. diff --git a/modules/server.nix b/modules/server.nix index 1996f5e..d9449a8 100644 --- a/modules/server.nix +++ b/modules/server.nix @@ -34,6 +34,13 @@ let replacement = "$1"; }]; + # Where Grafana's secret key actually comes from: a file you supply, or one this + # module generates into Grafana's data directory on first start. + secretKeyPath = + if cfg.grafana.generateSecretKey + then "${config.services.grafana.dataDir}/secret-key" + else cfg.grafana.secretKeyFile; + builtinDashboards = pkgs.callPackage ./dashboards.nix { inherit dsUid; }; selected = filterAttrs (name: _: cfg.grafana.dashboards.${name} or false) builtinDashboards; @@ -185,6 +192,31 @@ in description = "Run Grafana, provisioned with datasources and dashboards."; }; + generateSecretKey = mkOption { + type = types.bool; + default = cfg.grafana.secretKeyFile == null; + defaultText = lib.literalMD "`true` unless {option}`secretKeyFile` is set"; + description = '' + Generate a random `security.secret_key` on first start and keep it in + Grafana's data directory, instead of supplying one yourself. + + Grafana needs this key to be *stable* -- it encrypts credentials stored in + Grafana's database, and they become undecryptable if it changes. It has an + upstream default, but that default is a constant compiled into every Grafana + release, so leaving it unset means encrypting with a publicly known key; + nixpkgs asserts rather than let that happen silently. + + The generated file is written by the grafana user with mode 0600, so only + Grafana and root can read it. Note it then lives beside the database it + protects: anyone who can read `grafana.db` can read the key too. That is a + real limitation, and it is why {option}`secretKeyFile` exists -- but the key + is still per-install and secret from anyone without filesystem access, which + is the property that actually matters here. Prefer `secretKeyFile` once + Grafana's database holds credentials worth protecting, such as a datasource + password or a contact-point API token. + ''; + }; + secretKeyFile = mkOption { type = types.nullOr types.path; default = null; @@ -245,14 +277,24 @@ in # Grafana uses this key to encrypt datasource credentials in its database, and # nixpkgs stopped shipping a default in 26.05. Caught here rather than left to # Grafana's own assertion, which names an option this module manages for you. - assertions = [{ - assertion = !cfg.grafana.enable || cfg.grafana.secretKeyFile != null; - message = '' - services.telemetry.server.grafana.secretKeyFile must be set when Grafana is - enabled. Generate a key (`openssl rand -base64 32`), store it with whatever - secret manager you use, and point this option at the resulting file. - ''; - }]; + assertions = [ + { + assertion = !(cfg.grafana.generateSecretKey && cfg.grafana.secretKeyFile != null); + message = '' + services.telemetry.server.grafana.generateSecretKey and .secretKeyFile are + mutually exclusive. Setting secretKeyFile turns generation off by default, so + this only fires if you asked for both explicitly. + ''; + } + { + assertion = !cfg.grafana.enable || secretKeyPath != null; + message = '' + Grafana needs a secret key. Either leave + services.telemetry.server.grafana.generateSecretKey at its default so one is + generated on first start, or point .secretKeyFile at a file you manage. + ''; + } + ]; # -------------------------------------------------------------------------------- # Loki @@ -338,7 +380,7 @@ in } # A file provider rather than a literal, so the key is never written into a # world-readable store path. The credential is mounted by LoadCredential below. - // lib.optionalAttrs (cfg.grafana.secretKeyFile != null) { + // lib.optionalAttrs (secretKeyPath != null) { security.secret_key = "$__file{/run/credentials/grafana.service/secret_key}"; }; @@ -389,10 +431,35 @@ in }; }; - systemd.services.grafana = mkIf (cfg.grafana.enable && cfg.grafana.secretKeyFile != null) { - serviceConfig.LoadCredential = [ "secret_key:${cfg.grafana.secretKeyFile}" ]; + systemd.services.grafana = mkIf (cfg.grafana.enable && secretKeyPath != null) { + serviceConfig.LoadCredential = [ "secret_key:${secretKeyPath}" ]; }; + # Runs as the grafana user, into a directory users.users.grafana.createHome already + # made and owns -- so there is no chown, and no fighting with Grafana over the + # directory's ownership. UMask makes the file 0600 on creation; the explicit chmod + # also repairs a file left too permissive by an earlier version or a manual copy. + systemd.services.grafana-secret-key = + mkIf (cfg.grafana.enable && cfg.grafana.generateSecretKey) { + description = "Generate Grafana's secret key if it does not exist yet"; + wantedBy = [ "grafana.service" ]; + before = [ "grafana.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "grafana"; + Group = "grafana"; + UMask = "0077"; + }; + script = '' + key=${lib.escapeShellArg secretKeyPath} + if [ ! -s "$key" ]; then + ${pkgs.openssl}/bin/openssl rand -base64 32 > "$key" + fi + chmod 0600 "$key" + ''; + }; + environment.etc = mkIf cfg.grafana.enable ( mapAttrs' (name: src: nameValuePair "grafana-dashboards/${name}.json" { source = src; }) dashboards |
