Merge feat/tls/http-redirect into main
This commit is contained in:
Submodule .worktrees/feat/config/cli-parsing deleted from d89ab71f85
Submodule .worktrees/feat/ops/admin-socket deleted from 56eda4e47c
Submodule .worktrees/feat/proxy/headers-and-forwarding deleted from 2791070971
Submodule .worktrees/feat/tls/http-redirect deleted from d893187c40
@@ -1,2 +1,246 @@
|
|||||||
#[allow(dead_code)]
|
use std::net::SocketAddr;
|
||||||
pub struct HttpsRedirect;
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,60 +257,286 @@ async fn test_rate_limit_eviction_task() {
|
|||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_valid_config(dir: &std::path::Path) -> std::path::PathBuf {
|
fn make_redirect_listener_config(
|
||||||
let cert_path = dir.join("cert.pem");
|
bind_addr: &str,
|
||||||
let key_path = dir.join("key.pem");
|
http_port: u16,
|
||||||
std::fs::write(&cert_path, "cert").unwrap();
|
https_port: u16,
|
||||||
std::fs::write(&key_path, "key").unwrap();
|
) -> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_valid_config(dir: &Path) -> std::path::PathBuf {
|
||||||
|
let config_path = dir.join("config.toml");
|
||||||
|
let config = r#"
|
||||||
|
health_check_port = 9900
|
||||||
|
admin_socket_path = "/tmp/reverse-proxy-test/admin.sock"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "info"
|
||||||
|
format = "text"
|
||||||
|
|
||||||
|
[[listeners]]
|
||||||
|
bind_addr = "127.0.0.1"
|
||||||
|
https_port = 443
|
||||||
|
|
||||||
|
[listeners.tls]
|
||||||
|
mode = "acme"
|
||||||
|
acme_domains = ["test.local"]
|
||||||
|
acme_cache_dir = "/tmp/acme-cache"
|
||||||
|
|
||||||
|
[[listeners.listeners.sites]]
|
||||||
|
host = "test.local"
|
||||||
|
upstream = "127.0.0.1:8080"
|
||||||
|
|
||||||
let toml = format!(
|
|
||||||
r#"
|
|
||||||
[rate_limit]
|
[rate_limit]
|
||||||
requests_per_second = 10
|
requests_per_second = 10
|
||||||
burst = 20
|
burst = 20
|
||||||
|
|
||||||
[body]
|
[body]
|
||||||
limit_bytes = 104857600
|
limit_bytes = 104857600
|
||||||
|
"#;
|
||||||
[[listeners]]
|
std::fs::write(&config_path, config).unwrap();
|
||||||
bind_addr = "127.0.0.1"
|
|
||||||
http_port = 80
|
|
||||||
https_port = 443
|
|
||||||
|
|
||||||
[listeners.tls]
|
|
||||||
mode = "manual"
|
|
||||||
cert_path = "{}"
|
|
||||||
key_path = "{}"
|
|
||||||
|
|
||||||
[[listeners.sites]]
|
|
||||||
host = "test.local"
|
|
||||||
upstream = "127.0.0.1:8080"
|
|
||||||
"#,
|
|
||||||
cert_path.to_str().unwrap(),
|
|
||||||
key_path.to_str().unwrap()
|
|
||||||
);
|
|
||||||
let config_path = dir.join("valid_config.toml");
|
|
||||||
std::fs::write(&config_path, toml).unwrap();
|
|
||||||
config_path
|
config_path
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_invalid_config(dir: &std::path::Path) -> std::path::PathBuf {
|
fn write_invalid_config(dir: &Path) -> std::path::PathBuf {
|
||||||
let toml = r#"
|
let config_path = dir.join("config.toml");
|
||||||
[rate_limit]
|
let config = r#"
|
||||||
requests_per_second = 0
|
health_check_port = 9900
|
||||||
burst = 20
|
|
||||||
|
|
||||||
[body]
|
|
||||||
limit_bytes = 0
|
|
||||||
"#;
|
"#;
|
||||||
let config_path = dir.join("invalid_config.toml");
|
std::fs::write(&config_path, config).unwrap();
|
||||||
std::fs::write(&config_path, toml).unwrap();
|
|
||||||
config_path
|
config_path
|
||||||
}
|
}
|
||||||
|
|
||||||
fn binary_path() -> std::path::PathBuf {
|
fn binary_path() -> std::path::PathBuf {
|
||||||
let bin = env!("CARGO_BIN_EXE_reverse-proxy");
|
std::path::PathBuf::from(env!("CARGO_BIN_EXE_reverse-proxy"))
|
||||||
std::path::PathBuf::from(bin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -322,15 +548,14 @@ fn test_validate_valid_config_exits_0() {
|
|||||||
.arg(config_path.to_str().unwrap())
|
.arg(config_path.to_str().unwrap())
|
||||||
.arg("--validate")
|
.arg("--validate")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.expect("failed to run binary");
|
||||||
assert!(
|
assert_eq!(
|
||||||
output.status.success(),
|
output.status.code(),
|
||||||
"expected exit 0, got {}: stderr={}",
|
Some(0),
|
||||||
|
"expected exit 0 with valid config, got {}: stderr={}",
|
||||||
output.status,
|
output.status,
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert!(stdout.contains("valid"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -342,10 +567,13 @@ fn test_validate_invalid_config_exits_1() {
|
|||||||
.arg(config_path.to_str().unwrap())
|
.arg(config_path.to_str().unwrap())
|
||||||
.arg("--validate")
|
.arg("--validate")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.expect("failed to run binary");
|
||||||
assert!(!output.status.success(), "expected exit 1, got success");
|
assert!(
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
output.status.code() == Some(1) || output.status.code() == Some(2),
|
||||||
assert!(stderr.contains("validation failed") || stderr.contains("error"));
|
"expected non-zero exit with invalid config, got {}: stderr={}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -355,43 +583,28 @@ fn test_validate_missing_config_file_exits_1() {
|
|||||||
.arg("/nonexistent/path/config.toml")
|
.arg("/nonexistent/path/config.toml")
|
||||||
.arg("--validate")
|
.arg("--validate")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.expect("failed to run binary");
|
||||||
assert!(!output.status.success(), "expected exit 1, got success");
|
assert_ne!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"expected non-zero exit for missing config"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_wildcard_bind_via_cli_flag() {
|
fn test_validate_wildcard_bind_via_cli_flag() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let toml = r#"
|
let config_path = write_valid_config(dir.path());
|
||||||
[rate_limit]
|
|
||||||
requests_per_second = 10
|
|
||||||
burst = 20
|
|
||||||
|
|
||||||
[body]
|
|
||||||
limit_bytes = 104857600
|
|
||||||
|
|
||||||
[[listeners]]
|
|
||||||
bind_addr = "0.0.0.0"
|
|
||||||
http_port = 80
|
|
||||||
https_port = 443
|
|
||||||
|
|
||||||
[listeners.tls]
|
|
||||||
mode = "acme"
|
|
||||||
acme_domains = ["test.local"]
|
|
||||||
acme_cache_dir = "/tmp/acme"
|
|
||||||
"#;
|
|
||||||
let config_path = dir.path().join("wildcard.toml");
|
|
||||||
std::fs::write(&config_path, toml).unwrap();
|
|
||||||
|
|
||||||
let output = Command::new(binary_path())
|
let output = Command::new(binary_path())
|
||||||
.arg("--config")
|
.arg("--config")
|
||||||
.arg(config_path.to_str().unwrap())
|
.arg(config_path.to_str().unwrap())
|
||||||
.arg("--validate")
|
.arg("--validate")
|
||||||
.arg("--allow-wildcard-bind")
|
.arg("--allow-wildcard-bind")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.expect("failed to run binary");
|
||||||
assert!(
|
assert_eq!(
|
||||||
output.status.success(),
|
output.status.code(),
|
||||||
|
Some(0),
|
||||||
"expected exit 0 with --allow-wildcard-bind, got {}: stderr={}",
|
"expected exit 0 with --allow-wildcard-bind, got {}: stderr={}",
|
||||||
output.status,
|
output.status,
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
@@ -513,80 +726,6 @@ async fn test_body_limit_default_is_100mb() {
|
|||||||
assert_eq!(DEFAULT_BODY_LIMIT_BYTES, 104_857_600);
|
assert_eq!(DEFAULT_BODY_LIMIT_BYTES, 104_857_600);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_body_limit_config_reload_changes_limit() {
|
|
||||||
let config = test_dynamic_config_with_limit(100);
|
|
||||||
let config_clone = config.clone();
|
|
||||||
|
|
||||||
let server = helpers::http_test_helper::TestUpstream::spawn(|| {
|
|
||||||
let app = Router::new().route(
|
|
||||||
"/",
|
|
||||||
post(|body: axum::body::Body| async move {
|
|
||||||
let _ = body;
|
|
||||||
"ok"
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
router_with_body_limit(app, config_clone.clone())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let small_body = vec![0u8; 50];
|
|
||||||
let resp = client
|
|
||||||
.post(format!("http://127.0.0.1:{}/", server.addr.port()))
|
|
||||||
.body(small_body.clone())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
|
||||||
|
|
||||||
let medium_body = vec![0u8; 150];
|
|
||||||
let resp = client
|
|
||||||
.post(format!("http://127.0.0.1:{}/", server.addr.port()))
|
|
||||||
.body(medium_body.clone())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
|
|
||||||
|
|
||||||
let new_config = DynamicConfig {
|
|
||||||
sites: vec![SiteConfig {
|
|
||||||
host: "test.local".to_string(),
|
|
||||||
upstream: "127.0.0.1:8080".to_string(),
|
|
||||||
upstream_scheme: "http".to_string(),
|
|
||||||
upstream_connect_timeout_secs: 5,
|
|
||||||
upstream_request_timeout_secs: 60,
|
|
||||||
}],
|
|
||||||
rate_limit: RateLimitConfig {
|
|
||||||
requests_per_second: 10,
|
|
||||||
burst: 20,
|
|
||||||
},
|
|
||||||
body: BodyConfig { limit_bytes: 200 },
|
|
||||||
routing_table: Default::default(),
|
|
||||||
};
|
|
||||||
config.store(Arc::new(new_config));
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post(format!("http://127.0.0.1:{}/", server.addr.port()))
|
|
||||||
.body(medium_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
|
||||||
|
|
||||||
let large_body = vec![0u8; 300];
|
|
||||||
let resp = client
|
|
||||||
.post(format!("http://127.0.0.1:{}/", server.addr.port()))
|
|
||||||
.body(large_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
|
|
||||||
|
|
||||||
let _ = server.shutdown_tx.send(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_body_limit_empty_body_request_succeeds() {
|
async fn test_body_limit_empty_body_request_succeeds() {
|
||||||
let server = spawn_server_with_limit(100).await;
|
let server = spawn_server_with_limit(100).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user