Watch
0
0
Fork
You've already forked hyperhive
0

nix(authelia): start with a disabled placeholder user when the user set is empty

authelia 4.39.20 exits at startup on `users: {}` ("users: non zero value
required"), and the first-boot unit seeded exactly that, so a swarm with
no users crash-looped authelia and answered 502 until `swarmctl user add`
ran.

The first-boot unit now writes one subject, `swarm.placeholder`, when
the users database is absent, empty, or exactly `users: {}`:

- `disabled: true` — authelia returns "user not found" for a disabled
  user before any password check (file_user_provider.go,
  CheckUserPassword).
- password: an argon2id digest with an all-zero key. It decodes (authelia
  rejects a non-digest at startup) and no known password hashes to it.
- the `.` keeps it out of agent names (`[a-z0-9-]`), and `swarmctl user
  add` refuses it as already existing. Neither writer removes users, and
  both round-trip `disabled`.

A file with any user in it is never touched.

The docs that described the crash-loop (sso.md, gateway.md, setup.md,
the sso-unavailable error page) now describe the placeholder; the
writers' load_store docs and the seed fixtures follow. module-eval
nats-authelia asserts the seed branch.
This commit is contained in:
atlas 2026-10-02 11:57:55 +02:00 • committed by mara
commit 99905f50b0
9 changed files with 102 additions and 62 deletions

View file

@ -57,8 +57,8 @@ What the granter is, why it's root-equivalent, and the store's TLS:
## 2 · Your SSO account ## 2 · Your SSO account
_On the host running authelia._ Authelia refuses to start with no users, _On the host running authelia._ Until this runs, `auth.<swarm.domain>` serves
so until this runs `auth.<swarm.domain>` answers `502 Bad Gateway`. a login page that refuses everyone: its only subject is a disabled placeholder.
```bash ```bash
swarmctl user add mara --display-name Mara --email mara@example.com --group admins swarmctl user add mara --display-name Mara --email mara@example.com --group admins

View file

@ -20,7 +20,7 @@ This host's nginx fronts the hyperhive web surfaces running on it — next to hi
Only the host that **runs** authelia declares the authelia vhost, not every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. Only the host that **runs** authelia declares the authelia vhost, not every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
⚠️ **A `502` from this vhost typically means authelia has no users yet, not that the proxy is misconfigured.** Authelia treats an empty user store as a fatal startup error, so an enabled-but-unbootstrapped swarm crash-loops the container while the vhost in front of it works perfectly. Check `journalctl -M swarm-authelia -u authelia-swarm` before suspecting anything here; the bootstrap step is in [`swarm/sso.md`](../swarm/sso.md). ⚠️ **A `502` from this vhost means authelia itself isn't answering, not that the proxy is misconfigured.** Check `journalctl -M swarm-authelia -u authelia-swarm` before suspecting anything here. A swarm with no users of its own answers with a login page that refuses everyone; the first account is created in [`swarm/sso.md`](../swarm/sso.md).
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
Per-agent UIs stay sub-path, forge and matrix get sub-domains — see Per-agent UIs stay sub-path, forge and matrix get sub-domains — see

View file

@ -18,25 +18,24 @@ name all follow `deploy.authelia`, so there is nothing to turn on
separately. (Details, including why a client hive must not declare that separately. (Details, including why a client hive must not declare that
vhost: [`../networking/gateway.md`](../networking/gateway.md).) vhost: [`../networking/gateway.md`](../networking/gateway.md).)
**Authelia doesn't start until at least one user exists.** This module **Nobody can log in until the first user exists.** authelia exits at
generates the user store empty — deliberately, since seeding a default startup on an empty user set:
account would put a credential in a config file — but authelia validates
it at startup and treats "no users" as fatal:
``` ```
error reading the authentication database: could not validate the schema: error reading the authentication database: could not validate the schema:
users: non zero value required users: non zero value required
``` ```
It then exits 1 and systemd restarts it, so a swarm the operator has While the set is empty this module writes one placeholder subject,
enabled but not bootstrapped shows a **crash-looping unit** and `502 Bad `swarm.placeholder`, that authelia accepts and that can't log in: it's
Gateway` from the vhost — not a login page with nobody able to use it. `disabled: true`, and its password digest has an all-zero key that no
The gateway is working in that state; the upstream isn't up. known password hashes to. authelia starts and serves its login page, and
refuses every login. The placeholder stays in `users.yml` once real
users exist, shows up in `swarmctl user list`, and `swarmctl user add`
refuses its name.
⚠️ The step below is **required to finish the install**, not an ⚠️ The step below is **required to finish the install**, not an
optional first-login convenience. Run it before concluding anything is optional first-login convenience.
wrong with the proxy: a 502 here means "no users yet" far more often
than it means a routing fault.
Add the first subject on the host running authelia: Add the first subject on the host running authelia:

View file

@ -56,21 +56,15 @@ in
''; '';
}; };
# Shown when the authelia vhost's upstream refuses the connection. # Shown when the authelia vhost's upstream refuses the connection: authelia
# Leads with the bootstrap because that is overwhelmingly the cause: # is down behind a working gateway, and the raw 502 points at the proxy.
# authelia treats an empty user store as a FATAL startup error, so an
# enabled-but-unbootstrapped swarm crash-loops behind a vhost that is
# working perfectly, and the raw 502 points at the proxy instead.
ssoUnavailable = mkPage { ssoUnavailable = mkPage {
name = "sso-unavailable"; name = "sso-unavailable";
title = "sso unavailable"; title = "sso unavailable";
accent = "#f9e2af"; accent = "#f9e2af";
body = '' body = ''
<p>The swarm's identity provider isn't answering. The gateway is fine — nothing is listening behind it.</p> <p>The swarm's identity provider isn't answering. The gateway is fine — nothing is listening behind it.</p>
<p class="hint">Most likely: <strong>no users exist yet.</strong> Authelia refuses to start with an empty user store, so it never finishes booting. Add the first account on the host running it:</p> <p class="hint">Check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p>
<pre>swarmctl user add &lt;username&gt; \
--display-name &lt;Name&gt; --email &lt;addr&gt; --group admins</pre>
<p class="hint">Otherwise check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p>
''; '';
}; };

