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:
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,65 @@
|
|||||||
#[allow(dead_code)]
|
use std::net::SocketAddr;
|
||||||
pub struct HealthCheck;
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,12 @@
|
|||||||
#[allow(dead_code)]
|
use axum::http::StatusCode;
|
||||||
pub struct ProxyHandler;
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,13 +207,27 @@ mod tests {
|
|||||||
.map(|cs| format!("{cs:?}"))
|
.map(|cs| format!("{cs:?}"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_256_GCM_SHA384")));
|
assert!(cipher_suites
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("AES_128_GCM_SHA256")));
|
.iter()
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("CHACHA20_POLY1305_SHA256")));
|
.any(|cs| cs.contains("AES_256_GCM_SHA384")));
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")));
|
assert!(cipher_suites
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")));
|
.iter()
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_AES_256_GCM_SHA384")));
|
.any(|cs| cs.contains("AES_128_GCM_SHA256")));
|
||||||
assert!(cipher_suites.iter().any(|cs| cs.contains("ECDHE_RSA_WITH_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]
|
#[test]
|
||||||
@@ -310,4 +324,4 @@ mod tests {
|
|||||||
let result = load_private_key("/nonexistent/path/key.pem");
|
let result = load_private_key("/nonexistent/path/key.pem");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,42 @@ fn test_config_fixtures() {
|
|||||||
let dynamic_config = reverse_proxy::config::test_fixtures::test_dynamic_config();
|
let dynamic_config = reverse_proxy::config::test_fixtures::test_dynamic_config();
|
||||||
assert!(!dynamic_config.sites.is_empty());
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user