Compare commits

..
5 changed files with 45 additions and 17 deletions

View file

@ -26,6 +26,8 @@ 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,16 +967,7 @@ in
server.address = "tcp://127.0.0.1:${toString cfg.port}";
log.level = "info";
# `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;
};
authentication_backend.file.path = cfg.usersFile;
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
# user", which is authentication, not authorisation. The

View file

@ -29,6 +29,8 @@ 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,6 +69,12 @@ 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>,
@ -77,6 +83,8 @@ struct PathArgs {
struct Paths {
authelia_bin: PathBuf,
users_file: PathBuf,
machine: String,
unit: String,
store: PathBuf,
}
@ -85,6 +93,8 @@ 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))
@ -98,6 +108,11 @@ 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 \
@ -343,12 +358,7 @@ fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
write_atomic(&paths.store, &format!("{store_json}\n"))?;
write_atomic(&paths.users_file, &rendered)?;
// 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(())
restart_authelia(&paths.machine, &paths.unit)
}
/// Load the canonical store, or start an empty one if this deployment has
@ -446,6 +456,29 @@ 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 rewrite the user database");
.expect_err("a no-op must not restart authelia");
assert!(err.to_string().contains("nothing to change"), "{err}");
}