//! 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://`, with no path. pub origin: url::Url, /// A PEM CA certificate to trust beside the system roots. pub ca_file: Option, } impl Swarm { pub(crate) fn parse(url: &str, ca_file: Option) -> Result { 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 { 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 { 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>, 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 { 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, } 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 { 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( resp: reqwest::Response, ) -> Result { 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> { 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 { 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()); } }