Implement HTTP to HTTPS redirect with per-listener binding

Adds the HTTP redirect listener that redirects all plain HTTP requests to
the HTTPS equivalent URL. Each listener with http_port > 0 runs its own
redirect server on bind_addr:http_port.

- build_redirect_url: constructs https://{host}:{port}/{path}?{query},
  omitting port 443 and stripping the host port from the Host header
- redirect_handler: axum handler returning 301 with Location header,
  400 for missing/empty Host, 404 for ACME challenge paths
- redirect_router: creates axum Router with fallback handler
- start_http_redirect_listener: binds TCP and spawns redirect server
- ACME HTTP-01 challenge path returns 404 (placeholder for future)
- 19 unit tests for URL construction and host parsing
- 8 integration tests covering 301 redirect, 400 on missing Host,
  port 443 omission, non-443 port inclusion, query preservation,
  ACME challenge 404
This commit is contained in:
2026-06-11 13:14:27 +00:00
parent 2791070971
commit d893187c40
2 changed files with 481 additions and 2 deletions

View File

@@ -1,2 +1,246 @@
#[allow(dead_code)]
pub struct HttpsRedirect;
use std::net::SocketAddr;
use axum::extract::Request;
use axum::http::header::{HeaderName, HOST, LOCATION};
use axum::http::{HeaderValue, StatusCode};
use axum::response::IntoResponse;
use axum::routing::any;
use axum::Router;
use tokio::net::TcpListener;
use tracing::info;
use crate::config::static_config::ListenerConfig;
const ACME_CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
fn strip_port_from_host(host: &str) -> &str {
if host.starts_with('[') {
if let Some(bracket_end) = host.find(']') {
&host[..bracket_end + 1]
} else {
host
}
} else if let Some(colon_pos) = host.rfind(':') {
&host[..colon_pos]
} else {
host
}
}
pub fn build_redirect_url(host: &str, https_port: u16, path: &str, query: &str) -> String {
let hostname = strip_port_from_host(host);
let port_suffix = if https_port == 443 {
String::new()
} else {
format!(":{https_port}")
};
let path_part = if path.is_empty() || !path.starts_with('/') {
format!("/{path}")
} else {
path.to_string()
};
if query.is_empty() {
format!("https://{hostname}{port_suffix}{path_part}")
} else {
format!("https://{hostname}{port_suffix}{path_part}?{query}")
}
}
async fn redirect_handler(https_port: u16, request: Request) -> axum::response::Response {
let host = request
.headers()
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
let Some(host) = host else {
return (StatusCode::BAD_REQUEST, "Bad Request").into_response();
};
let path = request.uri().path().to_string();
let query = request.uri().query().unwrap_or("").to_string();
if path.starts_with(ACME_CHALLENGE_PREFIX) {
return (
StatusCode::NOT_FOUND,
[(
HeaderName::from_static("content-type"),
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
"Not Found",
)
.into_response();
}
let location = build_redirect_url(&host, https_port, &path, &query);
match HeaderValue::from_str(&location) {
Ok(location_value) => (
StatusCode::MOVED_PERMANENTLY,
[(LOCATION, location_value)],
StatusCode::MOVED_PERMANENTLY.to_string(),
)
.into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response(),
}
}
pub fn redirect_router(https_port: u16) -> Router {
Router::new().fallback(any(move |req| redirect_handler(https_port, req)))
}
pub async fn start_http_redirect_listener(
listener_config: &ListenerConfig,
) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle<anyhow::Result<()>>)> {
let bind_addr: SocketAddr = format!(
"{}:{}",
listener_config.bind_addr, listener_config.http_port
)
.parse()
.map_err(|e| {
anyhow::anyhow!(
"invalid bind address {}:{} for HTTP redirect: {}",
listener_config.bind_addr,
listener_config.http_port,
e
)
})?;
let tcp_listener = TcpListener::bind(bind_addr).await?;
let local_addr = tcp_listener.local_addr()?;
info!(
addr = %local_addr,
https_port = listener_config.https_port,
"HTTP redirect listener bound"
);
let https_port = listener_config.https_port;
let app = redirect_router(https_port);
let handle = tokio::spawn(async move {
axum::serve(tcp_listener, app)
.await
.map_err(anyhow::Error::from)
});
Ok((local_addr, handle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redirect_url_standard_443() {
let url = build_redirect_url("example.com", 443, "/", "");
assert_eq!(url, "https://example.com/");
}
#[test]
fn test_redirect_url_non_standard_port() {
let url = build_redirect_url("example.com", 8443, "/", "");
assert_eq!(url, "https://example.com:8443/");
}
#[test]
fn test_redirect_url_with_path() {
let url = build_redirect_url("example.com", 443, "/some/path", "");
assert_eq!(url, "https://example.com/some/path");
}
#[test]
fn test_redirect_url_with_query() {
let url = build_redirect_url("example.com", 443, "/path", "key=val");
assert_eq!(url, "https://example.com/path?key=val");
}
#[test]
fn test_redirect_url_with_path_and_query() {
let url = build_redirect_url("example.com", 8443, "/path", "a=b&c=d");
assert_eq!(url, "https://example.com:8443/path?a=b&c=d");
}
#[test]
fn test_redirect_url_strips_host_port() {
let url = build_redirect_url("example.com:8080", 443, "/", "");
assert_eq!(url, "https://example.com/");
}
#[test]
fn test_redirect_url_strips_host_port_non_standard_https() {
let url = build_redirect_url("example.com:8080", 8443, "/api", "token=abc");
assert_eq!(url, "https://example.com:8443/api?token=abc");
}
#[test]
fn test_redirect_url_empty_path() {
let url = build_redirect_url("example.com", 443, "", "");
assert_eq!(url, "https://example.com/");
}
#[test]
fn test_redirect_url_path_without_leading_slash() {
let url = build_redirect_url("example.com", 443, "path", "");
assert_eq!(url, "https://example.com/path");
}
#[test]
fn test_redirect_url_root_path_with_query() {
let url = build_redirect_url("git.alk.dev", 443, "/", "repo=test");
assert_eq!(url, "https://git.alk.dev/?repo=test");
}
#[test]
fn test_redirect_url_ipv6_host() {
let url = build_redirect_url("[::1]", 443, "/", "");
assert_eq!(url, "https://[::1]/");
}
#[test]
fn test_redirect_url_ipv6_host_with_port() {
let url = build_redirect_url("[::1]:8080", 443, "/", "");
assert_eq!(url, "https://[::1]/");
}
#[test]
fn test_redirect_url_ipv6_host_non_standard_https_port() {
let url = build_redirect_url("[::1]:8080", 8443, "/", "");
assert_eq!(url, "https://[::1]:8443/");
}
#[test]
fn test_redirect_url_ipv4_host() {
let url = build_redirect_url("203.0.113.10", 443, "/", "");
assert_eq!(url, "https://203.0.113.10/");
}
#[test]
fn test_strip_port_from_host_plain() {
assert_eq!(strip_port_from_host("example.com"), "example.com");
}
#[test]
fn test_strip_port_from_host_with_port() {
assert_eq!(strip_port_from_host("example.com:8080"), "example.com");
}
#[test]
fn test_strip_port_from_host_ipv6_bare() {
assert_eq!(strip_port_from_host("[::1]"), "[::1]");
}
#[test]
fn test_strip_port_from_host_ipv6_with_port() {
assert_eq!(strip_port_from_host("[::1]:8080"), "[::1]");
}
#[test]
fn test_strip_port_from_host_ipv4_with_port() {
assert_eq!(strip_port_from_host("192.168.1.1:8080"), "192.168.1.1");
}
}

View File

@@ -248,3 +248,238 @@ async fn test_rate_limit_eviction_task() {
handle.abort();
}
fn make_redirect_listener_config(
bind_addr: &str,
http_port: u16,
https_port: u16,
) -> reverse_proxy::config::static_config::ListenerConfig {
reverse_proxy::config::static_config::ListenerConfig {
bind_addr: bind_addr.to_string(),
http_port,
https_port,
tls: reverse_proxy::config::static_config::TlsConfig {
mode: "manual".to_string(),
acme_domains: vec![],
acme_cache_dir: String::new(),
acme_directory: "production".to_string(),
cert_path: String::new(),
key_path: String::new(),
},
sites: vec![],
}
}
#[tokio::test]
async fn test_http_redirect_returns_301_with_location() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get(format!("http://127.0.0.1:{}/some/path", addr.port()))
.header("Host", "example.com")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, "https://example.com/some/path");
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_port_443_omitted_from_url() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get(format!("http://127.0.0.1:{}/", addr.port()))
.header("Host", "example.com")
.send()
.await
.unwrap();
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, "https://example.com/");
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_non_443_port_included_in_url() {
let config = make_redirect_listener_config("127.0.0.1", 0, 8443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get(format!("http://127.0.0.1:{}/", addr.port()))
.header("Host", "example.com")
.send()
.await
.unwrap();
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, "https://example.com:8443/");
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_empty_host_returns_400() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET / HTTP/1.1\r\nHost: \r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = vec![0u8; 4096];
let n = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.read(&mut response),
)
.await
.unwrap()
.unwrap();
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains(" 400 "),
"expected 400 status, got: {response_str}"
);
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_no_host_header_returns_400() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET / HTTP/1.0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = vec![0u8; 4096];
let n = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.read(&mut response),
)
.await
.unwrap()
.unwrap();
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains(" 400 "),
"expected 400 status, got: {response_str}"
);
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_strips_host_port() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get(format!("http://127.0.0.1:{}/path", addr.port()))
.header("Host", "example.com:8080")
.send()
.await
.unwrap();
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, "https://example.com/path");
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_preserves_query_string() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let resp = client
.get(format!(
"http://127.0.0.1:{}/search?q=test&page=1",
addr.port()
))
.header("Host", "git.alk.dev")
.send()
.await
.unwrap();
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, "https://git.alk.dev/search?q=test&page=1");
handle.abort();
}
#[tokio::test]
async fn test_http_redirect_acme_challenge_returns_404() {
let config = make_redirect_listener_config("127.0.0.1", 0, 443);
let (addr, handle) = reverse_proxy::tls::redirect::start_http_redirect_listener(&config)
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.get(format!(
"http://127.0.0.1:{}/.well-known/acme-challenge/abc123",
addr.port()
))
.header("Host", "example.com")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
handle.abort();
}