use std::fs::File;
use std::io::BufReader;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::http::{HeaderValue, Request, StatusCode};
use axum::response::IntoResponse;
use axum::Router;
use hyper::body::Incoming;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as ConnBuilder;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tokio_rustls::TlsAcceptor;
use tower::{Service, ServiceExt};
use tracing::{debug, info, warn};
pub fn init_crypto() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
pub fn load_server_config(cert_path: &str, key_path: &str) -> Result<Arc<ServerConfig>> {
let certs = load_certs(cert_path)?;
let key = load_key(key_path)?;
let mut config =
ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.context("selecting TLS protocol versions")?
.with_no_client_auth()
.with_single_cert(certs, key)
.context("building rustls ServerConfig (does the key match the certificate?)")?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
fn load_certs(path: &str) -> Result<Vec<CertificateDer<'static>>> {
let file = File::open(path).with_context(|| format!("opening certificate file {path}"))?;
let mut reader = BufReader::new(file);
let certs = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("parsing certificates from {path}"))?;
anyhow::ensure!(!certs.is_empty(), "no certificates found in {path}");
Ok(certs)
}
fn load_key(path: &str) -> Result<PrivateKeyDer<'static>> {
let file = File::open(path).with_context(|| format!("opening private key file {path}"))?;
let mut reader = BufReader::new(file);
rustls_pemfile::private_key(&mut reader)
.with_context(|| format!("parsing private key from {path}"))?
.with_context(|| format!("no private key found in {path}"))
}
pub async fn serve(
listener: TcpListener,
config: Arc<ServerConfig>,
app: Router,
mut shutdown: watch::Receiver<bool>,
) -> Result<()> {
let acceptor = TlsAcceptor::from(config);
let mut make_service = app.into_make_service_with_connect_info::<SocketAddr>();
info!(listen = %listener.local_addr().map(|a| a.to_string()).unwrap_or_default(), "TLS listener up");
loop {
let (stream, peer) = tokio::select! {
_ = shutdown.changed() => {
if *shutdown.borrow() { break; }
continue;
}
accepted = listener.accept() => match accepted {
Ok(v) => v,
Err(e) => { warn!(error = %e, "TLS accept error"); continue; }
},
};
let acceptor = acceptor.clone();
let tower_service = unwrap_infallible(make_service.call(peer).await);
tokio::spawn(async move {
let tls_stream = match tokio::time::timeout(
Duration::from_secs(10),
acceptor.accept(stream),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!(error = %e, %peer, "TLS handshake failed");
return;
}
Err(_) => {
debug!(%peer, "TLS handshake timed out");
return;
}
};
let io = TokioIo::new(tls_stream);
let hyper_service = hyper::service::service_fn(move |request: Request<Incoming>| {
tower_service.clone().oneshot(request)
});
if let Err(e) = ConnBuilder::new(TokioExecutor::new())
.serve_connection_with_upgrades(io, hyper_service)
.await
{
debug!(error = %e, %peer, "error serving TLS connection");
}
});
}
Ok(())
}
const ACME_CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
pub fn redirect_location(
host_header: Option<&str>,
path_and_query: &str,
tls_port: u16,
allowed: &[String],
) -> Option<String> {
let host = host_header?;
let bare = split_authority(host)?;
if !allowed.is_empty() && !allowed.iter().any(|a| a.eq_ignore_ascii_case(bare)) {
return None;
}
Some(if tls_port == 443 {
format!("https://{bare}{path_and_query}")
} else {
format!("https://{bare}:{tls_port}{path_and_query}")
})
}
fn split_authority(host: &str) -> Option<&str> {
let (bare, port) = match host.rfind(']') {
Some(close) => {
let rest = &host[close + 1..];
match rest.strip_prefix(':') {
Some(p) => (&host[..=close], Some(p)),
None if rest.is_empty() => (&host[..=close], None),
None => return None,
}
}
None => match host.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (host, None),
},
};
if let Some(digits) = port {
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
}
is_valid_host(bare).then_some(bare)
}
fn is_valid_host(host: &str) -> bool {
if host.is_empty() || host.len() > 253 {
return false;
}
if let Some(inner) = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
return !inner.is_empty() && inner.parse::<std::net::Ipv6Addr>().is_ok();
}
!host.starts_with(['.', '-'])
&& !host.ends_with(['.', '-'])
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
}
fn redirect_router(tls_port: u16, status: StatusCode, allowed: Vec<String>) -> Router {
let allowed = Arc::new(allowed);
Router::new().fallback(move |req: Request<axum::body::Body>| {
let allowed = Arc::clone(&allowed);
async move {
let path_and_query = req
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
if path_and_query.starts_with(ACME_CHALLENGE_PREFIX) {
return (StatusCode::NOT_FOUND, "not found\n").into_response();
}
let host = req
.headers()
.get(axum::http::header::HOST)
.and_then(|h| h.to_str().ok());
match redirect_location(host, path_and_query, tls_port, &allowed) {
Some(location) => match HeaderValue::from_str(&location) {
Ok(value) => (status, [(axum::http::header::LOCATION, value)]).into_response(),
Err(_) => (StatusCode::BAD_REQUEST, "bad host\n").into_response(),
},
None => (
StatusCode::BAD_REQUEST,
"this port serves only an HTTPS redirect; send a valid Host header\n",
)
.into_response(),
}
}
})
}
pub fn parse_redirect_status(status: u16) -> Result<StatusCode> {
let parsed = StatusCode::from_u16(status)
.with_context(|| format!("invalid tls.redirect_status {status}"))?;
anyhow::ensure!(
matches!(status, 301 | 302 | 303 | 307 | 308),
"tls.redirect_status must be 301, 302, 303, 307 or 308 (got {parsed}); \
308 preserves the method and body, 301 is the older browser-facing convention"
);
Ok(parsed)
}
pub async fn serve_redirect(
listener: TcpListener,
tls_port: u16,
status: u16,
allowed: Vec<String>,
shutdown: watch::Receiver<bool>,
) -> Result<()> {
let status = parse_redirect_status(status)?;
info!(
listen = %listener.local_addr().map(|a| a.to_string()).unwrap_or_default(),
to_port = tls_port,
%status,
"HTTP→HTTPS redirect listener up"
);
axum::serve(
listener,
redirect_router(tls_port, status, allowed).into_make_service(),
)
.with_graceful_shutdown(async move {
let mut shutdown = shutdown;
while shutdown.changed().await.is_ok() {
if *shutdown.borrow() {
break;
}
}
})
.await
.context("HTTP→HTTPS redirect server error")
}
fn unwrap_infallible<T>(result: Result<T, std::convert::Infallible>) -> T {
match result {
Ok(value) => value,
Err(never) => match never {},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_server_config_errors_on_missing_files() {
assert!(load_server_config("/no/such/cert.pem", "/no/such/key.pem").is_err());
}
fn loc(host: Option<&str>, pq: &str, port: u16) -> Option<String> {
redirect_location(host, pq, port, &[])
}
#[test]
fn redirects_preserving_path_and_query() {
assert_eq!(
loc(Some("app.example.com"), "/a/b?x=1&y=2", 443).as_deref(),
Some("https://app.example.com/a/b?x=1&y=2")
);
}
#[test]
fn rewrites_to_the_tls_port_when_it_is_not_443() {
assert_eq!(
loc(Some("localhost:80"), "/", 8443).as_deref(),
Some("https://localhost:8443/")
);
}
#[test]
fn strips_the_plaintext_port_from_the_host_header() {
assert_eq!(
loc(Some("app.example.com:80"), "/", 443).as_deref(),
Some("https://app.example.com/")
);
}
#[test]
fn keeps_ipv6_literals_bracketed() {
assert_eq!(
loc(Some("[::1]:80"), "/health", 8443).as_deref(),
Some("https://[::1]:8443/health")
);
assert_eq!(loc(Some("[not-an-ip]"), "/", 443), None);
}
#[test]
fn refuses_a_missing_or_malformed_host() {
assert_eq!(loc(None, "/", 443), None);
assert_eq!(loc(Some(""), "/", 443), None);
assert_eq!(loc(Some("evil.example/@good.example"), "/", 443), None);
assert_eq!(loc(Some("host\r\nX-Injected: 1"), "/", 443), None);
assert_eq!(loc(Some("has space"), "/", 443), None);
assert_eq!(loc(Some("user@evil.example"), "/", 443), None);
assert_eq!(
loc(Some("attacker.example:443@victim.example"), "/", 443),
None
);
assert_eq!(loc(Some("host.example:80:81"), "/", 443), None);
assert_eq!(loc(Some("host.example:"), "/", 443), None);
assert_eq!(loc(Some("host.example:notaport"), "/", 443), None);
assert_eq!(loc(Some("[::1]:junk"), "/", 443), None);
assert_eq!(loc(Some(".leading-dot"), "/", 443), None);
assert_eq!(loc(Some(&"a".repeat(254)), "/", 443), None);
}
#[test]
fn allow_list_pins_the_redirect_target() {
let allowed = vec!["app.example.com".to_string()];
assert!(redirect_location(Some("app.example.com"), "/", 443, &allowed).is_some());
assert_eq!(
redirect_location(Some("attacker.example"), "/", 443, &allowed),
None
);
assert!(redirect_location(Some("APP.Example.com:80"), "/", 443, &allowed).is_some());
}
#[tokio::test]
async fn redirect_router_answers_end_to_end() {
use axum::body::Body;
use tower::ServiceExt;
let app = redirect_router(8443, StatusCode::PERMANENT_REDIRECT, vec![]);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/submit?a=1")
.header("host", "localhost")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
res.headers().get("location").unwrap(),
"https://localhost:8443/submit?a=1"
);
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/.well-known/acme-challenge/tok")
.header("host", "localhost")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(res.headers().get("location").is_none());
let res = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn redirect_status_accepts_only_navigational_redirects() {
for ok in [301, 302, 303, 307, 308] {
assert!(parse_redirect_status(ok).is_ok(), "{ok} should be accepted");
}
for bad in [200, 404, 0] {
assert!(
parse_redirect_status(bad).is_err(),
"{bad} should be rejected"
);
}
for not_navigational in [300, 304, 305, 306] {
assert!(
parse_redirect_status(not_navigational).is_err(),
"{not_navigational} is 3xx but does not navigate; it must be rejected"
);
}
}
#[tokio::test]
async fn serve_redirect_rejects_a_non_3xx_status() {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let (_tx, rx) = watch::channel(false);
assert!(serve_redirect(listener, 443, 200, vec![], rx)
.await
.is_err());
}
}