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:
parent
5c15580060
commit
2df19e4899
17 changed files with 1118 additions and 122 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue