Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: log in with the operator's own SSO account and reach the controller through the swarm domain

`swarmctl --swarm https://<swarm> login` runs the OAuth 2.0
authorization-code flow with PKCE against the swarm's authelia as a new
public `swarmctl` client: a pushed authorization request, the browser login
and explicit consent, and the code delivered by form_post to a loopback
port. The access and refresh tokens are cached at mode 0600 under
$XDG_STATE_HOME/swarmctl/ and refreshed under a file lock, since authelia
rotates the refresh token and revokes the login when a used one is
presented again. With --swarm (or SWARMCTL_SWARM), the agent, schedule and
forge verbs send their requests to https://<swarm>/api/ with the bearer
token instead of over the controller's unix socket, which stays the default.

authelia registers the client on every authelia host with a swarm domain:
`kind = "native"`, public, no minted secret, scopes offline_access and
authelia.bearer.authz, audience https://<swarm>/api/ (matched as a URL
prefix), redirect http://127.0.0.1/callback (any loopback port). Every
field is one authelia 4.39.20's validator requires for a public client
holding the bearer scope.

The swarm apex's /api/ answers a refused caller that sent an
Authorization header with the 401 itself instead of the login redirect,
so the CLI can tell "log in again" apart from an answer; a browser still
gets the redirect. The bearer token is not forwarded to the controller.

Refs #4409 (mara: c94764, c94782).
This commit is contained in:
atlas 2026-10-11 23:43:29 +02:00 • committed by mara
commit 2df19e4899
17 changed files with 1118 additions and 122 deletions

View file

