use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::error::{Error, Result};
use crate::solvers::http::handle_http_challenge_request_async;
#[derive(Debug, Clone)]
pub struct HttpsRequest {
pub method: String,
pub target: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpsRequest {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
#[derive(Debug, Clone)]
pub struct HttpsResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct HttpsRedirectHandler {
pub https_port: u16,
canonical_host: Option<String>,
allowed_hosts: HashSet<String>,
}
impl Default for HttpsRedirectHandler {
fn default() -> Self {
Self {
https_port: 443,
canonical_host: None,
allowed_hosts: HashSet::new(),
}
}
}
impl HttpsRedirectHandler {
#[must_use]
pub fn new(https_port: u16) -> Self {
Self {
https_port,
..Self::default()
}
}
pub fn with_canonical_host(mut self, host: &str) -> Result<Self> {
self.canonical_host = Some(normalize_redirect_host(host)?);
Ok(self)
}
pub fn allow_host(mut self, host: &str) -> Result<Self> {
self.allowed_hosts.insert(normalize_redirect_host(host)?);
Ok(self)
}
#[must_use]
pub fn redirect_url(&self, host: &str, target: &str) -> String {
let host = self
.canonical_host
.clone()
.or_else(|| normalize_redirect_host(host).ok())
.unwrap_or_else(|| "localhost".into());
format_redirect_url(&host, self.https_port, target)
}
pub fn try_redirect_url(&self, host: &str, target: &str) -> Result<String> {
let host = if let Some(canonical) = &self.canonical_host {
canonical.clone()
} else {
let normalized = normalize_redirect_host(host)?;
if !self.allowed_hosts.contains(&normalized) {
return Err(Error::Config(crate::error::ConfigError::Invalid(format!(
"request Host {normalized:?} is not allowed for redirect"
))));
}
normalized
};
Ok(format_redirect_url(&host, self.https_port, target))
}
pub async fn start(&self, bind_addr: &str) -> Result<tokio::task::JoinHandle<()>> {
if self.canonical_host.is_none() && self.allowed_hosts.is_empty() {
return Err(Error::Config(crate::error::ConfigError::Invalid(
"redirect requires a canonical host or an allowed host".into(),
)));
}
let listener = TcpListener::bind(bind_addr).await.map_err(Error::from)?;
let redirect = self.clone();
Ok(tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let redirect = redirect.clone();
tokio::spawn(async move {
if let Err(error) = serve_redirect(stream, &redirect).await {
tracing::debug!(%error, "redirect connection closed with error");
}
});
}
}))
}
}
pub async fn start_https_redirect(bind_addr: &str) -> Result<tokio::task::JoinHandle<()>> {
HttpsRedirectHandler::default().start(bind_addr).await
}
pub async fn start_https_redirect_with_port(
bind_addr: &str,
https_port: u16,
) -> Result<tokio::task::JoinHandle<()>> {
HttpsRedirectHandler::new(https_port).start(bind_addr).await
}
pub async fn start_https_redirect_to_host(
bind_addr: &str,
canonical_host: &str,
) -> Result<tokio::task::JoinHandle<()>> {
HttpsRedirectHandler::new(443)
.with_canonical_host(canonical_host)?
.start(bind_addr)
.await
}
fn normalize_redirect_host(host: &str) -> Result<String> {
let host = host.trim();
if host.is_empty()
|| host
.chars()
.any(|ch| ch.is_ascii_control() || ch.is_whitespace())
|| host.contains(['/', '\\', '@', '?', '#'])
{
return Err(Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
)));
}
if let Ok(addr) = host.parse::<std::net::SocketAddr>() {
return Ok(format_redirect_authority_host(addr.ip()));
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(format_redirect_authority_host(ip));
}
if let Some(inner) = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
&& let Ok(ip) = inner.parse::<std::net::IpAddr>()
{
return Ok(format_redirect_authority_host(ip));
}
if host.starts_with('[') || host.ends_with(']') {
return Err(Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
)));
}
let hostname = if host.matches(':').count() == 1 {
let (name, port) = host.rsplit_once(':').ok_or_else(|| {
Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
))
})?;
if name.is_empty() || port.is_empty() || port.parse::<u16>().is_err() {
return Err(Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
)));
}
name
} else if host.contains(':') {
return Err(Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
)));
} else {
host
};
if hostname.is_empty() {
return Err(Error::Config(crate::error::ConfigError::Invalid(
"invalid redirect host".into(),
)));
}
Ok(hostname.to_ascii_lowercase())
}
fn format_redirect_authority_host(ip: std::net::IpAddr) -> String {
match ip {
std::net::IpAddr::V4(ip) => ip.to_string(),
std::net::IpAddr::V6(ip) => format!("[{ip}]"),
}
}
fn format_redirect_url(host: &str, https_port: u16, target: &str) -> String {
let target = if target.starts_with('/')
&& !target.contains(['\r', '\n', '\\'])
&& !target.starts_with("//")
{
target
} else {
"/"
};
if https_port == 443 {
format!("https://{host}{target}")
} else {
format!("https://{host}:{https_port}{target}")
}
}
impl HttpsResponse {
#[must_use]
pub fn new(status: u16, content_type: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
headers: vec![("content-type".into(), content_type.into())],
body: body.into(),
}
}
}
pub async fn https<F, Fut>(domain_names: &[String], service: F) -> Result<()>
where
F: Fn(HttpsRequest) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = HttpsResponse> + Send + 'static,
{
let https_port = crate::HTTPS_PORT.load(std::sync::atomic::Ordering::Relaxed);
let http_port = crate::HTTP_PORT.load(std::sync::atomic::Ordering::Relaxed);
https_on(
std::net::SocketAddr::from(([0, 0, 0, 0], https_port)),
std::net::SocketAddr::from(([0, 0, 0, 0], http_port)),
domain_names,
service,
)
.await
}
pub async fn https_on<F, Fut>(
https_addr: std::net::SocketAddr,
http_addr: std::net::SocketAddr,
domain_names: &[String],
service: F,
) -> Result<()>
where
F: Fn(HttpsRequest) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = HttpsResponse> + Send + 'static,
{
let (https_listener, acceptor) = crate::listen_on(https_addr, domain_names).await?;
let http_listener = TcpListener::bind(http_addr).await.map_err(Error::from)?;
let mut redirect = HttpsRedirectHandler::new(https_addr.port());
for domain in domain_names {
if let Ok(policy) = redirect.clone().allow_host(domain) {
redirect = policy;
}
}
let service = Arc::new(service);
let tls_task = tokio::spawn(serve_tls(
https_listener,
Arc::new(acceptor),
Arc::clone(&service),
));
let http_task = tokio::spawn(serve_http(http_listener, redirect));
let result = tokio::select! {
result = tls_task => result.map_err(|e| Error::Internal(format!("HTTPS task: {e}")))?,
result = http_task => result.map_err(|e| Error::Internal(format!("HTTP task: {e}")))?,
};
result?;
Ok(())
}
async fn serve_tls<F, Fut>(
listener: TcpListener,
acceptor: Arc<crate::tls_integration::CertmagicAcceptor>,
service: Arc<F>,
) -> Result<()>
where
F: Fn(HttpsRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HttpsResponse> + Send + 'static,
{
loop {
let (stream, _) = listener.accept().await.map_err(Error::from)?;
let acceptor = Arc::clone(&acceptor);
let service = Arc::clone(&service);
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(handshake) => match handshake.await {
Ok(tls) => {
if let Err(error) = serve_connection(tls, service).await {
tracing::debug!(%error, "HTTPS connection closed with error");
}
}
Err(error) => tracing::debug!(%error, "TLS handshake failed"),
},
Err(error) => tracing::debug!(%error, "TLS handshake failed"),
}
});
}
}
async fn serve_http(listener: TcpListener, redirect: HttpsRedirectHandler) -> Result<()> {
loop {
let (stream, _) = listener.accept().await.map_err(Error::from)?;
let redirect = redirect.clone();
tokio::spawn(async move {
if let Err(error) = serve_redirect(stream, &redirect).await {
tracing::debug!(%error, "HTTP connection closed with error");
}
});
}
}
async fn serve_redirect(mut stream: TcpStream, redirect: &HttpsRedirectHandler) -> Result<()> {
let request = read_request(&mut stream).await?;
let host = request.header("host").unwrap_or_default();
if let Some(answer) =
handle_http_challenge_request_async(host, &request.method, &request.target).await
{
write_response(
&mut stream,
HttpsResponse::new(200, "text/plain", answer.into_bytes()),
)
.await?;
return Ok(());
}
let location = match redirect.try_redirect_url(host, &request.target) {
Ok(location) => location,
Err(_) => {
let response = HttpsResponse::new(400, "text/plain", b"invalid host\n".to_vec());
write_response(&mut stream, response).await?;
return Ok(());
}
};
let mut response = HttpsResponse::new(301, "text/plain", b"redirecting\n".to_vec());
response.headers.push(("location".into(), location));
write_response(&mut stream, response).await
}
async fn serve_connection<IO, F, Fut>(mut stream: IO, service: Arc<F>) -> Result<()>
where
IO: AsyncRead + AsyncWrite + Unpin,
F: Fn(HttpsRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HttpsResponse> + Send + 'static,
{
let request = read_request(&mut stream).await?;
let response = service(request).await;
write_response(&mut stream, response).await
}
async fn read_request<IO>(stream: &mut IO) -> Result<HttpsRequest>
where
IO: AsyncRead + Unpin,
{
const MAX_HEADERS: usize = 64 * 1024;
const MAX_BODY: usize = 1024 * 1024;
let mut bytes = Vec::with_capacity(2048);
let mut chunk = [0_u8; 2048];
let header_end = loop {
let read = stream.read(&mut chunk).await.map_err(Error::from)?;
if read == 0 {
return Err(Error::Internal("HTTP peer closed before headers".into()));
}
bytes.extend_from_slice(&chunk[..read]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
break end + 4;
}
if bytes.len() > MAX_HEADERS {
return Err(Error::Internal("HTTP headers exceed 64 KiB".into()));
}
};
let header_text = String::from_utf8_lossy(&bytes[..header_end]);
let mut lines = header_text.split("\r\n");
let request_line = lines
.next()
.ok_or_else(|| Error::Internal("HTTP request line missing".into()))?;
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default().to_owned();
let target = parts.next().unwrap_or_default().to_owned();
if method.is_empty() || target.is_empty() {
return Err(Error::Internal("HTTP request line malformed".into()));
}
let headers: Vec<(String, String)> = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned()))
.collect();
let content_length = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.parse::<usize>().ok())
.unwrap_or(0);
if content_length > MAX_BODY {
return Err(Error::Internal("HTTP body exceeds 1 MiB".into()));
}
while bytes.len() - header_end < content_length {
let read = stream.read(&mut chunk).await.map_err(Error::from)?;
if read == 0 {
return Err(Error::Internal("HTTP peer closed in body".into()));
}
bytes.extend_from_slice(&chunk[..read]);
}
Ok(HttpsRequest {
method,
target,
headers,
body: bytes[header_end..header_end + content_length].to_vec(),
})
}
async fn write_response<IO>(stream: &mut IO, mut response: HttpsResponse) -> Result<()>
where
IO: AsyncWrite + Unpin,
{
response
.headers
.retain(|(name, _)| !name.eq_ignore_ascii_case("content-length"));
let reason = match response.status {
200 => "OK",
301 => "Moved Permanently",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Response",
};
let mut output = format!("HTTP/1.1 {} {reason}\r\n", response.status);
for (name, value) in response.headers {
output.push_str(&name);
output.push_str(": ");
output.push_str(&value);
output.push_str("\r\n");
}
output.push_str(&format!(
"content-length: {}\r\nconnection: close\r\n\r\n",
response.body.len()
));
stream
.write_all(output.as_bytes())
.await
.map_err(Error::from)?;
stream
.write_all(&response.body)
.await
.map_err(Error::from)?;
stream.flush().await.map_err(Error::from)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redirect_policy_enforces_allowed_hosts() {
let handler = HttpsRedirectHandler::new(8443)
.allow_host("Example.com:80")
.unwrap();
assert_eq!(
handler.try_redirect_url("EXAMPLE.COM", "/a?b=1").unwrap(),
"https://example.com:8443/a?b=1"
);
assert!(handler.try_redirect_url("other.example.com", "/").is_err());
}
#[test]
fn redirect_policy_uses_canonical_host_and_sanitizes_target() {
let handler = HttpsRedirectHandler::default()
.with_canonical_host("Example.com")
.unwrap();
assert_eq!(
handler.redirect_url("attacker.example", "//evil.example/path"),
"https://example.com/"
);
assert_eq!(
handler.redirect_url("attacker.example", "/\\evil.example/path"),
"https://example.com/"
);
}
#[test]
fn redirect_policy_normalizes_ipv6_and_rejects_malformed_hosts() {
let handler = HttpsRedirectHandler::new(8443)
.allow_host("[2001:DB8::1]:80")
.unwrap();
assert_eq!(
handler
.try_redirect_url("[2001:db8::1]:8080", "/check?x=1")
.unwrap(),
"https://[2001:db8::1]:8443/check?x=1"
);
assert!(handler.try_redirect_url("[2001:db8::2]:8080", "/").is_err());
assert!(
HttpsRedirectHandler::default()
.with_canonical_host("example.com:not-a-port")
.is_err()
);
assert!(
HttpsRedirectHandler::default()
.with_canonical_host("[2001:db8::1")
.is_err()
);
assert!(
HttpsRedirectHandler::new(8443)
.allow_host("example.com:not-a-port")
.is_err()
);
}
#[test]
fn redirect_requires_a_host_policy_when_used_for_a_listener() {
let handler = HttpsRedirectHandler::new(8443);
assert!(handler.try_redirect_url("attacker.example", "/").is_err());
}
}