aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/auth.rs173
-rw-r--r--src/config/mod.rs32
-rw-r--r--src/main.rs19
3 files changed, 196 insertions, 28 deletions
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<HashMap<String, String>> {
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<HashMap<String, String>>, // user -> PHC hash string
+ /// None without an htpasswd file: then only the proxy's users.
+ users: Option<Arc<HashMap<String, String>>>, // user -> PHC hash string
+ proxy: Option<Arc<ProxyAuth>>,
cache: Arc<DashMap<[u8; 32], Instant>>,
/// Bounds concurrent Argon2 verifications (CPU and ~19 MiB each).
verify_slots: Arc<Semaphore>,
}
-impl AuthState {
- pub(crate) fn new(path: &str) -> anyhow::Result<Self> {
- let users = load_users(path)?;
+/// A reverse proxy authenticates, and names the user in `header`.
+struct ProxyAuth {
+ header: HeaderName,
+ trusted: Vec<Range>,
+}
+
+/// 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<Self> {
+ 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<Option<Self>> {
+ 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::<anyhow::Result<Vec<_>>>()?;
+ 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<IpAddr>) -> Option<String> {
+ 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<String> {
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<AuthState>,
headers: HeaderMap,
mut request: Request<Body>,
next: axum::middleware::Next,
) -> Response {
- let Some(user) = authenticate(&state, &headers).await else {
+ let from = request
+ .extensions()
+ .get::<ConnectInfo<SocketAddr>>()
+ .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<String>,
+ pub(crate) user_header: Option<String>,
+ #[serde(default = "default_trusted_proxies")]
+ pub(crate) trusted_proxies: Vec<String>,
#[serde(default)]
pub(crate) protocol_writers: Vec<String>,
#[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<String> {
+ 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::<std::net::SocketAddr>();
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<dyn Transport> = 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::<std::net::SocketAddr>(),
+ )
+ .with_graceful_shutdown(stopped(stop))
+ .await
}))
}
None => {
- info!("No htpasswd: no HTTP API");
+ info!("No htpasswd or user-header: no HTTP API");
None
}
};