View file

@ -255,6 +255,25 @@ let
clientsDir = "${stateDir}/oidc-clients"; clientsDir = "${stateDir}/oidc-clients";
clientsFile = "${stateDir}/oidc-clients.yml"; clientsFile = "${stateDir}/oidc-clients.yml";
# The users database an empty user set is replaced with. authelia exits at
# startup on `users: {}`, so the file always holds at least this subject,
# and it can never log in: authelia answers a `disabled` user as "not
# found" before checking any password, and the digest decodes but its key
# is all zeros, which no known password hashes to.
#
# The `.` keeps the name out of agent names (`[a-z0-9-]`), and once this
# file is written `swarmctl user add` refuses the name as already taken.
# The writers round-trip `disabled`, so it survives every later write.
placeholderUser = "swarm.placeholder";
placeholderUsersFile = pkgs.writeText "authelia-users-placeholder.yml" ''
users:
${placeholderUser}:
displayname: placeholder (cannot log in)
email: ${placeholderUser}@hyperhive.local
disabled: true
password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
'';
# Rendered at RUNTIME, not evaluated: the digest is read from disk by # Rendered at RUNTIME, not evaluated: the digest is read from disk by
# the script, so nothing secret ever enters a nix expression (and # the script, so nothing secret ever enters a nix expression (and
# therefore the store). Everything else here is public metadata that # therefore the store). Everything else here is public metadata that
@ -438,10 +457,10 @@ in
program. `swarm-controller` cannot write this file itself — a program. `swarm-controller` cannot write this file itself — a
different uid owns it — so the bridge is the only writer, different uid owns it — so the bridge is the only writer,
running inside this same container as this file's actual owner. running inside this same container as this file's actual owner.
This module only guarantees the file *exists* and is valid YAML When the user set is empty this module writes a single
at first boot, so authelia starts with no subjects rather than disabled placeholder subject, `${placeholderUser}`, so authelia
failing to start — a provider with nobody in it yet is the starts and serves its login page; nobody can log in until
correct state before anything has provisioned users. `swarmctl user add` creates the first account.
''; '';
}; };
@ -845,14 +864,14 @@ in
done done
${oidcGenScript} ${oidcGenScript}
# A users database that exists and parses, with nobody in # authelia exits at startup on an empty user set, so an empty
# it. authelia refuses to start without one, and the # database gets the disabled placeholder subject instead. A
# alternative to an empty file is a placeholder account — # file holding exactly `users: {}` is an empty set too; neither
# which is a credential nobody meant to create. # writer ever produces one, so replacing it drops no user.
users=${lib.escapeShellArg deployCfg.authelia.usersFile} users=${lib.escapeShellArg deployCfg.authelia.usersFile}
if [ ! -s "$users" ]; then if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then
echo "users: {}" > "$users" install -m 0600 ${placeholderUsersFile} "$users"
echo "seeded empty users database at $users" echo "seeded $users with the disabled placeholder ${placeholderUser}"
fi fi
chmod 0600 "$users" chmod 0600 "$users"
''; '';

