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

@ -29,6 +29,8 @@ use hyper_util::rt::TokioIo;
use serde::{Deserialize, Serialize};
use tokio::net::UnixStream;
use crate::remote::Target;
/// Body of `POST /api/agents`. Mirrors the controller's own
/// `CreateAgentRequest` — see this module's doc comment.
#[derive(Serialize)]
@ -70,7 +72,7 @@ struct MintForgeTokenResponse {
/// built here keeps that cost inside the one arm that incurs it, the same
/// way `PathArgs::resolve` is called only in the arms that need the
/// deployment's env vars.
pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> {
pub(crate) fn create(target: &Target, name: &str, hive: &str) -> Result<()> {
// Client-side before the round trip, so a typo is an immediate local
// error rather than a 400 the operator has to wait for. The controller
// validates both again — that is the gate that matters, this is the
@ -79,10 +81,10 @@ pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> {
let hive = parse_ident(hive, "hive")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")?;
let resp = rt.block_on(post_create(socket, &name, &hive))?;
let resp = rt.block_on(post_create(target, &name, &hive))?;
println!("queued: job node {}", resp.node_id);
println!(
@ -110,17 +112,17 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
///
/// Synchronous for the same reason [`create`] is, and built on the same
/// round trip.
pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> {
pub(crate) fn mint_identity(target: &Target, name: &str) -> Result<()> {
// Client-side first, so a typo is a local error rather than a 400 the
// operator waits for. The controller validates it again.
let name = parse_ident(name, "agent name")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")?;
let resp: MintIdentityResponse = rt.block_on(post(
socket,
target,
&format!("/api/agents/{name}/identity"),
&serde_json::json!({}),
"mint-identity",
@ -139,15 +141,15 @@ pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> {
///
/// Synchronous for the same reason [`create`] is, and built on the same
/// round trip. No `--hive`: the token's store path has no hive in it.
pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> {
pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> {
let name = parse_ident(name, "agent name")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")?;
let resp: MintForgeTokenResponse = rt.block_on(post(
socket,
target,
&format!("/api/agents/{name}/forge-token"),
&serde_json::json!({}),
"mint-forge-token",
@ -162,10 +164,10 @@ pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> {
Ok(())
}
/// One `POST /api/agents` round trip over the controller's unix socket.
async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> {
/// One `POST /api/agents` round trip at the controller.
async fn post_create(target: &Target, name: &str, hive: &str) -> Result<CreateAgentResponse> {
post(
socket,
target,
"/api/agents",
&CreateAgentRequest { name, hive },
"create-agent",
@ -173,25 +175,25 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgen
.await
}
/// One JSON `POST` to `uri` over the controller's unix socket.
/// One JSON `POST` to `uri` at the controller.
///
/// `what` names the request in error messages — the operator needs to know
/// which call failed, and every other part of this function is identical
/// between the two verbs.
pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
socket: &Path,
target: &Target,
uri: &str,
request: &Req,
what: &str,
) -> Result<Resp> {
send(socket, hyper::Method::POST, uri, Some(request), what).await
send(target, hyper::Method::POST, uri, Some(request), what).await
}
/// One request to `uri` over the controller's unix socket, with `request` as
/// its JSON body when there is one. A `204` decodes as JSON `null`, so a
/// caller expecting no body asks for `()`.
/// One request to `uri` at the controller, with `request` as its JSON body
/// when there is one. A `204` decodes as JSON `null`, so a caller expecting no
/// body asks for `()`.
pub(crate) async fn send<Req: Serialize, Resp: serde::de::DeserializeOwned>(
socket: &Path,
target: &Target,
method: hyper::Method,
uri: &str,
request: Option<&Req>,
@ -202,6 +204,34 @@ pub(crate) async fn send<Req: Serialize, Resp: serde::de::DeserializeOwned>(
.transpose()
.with_context(|| format!("serialising the {what} request"))?;
let (status, body) = match target {
Target::Socket(socket) => over_socket(socket, method, uri, body, what).await?,
Target::Swarm(swarm) => swarm.request(method, uri, body, what).await?,
};
if !status.is_success() {
bail!("{}", describe_error(status, &body));
}
let body = if status == hyper::StatusCode::NO_CONTENT {
bytes::Bytes::from_static(b"null")
} else {
body
};
serde_json::from_slice(&body).with_context(|| {
format!(
"swarm-controller answered {status} but its body is not a {what} response: {}",
String::from_utf8_lossy(&body).trim()
)
})
}
async fn over_socket(
socket: &Path,
method: hyper::Method,
uri: &str,
body: Option<Vec<u8>>,
what: &str,
) -> Result<(hyper::StatusCode, bytes::Bytes)> {
let stream = UnixStream::connect(socket)
.await
.with_context(|| connect_hint(socket))?;
@ -237,21 +267,7 @@ pub(crate) async fn send<Req: Serialize, Resp: serde::de::DeserializeOwned>(
.await
.context("reading swarm-controller's response")?
.to_bytes();
if !status.is_success() {
bail!("{}", describe_error(status, &body));
}
let body = if status == hyper::StatusCode::NO_CONTENT {
bytes::Bytes::from_static(b"null")
} else {
body
};
serde_json::from_slice(&body).with_context(|| {
format!(
"swarm-controller answered {status} but its body is not a {what} response: {}",
String::from_utf8_lossy(&body).trim()
)
})
Ok((status, body))
}
/// Turn a failed response into one line the operator can act on.