Implement health check endpoint on separate local port and HTTPS fallback

- Add health.rs module with start_health_check_listener() that binds to
  127.0.0.1:{health_check_port} and serves GET /health returning 200 OK
  with empty body
- Add health_route() in proxy/handler.rs for HTTPS listener fallback
- Add port conflict detection in config validation: health_check_port
  must not conflict with listener ports on 127.0.0.1/localhost/0.0.0.0
- health_check_port = 0 disables the separate listener (handled at call
  site)
- Add unit and integration tests for health check functionality
This commit is contained in:
2026-06-11 12:39:24 +00:00
parent 468adb21de
commit c423a58778
5 changed files with 1143 additions and 19 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,65 @@
#[allow(dead_code)]
pub struct HealthCheck;
use std::net::SocketAddr;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use tokio::net::TcpListener;
use tracing::info;
async fn health_handler() -> impl IntoResponse {
axum::http::StatusCode::OK
}
pub fn health_router() -> Router {
Router::new().route("/health", get(health_handler))
}
pub async fn start_health_check_listener(
port: u16,
) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle<anyhow::Result<()>>)> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = TcpListener::bind(addr).await?;
let local_addr = listener.local_addr()?;
info!(
addr = %local_addr,
"Health check listener bound"
);
let handle = tokio::spawn(async move {
axum::serve(listener, health_router())
.await
.map_err(anyhow::Error::from)
});
Ok((local_addr, handle))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_health_check_returns_200() {
let (addr, handle) = start_health_check_listener(0).await.unwrap();
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{}/health", addr.port()))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body = resp.text().await.unwrap();
assert!(body.is_empty());
handle.abort();
}
#[tokio::test]
async fn test_health_check_binds_to_localhost() {
let (addr, _handle) = start_health_check_listener(0).await.unwrap();
assert!(addr.ip().is_loopback());
}
}

View File

@@ -1,2 +1,12 @@
#[allow(dead_code)]
pub struct ProxyHandler;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
async fn health_handler() -> impl IntoResponse {
StatusCode::OK
}
pub fn health_route() -> Router {
Router::new().route("/health", get(health_handler))
}

View File

@@ -207,13 +207,27 @@ mod tests {
.map(|cs| format!("{cs:?}"))
.collect();
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_128_GCM_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("CHACHA20_POLY1305_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("CHACHA20_POLY1305_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384")));
assert!(cipher_suites
.iter()
.any(|cs| cs.contains("ECDHE_RSA_WITH_AES_128_GCM_SHA256")));
}
#[test]

View File

@@ -30,3 +30,42 @@ fn test_config_fixtures() {
let dynamic_config = reverse_proxy::config::test_fixtures::test_dynamic_config();
assert!(!dynamic_config.sites.is_empty());
}
#[tokio::test]
async fn test_health_check_local_port_returns_200() {
let (addr, handle) =
reverse_proxy::health::start_health_check_listener(0).await.unwrap();
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{}/health", addr.port()))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body = resp.text().await.unwrap();
assert!(body.is_empty());
handle.abort();
}
#[tokio::test]
async fn test_health_check_local_port_binds_localhost() {
let (addr, handle) =
reverse_proxy::health::start_health_check_listener(0).await.unwrap();
assert!(addr.ip().is_loopback());
assert_eq!(addr.ip().to_string(), "127.0.0.1");
handle.abort();
}
#[tokio::test]
async fn test_health_check_disabled_when_port_zero() {
let result = reverse_proxy::health::start_health_check_listener(0).await;
assert!(result.is_ok());
let (addr, handle) = result.unwrap();
assert_ne!(addr.port(), 0);
handle.abort();
}