View file

@ -224,6 +224,20 @@ let
name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace"; name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace";
ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script; ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script;
} }
{
# authelia exits at startup on `users: {}`, so an empty database, and a
# file holding exactly that document, must be replaced by the disabled
# placeholder rather than left for authelia to read.
name = "an empty authelia users database is seeded with the disabled placeholder";
ok =
let
s =
autheliaColocated.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script;
in
lib.hasInfix ''if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then'' s
&& lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s
&& !(lib.hasInfix ''echo "users: {}"'' s);
}
{ {
# Config NAMES the IdP; it never computes where the IdP is. Both arms # Config NAMES the IdP; it never computes where the IdP is. Both arms
# matter together: the address is the swarm's name on a hive that runs # matter together: the address is the swarm's name on a hive that runs

View file

@ -216,9 +216,10 @@ fn validate(store: &UserStore) -> Result<()> {
/// the same file we are about to write means we cannot clobber users we did /// the same file we are about to write means we cannot clobber users we did
/// not know about: we just read them. /// not know about: we just read them.
/// ///
/// An absent or empty file is an empty store, not an error: first boot is a /// An absent or empty file is an empty store, not an error, and so is
/// legitimate state, and the seed document authelia's own unit writes /// `users: {}`, with no special case. The `swarm-authelia` module seeds an
/// (`users: {}`) deserialises to exactly that with no special case. /// empty database with one disabled placeholder subject, which loads like any
/// other user.
pub fn load_store(users_file: &Path) -> Result<UserStore> { pub fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) { match fs::read_to_string(users_file) {
Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()), Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
@ -313,15 +314,17 @@ mod tests {
use super::*; use super::*;
/// What the `swarm-authelia` module's first-boot unit writes into /// What the `swarm-authelia` module's first-boot unit writes into
/// `users.yml` when there is no database yet. /// `users.yml` when the user set is empty: one disabled placeholder.
/// /// A copy of `placeholderUsersFile` in
/// A test fixture rather than a production constant: nothing in this /// `nix/host-modules/swarm-authelia.nix`; a test fixture because nothing
/// binary writes a seed any more — the store deserialises whatever is /// in this binary writes a seed.
/// there and an absent file is an empty store. Keeping it `pub` in the const SEED_USERS_FILE: &str = r"users:
/// module would be a constant nothing reads, which is exactly the kind swarm.placeholder:
/// of leftover that makes the next reader think the seed dance is still displayname: placeholder (cannot log in)
/// load-bearing. email: swarm.placeholder@hyperhive.local
const SEED_USERS_FILE: &str = "users: {}"; disabled: true
password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
";
fn user(password: &str) -> User { fn user(password: &str) -> User {
User { User {
@ -350,14 +353,24 @@ mod tests {
} }
} }
/// The document `swarm-authelia`'s first-boot unit writes must load as /// The document `swarm-authelia`'s first-boot unit writes must load with
/// an empty store with no special case — otherwise a fresh hive's very /// no special case — otherwise a fresh hive's very first
/// first `EnsureAgentIdentity` fails on a file we wrote ourselves. /// `EnsureAgentIdentity` fails on a file we wrote ourselves — and its
/// placeholder must stay out of the roster and stay disabled through a
/// write.
#[test] #[test]
fn the_first_boot_seed_loads_as_an_empty_store() { fn the_first_boot_seed_loads_and_its_placeholder_stays_disabled() {
let store: UserStore = serde_norway::from_str(SEED_USERS_FILE).expect("seed parses"); let store: UserStore = serde_norway::from_str(SEED_USERS_FILE).expect("seed parses");
assert!(store.users.is_empty()); assert_eq!(store.users.len(), 1);
assert!(store.extra.is_empty()); assert!(agent_names(&store).is_empty());
let out = render_yaml(&store).expect("renders");
let back: UserStore = serde_norway::from_str(&out).expect("reparses");
assert_eq!(
back.users["swarm.placeholder"].extra.get("disabled"),
Some(&serde_norway::Value::Bool(true)),
"dropped after a write:\n{out}"
);
} }
/// An argon2 digest is `$`, `=`, `,` and `/` — the reason a real /// An argon2 digest is `$`, `=`, `,` and `/` — the reason a real

View file

@ -570,9 +570,10 @@ fn publish(paths: &Paths, store: &mut UserStore) -> Result<()> {
/// Reading the same file we are about to write means we cannot clobber users /// Reading the same file we are about to write means we cannot clobber users
/// we did not know about: we just read them. /// we did not know about: we just read them.
/// ///
/// An absent or empty file is an empty store, not an error. First boot is a /// An absent or empty file is an empty store, not an error, and so is
/// legitimate state, and the seed document authelia's own unit writes /// `users: {}`, with no special case. The `swarm-authelia` module seeds an
/// (`users: {}`) deserialises to exactly that with no special case. /// empty database with one disabled placeholder subject, which loads like any
/// other user.
fn load_store(users_file: &Path) -> Result<UserStore> { fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) { match fs::read_to_string(users_file) {
Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()), Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
@ -963,10 +964,10 @@ mod tests {
fs::write(&users_file, "users: {}\n").expect("seed"); fs::write(&users_file, "users: {}\n").expect("seed");
assert!( assert!(
load_store(&users_file) load_store(&users_file)
.expect("the seed loads") .expect("`users: {}` loads")
.users .users
.is_empty(), .is_empty(),
"the first-boot seed is an empty store, with no special case" "`users: {{}}` is an empty store, with no special case"
); );
fs::remove_dir_all(&dir).ok(); fs::remove_dir_all(&dir).ok();

View file

@ -675,12 +675,12 @@ users:
/// store claimed to be canonical. There is no overwrite to gate now: the /// store claimed to be canonical. There is no overwrite to gate now: the
/// file is read before it is written. /// file is read before it is written.
/// ///
/// What still has to hold is that the first-boot seed and a zero-byte /// What has to hold is that `users: {}` and a zero-byte file both mean
/// file both mean "no users yet" rather than an error, so a fresh /// "no users" rather than an error, so a deployment holding either is
/// deployment is not stranded. /// not stranded.
#[test] #[test]
fn the_first_boot_seed_and_an_empty_file_both_mean_no_users() { fn an_empty_user_set_and_an_empty_file_both_mean_no_users() {
let seeded: UserStore = serde_norway::from_str("users: {}").expect("the seed parses"); let seeded: UserStore = serde_norway::from_str("users: {}").expect("`users: {}` parses");
assert!(seeded.users.is_empty()); assert!(seeded.users.is_empty());
// The empty-file case is handled before deserialisation (an empty // The empty-file case is handled before deserialisation (an empty