diff options
| author | tv <tv@krebsco.de> | 2026-11-02 22:46:48 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 22:46:48 +0100 |
| commit | e4331d13f313f439aff9cef1ea221c8adeeebb3a (patch) | |
| tree | 38648722d73ddf9b1deea6c3b05430994b82f4e6 /src/api | |
| parent | 4ae3326ebca32252020b5899e4a3566d0a6b5ec7 (diff) | |
api: allow listening to Unix sockets
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/auth.rs | 47 | ||||
| -rw-r--r-- | src/api/listen.rs | 220 | ||||
| -rw-r--r-- | src/api/mod.rs | 1 |
3 files changed, 258 insertions, 10 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs index 48ec02d..400feed 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,6 +1,8 @@ use anyhow::Context; use argon2::{Argon2, password_hash::PasswordVerifier}; use axum::extract::{ConnectInfo, State}; + +use super::listen::Peer; use axum::{ body::Body, http::{ @@ -16,7 +18,7 @@ use std::{ collections::HashMap, fs::File, io::{BufRead, BufReader}, - net::{IpAddr, SocketAddr}, + net::IpAddr, sync::Arc, time::{Duration, Instant}, }; @@ -68,7 +70,32 @@ pub(crate) struct AuthState { /// A reverse proxy authenticates, and names the user in `header`. struct ProxyAuth { header: HeaderName, - trusted: Vec<Range>, + trusted: Vec<Trusted>, +} + +/// Where a proxy is believed from (`--trusted-proxies`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Trusted { + Addresses(Range), + /// The API's Unix sockets (`unix`). + Unix, +} + +impl Trusted { + fn parse(text: &str) -> anyhow::Result<Self> { + if text.trim() == "unix" { + return Ok(Self::Unix); + } + Range::parse(text).map(Self::Addresses) + } + + fn holds(&self, peer: Peer) -> bool { + match (self, peer) { + (Self::Addresses(range), Peer::Tcp(address)) => range.contains(address.ip()), + (Self::Unix, Peer::Unix) => true, + _ => false, + } + } } /// An address, or the addresses sharing its first `bits` bits. @@ -84,9 +111,9 @@ impl Range { 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 address: IpAddr = address.parse().with_context(|| { + format!("trusted-proxies: {text:?} is neither an IP address nor unix") + })?; let all = if address.is_ipv4() { 32 } else { 128 }; let bits = match bits { None => all, @@ -140,7 +167,7 @@ impl AuthState { let trusted = settings .trusted_proxies .iter() - .map(|range| Range::parse(range)) + .map(|trusted| Trusted::parse(trusted)) .collect::<anyhow::Result<Vec<_>>>()?; Ok::<_, anyhow::Error>(Arc::new(ProxyAuth { header, trusted })) }) @@ -174,13 +201,13 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { 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> { +fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<Peer>) -> 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))); + let trusted = from.is_some_and(|from| proxy.trusted.iter().any(|trusted| trusted.holds(from))); if !trusted { tracing::debug!(from = ?from, header = %proxy.header, "User header from an address not in trusted-proxies; ignored"); return None; @@ -247,8 +274,8 @@ pub(crate) async fn basic_auth( ) -> Response { let from = request .extensions() - .get::<ConnectInfo<SocketAddr>>() - .map(|ConnectInfo(address)| address.ip()); + .get::<ConnectInfo<Peer>>() + .map(|ConnectInfo(peer)| *peer); let user = match proxied_user(&state, &headers, from) { Some(user) => Some(user), None => authenticate(&state, &headers).await, diff --git a/src/api/listen.rs b/src/api/listen.rs new file mode 100644 index 0000000..df2df22 --- /dev/null +++ b/src/api/listen.rs @@ -0,0 +1,220 @@ +//! Where the HTTP API listens (`--bind-addr`): TCP addresses, and Unix sockets +//! (`unix:PATH`), any number of them at once, all serving the same API. A request +//! carries where it came from (`Peer`), which decides whether a reverse proxy's user +//! header is believed (`api::auth`, `--trusted-proxies`): on a Unix socket, its file's +//! permissions decide who may connect at all, which on one host is what an address +//! can't. + +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use axum::Router; +use axum::extract::connect_info::Connected; +use axum::serve::IncomingStream; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tracing::{info, warn}; + +/// One place the API listens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Bind { + Tcp(SocketAddr), + /// A Unix socket at this path, made by the node (readable and writable by its user + /// and group only), and removed when it stops. + Unix(PathBuf), +} + +const UNIX: &str = "unix:"; + +impl FromStr for Bind { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result<Self> { + let text = text.trim(); + match text.strip_prefix(UNIX) { + Some(path) if !path.is_empty() => Ok(Self::Unix(PathBuf::from(path))), + Some(_) => { + anyhow::bail!("bind-addr: unix: needs a path (unix:/run/continuum/api.sock)") + } + None => text.parse().map(Self::Tcp).with_context(|| { + format!("bind-addr: {text:?} is neither ADDRESS:PORT nor unix:PATH") + }), + } + } +} + +impl std::fmt::Display for Bind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tcp(address) => write!(f, "{address}"), + Self::Unix(path) => write!(f, "{UNIX}{}", path.display()), + } + } +} + +impl Serialize for Bind { + fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Bind { + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let text = String::deserialize(deserializer)?; + text.parse() + .map_err(|error: anyhow::Error| serde::de::Error::custom(format!("{error:#}"))) + } +} + +/// `--bind-addr`: one place (as before it took more), or several. +pub(crate) fn one_or_more<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result<Vec<Bind>, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMore { + One(Bind), + More(Vec<Bind>), + } + Ok(match OneOrMore::deserialize(deserializer)? { + OneOrMore::One(bind) => vec![bind], + OneOrMore::More(binds) => binds, + }) +} + +/// Where a request came from (`axum::extract::ConnectInfo`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Peer { + Tcp(SocketAddr), + /// Over one of the API's Unix sockets. + Unix, +} + +impl Connected<IncomingStream<'_, tokio::net::TcpListener>> for Peer { + fn connect_info(stream: IncomingStream<'_, tokio::net::TcpListener>) -> Self { + Self::Tcp(*stream.remote_addr()) + } +} + +#[cfg(unix)] +impl Connected<IncomingStream<'_, tokio::net::UnixListener>> for Peer { + fn connect_info(_stream: IncomingStream<'_, tokio::net::UnixListener>) -> Self { + Self::Unix + } +} + +/// Serves `app` at every one of `binds` until `stop` resolves (each server then drains +/// its connections); resolves once all have stopped, with the first error if any did. +/// Binds them all before it returns: one that can't be bound is an error at once. +pub(crate) async fn serve<F>( + binds: &[Bind], + app: Router, + stop: impl Fn() -> F, +) -> Result<std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send>>> +where + F: Future<Output = ()> + Send + 'static, +{ + let mut servers = tokio::task::JoinSet::new(); + for bind in binds { + let service = app.clone().into_make_service_with_connect_info::<Peer>(); + let stopped = stop(); + match bind { + Bind::Tcp(address) => { + let listener = tokio::net::TcpListener::bind(address) + .await + .with_context(|| format!("Binding the HTTP API to {address}"))?; + info!(address = %listener.local_addr()?, "HTTP API listening"); + servers.spawn(async move { + axum::serve(listener, service) + .with_graceful_shutdown(stopped) + .await + .map_err(anyhow::Error::from) + }); + } + #[cfg(unix)] + Bind::Unix(path) => { + let listener = bind_unix(path)?; + info!(socket = %path.display(), "HTTP API listening"); + let path = path.clone(); + servers.spawn(async move { + let served = axum::serve(listener, service) + .with_graceful_shutdown(stopped) + .await; + if let Err(error) = std::fs::remove_file(&path) { + warn!(socket = %path.display(), error = %error, "Couldn't remove the HTTP API's socket"); + } + served.map_err(anyhow::Error::from) + }); + } + #[cfg(not(unix))] + Bind::Unix(_) => anyhow::bail!("bind-addr: Unix sockets need a Unix"), + } + } + Ok(Box::pin(async move { + let mut first = Ok(()); + while let Some(served) = servers.join_next().await { + let served = served + .map_err(anyhow::Error::from) + .and_then(|served| served); + if let Err(error) = served + && first.is_ok() + { + first = Err(error); + } + } + first + })) +} + +/// A Unix socket at `path`: one left there by an earlier run is replaced, anything else +/// there is an error. Its user and group may connect, nobody else. +#[cfg(unix)] +fn bind_unix(path: &std::path::Path) -> Result<tokio::net::UnixListener> { + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => std::fs::remove_file(path) + .with_context(|| format!("Removing the old socket {}", path.display()))?, + Ok(_) => anyhow::bail!( + "bind-addr: {} exists and is no socket; not replacing it", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("Looking at {}", path.display())); + } + } + let listener = tokio::net::UnixListener::bind(path) + .with_context(|| format!("Binding the HTTP API to {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .with_context(|| format!("Setting who may use {}", path.display()))?; + Ok(listener) +} + +#[cfg(test)] +mod tests { + use super::Bind; + + #[test] + fn binds_parse() { + assert_eq!( + "127.0.0.1:3000".parse::<Bind>().unwrap(), + Bind::Tcp("127.0.0.1:3000".parse().unwrap()) + ); + assert_eq!( + "unix:/run/continuum/api.sock".parse::<Bind>().unwrap(), + Bind::Unix("/run/continuum/api.sock".into()) + ); + assert_eq!( + "unix:/run/continuum/api.sock" + .parse::<Bind>() + .unwrap() + .to_string(), + "unix:/run/continuum/api.sock" + ); + for bad in ["unix:", "localhost", "3000"] { + assert!(bad.parse::<Bind>().is_err(), "{bad}"); + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 35b1a06..e4ccee6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ mod dashboard; mod debug; mod document; mod health; +pub(crate) mod listen; mod log; mod peers; mod stats; |
