nixos/plausible: re-add declarative admin user creation (secure initial deployment) (#531040)

This commit is contained in:
Niklas Hambüchen
2026-06-22 20:28:21 +00:00
committed by GitHub
6 changed files with 310 additions and 55 deletions
@@ -62,4 +62,8 @@
- `security.polkit.settings` added for RFC42 style configuration of the polkitd daemon.
- `services.plausible` can now again seed an initial admin user declaratively via [`services.plausible.adminUser.email`](#opt-services.plausible.adminUser.email).
This makes fully declarative deployments safer: Otherwise the user needed to either accept Plausible's unauthenticated "first launch" setup wizard, which lets anyone reaching the instance create the first admin account, or do more work (deploying with NixOS's default binding to `localhost` without exposing it publicly, going through the wizard, and then deploying Plausible exposed to the Internet).
This option was previously removed with NixOS 25.05 due to an upstream Plausible change making declarative admin creation more difficult, but this change re-implements the admin creation directly.
- The `newuidmap` and `newgidmap` security wrappers are now installed with `cap_setuid`/`cap_setgid` file capabilities instead of the setuid-root bit, matching shadow's `--with-fcaps` install mode and other major distributions. Rootless containers (podman, docker-rootless, unprivileged user namespaces) are unaffected. The only behavioural change is that mapping host uid 0 via `/etc/subuid` (which NixOS never configures by default) additionally requires `cap_setfcap`; users who explicitly grant uid 0 in a subuid range can restore the previous behaviour with `security.wrappers.newuidmap.capabilities = lib.mkForce "cap_setuid,cap_setfcap+ep";`.
@@ -20,6 +20,19 @@ After that, `plausible` can be deployed like this:
# secretKeybaseFile is a path to the file which contains the secret generated
# with openssl as described above.
secretKeybaseFile = "/run/secrets/plausible-secret-key-base";
# With an admin user seeded (below), registration can be locked down
# so only invited users (or nobody) can create further accounts.
disableRegistration = "invite_only";
};
# If you do not declare `adminUser`, Plausible shows an unauthenticated
# "first launch" setup wizard where anybody reaching the instance can create
# the first admin account. That may be convenient, but is also a security
# risk if somebody else uses it before you do.
adminUser = {
email = "admin@analytics.example.org";
# passwordHashFile is a path to a file containing the bcrypt hash of the
# admin user's password, e.g. generated with `mkpasswd -m bcrypt`.
passwordHashFile = "/run/secrets/plausible-admin-password-hash";
};
};
}
+153 -24
View File
@@ -10,6 +10,68 @@ with lib;
let
cfg = config.services.plausible;
seedAdminEnabled = cfg.adminUser.email != null;
# Note [plausible-seed-admin-no-wizard-race]:
# Plausible Community Edition shows an unauthenticated "first launch" setup
# wizard to create the admin user whenever no user exists in the database
# (`Plausible.Release.should_be_first_launch?` is
# `not Repo.exists?(Plausible.Auth.User)`, and `PlausibleWeb.FirstLaunchPlug`
# 302-redirects every page to `/register` while that is true). On an instance
# reachable over the network this lets any stranger create the first admin
# account.
#
# `DISABLE_REGISTRATION` does NOT gate this wizard (it must not, otherwise the
# first user could never be created), so the only robust fix is to ensure a
# user already exists before the web server accepts any connection.
#
# We therefore seed the admin user inside the service's main `script`, after
# the DB migrations and strictly before `exec plausible start`. This
# guarantees there is no time window in which the wizard is reachable. The
# seed is idempotent (it only inserts when no user exists), so it is safe to
# run on every (re)start.
#
# We insert the precomputed bcrypt `password_hash` directly rather than going
# through `Plausible.Auth.User.new/1`, so the plaintext password never has to
# be stored on disk. `email_verified` is set to `true` because self-hosted
# Plausible does not require email verification by default.
#
# This Elixir script may need updating as newer Plausible versions get
# released (e.g. if the `Plausible.Auth.User` schema changes). The NixOS VM
# test `nixos/tests/plausible.nix` validates that the wizard is unreachable
# once an admin user is configured.
seedAdminScript = pkgs.writeText "plausible-seed-admin.exs" ''
# This script runs via `plausible eval`, which evaluates it WITHOUT
# starting the `:plausible` application or its Ecto repos. We therefore
# start them ourselves before querying/inserting, mirroring the private
# `Plausible.Release.prepare/0` (the same startup the release uses for its
# `migrate`/`seed` commands): load the app, start the DB-related apps and
# start each Ecto repo. Otherwise `Repo.exists?/1` raises
# `could not lookup Ecto repo Plausible.Repo because it was not started`.
:ok = Application.ensure_loaded(:plausible)
Enum.each([:ssl, :postgrex, :ch, :ecto], &Application.ensure_all_started/1)
Enum.each(Application.fetch_env!(:plausible, :ecto_repos), & &1.start_link(pool_size: 2))
alias Plausible.Repo
alias Plausible.Auth.User
unless Repo.exists?(User) do
email = System.fetch_env!("SEED_ADMIN_USER_EMAIL")
name = System.fetch_env!("SEED_ADMIN_USER_NAME")
password_hash = System.fetch_env!("SEED_ADMIN_USER_PASSWORD_HASH")
%User{
email: email,
name: name,
password_hash: password_hash,
email_verified: true
}
|> Repo.insert!()
IO.puts("plausible: seeded admin user #{email}")
end
'';
in
{
options.services.plausible = {
@@ -51,6 +113,57 @@ in
};
};
adminUser = {
email = mkOption {
default = null;
type = types.nullOr types.str;
description = ''
Email address of an admin user to seed into the database before the
Plausible web server starts accepting connections.
Plausible Community Edition shows an unauthenticated "first launch"
setup wizard whenever no user exists in the database, which redirects
every page to `/register` and lets anyone reaching the instance over
the network create the first admin account. Setting this option (and
{option}`services.plausible.adminUser.passwordHashFile`) seeds an
admin user before the port is opened, so the wizard is never reachable
by strangers.
When `null`, no user is seeded and Plausible's setup wizard is used as
usual.
Seeding is idempotent: if any user already exists, no user is created.
'';
example = "admin@example.org";
};
name = mkOption {
default = "Admin";
type = types.str;
description = ''
Display name of the seeded admin user (see
{option}`services.plausible.adminUser.email`).
'';
};
passwordHashFile = mkOption {
default = null;
type = with types; nullOr (either str path);
description = ''
Path to a file containing the bcrypt hash of the seeded admin user's
password (see {option}`services.plausible.adminUser.email`).
Using a hash file (rather than the plaintext password) means the
plaintext never has to be stored on disk or in the Nix store. Generate
a hash e.g. with `mkpasswd -m bcrypt` (the resulting `$2b$...` string).
This file is read via systemd's `LoadCredential`, so it does not enter
the Nix store.
'';
example = "/run/secrets/plausible-admin-password-hash";
};
};
server = {
disableRegistration = mkOption {
default = true;
@@ -150,33 +263,34 @@ in
(mkRemovedOptionModule [ "services" "plausible" "releaseCookiePath" ]
"Plausible uses no distributed Erlang features, so this option is no longer necessary and was removed"
)
(mkRemovedOptionModule [
"services"
"plausible"
"adminUser"
"name"
] "Admin user is now created using first start wizard")
(mkRemovedOptionModule [
"services"
"plausible"
"adminUser"
"email"
] "Admin user is now created using first start wizard")
(mkRemovedOptionModule [
"services"
"plausible"
"adminUser"
"passwordFile"
] "Admin user is now created using first start wizard")
(mkRemovedOptionModule [
"services"
"plausible"
"adminUser"
"activate"
] "Admin user is now created using first start wizard")
(mkRemovedOptionModule
[
"services"
"plausible"
"adminUser"
"passwordFile"
]
"Use services.plausible.adminUser.passwordHashFile instead, which keeps the plaintext password out of the Nix store"
)
(mkRemovedOptionModule
[
"services"
"plausible"
"adminUser"
"activate"
]
"The seeded admin user is always created as email-verified; self-hosted Plausible does not require email verification"
)
];
config = mkIf cfg.enable {
assertions = [
{
assertion = seedAdminEnabled -> (cfg.adminUser.passwordHashFile != null);
message = "services.plausible.adminUser.passwordHashFile must be set when services.plausible.adminUser.email is set.";
}
];
services.postgresql = mkIf cfg.database.postgres.setup {
enable = true;
};
@@ -285,6 +399,18 @@ in
''}
${cfg.package}/migrate.sh
${lib.optionalString seedAdminEnabled ''
# Seed the admin user before the web server starts, so the
# unauthenticated "first launch" setup wizard is never reachable;
# see note [plausible-seed-admin-no-wizard-race].
export SEED_ADMIN_USER_EMAIL=${lib.escapeShellArg cfg.adminUser.email}
export SEED_ADMIN_USER_NAME=${lib.escapeShellArg cfg.adminUser.name}
SEED_ADMIN_USER_PASSWORD_HASH="$(< "$CREDENTIALS_DIRECTORY/ADMIN_USER_PASSWORD_HASH" )"
export SEED_ADMIN_USER_PASSWORD_HASH
plausible eval "$(< ${seedAdminScript} )"
''}
export IP_GEOLOCATION_DB=${pkgs.dbip-country-lite}/share/dbip/dbip-country-lite.mmdb
exec plausible start
@@ -300,6 +426,9 @@ in
]
++ lib.optionals (cfg.mail.smtp.passwordFile != null) [
"SMTP_USER_PWD:${cfg.mail.smtp.passwordFile}"
]
++ lib.optionals seedAdminEnabled [
"ADMIN_USER_PASSWORD_HASH:${cfg.adminUser.passwordHashFile}"
];
};
};
+1 -1
View File
@@ -1341,7 +1341,7 @@ in
pixelfed = import ./web-apps/pixelfed { inherit runTestOn; };
plantuml-server = runTest ./plantuml-server.nix;
plasma6 = runTest ./plasma6.nix;
plausible = runTest ./plausible.nix;
plausible = import ./plausible.nix { inherit runTest; };
playwright-python = runTest ./playwright-python.nix;
please = runTest ./please.nix;
pleroma = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./pleroma.nix { };
+134 -26
View File
@@ -1,34 +1,142 @@
{ lib, ... }:
{
name = "plausible";
meta = {
maintainers = [ ];
};
{ runTest }:
nodes.machine =
{ pkgs, ... }:
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 =
{
virtualisation.memorySize = 4096;
services.plausible = {
enable = true;
server = {
baseUrl = "http://localhost:8000";
secretKeybaseFile = "${pkgs.writeText "dont-try-this-at-home" "nannannannannannannannannannannannannannannannannannannan_batman!"}";
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
];
};
};
};
testScript = ''
start_all()
machine.wait_for_unit("plausible.service")
machine.wait_for_open_port(8000)
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;
# Ensure that the software does not make not make the machine
# listen on any public interfaces by default.
machine.fail("ss -tlpn 'src = 0.0.0.0 or src = [::]' | grep LISTEN")
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;
};
};
};
machine.succeed("curl -f localhost:8000 >&2")
testScript = ''
machine.wait_for_unit("plausible.service")
machine.wait_for_open_port(8000)
machine.succeed("curl -f localhost:8000/js/script.js >&2")
'';
# 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; };
}
+5 -4
View File
@@ -160,9 +160,7 @@ beamPackages.mixRelease rec {
'';
passthru = {
tests = {
inherit (nixosTests) plausible;
};
tests = nixosTests.plausible;
updateScript = nix-update-script {
extraArgs = [
"-s"
@@ -217,7 +215,10 @@ beamPackages.mixRelease rec {
changelog = "https://github.com/plausible/analytics/blob/${src.rev}/CHANGELOG.md";
description = "Simple, open-source, lightweight (< 1 KB) and privacy-friendly web analytics alternative to Google Analytics";
mainProgram = "plausible";
maintainers = [ ];
maintainers = with lib.maintainers; [
stepbrobd
nh2
];
platforms = lib.platforms.unix;
};
}