From 3702da1aee2bbf377d62ac633cba60b0559c1973 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 1 Jul 2026 18:40:01 +0000 Subject: [PATCH] feat(http): implement /healthz raw route and stealth decoy fallback GET /healthz: raw route, no auth, no OperationContext, returns 200 OK with plain-text 'ok' (ADR-036). Decoy fallback for unknown paths via DecoyConfig: fake nginx 404 (default), static site serving, or redirect. Decoy does not leak alknet presence (no alknet headers/format). Custom routes take precedence over decoy (decoy is fallback only). Wire real handlers into HttpAdapter router replacing placeholder 501s. --- Cargo.lock | 2 + crates/alknet-http/Cargo.toml | 6 +- crates/alknet-http/src/server/adapter.rs | 101 +++++++- crates/alknet-http/src/server/decoy.rs | 303 +++++++++++++++++++++++ crates/alknet-http/src/server/healthz.rs | 62 +++++ crates/alknet-http/src/server/mod.rs | 6 +- 6 files changed, 473 insertions(+), 7 deletions(-) create mode 100644 crates/alknet-http/src/server/decoy.rs create mode 100644 crates/alknet-http/src/server/healthz.rs diff --git a/Cargo.lock b/Cargo.lock index 23e25e9..62de51c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,7 @@ dependencies = [ "axum", "futures", "http", + "http-body-util", "httpdate", "hyper", "hyper-util", @@ -122,6 +123,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tower", "tracing", "url", "uuid", diff --git a/crates/alknet-http/Cargo.toml b/crates/alknet-http/Cargo.toml index f3541d1..601eb9f 100644 --- a/crates/alknet-http/Cargo.toml +++ b/crates/alknet-http/Cargo.toml @@ -42,4 +42,8 @@ rmcp = { version = "1.8", optional = true, default-features = false, features = "server", "transport-streamable-http-client-reqwest", "transport-streamable-http-server", -] } \ No newline at end of file +] } + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } \ No newline at end of file diff --git a/crates/alknet-http/src/server/adapter.rs b/crates/alknet-http/src/server/adapter.rs index 3cf378c..22ba37c 100644 --- a/crates/alknet-http/src/server/adapter.rs +++ b/crates/alknet-http/src/server/adapter.rs @@ -28,6 +28,9 @@ use alknet_call::registry::registration::OperationRegistry; use alknet_core::auth::{AuthContext, IdentityProvider}; use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError}; +use crate::server::decoy::decoy_fallback; +use crate::server::healthz::healthz; + const ALPN_HTTP1: &[u8] = b"http/1.1"; const ALPN_H2: &[u8] = b"h2"; @@ -47,6 +50,12 @@ struct RouterState { decoy: DecoyConfig, } +impl axum::extract::FromRef for DecoyConfig { + fn from_ref(state: &RouterState) -> Self { + state.decoy.clone() + } +} + pub struct HttpAdapter { identity_provider: Arc, registry: Arc, @@ -129,9 +138,10 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { .route("/call", any(not_implemented)) .route("/batch", any(not_implemented)) .route("/subscribe", any(not_implemented)) - .route("/healthz", get(not_implemented)) + .route("/healthz", get(healthz)) .route("/openapi.json", get(not_implemented)) - .route("/mcp", post(not_implemented)); + .route("/mcp", post(not_implemented)) + .fallback(decoy_fallback); let with_extras = match extra_routes { Some(extra) => { @@ -410,8 +420,8 @@ mod tests { let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; let (response, handle) = send_request_and_read_response(request).await; handle.await.ok(); - assert!(response.starts_with("HTTP/1.1 501 "), "expected 501, got: {response}"); - assert!(response.contains("501 Not Implemented")); + assert!(response.starts_with("HTTP/1.1 200 "), "expected 200, got: {response}"); + assert!(response.contains("\r\n\r\nok")); } #[tokio::test] @@ -482,7 +492,88 @@ mod tests { } handle.await.ok(); let response_str = String::from_utf8_lossy(&response); - assert!(response_str.starts_with("HTTP/1.1 501 "), "default GET /healthz wins, got: {response_str}"); + assert!(response_str.starts_with("HTTP/1.1 200 "), "default GET /healthz wins, got: {response_str}"); + assert!(response_str.contains("\r\n\r\nok")); assert!(!response_str.contains("custom-healthz")); } + + async fn serve_and_read(adapter: HttpAdapter, request: &[u8]) -> String { + let (mut client_send, server_recv) = duplex(8 * 1024); + let (server_send, mut client_recv) = duplex(8 * 1024); + let server_io = QuicStreamDuplex { + read: server_recv, + write: server_send, + }; + let handle = tokio::spawn(async move { + adapter.serve_io(server_io).await.ok(); + }); + client_send.write_all(request).await.unwrap(); + client_send.flush().await.unwrap(); + let mut response = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match tokio::time::timeout(std::time::Duration::from_secs(5), client_recv.read(&mut buf)).await { + Ok(Ok(0)) => break, + Ok(Ok(n)) => response.extend_from_slice(&buf[..n]), + Ok(Err(_)) => break, + Err(_) => break, + } + } + handle.await.ok(); + String::from_utf8_lossy(&response).to_string() + } + + #[tokio::test] + async fn custom_route_matched_serves_custom_handler_not_decoy() { + let extra = Router::new().route( + "/v1/chat/completions", + post(|| async { (StatusCode::OK, "oai-proxy") }), + ); + let adapter = HttpAdapter::new(provider(), empty_registry()) + .with_decoy(DecoyConfig::NotFound) + .with_extra_routes(extra); + let request = b"POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; + let response = serve_and_read(adapter, request).await; + assert!(response.starts_with("HTTP/1.1 200"), "expected 200, got: {response}"); + assert!(response.contains("oai-proxy")); + assert!(!response.contains("404 Not Found")); + } + + #[tokio::test] + async fn unknown_path_not_matched_by_custom_route_falls_through_to_decoy() { + let extra = Router::new().route( + "/v1/chat/completions", + post(|| async { (StatusCode::OK, "oai-proxy") }), + ); + let adapter = HttpAdapter::new(provider(), empty_registry()) + .with_decoy(DecoyConfig::NotFound) + .with_extra_routes(extra); + let request = b"GET /totally/unknown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let response = serve_and_read(adapter, request).await; + assert!(response.starts_with("HTTP/1.1 404"), "expected 404 decoy, got: {response}"); + assert!(response.contains("404 Not Found")); + } + + #[tokio::test] + async fn healthz_takes_precedence_over_decoy() { + let adapter = HttpAdapter::new(provider(), empty_registry()) + .with_decoy(DecoyConfig::Redirect { + to: "https://example.com".to_string(), + }); + let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let response = serve_and_read(adapter, request).await; + assert!(response.starts_with("HTTP/1.1 200"), "expected 200 healthz, got: {response}"); + assert!(response.contains("\r\n\r\nok")); + } + + #[tokio::test] + async fn unknown_path_with_redirect_decoy_returns_redirect_over_wire() { + let adapter = HttpAdapter::new(provider(), empty_registry()).with_decoy(DecoyConfig::Redirect { + to: "https://example.com".to_string(), + }); + let request = b"GET /nope HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let response = serve_and_read(adapter, request).await; + assert!(response.starts_with("HTTP/1.1 302"), "expected 302 redirect, got: {response}"); + assert!(response.contains("location: https://example.com")); + } } \ No newline at end of file diff --git a/crates/alknet-http/src/server/decoy.rs b/crates/alknet-http/src/server/decoy.rs new file mode 100644 index 0000000..58cb514 --- /dev/null +++ b/crates/alknet-http/src/server/decoy.rs @@ -0,0 +1,303 @@ +//! Stealth decoy fallback for unknown paths (ADR-010, ADR-036). +//! +//! For paths not matched by the default surface (gateway endpoints, +//! `/healthz`, `/openapi.json`, the MCP route, the WS upgrade) nor by a +//! custom route (ADR-046), the HTTP handler serves a configurable decoy +//! (`DecoyConfig`): a fake nginx-style 404 (the default), a static site +//! served from a directory, or a redirect. The decoy must not leak alknet +//! presence — no alknet-specific headers, no alknet error format. See +//! `docs/architecture/crates/http/http-server.md` §"Stealth decoy". + +use std::path::{Component, Path, PathBuf}; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::Response; + +use crate::server::DecoyConfig; + +pub async fn decoy_fallback(State(decoy): State, request: Request) -> Response { + match decoy { + DecoyConfig::NotFound => fake_nginx_404(), + DecoyConfig::StaticSite { root } => serve_static(&root, request).await, + DecoyConfig::Redirect { to } => redirect(&to), + } +} + +pub fn fake_nginx_404() -> Response { + let body = nginx_404_body(); + let mut resp = Response::new(Body::from(body)); + *resp.status_mut() = StatusCode::NOT_FOUND; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + resp.headers_mut().insert( + header::SERVER, + HeaderValue::from_static("nginx"), + ); + resp +} + +pub fn redirect(to: &str) -> Response { + let mut resp = Response::new(Body::empty()); + *resp.status_mut() = StatusCode::FOUND; + if let Ok(value) = HeaderValue::from_str(to) { + resp.headers_mut().insert(header::LOCATION, value); + } + resp +} + +pub async fn serve_static(root: &Path, request: Request) -> Response { + let path = request.uri().path(); + let resolved = match resolve_static_path(root, path) { + Some(p) => p, + None => return fake_nginx_404(), + }; + + match tokio::fs::read(&resolved).await { + Ok(bytes) => { + let content_type = mime_for_path(&resolved); + let mut resp = Response::new(Body::from(bytes)); + *resp.status_mut() = StatusCode::OK; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type), + ); + resp + } + Err(_) => fake_nginx_404(), + } +} + +fn resolve_static_path(root: &Path, request_path: &str) -> Option { + let trimmed = request_path.trim_start_matches('/'); + let relative = if trimmed.is_empty() { + PathBuf::from("index.html") + } else { + let decoded = percent_decode(trimmed); + PathBuf::from(decoded) + }; + + let mut safe = PathBuf::new(); + for component in relative.components() { + match component { + Component::Normal(part) => safe.push(part), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + + if safe.as_os_str().is_empty() { + return None; + } + + let full = root.join(&safe); + if full.is_dir() { + return Some(full.join("index.html")); + } + if full.is_file() { + return Some(full); + } + None +} + +fn percent_decode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'%' && i + 2 < bytes.len() { + if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) { + out.push(((h << 4) | l) as char); + i += 3; + continue; + } + } else if b == b'+' { + out.push(' '); + i += 1; + continue; + } + out.push(b as char); + i += 1; + } + out +} + +fn hex_digit(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +fn mime_for_path(path: &Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()) { + Some("html") | Some("htm") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "application/javascript", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("svg") => "image/svg+xml", + Some("txt") => "text/plain; charset=utf-8", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + +fn nginx_404_body() -> String { + "\r\n404 Not Found\r\n\r\n

