diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 68acc6ce..d38368fc 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -26,6 +26,8 @@ swarm-level operator CLI * `--authelia-bin ` — 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 ` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root +* `--machine ` — Machine name of the authelia container, for `systemctl -M` +* `--unit ` — authelia's systemd unit inside that container * `--store ` — Canonical user store diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index cd63c2a6..7cb4b7c8 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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 diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 44464fd4..3e28e43a 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -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; diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 925605f6..b3ff4dc2 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -69,6 +69,12 @@ struct PathArgs { /// the container, prefixed with the container's root. #[arg(long, value_name = "PATH")] users_file: Option, + /// Machine name of the authelia container, for `systemctl -M`. + #[arg(long, value_name = "NAME")] + machine: Option, + /// authelia's systemd unit inside that container. + #[arg(long, value_name = "UNIT")] + unit: Option, /// Canonical user store. #[arg(long, value_name = "PATH")] store: Option, @@ -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, env: &str) -> Result { .with_context(|| missing(env)) } +fn string_from(flag: Option, env: &str) -> Result { + 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 { .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. /// diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs index 400d89a3..8d38e911 100644 --- a/swarmctl/src/users.rs +++ b/swarmctl/src/users.rs @@ -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}"); }