swarmctl: --swarm works after the subcommand, takes a bare domain, and login defaults to the host's swarm
`swarmctl login --swarm X` failed with "unexpected argument", and `login --help` listed no `--swarm`: the flag was only accepted before the verb. `--swarm` and `--ca-file` are now global, so they parse on either side of the subcommand and show in every subcommand's help. `--swarm` / `SWARMCTL_SWARM` accept a bare domain and prepend `https://`. A value with a scheme is parsed as before, so `http://` is still refused. On a swarm-controller host, the swarmctl wrapper sets `SWARMCTL_LOGIN_DEFAULT_SWARM=https://<swarm.domain>` (`--set-default`). Only `login` reads it, after `--swarm` and `SWARMCTL_SWARM`. The wrapper does not set `SWARMCTL_SWARM`: that would send every socket verb (agent create, schedule, inference, forge) through the gateway and make each need a login. Other verbs resolve exactly as before. No CA file is wired. The only CA material the module names is the controller's assembled trust bundle (system CAs plus the hive anchor). `--ca-file` hands every certificate in it to rustls-platform-verifier as an extra root, and that fails the whole client on any one it cannot parse, so pointing it at the system CAs is a risk nothing here can test. The controller daemon trusts the same CA through `SSL_CERT_FILE`, which skips unparsable certificates; using that for swarmctl is left open. Machines that are not swarm-controller hosts (an operator laptop) get no default; that case waits on an answer in the issue. Fixes #5239
This commit is contained in:
parent
62e45df88c
commit
7d95973d83
6 changed files with 176 additions and 15 deletions
|
|
@ -50,7 +50,7 @@ swarm-level operator CLI
|
|||
* `--users-file <PATH>` — 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 <URL>` — The swarm's domain, as `https://<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`
|
||||
* `--swarm <URL>` — The swarm's domain, as `https://<domain>` 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 <PATH>` — 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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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://<swarm domain>`: reach the controller there, not the socket |
|
||||
| `SWARMCTL_LOGIN_DEFAULT_SWARM` | `https://<swarm domain>` 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
|
||||
|
|
|
|||
|
|
@ -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://<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`.
|
||||
#[arg(long, value_name = "URL")]
|
||||
/// The swarm's domain, as `https://<domain>` 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<String>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<String>) -> Option<String> {
|
||||
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<Option<remote::Swarm>> {
|
||||
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<Option<remote::Swarm>> {
|
||||
self.swarm_from(LOGIN_SWARM_ENVS)
|
||||
}
|
||||
|
||||
fn swarm_from(&self, envs: &[&str]) -> Result<Option<remote::Swarm>> {
|
||||
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 <URL>"), "{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]
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>, state_dir: PathBuf) -> Result<Self> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue