use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::Router;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
#[derive(Clone, Default)]
pub struct Http01Tokens(Arc<RwLock<HashMap<String, String>>>);
impl Http01Tokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, token: impl Into<String>, key_authorization: impl Into<String>) {
self.write().insert(token.into(), key_authorization.into());
}
#[must_use]
pub fn get(&self, token: &str) -> Option<String> {
self.read().get(token).cloned()
}
pub fn remove(&self, token: &str) {
self.write().remove(token);
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<String, String>> {
self.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<String, String>> {
self.0
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(Clone)]
struct ChallengeState {
tokens: Http01Tokens,
https_port: u16,
}
pub fn challenge_router(tokens: Http01Tokens, https_port: u16) -> Router {
Router::new()
.route(
"/.well-known/acme-challenge/{token}",
axum::routing::get(serve_challenge),
)
.fallback(any(redirect_to_https))
.with_state(ChallengeState { tokens, https_port })
}
pub async fn bind_challenge_listeners(port: u16) -> std::io::Result<Vec<tokio::net::TcpListener>> {
if let Ok(listener) = bind_ipv6(port, false) {
return Ok(vec![listener]);
}
tracing::warn!(
port,
"Dual-stack [::] bind for the ACME HTTP-01 challenge listener is unavailable; binding \
IPv4 0.0.0.0 with a best-effort IPv6 [::] listener"
);
let v4 = tokio::net::TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, port)).await?;
let mut listeners = vec![v4];
match bind_ipv6(port, true) {
Ok(v6) => listeners.push(v6),
Err(e) => tracing::warn!(
error = %e,
port,
"IPv6 [::] bind for the ACME HTTP-01 challenge listener failed; serving IPv4 only"
),
}
Ok(listeners)
}
fn bind_ipv6(port: u16, only_v6: bool) -> std::io::Result<tokio::net::TcpListener> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))?;
socket.set_only_v6(only_v6)?;
socket.set_reuse_address(true)?;
let addr: std::net::SocketAddr = (std::net::Ipv6Addr::UNSPECIFIED, port).into();
socket.bind(&addr.into())?;
socket.listen(1024)?;
socket.set_nonblocking(true)?;
tokio::net::TcpListener::from_std(socket.into())
}
async fn serve_challenge(
State(state): State<ChallengeState>,
Path(token): Path<String>,
) -> Response {
state.tokens.get(&token).map_or_else(
|| (StatusCode::NOT_FOUND, "unknown ACME challenge token").into_response(),
|key_authorization| {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain")],
key_authorization,
)
.into_response()
},
)
}
async fn redirect_to_https(
State(state): State<ChallengeState>,
headers: HeaderMap,
uri: Uri,
) -> Response {
let Some(location) = https_location(&headers, &uri, state.https_port) else {
return (
StatusCode::BAD_REQUEST,
"missing or invalid Host header; cannot build HTTPS redirect",
)
.into_response();
};
header::HeaderValue::from_str(&location).map_or_else(
|_| (StatusCode::BAD_REQUEST, "invalid Host header").into_response(),
|value| (StatusCode::PERMANENT_REDIRECT, [(header::LOCATION, value)]).into_response(),
)
}
fn https_location(headers: &HeaderMap, uri: &Uri, https_port: u16) -> Option<String> {
let host_header = headers.get(header::HOST)?.to_str().ok()?;
let host = host_only(host_header);
if host.is_empty() {
return None;
}
let path_and_query = uri
.path_and_query()
.map_or_else(|| uri.path().to_owned(), ToString::to_string);
let authority = if https_port == 443 {
host.to_owned()
} else {
format!("{host}:{https_port}")
};
Some(format!("https://{authority}{path_and_query}"))
}
fn host_only(host_header: &str) -> &str {
if let Some(end) = host_header
.strip_prefix('[')
.and_then(|_| host_header.find(']'))
{
return &host_header[..=end];
}
match host_header.rsplit_once(':') {
Some((host, _port)) => host,
None => host_header,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
#[test]
fn tokens_round_trip() {
let tokens = Http01Tokens::new();
assert_eq!(tokens.get("tok"), None);
tokens.insert("tok", "tok.keyauth");
assert_eq!(tokens.get("tok").as_deref(), Some("tok.keyauth"));
tokens.remove("tok");
assert_eq!(tokens.get("tok"), None);
}
fn body_to_string(body: Body) -> String {
futures::executor::block_on(async {
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
})
}
#[tokio::test]
async fn present_token_returns_key_authorization() {
let tokens = Http01Tokens::new();
tokens.insert("abc", "abc.keyauth");
let app = challenge_router(tokens, 443);
let resp = app
.oneshot(
Request::builder()
.uri("/.well-known/acme-challenge/abc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/plain"
);
assert_eq!(body_to_string(resp.into_body()), "abc.keyauth");
}
#[tokio::test]
async fn absent_token_returns_404() {
let app = challenge_router(Http01Tokens::new(), 443);
let resp = app
.oneshot(
Request::builder()
.uri("/.well-known/acme-challenge/missing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn other_path_redirects_to_https_preserving_path_and_query() {
let app = challenge_router(Http01Tokens::new(), 443);
let resp = app
.oneshot(
Request::builder()
.uri("/dashboard?tab=2")
.header(header::HOST, "app.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"https://app.example.com/dashboard?tab=2"
);
}
#[tokio::test]
async fn redirect_strips_port_80_and_appends_nondefault_https_port() {
let app = challenge_router(Http01Tokens::new(), 8443);
let resp = app
.oneshot(
Request::builder()
.uri("/x")
.header(header::HOST, "app.example.com:80")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"https://app.example.com:8443/x"
);
}
#[tokio::test]
async fn missing_host_is_bad_request() {
let app = challenge_router(Http01Tokens::new(), 443);
let resp = app
.oneshot(Request::builder().uri("/x").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn host_only_strips_port_and_keeps_ipv6() {
assert_eq!(host_only("example.com"), "example.com");
assert_eq!(host_only("example.com:80"), "example.com");
assert_eq!(host_only("[::1]:80"), "[::1]");
assert_eq!(host_only("[::1]"), "[::1]");
}
async fn assert_ipv4_served(listeners: Vec<tokio::net::TcpListener>) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
assert!(!listeners.is_empty(), "must bind at least one listener");
let port = listeners[0].local_addr().unwrap().port();
let router = challenge_router(Http01Tokens::new(), 443);
let mut serve_handles = Vec::new();
for listener in listeners {
let router = router.clone();
serve_handles.push(tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
}));
}
let mut stream = tokio::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port))
.await
.expect("IPv4 loopback connection to the challenge listener");
stream
.write_all(b"GET / HTTP/1.0\r\nHost: app.example.com\r\n\r\n")
.await
.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf);
assert!(
response.contains("308"),
"challenge router must serve the IPv4 connection (expected a 308 redirect), \
got: {response}"
);
for handle in serve_handles {
handle.abort();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn challenge_listener_accepts_ipv4_and_is_dual_stack_when_available() {
let listeners = bind_challenge_listeners(0)
.await
.expect("bind ephemeral challenge listener");
assert_ipv4_served(listeners).await;
if let Ok(dual) = bind_ipv6(0, false) {
assert!(
dual.local_addr().unwrap().is_ipv6(),
"the dual-stack challenge listener must be bound on the IPv6 wildcard [::]"
);
assert_ipv4_served(vec![dual]).await;
}
}
}