Compare commits

...
Author SHA1 Message Date
atlas
4c7865cd8b docs: trim the reload rationale to the live constraint
Keeps why the setting exists (the bridge writes this file and cannot
restart authelia) and drops the incident narrative and the answered
objections -- that history belongs in the PR discussion, not in three
comment blocks a future reader has to scroll past.
2026-08-17 19:25:27 +02:00
atlas
63fc54edc5 refactor(#3393): stop restarting authelia from swarmctl
authelia now watches the users file, so the restart is redundant -- and it
was the wrong shape twice over. It could fail: a login was refused for a
user whose record was already correct on disk, with nothing in either log
implicating the reload. And it only ever worked for this writer --
swarm-authelia-bridge writes the same file and cannot restart anything,
since running unprivileged inside the container is the whole reason it may
write it at all. A reload that depends on which process did the writing is
not a reload.

--machine/--unit and their two env vars existed solely to name a
systemctl -M target, so they go with it. That drops two required settings
from the operator surface.

The three objections previously recorded against watch are all answered
now, and are kept next to the decision rather than deleted: the key is
verified against the pinned build (validate-config accepts it and rejects
a misspelling), the watch is on the directory so a rename is observed, and
partial reads are structurally impossible because every writer of this
file goes through write_atomic.
2026-08-17 19:22:27 +02:00
atlas
154ab6c4ae fix(#3401): let authelia re-read its user file on change
Authelia reads authentication_backend.file.path once at startup. Without
watch, an identity the bridge creates is real on disk and invisible to the
running authelia until something unrelated bounces the unit.

swarm-authelia-bridge cannot restart authelia -- running unprivileged
inside the container is the whole reason it may write that file, so
restarting the unit is exactly the privilege it was designed not to hold.
swarmctl does restart it, but that is a systemctl -M shellout that can
fail, and when it did the symptom was a login refused for a user whose
record was already correct on disk. This makes correctness stop depending
on that restart succeeding.
2026-08-17 19:15:54 +02:00
5 changed files with 17 additions and 45 deletions

View file

@ -26,8 +26,6 @@ swarm-level operator CLI
* `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the *configured* package rather than whatever is on `PATH`
* `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root
* `--machine <NAME>` — Machine name of the authelia container, for `systemctl -M`
* `--unit <UNIT>` — authelia's systemd unit inside that container
* `--store <PATH>` — Canonical user store

View file

@ -967,7 +967,16 @@ in
server.address = "tcp://127.0.0.1:${toString cfg.port}";
log.level = "info";
authentication_backend.file.path = cfg.usersFile;
# `watch` is load-bearing, not a convenience: authelia reads
# this file once at startup, and `swarm-authelia-bridge` writes
# it to create agent identities while being unable to restart
# authelia — running unprivileged is the whole reason it may
# write the file at all. Without this, an identity it creates is
# real on disk and invisible until something unrelated restarts.
authentication_backend.file = {
path = cfg.usersFile;
watch = true;
};
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
# user", which is authentication, not authorisation. The

View file

@ -29,8 +29,6 @@ let
# parameters baked into a hash have to match the verifier's.
SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia";
SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile;
SWARMCTL_AUTHELIA_MACHINE = autheliaCfg.machine;
SWARMCTL_AUTHELIA_UNIT = autheliaCfg.unit;
};
natsCfg = config.services.hyperhive.swarm.nats;

View file

@ -69,12 +69,6 @@ struct PathArgs {
/// the container, prefixed with the container's root.
#[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>,
/// Machine name of the authelia container, for `systemctl -M`.
#[arg(long, value_name = "NAME")]
machine: Option<String>,
/// authelia's systemd unit inside that container.
#[arg(long, value_name = "UNIT")]
unit: Option<String>,
/// Canonical user store.
#[arg(long, value_name = "PATH")]
store: Option<PathBuf>,
@ -83,8 +77,6 @@ struct PathArgs {
struct Paths {
authelia_bin: PathBuf,
users_file: PathBuf,
machine: String,
unit: String,
store: PathBuf,
}
@ -93,8 +85,6 @@ impl PathArgs {
Ok(Paths {
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
machine: string_from(self.machine, "SWARMCTL_AUTHELIA_MACHINE")?,
unit: string_from(self.unit, "SWARMCTL_AUTHELIA_UNIT")?,
store: self
.store
.or_else(|| std::env::var_os("SWARMCTL_STORE").map(PathBuf::from))
@ -108,11 +98,6 @@ fn path_from(flag: Option<PathBuf>, env: &str) -> Result<PathBuf> {
.with_context(|| missing(env))
}
fn string_from(flag: Option<String>, env: &str) -> Result<String> {
flag.or_else(|| std::env::var(env).ok())
.with_context(|| missing(env))
}
fn missing(env: &str) -> String {
format!(
"{env} is unset and no flag was given — swarmctl is installed and \
@ -358,7 +343,12 @@ fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
write_atomic(&paths.store, &format!("{store_json}\n"))?;
write_atomic(&paths.users_file, &rendered)?;
restart_authelia(&paths.machine, &paths.unit)
// No restart: authelia watches this file
// (`authentication_backend.file.watch`). Deliberately not `swarmctl`'s job
// — `swarm-authelia-bridge` writes the same file and *cannot* restart
// anything, since running unprivileged is the whole reason it may write
// it. A reload that depends on which process wrote is not a reload.
Ok(())
}
/// Load the canonical store, or start an empty one if this deployment has
@ -456,29 +446,6 @@ fn parse_field(stdout: &str, marker: &str) -> Option<String> {
.filter(|value| !value.is_empty())
}
/// authelia re-reads its file backend at startup, so a users change needs
/// a restart.
///
/// The file watcher (`authentication_backend.file.watch`) would remove
/// this step entirely, and is deliberately not relied on: it could not be
/// verified against the pinned build, and it carries two unknowns —
/// whether the watch survives the `rename(2)` used above, and whether it
/// can observe a partially written file. An explicit restart assumes
/// nothing.
fn restart_authelia(machine: &str, unit: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["-M", machine, "restart", unit])
.status()
.context("running systemctl")?;
if !status.success() {
bail!(
"restarting {unit} in {machine} failed ({status}); the users file is \
already written, so re-running the restart by hand completes the change"
);
}
Ok(())
}
/// Replace `path`'s contents atomically, preserving the existing owner
/// and mode.
///

View file

@ -495,7 +495,7 @@ mod tests {
..UserUpdate::default()
},
)
.expect_err("a no-op must not restart authelia");
.expect_err("a no-op must not rewrite the user database");
assert!(err.to_string().contains("nothing to change"), "{err}");
}