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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue