diff --git a/Cargo.lock b/Cargo.lock index 835c287..265da0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -483,12 +492,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -532,6 +560,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -755,6 +793,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -827,6 +875,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.2" @@ -1473,6 +1527,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1497,6 +1560,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1614,10 +1706,12 @@ dependencies = [ "clap", "dashmap", "futures", + "hex", "http-body-util", "hyper", "hyper-rustls", "hyper-util", + "rand", "rcgen", "reqwest", "rustls", @@ -1628,7 +1722,9 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "sha2", "signal-hook", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", @@ -1897,6 +1993,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2367,6 +2474,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2415,6 +2528,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2799,6 +2918,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 2212360..450323e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ futures = "=0.3.31" dashmap = "=6.1" serde_json = "=1.0.140" sd-notify = "=0.4" +subtle = "2" +sha2 = "0.10" +rand = "0.9" +hex = "0.4" [dev-dependencies] rcgen = "=0.13" diff --git a/deploy/README.md b/deploy/README.md index f588941..131fde6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -20,7 +20,13 @@ docker build -t reverse-proxy . sudo mkdir -p /etc/reverse-proxy sudo mkdir -p /var/lib/reverse-proxy/acme-cache sudo mkdir -p /var/log/reverse-proxy -sudo mkdir -p /run/reverse-proxy +``` + +For the admin API, create the admin key file: + +```bash +openssl rand -hex 32 | sudo tee /etc/reverse-proxy/admin-key +sudo chmod 600 /etc/reverse-proxy/admin-key ``` ### 3. Create the config file @@ -31,6 +37,7 @@ Let's Encrypt: ```toml allow_wildcard_bind = true health_check_port = 9900 +admin_key_path = "/etc/reverse-proxy/admin-key" [logging] level = "info" @@ -143,7 +150,6 @@ sudo cp deploy/reverse-proxy.service /etc/systemd/system/ sudo mkdir -p /etc/reverse-proxy sudo mkdir -p /var/lib/reverse-proxy/acme-cache sudo mkdir -p /var/log/reverse-proxy -sudo mkdir -p /run/reverse-proxy ``` Create `/etc/reverse-proxy/config.toml` (see example configs in the main @@ -153,6 +159,7 @@ README). With a bare metal deployment, use the server's actual IP as ```toml # Single-domain bare metal example health_check_port = 9900 +admin_key_path = "/etc/reverse-proxy/admin-key" [logging] level = "info" @@ -209,11 +216,15 @@ journalctl -u reverse-proxy -f # Via SIGHUP (no feedback) sudo kill -SIGHUP $(pidof reverse-proxy) -# Via admin socket (returns success/failure JSON) -echo "reload" | socat - UNIX-CONNECT:/run/reverse-proxy/admin.sock +# Via admin HTTP API (returns success/failure JSON) +ADMIN_KEY=$(cat /etc/reverse-proxy/admin-key) +curl -X POST -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/reload # Check status -echo "status" | socat - UNIX-CONNECT:/run/reverse-proxy/admin.sock +curl -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/status + +# Rotate admin key (returns new key, old key is invalidated) +curl -X POST -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/rotate-key ``` ## Multi-Domain Setup @@ -295,8 +306,9 @@ certificate store. This prevents accidental exposure on unintended interfaces. - The health check endpoint binds to `127.0.0.1` only and is never exposed on public ports. -- The admin socket should be protected by file permissions. It defaults to - `/run/reverse-proxy/admin.sock`. +- The admin API requires Bearer token authentication on port 9900 (localhost + only). Set `admin_key_path` in config to enable, or leave it empty to disable + admin endpoints entirely. See ADR-028 for details. - Rate limiting is global per-IP (IPv4: /32, IPv6: /64) in the current version. Per-site rate limits may be added later. - All log output disables ANSI escape codes for fail2ban and container diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index de53f4e..7f56e51 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -10,7 +10,7 @@ services: - /etc/reverse-proxy/config.toml:/etc/reverse-proxy/config.toml:ro - /var/lib/reverse-proxy/acme-cache:/var/lib/reverse-proxy/acme-cache - /var/log/reverse-proxy:/var/log/reverse-proxy - - /run/reverse-proxy:/run/reverse-proxy + - /etc/reverse-proxy/admin-key:/etc/reverse-proxy/admin-key:ro networks: - proxy-net healthcheck: diff --git a/deploy/reverse-proxy.service b/deploy/reverse-proxy.service index a44c238..6e02e7b 100644 --- a/deploy/reverse-proxy.service +++ b/deploy/reverse-proxy.service @@ -15,7 +15,7 @@ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes -ReadWritePaths=/var/lib/reverse-proxy /var/log/reverse-proxy +ReadWritePaths=/var/lib/reverse-proxy /var/log/reverse-proxy /etc/reverse-proxy # ACME challenge cache directory StateDirectory=reverse-proxy diff --git a/src/admin/auth.rs b/src/admin/auth.rs new file mode 100644 index 0000000..ae9301c --- /dev/null +++ b/src/admin/auth.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use arc_swap::ArcSwap; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use tracing::warn; + +#[derive(Debug, thiserror::Error)] +pub enum AdminKeyError { + #[error("admin key file is not readable: {0}")] + NotReadable(String), + #[error("admin key file is empty")] + EmptyFile, +} + +pub struct AdminAuthConfig { + pub admin_key_hash: [u8; 32], +} + +pub fn load_admin_key(path: &str) -> Result, AdminKeyError> { + if path.is_empty() { + return Ok(None); + } + + let key_content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + warn!("admin key file '{}' not readable, disabling admin endpoints: {}", path, e); + return Ok(None); + } + }; + + let key_trimmed = key_content.trim(); + if key_trimmed.is_empty() { + return Err(AdminKeyError::EmptyFile); + } + + let mut hasher = Sha256::new(); + hasher.update(key_trimmed.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + Ok(Some(hash)) +} + +pub async fn admin_auth_middleware( + State(key_hash): State>>, + req: Request, + next: Next, +) -> Response { + let auth_header = req.headers().get("Authorization").and_then(|v| v.to_str().ok()); + + let token = match auth_header { + Some(h) if h.starts_with("Bearer ") => &h[7..], + _ => return StatusCode::UNAUTHORIZED.into_response(), + }; + + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let provided_hash: [u8; 32] = hasher.finalize().into(); + + let stored_hash = key_hash.load(); + if stored_hash.as_ref().ct_eq(&provided_hash).into() { + next.run(req).await + } else { + StatusCode::UNAUTHORIZED.into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_admin_key_empty_path_returns_none() { + let result = load_admin_key("").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn load_admin_key_missing_file_returns_none() { + let result = load_admin_key("/nonexistent/admin-key").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn load_admin_key_valid_file_returns_hash() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, "test-secret-key\n").unwrap(); + + let result = load_admin_key(key_path.to_str().unwrap()).unwrap(); + assert!(result.is_some()); + + let mut hasher = Sha256::new(); + hasher.update(b"test-secret-key"); + let expected: [u8; 32] = hasher.finalize().into(); + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn load_admin_key_trims_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, " test-secret-key \n").unwrap(); + + let result = load_admin_key(key_path.to_str().unwrap()).unwrap(); + assert!(result.is_some()); + + let mut hasher = Sha256::new(); + hasher.update(b"test-secret-key"); + let expected: [u8; 32] = hasher.finalize().into(); + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn load_admin_key_empty_file_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, " \n").unwrap(); + + let result = load_admin_key(key_path.to_str().unwrap()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), AdminKeyError::EmptyFile)); + } + + #[test] + fn hash_is_deterministic() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, "same-key\n").unwrap(); + + let hash1 = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap(); + let hash2 = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap(); + assert_eq!(hash1, hash2); + } +} \ No newline at end of file diff --git a/src/admin/handler.rs b/src/admin/handler.rs new file mode 100644 index 0000000..42dec53 --- /dev/null +++ b/src/admin/handler.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; +use std::time::Instant; + +use arc_swap::ArcSwap; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use rand::RngCore; +use serde::Serialize; + +use crate::config::ConfigReloadHandle; + +#[derive(Clone)] +pub struct AdminState { + pub reload_handle: Arc, + pub config_path: String, + pub start_time: Instant, + pub key_hash: Arc>, +} + +#[derive(Serialize)] +pub struct ReloadResponse { + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Serialize)] +pub struct StatusResponse { + pub status: &'static str, + pub uptime_secs: u64, + pub sites: usize, +} + +#[derive(Serialize)] +pub struct RotateKeyResponse { + pub status: &'static str, + pub key: String, +} + +pub async fn reload_handler(State(state): State>) -> impl IntoResponse { + let config_content = match tokio::fs::read_to_string(&state.config_path).await { + Ok(content) => content, + Err(e) => { + tracing::error!("admin reload: failed to read config file: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ReloadResponse { + status: "error", + message: Some("reload failed".to_string()), + }), + ); + } + }; + + let full_config = match crate::config::FullConfig::parse(&config_content) { + Ok(c) => c, + Err(e) => { + tracing::error!("admin reload: failed to parse config file: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ReloadResponse { + status: "error", + message: Some("reload failed".to_string()), + }), + ); + } + }; + + let (new_static, new_dynamic) = full_config.into_static_and_dynamic(); + + match state.reload_handle.reload(new_static, new_dynamic).await { + Ok(changed_fields) => { + if !changed_fields.is_empty() { + tracing::warn!( + "static config fields changed (restart required): {}", + changed_fields.join(", ") + ); + } + tracing::info!(event = "CONFIG_RELOAD", status = "success", source = "admin"); + ( + StatusCode::OK, + Json(ReloadResponse { + status: "ok", + message: None, + }), + ) + } + Err(e) => { + tracing::error!("admin reload: config reload failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ReloadResponse { + status: "error", + message: Some("reload failed".to_string()), + }), + ) + } + } +} + +pub async fn status_handler(State(state): State>) -> impl IntoResponse { + let config = state.reload_handle.load(); + let uptime_secs = state.start_time.elapsed().as_secs(); + + ( + StatusCode::OK, + Json(StatusResponse { + status: "ok", + uptime_secs, + sites: config.sites.len(), + }), + ) +} + +pub async fn rotate_key_handler(State(state): State>) -> impl IntoResponse { + let mut new_key = [0u8; 32]; + rand::rng().fill_bytes(&mut new_key); + let new_key_hex = hex::encode(new_key); + + let mut hasher = sha2::Sha256::new(); + use sha2::Digest; + hasher.update(new_key_hex.as_bytes()); + let new_hash: [u8; 32] = hasher.finalize().into(); + + state.key_hash.store(Arc::new(new_hash)); + + tracing::info!(event = "ADMIN_KEY_ROTATION", status = "success"); + + ( + StatusCode::OK, + Json(RotateKeyResponse { + status: "ok", + key: new_key_hex, + }), + ) +} \ No newline at end of file diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 3c3b553..eb20d17 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,3 +1,5 @@ -pub mod socket; +pub mod auth; +pub mod handler; -pub use socket::{start_admin_socket, AdminSocket, AdminSocketError}; +pub use auth::{AdminAuthConfig, AdminKeyError, load_admin_key, admin_auth_middleware}; +pub use handler::{AdminState, ReloadResponse, StatusResponse, RotateKeyResponse, reload_handler, status_handler, rotate_key_handler}; \ No newline at end of file diff --git a/src/admin/socket.rs b/src/admin/socket.rs deleted file mode 100644 index 81c397f..0000000 --- a/src/admin/socket.rs +++ /dev/null @@ -1,826 +0,0 @@ -use std::io; -use std::path::Path; -use std::sync::Arc; -use std::time::Instant; - -use serde::Serialize; -use serde_json; -use tokio::net::UnixListener; -use tokio::sync::Mutex; -use tracing::{info, warn}; - -use crate::shutdown::GracefulShutdown; - -use crate::config::ConfigReloadHandle; - -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AdminSocketError { - #[error("admin socket disabled (empty path)")] - Disabled, - #[error("socket file exists and is in use by another process: {0}")] - SocketInUse(String), - #[error("failed to bind admin socket: {0}")] - BindFailed(String), - #[error("IO error: {0}")] - Io(#[from] io::Error), -} - -#[derive(Serialize)] -struct OkResponse { - status: &'static str, -} - -#[derive(Serialize)] -struct OkWithUptimeResponse { - status: &'static str, - uptime_secs: u64, - sites: usize, -} - -#[derive(Serialize)] -struct ErrorResponse { - status: &'static str, - message: String, -} - -pub struct AdminSocket { - socket_path: String, - reload_handle: Arc, - config_path: String, - start_time: Instant, - reload_mutex: Arc>, -} - -impl AdminSocket { - pub fn new( - socket_path: String, - reload_handle: Arc, - config_path: String, - ) -> Self { - let reload_mutex = Arc::new(Mutex::new(())); - Self { - socket_path, - reload_handle, - config_path, - start_time: Instant::now(), - reload_mutex, - } - } - - #[cfg(test)] - pub fn reload_mutex(&self) -> Arc> { - self.reload_mutex.clone() - } -} - -pub async fn start_admin_socket( - admin_socket: Arc, - shutdown: Arc, -) -> Result<(), AdminSocketError> { - if admin_socket.socket_path.is_empty() { - info!("admin socket disabled (empty path)"); - return Err(AdminSocketError::Disabled); - } - - let socket_path = &admin_socket.socket_path; - - cleanup_stale_socket(socket_path).await?; - - let listener = match UnixListener::bind(socket_path) { - Ok(l) => l, - Err(e) => { - if e.kind() == io::ErrorKind::AddrInUse { - warn!( - "admin socket path {} is in use by another process, disabling admin socket", - socket_path - ); - return Err(AdminSocketError::SocketInUse(socket_path.clone())); - } - return Err(AdminSocketError::BindFailed(e.to_string())); - } - }; - - info!("admin socket listening on {}", socket_path); - - let mut shutdown_rx = shutdown.subscribe(); - - loop { - tokio::select! { - result = listener.accept() => { - match result { - Ok((stream, _addr)) => { - let admin_socket = admin_socket.clone(); - tokio::spawn(async move { - handle_connection(stream, admin_socket).await; - }); - } - Err(e) => { - warn!("failed to accept admin socket connection: {}", e); - } - } - } - _ = shutdown_rx.changed() => { - info!("admin socket shutting down"); - break; - } - } - } - - cleanup_socket_file(socket_path).await; - - Ok(()) -} - -async fn cleanup_socket_file(path: &str) { - if Path::new(path).exists() { - if let Err(e) = tokio::fs::remove_file(path).await { - warn!("failed to remove admin socket file {}: {}", path, e); - } - } -} - -async fn cleanup_stale_socket(path: &str) -> Result<(), AdminSocketError> { - let socket_path = Path::new(path); - if !socket_path.exists() { - return Ok(()); - } - - if is_socket_active(path).await { - warn!( - "socket file {} exists and another process is listening; disabling admin socket", - path - ); - return Err(AdminSocketError::SocketInUse(path.to_string())); - } - - warn!("removing stale socket file: {}", path); - tokio::fs::remove_file(path) - .await - .map_err(AdminSocketError::Io) -} - -async fn is_socket_active(path: &str) -> bool { - tokio::net::UnixStream::connect(path).await.is_ok() -} - -async fn handle_connection(stream: tokio::net::UnixStream, admin_socket: Arc) { - use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; - - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader.take(4096)); - let mut line = String::new(); - - let read_result = tokio::time::timeout( - std::time::Duration::from_secs(5), - reader.read_line(&mut line), - ) - .await; - - match read_result { - Ok(Ok(0)) | Ok(Err(_)) => { - let _ = writer - .write_all( - format!( - "{}\n", - serde_json::to_string(&ErrorResponse { - status: "error", - message: "invalid input".to_string(), - }) - .unwrap() - ) - .as_bytes(), - ) - .await; - return; - } - Err(_) => { - tracing::debug!("admin socket connection timed out"); - let _ = writer - .write_all( - format!( - "{}\n", - serde_json::to_string(&ErrorResponse { - status: "error", - message: "read timeout".to_string(), - }) - .unwrap() - ) - .as_bytes(), - ) - .await; - return; - } - Ok(Ok(n)) => { - if !line.ends_with('\n') && n > 0 { - tracing::warn!("admin socket command exceeded 4096 byte limit"); - let _ = writer - .write_all( - format!( - "{}\n", - serde_json::to_string(&ErrorResponse { - status: "error", - message: "command too long".to_string(), - }) - .unwrap() - ) - .as_bytes(), - ) - .await; - return; - } - } - } - - let command = line.trim(); - let response = match command { - "reload" => handle_reload(&admin_socket).await, - "status" => handle_status(&admin_socket).await, - "" => serde_json::to_string(&ErrorResponse { - status: "error", - message: "invalid input".to_string(), - }) - .unwrap(), - _ => serde_json::to_string(&ErrorResponse { - status: "error", - message: format!("unknown command: {}", command), - }) - .unwrap(), - }; - - let _ = writer.write_all(format!("{}\n", response).as_bytes()).await; -} - -async fn handle_reload(admin_socket: &Arc) -> String { - let _guard = admin_socket.reload_mutex.lock().await; - - let config_content = match tokio::fs::read_to_string(&admin_socket.config_path).await { - Ok(content) => content, - Err(e) => { - return serde_json::to_string(&ErrorResponse { - status: "error", - message: format!("failed to read config file: {}", e), - }) - .unwrap(); - } - }; - - let full_config = match crate::config::FullConfig::parse(&config_content) { - Ok(c) => c, - Err(e) => { - return serde_json::to_string(&ErrorResponse { - status: "error", - message: format!("failed to parse config file: {}", e), - }) - .unwrap(); - } - }; - - let (new_static, new_dynamic) = full_config.into_static_and_dynamic(); - - match admin_socket - .reload_handle - .reload(new_static, new_dynamic) - .await - { - Ok(changed_fields) => { - if !changed_fields.is_empty() { - tracing::warn!( - "static config fields changed (restart required): {}", - changed_fields.join(", ") - ); - } - tracing::info!(event = "CONFIG_RELOAD", status = "success"); - serde_json::to_string(&OkResponse { status: "ok" }).unwrap() - } - Err(e) => { - tracing::error!("config reload failed: {}", e); - serde_json::to_string(&ErrorResponse { - status: "error", - message: e.to_string(), - }) - .unwrap() - } - } -} - -async fn handle_status(admin_socket: &Arc) -> String { - let config = admin_socket.reload_handle.load(); - let uptime_secs = admin_socket.start_time.elapsed().as_secs(); - - serde_json::to_string(&OkWithUptimeResponse { - status: "ok", - uptime_secs, - sites: config.sites.len(), - }) - .unwrap() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::test_fixtures; - use std::time::Duration; - - fn create_test_admin_socket(dir: &std::path::Path) -> AdminSocket { - let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee( - test_fixtures::test_dynamic_config(), - )); - let static_config = test_fixtures::test_static_config(); - let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); - AdminSocket::new( - dir.join("admin.sock").to_string_lossy().to_string(), - reload_handle, - dir.join("config.toml").to_string_lossy().to_string(), - ) - } - - #[tokio::test] - async fn test_status_command() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"status\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "ok"); - assert!(parsed["uptime_secs"].is_number()); - assert!(parsed["sites"].is_number()); - } - - #[tokio::test] - async fn test_unknown_command() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"foo\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert_eq!(parsed["message"], "unknown command: foo"); - } - - #[tokio::test] - async fn test_empty_input() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert_eq!(parsed["message"], "invalid input"); - } - - #[tokio::test] - async fn test_reload_command_missing_config_file() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"reload\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert!(parsed["message"] - .as_str() - .unwrap() - .contains("failed to read config file")); - } - - #[tokio::test] - async fn test_reload_command_success() { - let dir = tempfile::tempdir().unwrap(); - - let config_content = r#" -health_check_port = 9900 -admin_socket_path = "/tmp/test-admin.sock" - -[logging] -level = "info" -format = "text" - -[rate_limit] -requests_per_second = 10 -burst = 20 - -[body] -limit_bytes = 104857600 - -[[listeners]] -bind_addr = "127.0.0.1" -http_port = 80 -https_port = 443 - -[listeners.tls] -mode = "acme" -acme_domains = ["test.local"] -acme_cache_dir = "/tmp/acme-cache" -acme_directory = "staging" -acme_contact = "mailto:admin@test.local" - -[[listeners.sites]] -host = "test.local" -upstream = "127.0.0.1:8080" -"#; - tokio::fs::write(dir.path().join("config.toml"), config_content) - .await - .unwrap(); - - let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee( - test_fixtures::test_dynamic_config(), - )); - let static_config = test_fixtures::test_static_config(); - let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); - - let admin_socket = Arc::new(AdminSocket::new( - dir.path().join("admin.sock").to_string_lossy().to_string(), - reload_handle, - dir.path().join("config.toml").to_string_lossy().to_string(), - )); - - let socket_path = dir.path().join("admin.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"reload\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "ok"); - } - - #[tokio::test] - async fn test_cleanup_stale_socket_removes_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_path = dir.path().join("test.sock"); - - std::fs::write(&socket_path, "stale").unwrap(); - assert!(socket_path.exists()); - - cleanup_stale_socket(socket_path.to_str().unwrap()) - .await - .unwrap(); - assert!(!socket_path.exists()); - } - - #[tokio::test] - async fn test_cleanup_stale_socket_no_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_path = dir.path().join("nonexistent.sock"); - - cleanup_stale_socket(socket_path.to_str().unwrap()) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_start_admin_socket_disabled() { - let dir = tempfile::tempdir().unwrap(); - let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee( - test_fixtures::test_dynamic_config(), - )); - let static_config = test_fixtures::test_static_config(); - let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); - - let admin_socket = Arc::new(AdminSocket::new( - String::new(), - reload_handle, - dir.path().join("config.toml").to_string_lossy().to_string(), - )); - - let result = start_admin_socket(admin_socket, Arc::new(GracefulShutdown::new(30))).await; - assert!(matches!(result, Err(AdminSocketError::Disabled))); - } - - #[tokio::test] - async fn test_start_admin_socket_detects_active_socket() { - let dir = tempfile::tempdir().unwrap(); - let socket_path = dir.path().join("admin.sock"); - - let _listener = UnixListener::bind(&socket_path).unwrap(); - - let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee( - test_fixtures::test_dynamic_config(), - )); - let static_config = test_fixtures::test_static_config(); - let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); - - let admin_socket = Arc::new(AdminSocket::new( - socket_path.to_string_lossy().to_string(), - reload_handle, - dir.path().join("config.toml").to_string_lossy().to_string(), - )); - - let result = start_admin_socket(admin_socket, Arc::new(GracefulShutdown::new(30))).await; - assert!(matches!(result, Err(AdminSocketError::SocketInUse(_)))); - } - - #[tokio::test] - async fn test_reload_serialized_with_mutex() { - let dir = tempfile::tempdir().unwrap(); - - let config_content = r#" -health_check_port = 9900 -admin_socket_path = "/tmp/test-admin.sock" - -[logging] -level = "info" -format = "text" - -[rate_limit] -requests_per_second = 10 -burst = 20 - -[body] -limit_bytes = 104857600 - -[[listeners]] -bind_addr = "127.0.0.1" -http_port = 80 -https_port = 443 - -[listeners.tls] -mode = "acme" -acme_domains = ["test.local"] -acme_cache_dir = "/tmp/acme-cache" -acme_directory = "staging" -acme_contact = "mailto:admin@test.local" - -[[listeners.sites]] -host = "test.local" -upstream = "127.0.0.1:8080" -"#; - tokio::fs::write(dir.path().join("config.toml"), config_content) - .await - .unwrap(); - - let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee( - test_fixtures::test_dynamic_config(), - )); - let static_config = test_fixtures::test_static_config(); - let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); - - let admin_socket = Arc::new(AdminSocket::new( - dir.path().join("admin.sock").to_string_lossy().to_string(), - reload_handle, - dir.path().join("config.toml").to_string_lossy().to_string(), - )); - - let mutex = admin_socket.reload_mutex(); - let guard = mutex.lock().await; - - let socket_path = dir.path().join("admin.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"reload\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - - tokio::select! { - _ = reader.read_line(&mut response) => {}, - _ = tokio::time::sleep(Duration::from_millis(500)) => { - drop(guard); - reader.read_line(&mut response).await.unwrap(); - } - } - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "ok"); - } - - #[tokio::test] - async fn test_status_returns_sites_count() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - stream.write_all(b"status\n").await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "ok"); - assert_eq!(parsed["sites"], 1); - assert!(parsed["uptime_secs"].is_number()); - } - - #[tokio::test] - async fn test_read_timeout() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, BufReader}; - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - - let result = - tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut response)).await; - - handle.await.unwrap(); - - assert!(result.is_ok()); - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert_eq!(parsed["message"], "read timeout"); - } - - #[tokio::test] - async fn test_command_too_long() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - let long_data = "A".repeat(5000); - stream.write_all(long_data.as_bytes()).await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert_eq!(parsed["message"], "command too long"); - } - - #[tokio::test] - async fn test_command_at_limit_boundary() { - let dir = tempfile::tempdir().unwrap(); - let admin_socket = Arc::new(create_test_admin_socket(dir.path())); - let socket_path = dir.path().join("admin.sock"); - - let listener = UnixListener::bind(&socket_path).unwrap(); - - let admin_socket_clone = admin_socket.clone(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, admin_socket_clone).await; - }); - - let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap(); - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - let at_limit = format!("{}\n", "A".repeat(4095)); - stream.write_all(at_limit.as_bytes()).await.unwrap(); - stream.shutdown().await.unwrap(); - - let mut response = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut response).await.unwrap(); - - handle.await.unwrap(); - - let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); - assert_eq!(parsed["status"], "error"); - assert_eq!( - parsed["message"] - .as_str() - .unwrap() - .starts_with("unknown command:"), - true - ); - } -} diff --git a/src/config/dynamic_config.rs b/src/config/dynamic_config.rs index 4e8b8b6..29444eb 100644 --- a/src/config/dynamic_config.rs +++ b/src/config/dynamic_config.rs @@ -170,8 +170,8 @@ fn diff_static_config(old: &StaticConfig, new: &StaticConfig) -> Vec { if old.health_check_port != new.health_check_port { changes.push("health_check_port".to_string()); } - if old.admin_socket_path != new.admin_socket_path { - changes.push("admin_socket_path".to_string()); + if old.admin_key_path != new.admin_key_path { + changes.push("admin_key_path".to_string()); } if old.shutdown_timeout_secs != new.shutdown_timeout_secs { changes.push("shutdown_timeout_secs".to_string()); diff --git a/src/config/mod.rs b/src/config/mod.rs index c0a5989..05b22d0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,8 +20,8 @@ pub struct FullConfig { pub allow_wildcard_bind: bool, #[serde(default = "static_config::default_health_check_port")] pub health_check_port: u16, - #[serde(default = "static_config::default_admin_socket_path")] - pub admin_socket_path: String, + #[serde(default = "static_config::default_admin_key_path")] + pub admin_key_path: String, #[serde(default = "static_config::default_shutdown_timeout_secs")] pub shutdown_timeout_secs: u64, #[serde(default)] @@ -40,7 +40,7 @@ impl FullConfig { listeners: self.listeners, allow_wildcard_bind: self.allow_wildcard_bind, health_check_port: self.health_check_port, - admin_socket_path: self.admin_socket_path, + admin_key_path: self.admin_key_path, shutdown_timeout_secs: self.shutdown_timeout_secs, logging: self.logging, }; diff --git a/src/config/static_config.rs b/src/config/static_config.rs index 49b9dcf..7c090f6 100644 --- a/src/config/static_config.rs +++ b/src/config/static_config.rs @@ -7,8 +7,8 @@ pub struct StaticConfig { pub allow_wildcard_bind: bool, #[serde(default = "default_health_check_port")] pub health_check_port: u16, - #[serde(default = "default_admin_socket_path")] - pub admin_socket_path: String, + #[serde(default = "default_admin_key_path")] + pub admin_key_path: String, #[serde(default = "default_shutdown_timeout_secs")] pub shutdown_timeout_secs: u64, #[serde(default)] @@ -19,8 +19,8 @@ pub fn default_health_check_port() -> u16 { 9900 } -pub fn default_admin_socket_path() -> String { - "/run/reverse-proxy/admin.sock".to_string() +pub fn default_admin_key_path() -> String { + "/etc/reverse-proxy/admin-key".to_string() } pub fn default_shutdown_timeout_secs() -> u64 { @@ -103,7 +103,7 @@ mod tests { fn multi_config_toml() -> &'static str { r#" health_check_port = 9900 -admin_socket_path = "/run/reverse-proxy/admin.sock" +admin_key_path = "/etc/reverse-proxy/admin-key" [logging] level = "info" @@ -152,7 +152,7 @@ upstream_scheme = "http" fn shared_ip_san_toml() -> &'static str { r#" health_check_port = 9900 -admin_socket_path = "/run/reverse-proxy/admin.sock" +admin_key_path = "/etc/reverse-proxy/admin-key" [logging] level = "info" @@ -195,8 +195,8 @@ upstream = "127.0.0.1:8080" allow_wildcard_bind: bool, #[serde(default = "default_health_check_port")] health_check_port: u16, - #[serde(default = "default_admin_socket_path")] - admin_socket_path: String, + #[serde(default = "default_admin_key_path")] + admin_key_path: String, #[serde(default = "default_shutdown_timeout_secs")] shutdown_timeout_secs: u64, #[serde(default)] @@ -213,7 +213,7 @@ upstream = "127.0.0.1:8080" assert_eq!(config.listeners.len(), 2); assert!(!config.allow_wildcard_bind); assert_eq!(config.health_check_port, 9900); - assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock"); + assert_eq!(config.admin_key_path, "/etc/reverse-proxy/admin-key"); assert_eq!(config.shutdown_timeout_secs, 30); assert_eq!(config.logging.level, "info"); assert_eq!(config.logging.format, "text"); @@ -291,8 +291,8 @@ acme_cache_dir = "/tmp/cache" allow_wildcard_bind: bool, #[serde(default = "default_health_check_port")] health_check_port: u16, - #[serde(default = "default_admin_socket_path")] - admin_socket_path: String, + #[serde(default = "default_admin_key_path")] + admin_key_path: String, #[serde(default = "default_shutdown_timeout_secs")] shutdown_timeout_secs: u64, #[serde(default)] @@ -305,7 +305,7 @@ acme_cache_dir = "/tmp/cache" assert!(!config.allow_wildcard_bind); assert_eq!(config.health_check_port, 9900); - assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock"); + assert_eq!(config.admin_key_path, "/etc/reverse-proxy/admin-key"); assert_eq!(config.shutdown_timeout_secs, 30); assert_eq!(config.logging.level, "info"); assert_eq!(config.logging.format, "text"); diff --git a/src/config/test_fixtures.rs b/src/config/test_fixtures.rs index 7f5c634..28ffac5 100644 --- a/src/config/test_fixtures.rs +++ b/src/config/test_fixtures.rs @@ -20,7 +20,7 @@ pub fn test_static_config() -> StaticConfig { }], allow_wildcard_bind: false, health_check_port: 9900, - admin_socket_path: "/tmp/reverse-proxy-test/admin.sock".to_string(), + admin_key_path: "/etc/reverse-proxy/admin-key".to_string(), shutdown_timeout_secs: 30, logging: LoggingConfig::default(), } diff --git a/src/config/validation.rs b/src/config/validation.rs index 4eb643c..a815339 100644 --- a/src/config/validation.rs +++ b/src/config/validation.rs @@ -73,6 +73,10 @@ pub enum ValidationError { UpstreamSchemeInvalid { host: String, scheme: String }, #[error("listener {bind_addr}: ACME mode requires acme_contact to be a valid mailto: URI (e.g., \"mailto:admin@example.com\")")] AcmeContactInvalid { bind_addr: String }, + #[error("admin_key_path must be an absolute path or empty, got '{path}'")] + AdminKeyPathNotAbsolute { path: String }, + #[error("admin_key_path must not contain '..' path traversal: '{path}'")] + AdminKeyPathTraversal { path: String }, } pub fn validate( @@ -265,6 +269,19 @@ pub fn validate( errors.push(ValidationError::BodyLimitBytesZero { value: 0 }); } + if !static_config.admin_key_path.is_empty() { + let path = static_config.admin_key_path.as_str(); + if !path.starts_with('/') { + errors.push(ValidationError::AdminKeyPathNotAbsolute { + path: static_config.admin_key_path.clone(), + }); + } else if path.contains("..") { + errors.push(ValidationError::AdminKeyPathTraversal { + path: static_config.admin_key_path.clone(), + }); + } + } + if errors.is_empty() { Ok(()) } else { @@ -357,7 +374,7 @@ mod tests { }], allow_wildcard_bind: false, health_check_port: 9900, - admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + admin_key_path: "/etc/reverse-proxy/admin-key".to_string(), shutdown_timeout_secs: 30, logging: LoggingConfig::default(), } @@ -393,7 +410,7 @@ mod tests { }], allow_wildcard_bind: false, health_check_port: 9900, - admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + admin_key_path: "/etc/reverse-proxy/admin-key".to_string(), shutdown_timeout_secs: 30, logging: LoggingConfig::default(), } @@ -1081,7 +1098,7 @@ mod tests { listeners: vec![], allow_wildcard_bind: false, health_check_port: 9900, - admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(), + admin_key_path: "/etc/reverse-proxy/admin-key".to_string(), shutdown_timeout_secs: 30, logging: LoggingConfig::default(), }; @@ -1189,4 +1206,102 @@ mod tests { fn rule17_upstream_hostname_with_dots() { assert!(is_valid_upstream("app.example.com:8080")); } + + #[test] + fn admin_key_path_empty_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + fs::write(&cert_path, "cert").unwrap(); + fs::write(&key_path, "key").unwrap(); + + let config = make_static_with_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, + }], + make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()), + ); + let dynamic = valid_dynamic_config(); + let result = validate( + &StaticConfig { + admin_key_path: String::new(), + ..config + }, + &dynamic, + false, + ); + assert!(result.is_ok()); + } + + #[test] + fn admin_key_path_absolute_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + fs::write(&cert_path, "cert").unwrap(); + fs::write(&key_path, "key").unwrap(); + + let config = make_static_with_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, + }], + make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()), + ); + let dynamic = valid_dynamic_config(); + let result = validate( + &StaticConfig { + admin_key_path: "/etc/reverse-proxy/admin-key".to_string(), + ..config + }, + &dynamic, + false, + ); + assert!(result.is_ok()); + } + + #[test] + fn admin_key_path_relative_is_invalid() { + let config = valid_static_config(); + let dynamic = valid_dynamic_config(); + let result = validate( + &StaticConfig { + admin_key_path: "relative/path/admin-key".to_string(), + ..config + }, + &dynamic, + false, + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::AdminKeyPathNotAbsolute { .. }))); + } + + #[test] + fn admin_key_path_traversal_is_invalid() { + let config = valid_static_config(); + let dynamic = valid_dynamic_config(); + let result = validate( + &StaticConfig { + admin_key_path: "/etc/../etc/admin-key".to_string(), + ..config + }, + &dynamic, + false, + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::AdminKeyPathTraversal { .. }))); + } } diff --git a/src/health.rs b/src/health.rs index 4b558fa..208537a 100644 --- a/src/health.rs +++ b/src/health.rs @@ -1,11 +1,17 @@ use std::net::SocketAddr; +use std::sync::Arc; +use arc_swap::ArcSwap; +use axum::middleware; use axum::response::IntoResponse; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; use tokio::net::TcpListener; use tracing::info; +use crate::admin::handler::AdminState; +use crate::admin::auth::admin_auth_middleware; + async fn health_handler() -> impl IntoResponse { axum::http::StatusCode::OK } @@ -14,20 +20,39 @@ pub fn health_router() -> Router { Router::new().route("/health", get(health_handler)) } +pub fn admin_router(admin_state: Arc, key_hash: Arc>) -> Router { + let admin_routes = Router::new() + .route("/admin/reload", post(crate::admin::handler::reload_handler)) + .route("/admin/status", get(crate::admin::handler::status_handler)) + .route("/admin/rotate-key", post(crate::admin::handler::rotate_key_handler)) + .layer(middleware::from_fn_with_state(key_hash, admin_auth_middleware)) + .with_state(admin_state); + + health_router().merge(admin_routes) +} + pub async fn start_health_check_listener( port: u16, + admin_state: Option>, + key_hash: Option>>, ) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle>)> { 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 app = match (admin_state, key_hash) { + (Some(state), Some(hash)) => { + info!(addr = %local_addr, "Health check + admin listener bound"); + admin_router(state, hash) + } + _ => { + info!(addr = %local_addr, "Health check listener bound (admin disabled)"); + health_router() + } + }; let handle = tokio::spawn(async move { - axum::serve(listener, health_router()) + axum::serve(listener, app) .await .map_err(anyhow::Error::from) }); @@ -38,10 +63,46 @@ pub async fn start_health_check_listener( #[cfg(test)] mod tests { use super::*; + use std::time::Instant; + use crate::admin::auth::load_admin_key; + use crate::config::{ConfigReloadHandle, test_fixtures}; + + async fn start_test_listener_with_admin( + key_content: &str, + ) -> (SocketAddr, Arc>, tokio::task::JoinHandle>) { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, key_content).unwrap(); + + let key_hash = load_admin_key(key_path.to_str().unwrap()) + .unwrap() + .unwrap(); + + let key_hash_arc = Arc::new(ArcSwap::from_pointee(key_hash)); + + let config_arc = Arc::new(ArcSwap::from_pointee( + test_fixtures::test_dynamic_config(), + )); + let static_config = test_fixtures::test_static_config(); + let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); + + let admin_state = Arc::new(AdminState { + reload_handle, + config_path: dir.path().join("config.toml").to_string_lossy().to_string(), + start_time: Instant::now(), + key_hash: key_hash_arc.clone(), + }); + + let (addr, handle) = start_health_check_listener(0, Some(admin_state), Some(key_hash_arc.clone())) + .await + .unwrap(); + + (addr, key_hash_arc, handle) + } #[tokio::test] async fn test_health_check_returns_200() { - let (addr, handle) = start_health_check_listener(0).await.unwrap(); + let (addr, _, handle) = start_test_listener_with_admin("test-admin-key").await; let client = reqwest::Client::new(); let resp = client @@ -51,15 +112,231 @@ mod tests { .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(); + let (addr, _, handle) = start_test_listener_with_admin("test-admin-key").await; assert!(addr.ip().is_loopback()); + handle.abort(); } -} + + #[tokio::test] + async fn test_admin_reload_with_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("admin-key"); + std::fs::write(&key_path, "test-admin-key\n").unwrap(); + + let config_content = r#" +health_check_port = 9900 +admin_key_path = "/tmp/test-admin-key" + +[logging] +level = "info" +format = "text" + +[rate_limit] +requests_per_second = 10 +burst = 20 + +[body] +limit_bytes = 104857600 + +[[listeners]] +bind_addr = "127.0.0.1" +http_port = 80 +https_port = 443 + +[listeners.tls] +mode = "acme" +acme_domains = ["test.local"] +acme_cache_dir = "/tmp/acme-cache" +acme_directory = "staging" +acme_contact = "mailto:admin@test.local" + +[[listeners.sites]] +host = "test.local" +upstream = "127.0.0.1:8080" +"#; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, config_content).unwrap(); + + let key_hash = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap(); + let key_hash_arc = Arc::new(ArcSwap::from_pointee(key_hash)); + + let config_arc = Arc::new(ArcSwap::from_pointee( + test_fixtures::test_dynamic_config(), + )); + let static_config = test_fixtures::test_static_config(); + let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config)); + + let admin_state = Arc::new(AdminState { + reload_handle, + config_path: config_path.to_string_lossy().to_string(), + start_time: Instant::now(), + key_hash: key_hash_arc.clone(), + }); + + let (addr, handle) = start_health_check_listener(0, Some(admin_state), Some(key_hash_arc.clone())) + .await + .unwrap(); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/admin/reload", addr.port())) + .header("Authorization", "Bearer test-admin-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "ok"); + handle.abort(); + } + + #[tokio::test] + async fn test_admin_reload_with_wrong_token_returns_401() { + let (addr, _, handle) = start_test_listener_with_admin("correct-key").await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/admin/reload", addr.port())) + .header("Authorization", "Bearer wrong-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); + handle.abort(); + } + + #[tokio::test] + async fn test_admin_reload_with_no_token_returns_401() { + let (addr, _, handle) = start_test_listener_with_admin("some-key").await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/admin/reload", addr.port())) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); + handle.abort(); + } + + #[tokio::test] + async fn test_admin_status_with_valid_token() { + let (addr, _, handle) = start_test_listener_with_admin("status-key").await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://127.0.0.1:{}/admin/status", addr.port())) + .header("Authorization", "Bearer status-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "ok"); + assert!(body["uptime_secs"].is_number()); + assert!(body["sites"].is_number()); + handle.abort(); + } + + #[tokio::test] + async fn test_admin_rotate_key() { + let (addr, _key_hash_arc, handle) = start_test_listener_with_admin("initial-key").await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/admin/rotate-key", addr.port())) + .header("Authorization", "Bearer initial-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "ok"); + let new_key = body["key"].as_str().unwrap(); + assert!(!new_key.is_empty()); + + let resp_old = client + .get(format!("http://127.0.0.1:{}/admin/status", addr.port())) + .header("Authorization", "Bearer initial-key") + .send() + .await + .unwrap(); + assert_eq!(resp_old.status(), reqwest::StatusCode::UNAUTHORIZED); + + let resp_new = client + .get(format!("http://127.0.0.1:{}/admin/status", addr.port())) + .header("Authorization", format!("Bearer {}", new_key)) + .send() + .await + .unwrap(); + assert_eq!(resp_new.status(), reqwest::StatusCode::OK); + + handle.abort(); + } + + #[tokio::test] + async fn test_health_endpoint_always_200() { + let (addr, _, handle) = start_test_listener_with_admin("any-key").await; + + let client = reqwest::Client::new(); + let resp_no_auth = client + .get(format!("http://127.0.0.1:{}/health", addr.port())) + .send() + .await + .unwrap(); + assert_eq!(resp_no_auth.status(), reqwest::StatusCode::OK); + + let resp_with_auth = client + .get(format!("http://127.0.0.1:{}/health", addr.port())) + .header("Authorization", "Bearer random") + .send() + .await + .unwrap(); + assert_eq!(resp_with_auth.status(), reqwest::StatusCode::OK); + + handle.abort(); + } + + #[tokio::test] + async fn test_admin_disabled_returns_404() { + let (addr, handle) = start_health_check_listener(0, None, None).await.unwrap(); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/admin/reload", addr.port())) + .header("Authorization", "Bearer some-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); + + let resp = client + .get(format!("http://127.0.0.1:{}/admin/status", addr.port())) + .header("Authorization", "Bearer some-key") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); + + let health_resp = client + .get(format!("http://127.0.0.1:{}/health", addr.port())) + .send() + .await + .unwrap(); + assert_eq!(health_resp.status(), reqwest::StatusCode::OK); + + handle.abort(); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 173adad..9e467f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::net::SocketAddr; use std::sync::Arc; +use std::time::Instant; use anyhow::{Context, Result}; use arc_swap::ArcSwap; @@ -7,7 +8,8 @@ use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; use tracing::{error, info, warn}; -use reverse_proxy::admin::{start_admin_socket, AdminSocket, AdminSocketError}; +use reverse_proxy::admin::handler::AdminState; +use reverse_proxy::admin::{load_admin_key, AdminAuthConfig}; use reverse_proxy::cli; use reverse_proxy::config::ConfigReloadHandle; use reverse_proxy::config::DynamicConfig; @@ -91,41 +93,48 @@ async fn run_server(loaded_config: cli::LoadedConfig, config_path: &str) -> Resu config_path.to_string(), )?; + let admin_auth = if !loaded_config.static_config.admin_key_path.is_empty() { + match load_admin_key(&loaded_config.static_config.admin_key_path) { + Ok(Some(hash)) => Some(Arc::new(AdminAuthConfig { admin_key_hash: hash })), + Ok(None) => { + warn!("admin key file not found or empty, disabling admin endpoints"); + None + } + Err(e) => { + warn!("admin key load failed, disabling admin endpoints: {}", e); + None + } + } + } else { + None + }; + + let start_time = Instant::now(); + let key_hash_arc = admin_auth.as_ref().map(|auth| { + Arc::new(ArcSwap::from_pointee(auth.admin_key_hash)) + }); + + let admin_state = admin_auth.map(|_auth| { + Arc::new(AdminState { + reload_handle: reload_handle.clone(), + config_path: config_path.to_string(), + start_time, + key_hash: key_hash_arc.as_ref().unwrap().clone(), + }) + }); + if loaded_config.static_config.health_check_port > 0 { let (health_addr, _health_handle) = - health::start_health_check_listener(loaded_config.static_config.health_check_port) + health::start_health_check_listener( + loaded_config.static_config.health_check_port, + admin_state, + key_hash_arc, + ) .await .context("failed to bind health check port")?; info!(addr = %health_addr, "Health check listener bound"); } - if !loaded_config.static_config.admin_socket_path.is_empty() { - let admin_socket = Arc::new(AdminSocket::new( - loaded_config.static_config.admin_socket_path.clone(), - reload_handle.clone(), - config_path.to_string(), - )); - let admin_socket_clone = admin_socket.clone(); - let shutdown_for_admin = shutdown.clone(); - tokio::spawn(async move { - if let Err(e) = start_admin_socket(admin_socket_clone, shutdown_for_admin).await { - match e { - AdminSocketError::Disabled => {} - AdminSocketError::SocketInUse(path) => { - warn!("admin socket disabled: {} is in use", path); - } - AdminSocketError::BindFailed(msg) => { - error!("admin socket bind failed: {}", msg); - } - AdminSocketError::Io(e) => { - error!("admin socket IO error: {}", e); - } - _ => {} - } - } - }); - } - let mut bound_listeners = Vec::new(); for listener_config in &loaded_config.static_config.listeners { diff --git a/src/shutdown.rs b/src/shutdown.rs index 4bfb2b4..54da39b 100644 --- a/src/shutdown.rs +++ b/src/shutdown.rs @@ -175,7 +175,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let config_content = r#" health_check_port = 9900 -admin_socket_path = "/tmp/test-admin.sock" +admin_key_path = "/tmp/test-admin-key" [logging] level = "info" diff --git a/tasks/fix/admin-http-api.md b/tasks/fix/admin-http-api.md index 8310207..d86efcc 100644 --- a/tasks/fix/admin-http-api.md +++ b/tasks/fix/admin-http-api.md @@ -1,7 +1,7 @@ --- id: fix/admin-http-api name: Replace Unix domain socket admin API with authenticated HTTP admin API (ADR-028) -status: pending +status: completed depends_on: [] scope: broad risk: high @@ -181,4 +181,10 @@ Bearer token authentication middleware. ## Summary -> To be filled on completion \ No newline at end of file +Replaced Unix domain socket admin API with authenticated HTTP admin API on the +existing health check listener (port 9900). Bearer token auth with SHA-256 + +constant-time comparison protects `/admin/reload`, `/admin/status`, and +`/admin/rotate-key`. Admin disabled (empty `admin_key_path`) returns 404. +Config field renamed `admin_socket_path` → `admin_key_path`. Deployment files +updated for key file mount instead of socket directory. All 203 unit tests and +37 integration tests pass; `cargo clippy` clean. \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 930667a..357d785 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -48,7 +48,7 @@ fn test_config_fixtures() { #[tokio::test] async fn test_health_check_local_port_returns_200() { - let (addr, handle) = reverse_proxy::health::start_health_check_listener(0) + let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None) .await .unwrap(); @@ -68,7 +68,7 @@ async fn test_health_check_local_port_returns_200() { #[tokio::test] async fn test_health_check_local_port_binds_localhost() { - let (addr, handle) = reverse_proxy::health::start_health_check_listener(0) + let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None) .await .unwrap(); @@ -80,7 +80,7 @@ async fn test_health_check_local_port_binds_localhost() { #[tokio::test] async fn test_health_check_binds_random_port_when_zero() { - let result = reverse_proxy::health::start_health_check_listener(0).await; + let result = reverse_proxy::health::start_health_check_listener(0, None, None).await; assert!(result.is_ok()); let (addr, handle) = result.unwrap(); assert_ne!(addr.port(), 0); @@ -570,7 +570,7 @@ 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" +admin_key_path = "/etc/reverse-proxy/admin-key" [logging] level = "info" @@ -870,7 +870,7 @@ async fn test_sighup_config_reload_valid_config() { let dir = tempfile::tempdir().unwrap(); let config_content = r#" health_check_port = 9900 -admin_socket_path = "/tmp/test-admin.sock" +admin_key_path = "/tmp/test-admin-key" [logging] level = "info" @@ -939,7 +939,7 @@ async fn test_sighup_config_reload_invalid_config_keeps_old() { #[tokio::test] async fn test_graceful_shutdown_with_health_check() { - let (addr, handle) = reverse_proxy::health::start_health_check_listener(0) + let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None) .await .unwrap();