diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index dd6d4c1f..3a54968c 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -50,7 +50,7 @@ swarm-level operator CLI * `--users-file ` — Host-side path of authelia's users database — that is, the path inside the container, prefixed with the container's root. This is the only user store: it's read before every change and written in place, and `swarm-authelia-bridge` writes the same file. -* `--swarm ` — The swarm's domain, as `https://`. Given, the `agent`, `schedule` and `forge` verbs go through its gateway as the account `swarmctl login` logged in, instead of over the controller's socket. Falls back to `SWARMCTL_SWARM` +* `--swarm ` — The swarm's domain, as `https://` or the bare domain. Given, the `agent`, `schedule` and `forge` verbs go through its gateway as the account `swarmctl login` logged in, instead of over the controller's socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module sets to its host's own swarm * `--ca-file ` — A PEM CA certificate to trust beside the system roots, for a swarm whose gateway serves a certificate from the swarm's own CA. Falls back to `SWARMCTL_CA_FILE` @@ -449,6 +449,8 @@ The forge creates a human's account on their first SSO login, and this fails unt Log in to the swarm named by `--swarm` as your own SSO account. +With no `--swarm` or `SWARMCTL_SWARM`, logs in to `SWARMCTL_LOGIN_DEFAULT_SWARM`: on a swarm-controller host, the swarm that host belongs to. + Opens the swarm's SSO login in your browser (and prints the URL) and receives the answer on a loopback port, so run it where that browser runs. swarmctl keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` and refreshes it as it's used. To log out, delete that file. **Usage:** `swarmctl login` diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index f1e2d1de..5bd8be9f 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -145,6 +145,14 @@ let swarmDomain = config.services.hyperhive.swarm.domain; matrixCfg = config.services.hyperhive.swarm.matrix; + # Which swarm `swarmctl login` logs in to when given none: this host's own. + # Read by `login` alone. `SWARMCTL_SWARM` would also move every socket verb + # onto the gateway, where each needs a login. A default, so an operator's + # own value still wins. + loginDefaultEnv = lib.optionalAttrs (swarmDomain != null) { + SWARMCTL_LOGIN_DEFAULT_SWARM = "https://${swarmDomain}"; + }; + # The controller's forge client speaks TLS to `https://${forgeCfg.domain}`, # which the gateway serves with a leaf issued by the secret store's `pki` # mount, whose root is generated inside the store at runtime, so nothing @@ -306,6 +314,9 @@ let lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") ( autheliaEnv // controllerSocketEnv ) + ++ lib.mapAttrsToList ( + name: value: "--set-default ${name} ${lib.escapeShellArg value}" + ) loginDefaultEnv ) } ''; diff --git a/nix/module-eval/bao-controller.nix b/nix/module-eval/bao-controller.nix index 04d21ca3..e74333ec 100644 --- a/nix/module-eval/bao-controller.nix +++ b/nix/module-eval/bao-controller.nix @@ -97,6 +97,12 @@ let baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or ""); + swarmctlWrapper = lib.findFirst ( + p: (p.name or "") == "swarmctl-configured" + ) null controllerNoStore.environment.systemPackages; + + swarmctlWrapperCmd = if swarmctlWrapper == null then "" else (swarmctlWrapper.buildCommand or ""); + refusedWithoutIdentity = m: lib.any ( @@ -130,6 +136,16 @@ let controllerPinnedGateway.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_HOMESERVER_URL or null == "https://matrix.hive.t.local"; } + { + # Login-only: `SWARMCTL_SWARM` would move every socket verb onto the + # gateway. The socket variable is the control that the wrapper text was + # found at all. + name = "the host's swarmctl logs in to its own swarm by default, and its other verbs stay on the socket"; + ok = + lib.hasInfix "--set SWARM_CONTROLLER_SOCKET " swarmctlWrapperCmd + && lib.hasInfix "--set-default SWARMCTL_LOGIN_DEFAULT_SWARM https://t.local" swarmctlWrapperCmd + && !(lib.hasInfix "SWARMCTL_SWARM" swarmctlWrapperCmd); + } { name = "a swarm with no matrix gateway vhost gives the controller no homeserver variable"; ok = diff --git a/swarmctl/README.md b/swarmctl/README.md index e6525ae6..52e4f2be 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -51,7 +51,8 @@ an error. | `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` | | `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container | | `SWARM_CONTROLLER_SOCKET` | the controller's unix socket, for `agent` and `forge` verbs | -| `SWARMCTL_SWARM` | `https://`: reach the controller there, not the socket | +| `SWARMCTL_LOGIN_DEFAULT_SWARM` | `https://` of this host's swarm, for `login` only | +| `SWARMCTL_SWARM` | the swarm's domain, bare or `https://`: reach the controller there | | `SWARMCTL_CA_FILE` | a PEM CA to trust beside the system roots, for `SWARMCTL_SWARM` | Every verb and flag: [`docs/tools/swarmctl-cli.md`](../docs/tools/swarmctl-cli.md). @@ -172,6 +173,12 @@ logged in to https://swarm.example.com/; login kept in ~/.local/state/swarmctl/s $ swarmctl schedule list ``` +`--swarm` (before or after the verb) names the swarm, else `SWARMCTL_SWARM`; +either takes the bare domain too. With neither, `login` alone falls back to +`SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller module sets to its +host's own swarm. It's not `SWARMCTL_SWARM` there because that would send +the host's other verbs through the gateway instead of the socket. + `login` opens the swarm's SSO login in a browser and receives the result on a loopback port, so run it on the machine whose browser you use. swarmctl keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` (default diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index e6276b16..92f802fe 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -56,29 +56,55 @@ struct Cli { command: Verb, } +/// Where a verb that can reach a swarm looks for it when `--swarm` is absent. +const SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM"]; +/// The same for `login`. The swarm-controller nix module sets +/// `SWARMCTL_LOGIN_DEFAULT_SWARM` to its host's own swarm; setting +/// `SWARMCTL_SWARM` there instead would send every other verb through the +/// gateway rather than the local socket. +const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SWARM"]; + /// Which swarm to reach through its domain rather than the local socket. #[derive(Args)] struct RemoteArgs { - /// The swarm's domain, as `https://`. Given, the `agent`, - /// `schedule` and `forge` verbs go through its gateway as the account - /// `swarmctl login` logged in, instead of over the controller's socket. - /// Falls back to `SWARMCTL_SWARM`. - #[arg(long, value_name = "URL")] + /// The swarm's domain, as `https://` or the bare domain. Given, + /// the `agent`, `schedule` and `forge` verbs go through its gateway as the + /// account `swarmctl login` logged in, instead of over the controller's + /// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to + /// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module + /// sets to its host's own swarm. + #[arg(long, value_name = "URL", global = true)] swarm: Option, /// A PEM CA certificate to trust beside the system roots, for a swarm /// whose gateway serves a certificate from the swarm's own CA. Falls back /// to `SWARMCTL_CA_FILE`. - #[arg(long, value_name = "PATH")] + #[arg(long, value_name = "PATH", global = true)] ca_file: Option, } impl RemoteArgs { + /// `--swarm`, else the first of `envs` that `env` finds set and + /// non-empty. An empty `--swarm` names no swarm and skips the env. + fn swarm_url(&self, envs: &[&str], env: impl Fn(&str) -> Option) -> Option { + match &self.swarm { + Some(flag) => Some(flag.clone()), + None => envs + .iter() + .find_map(|name| env(name).filter(|u| !u.is_empty())), + } + .filter(|u| !u.is_empty()) + } + fn swarm(&self) -> Result> { - let url = self - .swarm - .clone() - .or_else(|| std::env::var("SWARMCTL_SWARM").ok()) - .filter(|u| !u.is_empty()); + self.swarm_from(SWARM_ENVS) + } + + fn login_swarm(&self) -> Result> { + self.swarm_from(LOGIN_SWARM_ENVS) + } + + fn swarm_from(&self, envs: &[&str]) -> Result> { + let url = self.swarm_url(envs, |name| std::env::var(name).ok()); let ca_file = self .ca_file .clone() @@ -186,6 +212,10 @@ enum Verb { }, /// Log in to the swarm named by `--swarm` as your own SSO account. /// + /// With no `--swarm` or `SWARMCTL_SWARM`, logs in to + /// `SWARMCTL_LOGIN_DEFAULT_SWARM`: on a swarm-controller host, the swarm + /// that host belongs to. + /// /// Opens the swarm's SSO login in your browser (and prints the URL) and /// receives the answer on a loopback port, so run it where that browser /// runs. swarmctl keeps the login, mode 0600, under @@ -583,7 +613,7 @@ fn main() -> Result<()> { Verb::Schedule { command } => schedule(&remote, command), Verb::Login => login::login( &remote - .swarm()? + .login_swarm()? .context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?, ), // Resolved lazily, inside the one arm that actually touches the @@ -1037,6 +1067,80 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn swarm_parses_before_and_after_the_subcommand() { + for argv in [ + ["swarmctl", "--swarm", "s.test", "login"], + ["swarmctl", "login", "--swarm", "s.test"], + ] { + let cli = Cli::try_parse_from(argv).expect("parses"); + assert_eq!(cli.remote.swarm.as_deref(), Some("s.test"), "{argv:?}"); + } + let cli = Cli::try_parse_from(["swarmctl", "schedule", "list", "--ca-file", "/ca.pem"]) + .expect("parses"); + assert_eq!(cli.remote.ca_file.as_deref(), Some(Path::new("/ca.pem"))); + } + + #[test] + fn login_help_lists_swarm_and_its_env_fallback() { + let Err(err) = Cli::try_parse_from(["swarmctl", "login", "--help"]) else { + panic!("--help parsed as a command"); + }; + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + let help = err.to_string(); + assert!(help.contains("--swarm "), "{help}"); + assert!(help.contains("SWARMCTL_SWARM"), "{help}"); + } + + /// `--swarm` over `SWARMCTL_SWARM` over the login-only default, and only + /// `login` reads the default: anywhere else it would turn the socket verbs + /// into gateway calls. + #[test] + fn the_swarm_resolves_flag_then_env_then_the_login_default() { + let env = |set: &'static [(&'static str, &'static str)]| { + move |name: &str| { + set.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| (*v).to_owned()) + } + }; + let both = env(&[ + ("SWARMCTL_SWARM", "env.test"), + ("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"), + ]); + let only_default = env(&[ + ("SWARMCTL_SWARM", ""), + ("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"), + ]); + + let flag = + Cli::try_parse_from(["swarmctl", "login", "--swarm", "flag.test"]).expect("parses"); + assert_eq!( + flag.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(), + Some("flag.test") + ); + + let bare = Cli::try_parse_from(["swarmctl", "login"]).expect("parses"); + assert_eq!( + bare.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(), + Some("env.test") + ); + assert_eq!( + bare.remote.swarm_url(SWARM_ENVS, both).as_deref(), + Some("env.test") + ); + assert_eq!( + bare.remote + .swarm_url(LOGIN_SWARM_ENVS, only_default) + .as_deref(), + Some("default.test") + ); + assert_eq!(bare.remote.swarm_url(SWARM_ENVS, only_default), None); + + let empty = Cli::try_parse_from(["swarmctl", "login", "--swarm", ""]).expect("parses"); + assert_eq!(empty.remote.swarm_url(LOGIN_SWARM_ENVS, both), None); + } + /// A schedule with no agent would fire nowhere, so `--agent` is required /// at parse time, and repeats collect into the target list. #[test] diff --git a/swarmctl/src/remote.rs b/swarmctl/src/remote.rs index 44a31614..541d388d 100644 --- a/swarmctl/src/remote.rs +++ b/swarmctl/src/remote.rs @@ -44,9 +44,15 @@ pub(crate) struct Swarm { } impl Swarm { + /// `url` with no scheme is taken as a bare domain, under `https://`. pub(crate) fn parse(url: &str, ca_file: Option, state_dir: PathBuf) -> Result { + let full = if url.contains("://") { + url.to_owned() + } else { + format!("https://{url}") + }; let parsed = - url::Url::parse(url).with_context(|| format!("--swarm {url:?} is not a URL"))?; + url::Url::parse(&full).with_context(|| format!("--swarm {url:?} is not a URL"))?; if parsed.scheme() != "https" || parsed.host_str().is_none() { bail!("--swarm {url:?} must be an https:// URL naming the swarm's domain"); } @@ -382,6 +388,21 @@ mod tests { assert!(Swarm::parse("http://swarm.example.test", None, "/s".into()).is_err()); } + /// A port is no scheme: `host:8443` would otherwise parse as a URL whose + /// scheme is `host`. + #[test] + fn a_bare_domain_is_taken_as_https() { + let origin = |url| Swarm::parse(url, None, "/s".into()).expect("parses").origin; + assert_eq!( + origin("swarm.example.test").as_str(), + "https://swarm.example.test/" + ); + assert_eq!( + origin("swarm.example.test:8443").as_str(), + "https://swarm.example.test:8443/" + ); + } + /// authelia's token endpoint and the gateway's `/api/`, as far as these /// tests need them: a refresh rotates both tokens, a used refresh token /// revokes the login (as authelia does), and `/api/` admits only the