Watch
0
0
Fork
You've already forked hyperhive
0

grafana: mint the admin password into the store and apply it on every start

Grafana's built-in admin kept the nixpkgs default password `admin`, and
Grafana's API takes basic auth whatever `auth.disable_login_form` says, so
the public vhost gave server-admin to `admin:admin`.

`swarm-bao-grafana-admin` creates a random password in the store under
Grafana's existing leaf when none is stored (`create` without `update`, so
the first one is kept), then copies it into the container.
`security.admin_password` reads it for a new database, and a `grafana cli
admin reset-admin-password` in `grafana.service`'s `preStart` applies it to
an existing one on every start.

Closes #5138
This commit is contained in:
atlas 2026-10-12 11:22:59 +02:00 • committed by mara
commit 993f5fd99d
7 changed files with 251 additions and 48 deletions

View file

@ -66,6 +66,12 @@ let
"swarm-bao-secret-publisher-policy"
];
};
# ./swarm-grafana.nix, under Grafana's leaf, which may create the admin
# password only once that policy is written.
swarm-bao-grafana-admin = {
here = deployCfg.grafana.enable;
policies = [ "swarm-bao-grafana-oidc-policy" ];
};
# ./hive-matrix.nix, under matrix-ctl's leaf.
swarm-bao-matrix-oidc = {
here = deployCfg.matrix.enable;

View file

@ -850,11 +850,17 @@ let
# Singletons, so a list of one rather than a second shape: the granting unit
# below takes a list either way and the units stay identical apart from
# the objects they write.
#
# Grafana's leaf also creates its admin password (`swarm-bao-grafana-admin`
# in ./swarm-grafana.nix). `create` without `update`, so a stored password is
# never replaced.
grafanaOidcReaders = [
{
name = "swarm-grafana-oidc";
cn = baoDeploy.grafanaOidcCommonName;
policyText = readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.grafana.oidc.clientId}/oidc/client";
policyText =
readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.grafana.oidc.clientId}/oidc/client"
+ createStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.grafana.oidc.clientId}/admin/password";
}
];

View file

@ -253,6 +253,15 @@ let
# the OIDC secret above, whose other reader is authelia's container.
secretKeyPath = "/var/lib/grafana-secret/secret_key";
# The built-in `admin` account's password. Grafana's API takes basic auth
# whatever the login form says, so this account is reachable from the public
# vhost and its password cannot be the upstream default. Minted once into the
# store by `swarm-bao-grafana-admin` below, which `swarm-bao.nix`'s
# `grafanaOidcReaders` grants create and read on this one leaf.
adminPasswordStorePath = "secret/swarm/services/${cfg.oidc.clientId}/admin/password";
adminPasswordPath = "/var/lib/grafana-admin/password";
hostAdminPasswordPath = "/var/lib/nixos-containers/${cfg.machine}${adminPasswordPath}";
socketPath = "${deployCfg.grafana.socketDir}/grafana.sock";
# Both static NixOS ids, and both checked rather than assumed: nginx has
@ -656,6 +665,97 @@ in
'';
};
# The admin password: created in the store if it holds none, then copied
# into the container, under Grafana's own leaf. Same shape as
# `swarm-bao-nats-callout-xkey` in ./swarm-nats.nix. The grant is
# `create` without `update`, so the first password stored is the one every
# later run reads, and a race between two runs leaves one value.
#
# Grafana does not start without the file (`$__file{}` on a missing path is
# a startup error), so a store that is unreachable on first boot keeps
# Grafana down rather than up with the upstream default.
systemd.services.swarm-bao-grafana-admin = {
description = "create Grafana's admin password in the swarm secret store if absent and fetch it";
after = lib.optionals baoDeploy.enable [
"swarm-bao-pki.service"
"container@${baoCfg.machine}.service"
];
wants = lib.optionals baoDeploy.enable [ "container@${baoCfg.machine}.service" ];
requires = lib.optionals baoDeploy.enable [ "swarm-bao-pki.service" ];
before = [ "container@${cfg.machine}.service" ];
wantedBy = [
"multi-user.target"
"container@${cfg.machine}.service"
];
path = [
baoDeploy.package
pkgs.coreutils
pkgs.openssl
pkgs.systemd
];
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "swarm-bao-grafana-admin";
TimeoutStartSec = 30;
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = baoDeploy.grafanaOidcClientCertFile;
BAO_CLIENT_KEY = baoDeploy.grafanaOidcClientKeyFile;
}
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile;
};
script = ''
set -euo pipefail
${atomicWriteSecret}
${refreshConsumer}
err="$(mktemp)"
trap 'rm -f "$err"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to swarm-bao with Grafana's certificate; leaving Grafana's admin password file as it is." >&2
cat "$err" >&2
exit 1
fi
export BAO_TOKEN
# `bao kv get` exits 2 both for "nothing stored" and for a refusal, so
# a failed read always tries the create, and the second read decides.
# The value reaches `bao` on stdin, never in an argument.
if ! password="$(bao kv get -field=value ${lib.escapeShellArg adminPasswordStorePath} 2>"$err")"; then
minted="$(openssl rand -hex 32)"
if ! printf '%s' "$minted" | bao kv put -cas=0 ${lib.escapeShellArg adminPasswordStorePath} value=- >/dev/null 2>"$err"; then
echo "could not create ${adminPasswordStorePath}; reading it back in case another run created it." >&2
cat "$err" >&2
fi
if ! password="$(bao kv get -field=value ${lib.escapeShellArg adminPasswordStorePath} 2>"$err")"; then
echo "swarm-bao did not return ${adminPasswordStorePath}; Grafana does not start without it." >&2
cat "$err" >&2
exit 1
fi
fi
if [ -z "$password" ]; then
echo "swarm-bao returned an empty ${adminPasswordStorePath}; leaving the file as it is." >&2
exit 1
fi
# Owner and mode as for the OIDC secret above, for its reasons.
install -d -m 0755 ${lib.escapeShellArg (builtins.dirOf hostAdminPasswordPath)}
if secret_differs ${lib.escapeShellArg hostAdminPasswordPath} "$password"; then
atomic_write_secret 0400 ${lib.escapeShellArg "${toString config.ids.uids.grafana}:0"} ${lib.escapeShellArg hostAdminPasswordPath} "$password"
fi
# Grafana's `preStart` applies the file to the database, so a file that
# changed after it started needs a restart.
refresh_consumer ${lib.escapeShellArg cfg.machine} grafana.service ${lib.escapeShellArg hostAdminPasswordPath}
'';
};
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;
@ -681,7 +781,7 @@ in
// caTrust.bindMount;
config =
{ ... }:
{ config, ... }:
{
imports = [
../container-modules/swarm-container.nix
@ -752,6 +852,32 @@ in
'';
};
# Upstream applies `security.admin_password` only when it creates the
# database, so a database created before that setting keeps the
# password it was created with. This resets it from the delivered
# file on every start, before the server listens.
#
# `grafana cli` must open the database the server opens, which the
# server's config.ini names. nixpkgs keeps that file private, so it
# is rendered again from the same settings with nixpkgs' formatter.
systemd.services.grafana.preStart =
let
grafana = config.services.grafana;
configFile =
(pkgs.formats.ini {
listToValue = lib.concatMapStringsSep " " (lib.generators.mkValueStringDefault { });
mkKeyValue = lib.generators.mkKeyValueDefault {
mkValueString = v: if v == null then "" else lib.generators.mkValueStringDefault { } v;
} "=";
}).generate
"config.ini"
grafana.settings;
in
lib.mkAfter ''
${lib.getExe' grafana.package "grafana"} cli --homepath ${grafana.dataDir} --config ${configFile} \
admin reset-admin-password --password-from-stdin < ${adminPasswordPath}
'';
# The group Grafana chgrps its socket to. Declared here because a
# container has its own user database — the host knowing gid 60 as
# `nginx` means nothing in here, only the number crosses.
@ -837,10 +963,14 @@ in
# whole host fails to build. The unit below generates it.
security.secret_key = "$__file{${secretKeyPath}}";
# No local password path, ever. This is not tidiness: Grafana
# ships an `admin`/`admin` account and this vhost is on the
# public gateway, so SSO is the only way in — not merely the
# preferred one.
# The password a newly created database gets; the `preStart`
# above gives an existing one the same. Written by
# `swarm-bao-grafana-admin` on the host.
security.admin_password = "$__file{${adminPasswordPath}}";
# No password box on the public login page: people log in through
# SSO. This hides the form only; the API still takes basic auth,
# which is why the admin password above is minted.
#
# Not conditional on anything, and the block below is not
# either, which is what keeps the two from ever disagreeing.