From f2b90bbcdb5347bfb68ee0e3343358e2a8e466dc Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 21:34:12 +0100 Subject: api: allow auth delegation --- docs/api.md | 7 ++- docs/dashboard.md | 6 +- docs/example.toml | 14 ++++- docs/openapi.yaml | 3 +- docs/operations.md | 22 +++++++ docs/trust.md | 5 +- src/api/auth.rs | 173 +++++++++++++++++++++++++++++++++++++++++++++++------ src/config/mod.rs | 32 ++++++++-- src/main.rs | 19 ++++-- 9 files changed, 243 insertions(+), 38 deletions(-) diff --git a/docs/api.md b/docs/api.md index 5b4dac4..e813845 100644 --- a/docs/api.md +++ b/docs/api.md @@ -31,8 +31,9 @@ curl -u alice:secret -X POST 'localhost:3000/store/stats/visits?increment=1' # * Bodies are JSON (`application/json`, the default), text (`text/plain`, stored as a string), or a value of a type of its own (Types); answers are JSON, or as `Accept` asks. Numbers in JSON are stored as integers where they fit, as floats otherwise. -* Every request needs a user from the node's `--htpasswd` (basic authentication). Users - are all alike: each can read and write everything but protocol data (below). +* Every request needs a user from the node's `--htpasswd` (basic authentication), or + named by a reverse proxy in front that authenticated it (`--user-header`; + [`operations.md`](operations.md), Behind a reverse proxy). Users are all alike: each can read and write everything but protocol data (below). * A key with `/` in it, or an empty key, can be written inside a value at its parent, but not named in a path: read it through the parent. @@ -222,7 +223,7 @@ Load balancers can send requests to any ready node: `/ready` is the check. Every change records the node that committed it (as its Automerge author) and, for a write through the API, the user who wrote it (in its message, as a trailer like Git's `Signed-off-by:`: `User: alice`), with when (by the writer's clock). User names are -each node's own (`--htpasswd`), so who wrote it is that user at that node: `alice` on +each node's own (`--htpasswd`, or its proxy's), so who wrote it is that user at that node: `alice` on two nodes is one person only where their operators make it so, which nothing checks. `GET /admin/changes` lists them, newest first, with the paths each touched; `GET /admin/changes/settings` those that diff --git a/docs/dashboard.md b/docs/dashboard.md index 2d55db1..6a75036 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -14,10 +14,10 @@ cargo build --release --features mycelium,dashboard ``` It is at `/admin/dashboard` on the node's HTTP API, by default -`http://127.0.0.1:3000/admin/dashboard`, and asks for a user from `--htpasswd`, as the -rest of the API does. The API listens on localhost unless `--bind-addr` says otherwise; +`http://127.0.0.1:3000/admin/dashboard`, and asks for a user from `--htpasswd` (or one +a reverse proxy names, `--user-header`), as the rest of the API does. The API listens on localhost unless `--bind-addr` says otherwise; to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000 -`) or put a TLS proxy in front ([`trust.md`](trust.md)). +`) or put a TLS proxy in front ([`operations.md`](operations.md), Behind a reverse proxy). Every node has its own dashboard, and shows the swarm as that node sees it: what it knows of the others, what they report to it, and its own figures. diff --git a/docs/example.toml b/docs/example.toml index 95b6f52..67f2cfd 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -51,10 +51,20 @@ node-key-file = "/var/lib/continuum/node.key" # bind-addr = "127.0.0.1:3000" # Users of the HTTP API (an htpasswd file with Argon2 hashes). Required (but with -# relay-only: without it, no HTTP API). +# user-header, or relay-only: without either, no HTTP API). htpasswd = "/etc/continuum/htpasswd" -# HTTP API users (from htpasswd) who may write protocol data under /store/_continuum +# A request header naming the user, set by a reverse proxy in front of the HTTP API that +# authenticated the request itself (e.g. X-Remote-User, from nginx's $remote_user). +# Believed only from trusted-proxies; other requests need htpasswd's basic +# authentication. The proxy must set or clear it on every request. +# user-header = "X-Remote-User" + +# Addresses (an IP address, or a range as ADDRESS/BITS) of the reverse proxies whose +# user-header is believed. +# trusted-proxies = ["127.0.0.1", "::1"] + +# HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum # (remake records, members, forget records), e.g. to repair a stuck remake. Such writes # act on the whole swarm and are logged. Epoch metadata and the swarm ID are never # writable. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 78912fa..5460a9c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -7,7 +7,8 @@ info: as JSON by path, that syncs over Mycelium, libp2p or Tor. Every endpoint requires HTTP basic authentication (users from `--htpasswd`, - Argon2 hashes), except `/health`, `/ready`, the Swagger UI (`/swagger-ui`) and + Argon2 hashes), or a user named by a trusted reverse proxy in front + (`--user-header`), except `/health`, `/ready`, the Swagger UI (`/swagger-ui`) and this description (`/openapi.yaml`). `/debug/*` exists only with `--debug-api`. diff --git a/docs/operations.md b/docs/operations.md index fe09537..be185f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -103,6 +103,28 @@ One node at a time, restarting each with its files. What to know: switching ([`remakes.md`](remakes.md), Limits). Don't remake while the swarm runs two versions. * **The dashboard** reloads by itself once its node serves another version of it. +## Behind a reverse proxy + +The HTTP API speaks plain HTTP on `127.0.0.1:3000`; a reverse proxy in front adds TLS, +and can authenticate users itself (single sign-on, client certificates, its own +password file). It then names the user in a header, which the node believes from the +proxy's address alone (`--user-header`, `--trusted-proxies`, by default this host's +loopback addresses); requests from elsewhere still need `--htpasswd`, which becomes +optional. The proxy must set or clear that header on every request, or a client could +set it itself. With nginx: + +```nginx +location / { + auth_basic "continuum"; + auth_basic_user_file /etc/nginx/continuum.htpasswd; + proxy_set_header X-Remote-User $remote_user; + proxy_pass http://127.0.0.1:3000; +} +``` + +and the node started with `--user-header X-Remote-User`. The names the proxy gives are +the users of `--protocol-writers` and of who wrote what ([`api.md`](api.md)). + ## What to alert on From the metrics (`GET /admin/stats` as Prometheus, [`measurements.md`](measurements.md)): diff --git a/docs/trust.md b/docs/trust.md index 521a175..dc80045 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -34,7 +34,10 @@ secret like the swarm key: readable by its owner only (a node warns otherwise; a it creates is). **The HTTP API.** Every endpoint but the API description needs a user from -`--htpasswd`. It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks +`--htpasswd`, or one a reverse proxy names (`--user-header`): the node believes that +header from `--trusted-proxies` alone, but from those blindly, so whoever can send +requests from such an address (another user of the same host, with the default +loopback addresses) can be any user. It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks plain HTTP: to expose it, put a TLS proxy in front. All users may read and write user data and use the admin endpoints (remakes, forgetting peers, exports, log filters). There are no read-only users: whoever may open the dashboard may also edit and delete diff --git a/src/api/auth.rs b/src/api/auth.rs index d58640e..48ec02d 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,10 +1,10 @@ use anyhow::Context; use argon2::{Argon2, password_hash::PasswordVerifier}; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::{ body::Body, http::{ - HeaderMap, Request, StatusCode, + HeaderMap, HeaderName, Request, StatusCode, header::{AUTHORIZATION, WWW_AUTHENTICATE}, }, response::{IntoResponse, Response}, @@ -16,6 +16,7 @@ use std::{ collections::HashMap, fs::File, io::{BufRead, BufReader}, + net::{IpAddr, SocketAddr}, sync::Arc, time::{Duration, Instant}, }; @@ -49,26 +50,111 @@ fn load_users(path: &str) -> anyhow::Result> { Ok(users) } +/// Who may use the HTTP API: the users of an htpasswd file (basic authentication), +/// and the user a reverse proxy in front names (`--user-header`), if it is one of +/// `--trusted-proxies`. #[derive(Clone)] pub(crate) struct AuthState { idle_timeout: Duration, - users: Arc>, // user -> PHC hash string + /// None without an htpasswd file: then only the proxy's users. + users: Option>>, // user -> PHC hash string + proxy: Option>, cache: Arc>, /// Bounds concurrent Argon2 verifications (CPU and ~19 MiB each). verify_slots: Arc, } -impl AuthState { - pub(crate) fn new(path: &str) -> anyhow::Result { - let users = load_users(path)?; +/// A reverse proxy authenticates, and names the user in `header`. +struct ProxyAuth { + header: HeaderName, + trusted: Vec, +} + +/// An address, or the addresses sharing its first `bits` bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Range { + address: IpAddr, + bits: u32, +} + +impl Range { + fn parse(text: &str) -> anyhow::Result { + let (address, bits) = match text.trim().split_once('/') { + Some((address, bits)) => (address, Some(bits)), + None => (text.trim(), None), + }; + let address: IpAddr = address + .parse() + .with_context(|| format!("trusted-proxies: {text:?} is no IP address"))?; + let all = if address.is_ipv4() { 32 } else { 128 }; + let bits = match bits { + None => all, + Some(bits) => bits + .parse() + .ok() + .filter(|bits| *bits <= all) + .with_context(|| format!("trusted-proxies: {text:?}: not 0 to {all} bits"))?, + }; + Ok(Self { address, bits }) + } - Ok(Self { + fn contains(&self, address: IpAddr) -> bool { + // An IPv4 address as IPv6 (`::ffff:a.b.c.d`) is that IPv4 address. + let address = match address { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(address, IpAddr::V4), + v4 => v4, + }; + let leading = + |a: u128, b: u128, width: u32| self.bits == 0 || (a ^ b) >> (width - self.bits) == 0; + match (self.address, address) { + (IpAddr::V4(range), IpAddr::V4(address)) => { + leading(u32::from(range).into(), u32::from(address).into(), 32) + } + (IpAddr::V6(range), IpAddr::V6(address)) => { + leading(u128::from(range), u128::from(address), 128) + } + _ => false, + } + } +} + +impl AuthState { + /// From the options; None if there is neither an htpasswd file nor a user header + /// (no users: no HTTP API, for a relay). + pub(crate) fn from_settings( + settings: &crate::config::Settings, + ) -> anyhow::Result> { + let users = settings + .htpasswd + .as_deref() + .map(load_users) + .transpose()? + .map(Arc::new); + let proxy = settings + .user_header + .as_deref() + .map(|header| { + let header = HeaderName::try_from(header.trim()) + .with_context(|| format!("user-header: {header:?} is no header name"))?; + let trusted = settings + .trusted_proxies + .iter() + .map(|range| Range::parse(range)) + .collect::>>()?; + Ok::<_, anyhow::Error>(Arc::new(ProxyAuth { header, trusted })) + }) + .transpose()?; + if users.is_none() && proxy.is_none() { + return Ok(None); + } + Ok(Some(Self { idle_timeout: Duration::from_secs(300), cache: Arc::new(DashMap::new()), - users: Arc::new(users), + users, + proxy, verify_slots: Arc::new(Semaphore::new(2)), - }) + })) } } @@ -87,10 +173,25 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { #[derive(Clone, Debug)] pub(crate) struct AuthenticatedUser(pub(crate) String); +/// The user the trusted proxy the request came from names, if it does. +fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option) -> Option { + let proxy = state.proxy.as_ref()?; + let user = headers.get(&proxy.header)?.to_str().ok()?.trim(); + if user.is_empty() { + return None; + } + let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|range| range.contains(from))); + if !trusted { + tracing::debug!(from = ?from, header = %proxy.header, "User header from an address not in trusted-proxies; ignored"); + return None; + } + Some(user.to_owned()) +} + /// The user, if the credentials are valid. async fn authenticate(state: &AuthState, headers: &HeaderMap) -> Option { let (user, pass) = parse_basic(headers)?; - let hash = state.users.get(&user).cloned()?; + let hash = state.users.as_ref()?.get(&user).cloned()?; verify(state, &user, pass, hash).await.then_some(user) } @@ -136,22 +237,60 @@ async fn verify(state: &AuthState, user: &str, pass: String, hash: String) -> bo verified } +/// The user of a request: as the trusted proxy it came from names it, else by basic +/// authentication; `401` if neither. pub(crate) async fn basic_auth( State(state): State, headers: HeaderMap, mut request: Request, next: axum::middleware::Next, ) -> Response { - let Some(user) = authenticate(&state, &headers).await else { + let from = request + .extensions() + .get::>() + .map(|ConnectInfo(address)| address.ip()); + let user = match proxied_user(&state, &headers, from) { + Some(user) => Some(user), + None => authenticate(&state, &headers).await, + }; + let Some(user) = user else { sleep(Duration::from_millis(1500)).await; - return ( - StatusCode::UNAUTHORIZED, - [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)], - "Unauthorized", - ) - .into_response(); + // A challenge only where basic authentication can answer it. + return match state.users { + Some(_) => ( + StatusCode::UNAUTHORIZED, + [(WWW_AUTHENTICATE, r#"Basic realm="Restricted""#)], + "Unauthorized", + ) + .into_response(), + None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + }; }; request.extensions_mut().insert(AuthenticatedUser(user)); next.run(request).await } + +#[cfg(test)] +mod tests { + use super::Range; + + #[test] + fn ranges_hold_their_addresses() { + let range = |text: &str| Range::parse(text).unwrap(); + let ip = |text: &str| text.parse().unwrap(); + assert!(range("127.0.0.1").contains(ip("127.0.0.1"))); + assert!(!range("127.0.0.1").contains(ip("127.0.0.2"))); + assert!(range("10.0.0.0/8").contains(ip("10.1.2.3"))); + assert!(!range("10.0.0.0/8").contains(ip("11.0.0.1"))); + assert!(range("::1").contains(ip("::1"))); + assert!(range("fd00::/8").contains(ip("fd12::1"))); + assert!(range("0.0.0.0/0").contains(ip("192.0.2.1"))); + // IPv4 as IPv6, as a dual-stack listener sees it. + assert!(range("127.0.0.1").contains(ip("::ffff:127.0.0.1"))); + assert!(!range("127.0.0.1").contains(ip("::1"))); + for bad in ["localhost", "10.0.0.0/33", "::/129", "10.0.0.0/x"] { + assert!(Range::parse(bad).is_err(), "{bad}"); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index c7f96a3..37c010e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -100,14 +100,28 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::Text, "PATH", None, - "Users of the HTTP API (htpasswd file with Argon2 hashes). Required, but with relay-only (then without it, no HTTP API).", + "Users of the HTTP API (htpasswd file with Argon2 hashes). Required, but with user-header or relay-only (then without either, no HTTP API).", + ), + option( + "user-header", + Kind::Text, + "NAME", + None, + "A request header naming the user, set by a reverse proxy in front of the HTTP API that authenticated the request itself (e.g. X-Remote-User, from nginx's $remote_user). Believed only from trusted-proxies; other requests need htpasswd's basic authentication. The proxy must set or clear it on every request.", + ), + option( + "trusted-proxies", + Kind::List, + "ADDRESS,[ADDRESS...]", + Some("127.0.0.1,::1"), + "Addresses (an IP address, or a range as ADDRESS/BITS) of the reverse proxies whose user-header is believed.", ), option( "protocol-writers", Kind::List, "USER,[USER...]", None, - "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable.", + "HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable.", ), option( "log-format", @@ -482,8 +496,11 @@ pub(crate) fn spec(name: &str) -> Option<&'static OptionSpec> { pub(crate) struct Settings { #[serde(default = "default_bind_addr")] pub(crate) bind_addr: SocketAddr, - /// Required (`check`), but with `relay_only`. + /// Required (`check`), but with `user_header` or `relay_only`. pub(crate) htpasswd: Option, + pub(crate) user_header: Option, + #[serde(default = "default_trusted_proxies")] + pub(crate) trusted_proxies: Vec, #[serde(default)] pub(crate) protocol_writers: Vec, #[serde(default)] @@ -621,6 +638,9 @@ pub(crate) struct Settings { fn default_bind_addr() -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], 3000)) } +fn default_trusted_proxies() -> Vec { + vec!["127.0.0.1".to_owned(), "::1".to_owned()] +} fn default_document_append() -> Duration { Duration::from_secs(5) } @@ -696,8 +716,10 @@ impl Settings { if self.relay_only { self.check_relay_only()?; } else { - if self.htpasswd.is_none() { - bail!("htpasswd is required: the users of the HTTP API"); + if self.htpasswd.is_none() && self.user_header.is_none() { + bail!( + "htpasswd is required: the users of the HTTP API (or user-header, with a reverse proxy that authenticates them)" + ); } if self.node_key_file.is_none() { bail!( diff --git a/src/main.rs b/src/main.rs index a10eb73..b27db27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,7 +94,8 @@ async fn main() -> Result<()> { // the spot, and startup (starting transports, bootstrap) can take minutes. let stop = watch_for_shutdown().context("Installing signal handlers")?; // A broken htpasswd will abort startup. - let auth_state = AuthState::new(args.htpasswd.as_deref().context("htpasswd is required")?)?; + let auth_state = + AuthState::from_settings(&args)?.context("htpasswd or user-header is required")?; info!( bind_addr = args.bind_addr.to_string(), @@ -268,6 +269,9 @@ async fn main() -> Result<()> { // The node shutting down (`AppState::closing`): the server drains. let closing = state.closing.subscribe(); + // With the address each request came from: a proxy's user header is believed from + // `--trusted-proxies` only (`api::auth`). + let app = app.into_make_service_with_connect_info::(); let server_builder = axum::serve(listener, app).with_graceful_shutdown(async move { stopped(closing).await; info!("Shutdown signal received. Draining API connections...") @@ -336,7 +340,7 @@ async fn main() -> Result<()> { #[cfg(feature = "libp2p")] async fn relay_only(args: &config::Settings) -> Result<()> { let stop = watch_for_shutdown().context("Installing signal handlers")?; - let auth_state = args.htpasswd.as_deref().map(AuthState::new).transpose()?; + let auth_state = AuthState::from_settings(args)?; let transport: Arc = Arc::new(transport::libp2p::Libp2pTransport::start( transport::libp2p::Libp2pConfig::from_settings(args, true)?, )?); @@ -351,13 +355,16 @@ async fn relay_only(args: &config::Settings) -> Result<()> { info!(address = %listener.local_addr()?, "HTTP API listening"); let stop = stop.clone(); Some(tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(stopped(stop)) - .await + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(stopped(stop)) + .await })) } None => { - info!("No htpasswd: no HTTP API"); + info!("No htpasswd or user-header: no HTTP API"); None } }; -- cgit v1.3.1