@ -12,16 +12,14 @@
//! root.
//!
//! So this binary **serves** no socket, publishes no HTTP route and has
//! no privileged helper. When a verb eventually has to run as a non-root
//! user or from another host, the answer is a **group-gated admin
//! socket** — separate from the controller's `0666` gateway-facing one —
//! not a widening of what root does here.
//! no privileged helper.
//!
//! Acting directly is not the same as acting *alone*, though: `agent
//! create` is a client of the controller's own unix socket, because the
//! work it asks for is a job graph only the controller can queue (see
//! [`agent`]). That is the opposite direction from the socket the
//! paragraph above rules out — nothing here becomes reachable by it.
//! Acting directly is not the same as acting *alone*, though: the `agent`,
//! `schedule` and `forge` verbs are clients of the controller, because the
//! work they ask for is only the controller's to do (see [`agent`]). They
//! reach it over its unix socket on its host, or, with `--swarm`, through
//! the swarm domain's gateway as the operator's own SSO account
//! ([`remote`]). The `user` verbs act on the local users file only.
//!
//! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
//! admin socket. This crate deliberately does not link `swarm-controller`,
@ -30,6 +28,8 @@
mod agent;
mod forge;
mod inference;
mod login;
mod remote;
mod schedule;
mod users;
@ -42,6 +42,7 @@ use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use remote::Target;
use users::{User, UserStore};
#[derive(Parser)]
@ -49,10 +50,51 @@ use users::{User, UserStore};
struct Cli {
#[command(flatten)]
paths: PathArgs,
#[command(flatten)]
remote: RemoteArgs,
#[command(subcommand)]
command: Verb,
}
/// 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")]
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")]
ca_file: Option<PathBuf>,
}
impl RemoteArgs {
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());
let ca_file = self
.ca_file
.clone()
.or_else(|| std::env::var_os("SWARMCTL_CA_FILE").map(PathBuf::from));
url.map(|u| remote::Swarm::parse(&u, ca_file)).transpose()
}
/// The swarm when one is named, the controller's socket otherwise.
fn target(&self, socket: Option<PathBuf>) -> Result<Target> {
Ok(match self.swarm()? {
Some(swarm) => Target::Swarm(swarm),
None => Target::Socket(path_from(socket, "SWARM_CONTROLLER_SOCKET")?),
})
}
}
/// Where the deployment put the things this CLI has to touch.
///
/// Every one of these is supplied by the nix module that installs
@ -141,6 +183,14 @@ enum Verb {
#[command(subcommand)]
command: ForgeVerb,
},
/// Log in to the swarm named by `--swarm` as your own SSO account.
///
/// 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.
Login,
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command used by the docs build to keep the published
@ -183,8 +233,8 @@ enum AgentVerb {
/// own clock. Watch the swarm UI's job view, or the hive itself, for
/// the rest.
///
/// No approval gate guards this: running this binary already means
/// being root on the controller's host.
/// No approval gate guards this: the caller is root on the controller's
/// host, or logged in through the swarm domain's SSO gate.
Create(AgentCreateArgs),
/// Queue a re-mint of an existing agent's identity at the swarm's
/// secret store.
@ -446,8 +496,8 @@ struct ControllerSocketArg {
}
impl ControllerSocketArg {
fn resolve(self) -> Result<PathBuf> {
path_from(self.controller_socket, "SWARM_CONTROLLER_SOCKET")
fn resolve(self, remote: &RemoteArgs) -> Result<Target> {
remote.target(self.controller_socket)
}
}
@ -521,7 +571,11 @@ struct UpdateArgs {
}
fn main() -> Result<()> {
let Cli { paths, command } = Cli::parse();
let Cli {
paths,
remote,
command,
} = Cli::parse();
match command {
// Resolves its own socket path, not `PathArgs`: this verb needs
// none of the `SWARMCTL_AUTHELIA_*` values, and requiring them
@ -530,35 +584,40 @@ fn main() -> Result<()> {
Verb::Agent {
command: AgentVerb::Create(args),
} => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::create(&socket, &args.name, &args.hive)
let target = remote.target(args.controller_socket)?;
agent::create(&target, &args.name, &args.hive)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Agent {
command: AgentVerb::MintIdentity(args),
} => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::mint_identity(&socket, &args.name)
let target = remote.target(args.controller_socket)?;
agent::mint_identity(&target, &args.name)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Agent {
command: AgentVerb::MintForgeToken(args),
} => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::mint_forge_token(&socket, &args.name)
let target = remote.target(args.controller_socket)?;
agent::mint_forge_token(&target, &args.name)
}
Verb::Agent {
command: AgentVerb::Inference { command },
} => inference(command),
} => inference(&remote, command),
// Same socket-resolution reasoning as `Create` above.
Verb::Forge {
command: ForgeVerb::MakeAdmin(args),
} => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
forge::make_admin(&socket, &args.name)
let target = remote.target(args.controller_socket)?;
forge::make_admin(&target, &args.name)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(command),
Verb::Schedule { command } => schedule(&remote, command),
Verb::Login => login::login(
&remote
.swarm()?
.context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?,
),
// Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong.
@ -598,11 +657,11 @@ fn main() -> Result<()> {
}
}
fn inference(command: InferenceVerb) -> Result<()> {
fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> {
match command {
InferenceVerb::Get(a) => inference::get(&a.socket.resolve()?, &a.agent),
InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent),
InferenceVerb::Set(a) => inference::set(
&a.socket.resolve()?,
&a.socket.resolve(remote)?,
&a.agent,
inference::SetFlags {
model: a.model,
@ -610,14 +669,14 @@ fn inference(command: InferenceVerb) -> Result<()> {
subagent_models: a.subagent_models,
},
),
InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve()?, &a.agent),
InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve(remote)?, &a.agent),
}
}
fn schedule(command: ScheduleVerb) -> Result<()> {
fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
match command {
ScheduleVerb::Add(a) => schedule::add(
&a.socket.resolve()?,
&a.socket.resolve(remote)?,
&schedule::AddFlags {
name: &a.name,
agents: &a.agents,
@ -627,7 +686,7 @@ fn schedule(command: ScheduleVerb) -> Result<()> {
paused: a.paused,
},
),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve()?, a.agent.as_deref()),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()),
ScheduleVerb::Edit(a) => {
let edit = schedule::EditRequest::new(schedule::EditFlags {
name: a.name,
@ -638,11 +697,11 @@ fn schedule(command: ScheduleVerb) -> Result<()> {
add_agents: a.add_agents,
remove_agents: a.remove_agents,
})?;
schedule::edit(&a.socket.resolve()?, &a.id, &edit)
schedule::edit(&a.socket.resolve(remote)?, &a.id, &edit)
}
ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, true),
ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, false),
ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve()?, &a.id),
ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, true),
ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, false),
ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve(remote)?, &a.id),
}
}