404 Not Found

\r\n
nginx
\r\n\r\n\r\n".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + + fn decoy_router(decoy: DecoyConfig) -> axum::Router { + axum::Router::new() + .fallback(decoy_fallback) + .with_state(decoy) + } + + async fn send(router: axum::Router, uri: &str) -> axum::response::Response { + tower::ServiceExt::>::oneshot( + router, + Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + #[test] + fn decoy_config_default_is_not_found() { + assert!(matches!(DecoyConfig::default(), DecoyConfig::NotFound)); + } + + #[tokio::test] + async fn unknown_path_with_not_found_decoy_returns_404() { + let resp = send(decoy_router(DecoyConfig::NotFound), "/nonexistent").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let server = resp + .headers() + .get(header::SERVER) + .map(|v| v.to_str().unwrap().to_string()); + assert_eq!(server.as_deref(), Some("nginx")); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8_lossy(&bytes); + assert!(!body.contains("alknet")); + assert!(body.contains("404 Not Found")); + } + + #[tokio::test] + async fn unknown_path_with_redirect_decoy_returns_redirect() { + let decoy = DecoyConfig::Redirect { + to: "https://example.com".to_string(), + }; + let resp = send(decoy_router(decoy), "/anything").await; + assert_eq!(resp.status(), StatusCode::FOUND); + let location = resp + .headers() + .get(header::LOCATION) + .map(|v| v.to_str().unwrap().to_string()); + assert_eq!(location.as_deref(), Some("https://example.com")); + } + + #[tokio::test] + async fn unknown_path_with_static_site_decoy_serves_file() { + let dir = tempfile_dir(); + let file = dir.join("index.html"); + tokio::fs::write(&file, "

hello

") + .await + .unwrap(); + + let decoy = DecoyConfig::StaticSite { root: dir.clone() }; + let resp = send(decoy_router(decoy), "/").await; + assert_eq!(resp.status(), StatusCode::OK); + let ctype = resp + .headers() + .get(header::CONTENT_TYPE) + .map(|v| v.to_str().unwrap().to_string()); + assert!(ctype.as_deref().unwrap_or("").starts_with("text/html")); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&bytes[..], b"

hello

"); + } + + #[tokio::test] + async fn static_site_decoy_serves_named_file() { + let dir = tempfile_dir(); + tokio::fs::write(dir.join("about.html"), "

about

") + .await + .unwrap(); + + let decoy = DecoyConfig::StaticSite { root: dir }; + let resp = send(decoy_router(decoy), "/about.html").await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&bytes[..], b"

about

"); + } + + #[tokio::test] + async fn static_site_decoy_missing_file_returns_fake_404() { + let dir = tempfile_dir(); + let decoy = DecoyConfig::StaticSite { root: dir }; + let resp = send(decoy_router(decoy), "/missing.txt").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let server = resp + .headers() + .get(header::SERVER) + .map(|v| v.to_str().unwrap().to_string()); + assert_eq!(server.as_deref(), Some("nginx")); + } + + #[tokio::test] + async fn static_site_decoy_path_traversal_is_blocked() { + let dir = tempfile_dir(); + tokio::fs::write(dir.join("index.html"), "ok") + .await + .unwrap(); + let secret = dir.join("secret.txt"); + tokio::fs::write(&secret, "secret").await.unwrap(); + + let parent = dir.parent().unwrap().to_path_buf(); + let decoy = DecoyConfig::StaticSite { root: dir.clone() }; + let resp = send(decoy_router(decoy), "/../secret.txt").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + drop(parent); + } + + #[tokio::test] + async fn not_found_decoy_does_not_leak_alknet_headers() { + let resp = send(decoy_router(DecoyConfig::NotFound), "/whatever").await; + for (name, value) in resp.headers().iter() { + let name = name.as_str().to_lowercase(); + let value = value.to_str().unwrap_or(""); + assert!( + !name.contains("alknet") && !value.contains("alknet"), + "decoy leaked alknet: {name}={value}" + ); + } + } + + fn tempfile_dir() -> PathBuf { + let dir = PathBuf::from("/tmp").join(format!( + "alknet-http-decoy-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } +} \ No newline at end of file diff --git a/crates/alknet-http/src/server/healthz.rs b/crates/alknet-http/src/server/healthz.rs new file mode 100644 index 0000000..7a23b20 --- /dev/null +++ b/crates/alknet-http/src/server/healthz.rs @@ -0,0 +1,62 @@ +//! `GET /healthz` — the one raw HTTP route (ADR-036). +//! +//! No auth, no call protocol, no `OperationContext`. Returns `200 OK` with a +//! plain-text body (`"ok"`) if the endpoint is healthy. This is the +//! infrastructure endpoint load balancers and orchestrators call; it must +//! work before identity is resolvable. See +//! `docs/architecture/crates/http/http-server.md` §"/healthz (raw route)". + +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::IntoResponse; + +const HEALTHZ_BODY: &str = "ok"; + +pub async fn healthz() -> impl IntoResponse { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + HEALTHZ_BODY, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + + async fn call_healthz(req: Request) -> axum::response::Response { + let app = axum::Router::new().route("/healthz", axum::routing::get(healthz)); + tower::ServiceExt::>::oneshot(app, req) + .await + .unwrap() + } + + #[tokio::test] + async fn healthz_handler_returns_200_with_plain_text_ok() { + let req = Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(); + let resp = call_healthz(req).await; + assert_eq!(resp.status(), StatusCode::OK); + let ctype = resp + .headers() + .get(header::CONTENT_TYPE) + .map(|v| v.to_str().unwrap().to_string()); + assert_eq!(ctype.as_deref(), Some("text/plain")); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&bytes[..], b"ok"); + } + + #[tokio::test] + async fn healthz_works_with_no_authorization_header() { + let req = Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(); + let resp = call_healthz(req).await; + assert_eq!(resp.status(), StatusCode::OK); + } +} \ No newline at end of file diff --git a/crates/alknet-http/src/server/mod.rs b/crates/alknet-http/src/server/mod.rs index 1b76014..64914cc 100644 --- a/crates/alknet-http/src/server/mod.rs +++ b/crates/alknet-http/src/server/mod.rs @@ -7,5 +7,9 @@ //! `docs/architecture/crates/http/http-server.md`. pub mod adapter; +pub mod decoy; +pub mod healthz; -pub use adapter::{DecoyConfig, HttpAdapter}; \ No newline at end of file +pub use adapter::{DecoyConfig, HttpAdapter}; +pub use decoy::decoy_fallback; +pub use healthz::healthz; \ No newline at end of file