Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/remote.rs
atlas 2df19e4899 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).
2026-10-12 01:25:58 +02:00

352 lines
13 KiB
Rust

//! Reaching swarm-controller through the swarm domain's gateway instead of its
//! unix socket, as the operator's own SSO account.
//!
//! The gateway's `/api/` location runs authelia's `auth_request` and accepts an
//! OAuth 2.0 bearer token in place of a session cookie. `swarmctl login`
//! ([`crate::login`]) obtains one for the public `swarmctl` client and this
//! module caches it, refreshes it and presents it.
//!
//! ⚠️ authelia **rotates** the refresh token on every use, and presenting an
//! already-used one revokes the whole login. Every refresh therefore runs under
//! an exclusive lock on the cache, and rereads the cache once it holds it.
use std::fs::{self, DirBuilder, File, OpenOptions};
use std::io::Write as _;
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
/// Registered under the same id in `swarm-authelia.nix`.
pub(crate) const CLIENT_ID: &str = "swarmctl";
/// Refresh this long before the access token's stated expiry, so a token
/// doesn't lapse between the check and the gateway's look at it.
const EXPIRY_MARGIN_SECS: i64 = 60;
/// Where a controller request goes.
pub(crate) enum Target {
/// swarm-controller's unix socket on this host.
Socket(PathBuf),
/// The swarm domain's gateway, with the cached login.
Swarm(Swarm),
}
/// A swarm reached by its domain.
pub(crate) struct Swarm {
/// `https://<swarm domain>`, with no path.
pub origin: url::Url,
/// A PEM CA certificate to trust beside the system roots.
pub ca_file: Option<PathBuf>,
}
impl Swarm {
pub(crate) fn parse(url: &str, ca_file: Option<PathBuf>) -> Result<Self> {
let parsed =
url::Url::parse(url).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");
}
let origin = url::Url::parse(&parsed.origin().ascii_serialization())
.context("re-parsing the swarm's origin")?;
Ok(Self { origin, ca_file })
}
/// What the token must name for the gateway to admit it at `/api/`.
/// authelia matches it as a URL prefix.
pub(crate) fn audience(&self) -> String {
format!("{}api/", self.origin)
}
/// An HTTP client that never follows a redirect: from the gateway one
/// means "log in", and following it would land on the login page.
pub(crate) fn http(&self) -> Result<reqwest::Client> {
let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
if let Some(path) = &self.ca_file {
let pem = fs::read(path)
.with_context(|| format!("reading the CA certificate {}", path.display()))?;
let cert = reqwest::Certificate::from_pem(&pem)
.with_context(|| format!("parsing {} as a PEM certificate", path.display()))?;
builder = builder.add_root_certificate(cert);
}
builder.build().context("building the HTTPS client")
}
pub(crate) fn cache_path(&self) -> Result<PathBuf> {
let state = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
.context(
"neither XDG_STATE_HOME nor HOME is set, so there is nowhere to keep the login",
)?;
let host = self.origin.host_str().unwrap_or_default();
let name = match self.origin.port() {
Some(port) => format!("{host}_{port}.json"),
None => format!("{host}.json"),
};
Ok(state.join("swarmctl").join(name))
}
fn not_logged_in(&self) -> String {
format!(
"not logged in to {} — run `swarmctl --swarm {} login`",
self.origin, self.origin
)
}
/// One request to `uri` (a path under the origin) with the cached login.
pub(crate) async fn request(
&self,
method: hyper::Method,
uri: &str,
body: Option<Vec<u8>>,
what: &str,
) -> Result<(hyper::StatusCode, bytes::Bytes)> {
let http = self.http()?;
let token = self.access_token(&http).await?;
let url = self.origin.join(uri).context("joining the request path")?;
let mut req = http.request(method, url).bearer_auth(token);
if let Some(body) = body {
req = req
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let resp = req
.send()
.await
.with_context(|| format!("sending the {what} request to {}", self.origin))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
bail!(
"{} refused the cached login (401). {}",
self.origin,
self.not_logged_in()
);
}
if status.is_redirection() {
bail!(
"{} answered {status} instead of the controller's response; its `/api/` \
did not accept a bearer token",
self.origin
);
}
let body = resp.bytes().await.context("reading the response")?;
Ok((status, body))
}
/// The cached access token, refreshed first if it is about to expire.
async fn access_token(&self, http: &reqwest::Client) -> Result<String> {
let path = self.cache_path()?;
let now = chrono::Utc::now().timestamp();
let cached = read_cache(&path)?.with_context(|| self.not_logged_in())?;
if !needs_refresh(&cached, now) {
return Ok(cached.access_token);
}
let _lock = lock(&path)?;
let cached = read_cache(&path)?.with_context(|| self.not_logged_in())?;
if !needs_refresh(&cached, now) {
return Ok(cached.access_token);
}
let token = token_request(
http,
&cached.auth,
&[
("grant_type", "refresh_token"),
("client_id", CLIENT_ID),
("refresh_token", &cached.refresh_token),
],
)
.await
.with_context(|| format!("refreshing the login. {}", self.not_logged_in()))?;
let fresh = refreshed(cached, token, now);
write_cache(&path, &fresh)?;
Ok(fresh.access_token)
}
}
/// The token cache: one file per swarm, mode 0600, in a 0700 directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Cached {
/// authelia's origin, where the refresh goes.
pub auth: String,
pub access_token: String,
pub refresh_token: String,
/// Unix seconds.
pub expires_at: i64,
}
/// authelia's token endpoint answer.
#[derive(Deserialize)]
pub(crate) struct TokenResponse {
pub access_token: String,
pub expires_in: i64,
pub refresh_token: Option<String>,
}
pub(crate) fn needs_refresh(cached: &Cached, now: i64) -> bool {
cached.expires_at - EXPIRY_MARGIN_SECS <= now
}
/// The cache after a refresh. The new refresh token replaces the old one,
/// which is dead from the moment it was used.
pub(crate) fn refreshed(cached: Cached, token: TokenResponse, now: i64) -> Cached {
Cached {
refresh_token: token.refresh_token.unwrap_or(cached.refresh_token),
access_token: token.access_token,
expires_at: now + token.expires_in,
auth: cached.auth,
}
}
/// POST a form to authelia's token endpoint.
pub(crate) async fn token_request(
http: &reqwest::Client,
auth: &str,
form: &[(&str, &str)],
) -> Result<TokenResponse> {
let resp = http
.post(format!("{auth}/api/oidc/token"))
.form(form)
.send()
.await
.with_context(|| format!("calling {auth}/api/oidc/token"))?;
oauth_json(resp).await
}
/// Decode an OAuth 2.0 JSON answer, or turn its `error` into the message.
pub(crate) async fn oauth_json<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T> {
let status = resp.status();
let body = resp.bytes().await.context("reading authelia's response")?;
if !status.is_success() {
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let field = |k| v.get(k).and_then(serde_json::Value::as_str).unwrap_or("");
return Err(anyhow!(
"authelia answered {status}: {} {}",
field("error"),
field("error_description")
));
}
serde_json::from_slice(&body).context("decoding authelia's response")
}
pub(crate) fn read_cache(path: &Path) -> Result<Option<Cached>> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.with_context(|| format!("decoding the login cache {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading the login cache {}", path.display())),
}
}
/// Write through a 0600 temp file and rename, so the tokens are never
/// readable by anyone else, not even for the moment between create and chmod.
pub(crate) fn write_cache(path: &Path, cached: &Cached) -> Result<()> {
let dir = path
.parent()
.context("the login cache path has no directory")?;
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("creating {}", dir.display()))?;
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("creating {}", tmp.display()))?;
file.write_all(&serde_json::to_vec(cached)?)
.and_then(|()| file.sync_all())
.with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))
}
/// Hold an exclusive lock beside the cache until the returned file drops.
pub(crate) fn lock(path: &Path) -> Result<File> {
if let Some(dir) = path.parent() {
DirBuilder::new().recursive(true).mode(0o700).create(dir)?;
}
let lock_path = path.with_extension("lock");
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(&lock_path)
.with_context(|| format!("opening {}", lock_path.display()))?;
rustix::fs::flock(&file, rustix::fs::FlockOperation::LockExclusive)
.with_context(|| format!("locking {}", lock_path.display()))?;
Ok(file)
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use super::*;
fn cached(expires_at: i64) -> Cached {
Cached {
auth: "https://auth.example.test".into(),
access_token: "at-old".into(),
refresh_token: "rt-old".into(),
expires_at,
}
}
#[test]
fn a_token_is_refreshed_only_inside_the_margin() {
assert!(!needs_refresh(
&cached(1_000),
1_000 - EXPIRY_MARGIN_SECS - 1
));
assert!(needs_refresh(&cached(1_000), 1_000 - EXPIRY_MARGIN_SECS));
assert!(needs_refresh(&cached(1_000), 2_000));
}
/// authelia rotates the refresh token; keeping the used one would make
/// the next refresh revoke the whole login.
#[test]
fn a_refresh_replaces_both_tokens_and_the_expiry() {
let token = TokenResponse {
access_token: "at-new".into(),
expires_in: 3599,
refresh_token: Some("rt-new".into()),
};
let fresh = refreshed(cached(0), token, 5_000);
assert_eq!(fresh.access_token, "at-new");
assert_eq!(fresh.refresh_token, "rt-new");
assert_eq!(fresh.expires_at, 8_599);
assert_eq!(fresh.auth, "https://auth.example.test");
}
#[test]
fn the_cache_is_written_owner_only_and_reads_back() {
let dir = std::env::temp_dir().join(format!("swarmctl-cache-{}", std::process::id()));
let path = dir.join("swarmctl").join("swarm.example.test.json");
let _ = fs::remove_dir_all(&dir);
assert_eq!(read_cache(&path).expect("missing is not an error"), None);
write_cache(&path, &cached(42)).expect("writes");
write_cache(&path, &cached(43)).expect("overwrites");
let mode = |p: &Path| fs::metadata(p).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().expect("dir")), 0o700);
assert_eq!(read_cache(&path).expect("reads"), Some(cached(43)));
fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn the_audience_is_the_api_prefix_of_the_origin() {
let swarm = Swarm::parse("https://swarm.example.test/some/page", None).expect("parses");
assert_eq!(swarm.audience(), "https://swarm.example.test/api/");
assert!(Swarm::parse("http://swarm.example.test", None).is_err());
}
}