nixpkgs/nixos/tests/plausible.nix
Niklas Hambüchen 349582c965 nixos/plausible: re-add declarative admin user creation (secure initial deployment)
In #356221 nixpkgs removed `services.plausible.adminUser.*`
because Plausible upstream removed the ability to do that with an easy
CLI command, see:
https://github.com/plausible/analytics/pull/2357#issuecomment-4595200593

However, Plausible's default setup without automatic admin user creation
is unsafe for Plausible instances exposed to the Internet because it
offers a setup wizard, allowing anybody who can reach its webserver
to create the first admin users.
Plausible is usually exposed to the Internet, given that it is a web
analytics tool which must be web-reachable to function.
NixOS defaults configure it to listen only on `localhost`, which is
already good, but on NixOS it generally makes sense to be able to
configure services declaratively so they work out of the box on
first deploy. Workarounds such as "deploy first binding
only to localhost, manually do the setup wizard, then deploy another
config to expose it" are annoying.

Thus, this commit adds back the original declarative `adminUser`
implementation, doing the needed DB changes directly, race-free,
and with better security (allowing to configure a password has instead
of a plain-text password).

Assisted-By: Zoo Code with Claude Opus 4.8, human review
2026-06-22 20:20:59 +00:00

142 lines
6 KiB
Nix

{ runTest }:
let
secretKeybase = "nannannannannannannannannannannannannannannannannannannan_batman!";
# A fixed bcrypt password hash is fine for a test; the plaintext is never
# needed because the test only checks that an admin user exists (and thus the
# "first launch" setup wizard is unreachable), not that login with the
# password works.
adminEmail = "admin@localhost";
mkPlausibleTest =
{
seedAdmin ? false,
}:
runTest (
{ lib, pkgs, ... }:
let
adminPassword = "correct-horse-battery-staple";
adminPasswordHashFile = pkgs.runCommand "plausible-admin-password-hash" { } ''
${lib.getExe pkgs.mkpasswd} -m bcrypt ${lib.escapeShellArg adminPassword} > "$out"
'';
in
{
name = "plausible" + lib.optionalString seedAdmin "-declarative-admin-user";
meta = {
maintainers = with lib.maintainers; [
nh2
stepbrobd
];
};
nodes.machine = {
# On first boot, the ClickHouse migrations run by Plausible's
# `migrate.sh` intermittently fail with `(Mint.TransportError) socket
# closed`, which aborts startup before the web server opens its port.
# The failure is transient and succeeds on a subsequent attempt, so
# retry startup without a rate limit until the port opens. (Without
# this, the test is flaky.)
systemd.services.plausible.serviceConfig.Restart = lib.mkForce "always";
systemd.services.plausible.serviceConfig.RestartSec = 1;
systemd.services.plausible.unitConfig.StartLimitIntervalSec = 0;
services.plausible = {
enable = true;
adminUser = lib.mkIf seedAdmin {
email = adminEmail;
name = "Test Admin";
passwordHashFile = "${adminPasswordHashFile}";
};
server = {
baseUrl = "http://localhost:8000";
secretKeybaseFile = builtins.toFile "plausible-test-secret-keybase-file" secretKeybase;
};
};
};
testScript = ''
machine.wait_for_unit("plausible.service")
machine.wait_for_open_port(8000)
# Ensure that the software does not make the machine
# listen on any public interfaces by default.
machine.fail("ss -tlpn 'src = 0.0.0.0 or src = [::]' | grep LISTEN")
machine.succeed("curl -f localhost:8000 >&2")
machine.succeed("curl -f localhost:8000/js/script.js >&2")
def user_count():
# Plausible's "first launch" state is defined as "no user exists"
# (`Plausible.Release.should_be_first_launch?` is
# `not Repo.exists?(Plausible.Auth.User)`), so we inspect the `users`
# table directly. Local Postgres connections use `trust` auth in this
# VM, so the `postgres` superuser can query without a password.
return machine.succeed(
"sudo -u postgres psql --dbname plausible --tuples-only --no-align "
"--command 'SELECT count(*) FROM users'"
).strip()
def login_redirect_url():
return machine.succeed(
"curl -s -o /dev/null -w '%{redirect_url}' localhost:8000/login"
).strip()
''
+ (
if seedAdmin then
''
with subtest("the admin user is seeded"):
assert user_count() == "1", "expected exactly one seeded admin user"
email = machine.succeed(
"sudo -u postgres psql --dbname plausible --tuples-only --no-align "
"--command 'SELECT email FROM users'"
).strip()
assert email == "${adminEmail}", f"unexpected seeded admin email: {email!r}"
with subtest("the setup wizard is NOT reachable"):
# `/login` must render normally (HTTP 200) and must not redirect
# to the first-launch `/register` wizard.
status = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' localhost:8000/login"
).strip()
assert status == "200", f"expected /login to render (200), got: {status!r}"
location = login_redirect_url()
assert "/register" not in location, (
f"/login unexpectedly redirected to the setup wizard: {location!r}"
)
with subtest("seeding is idempotent across restarts"):
machine.succeed("systemctl restart plausible.service")
machine.wait_for_open_port(8000)
assert user_count() == "1", "expected still exactly one user after restart"
''
else
''
with subtest("without an admin user, the setup wizard is reachable"):
assert user_count() == "0", "expected no users in first-launch state"
# With no user seeded, `should_be_first_launch?` is true and the
# browser pipeline's `FirstLaunchPlug` 302-redirects every page to
# `/register`.
location = login_redirect_url()
assert "/register" in location, (
f"expected /login to redirect to the setup wizard /register, but redirect was: {location!r}"
)
''
);
}
);
in
{
# Basic test: Plausible without a declaratively configured admin user is in
# the "first launch" state, so the unauthenticated setup wizard is reachable.
# This also asserts that the way the `declarative-admin-user` test detects
# "wizard reachable" actually fires for this Plausible version, so a future
# Plausible change that breaks the detection would make that test fail rather
# than silently pass.
basic = mkPlausibleTest { seedAdmin = false; };
# Tests that a declaratively configured admin user is seeded before the web
# server accepts connections, so the unauthenticated "first launch" setup
# wizard is never reachable.
declarative-admin-user = mkPlausibleTest { seedAdmin = true; };
}