Compare commits
3 Commits
a65afb0dfb
...
35a7a37ba6
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a7a37ba6 | |||
| 78344d9280 | |||
| 3702da1aee |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -110,6 +110,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
|
"http-body-util",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -122,6 +123,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ rmcp = { version = "1.8", optional = true, default-features = false, features =
|
|||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
"transport-streamable-http-server",
|
"transport-streamable-http-server",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
@@ -28,6 +28,9 @@ use alknet_call::registry::registration::OperationRegistry;
|
|||||||
use alknet_core::auth::{AuthContext, IdentityProvider};
|
use alknet_core::auth::{AuthContext, IdentityProvider};
|
||||||
use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError};
|
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_HTTP1: &[u8] = b"http/1.1";
|
||||||
const ALPN_H2: &[u8] = b"h2";
|
const ALPN_H2: &[u8] = b"h2";
|
||||||
|
|
||||||
@@ -47,6 +50,12 @@ struct RouterState {
|
|||||||
decoy: DecoyConfig,
|
decoy: DecoyConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl axum::extract::FromRef<RouterState> for DecoyConfig {
|
||||||
|
fn from_ref(state: &RouterState) -> Self {
|
||||||
|
state.decoy.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct HttpAdapter {
|
pub struct HttpAdapter {
|
||||||
identity_provider: Arc<dyn IdentityProvider>,
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
registry: Arc<OperationRegistry>,
|
registry: Arc<OperationRegistry>,
|
||||||
@@ -129,9 +138,10 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
|||||||
.route("/call", any(not_implemented))
|
.route("/call", any(not_implemented))
|
||||||
.route("/batch", any(not_implemented))
|
.route("/batch", any(not_implemented))
|
||||||
.route("/subscribe", any(not_implemented))
|
.route("/subscribe", any(not_implemented))
|
||||||
.route("/healthz", get(not_implemented))
|
.route("/healthz", get(healthz))
|
||||||
.route("/openapi.json", get(not_implemented))
|
.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 {
|
let with_extras = match extra_routes {
|
||||||
Some(extra) => {
|
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 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;
|
let (response, handle) = send_request_and_read_response(request).await;
|
||||||
handle.await.ok();
|
handle.await.ok();
|
||||||
assert!(response.starts_with("HTTP/1.1 501 "), "expected 501, got: {response}");
|
assert!(response.starts_with("HTTP/1.1 200 "), "expected 200, got: {response}");
|
||||||
assert!(response.contains("501 Not Implemented"));
|
assert!(response.contains("\r\n\r\nok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -482,7 +492,88 @@ mod tests {
|
|||||||
}
|
}
|
||||||
handle.await.ok();
|
handle.await.ok();
|
||||||
let response_str = String::from_utf8_lossy(&response);
|
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"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
303
crates/alknet-http/src/server/decoy.rs
Normal file
303
crates/alknet-http/src/server/decoy.rs
Normal file
@@ -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<DecoyConfig>, 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<PathBuf> {
|
||||||
|
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<u8> {
|
||||||
|
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 {
|
||||||
|
"<html>\r\n<head><title>404 Not Found</title></head>\r\n<body>\r\n<center><h1>404 Not Found</h1></center>\r\n<hr><center>nginx</center>\r\n</body>\r\n</html>\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::<Request<Body>>::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, "<h1>hello</h1>")
|
||||||
|
.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"<h1>hello</h1>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn static_site_decoy_serves_named_file() {
|
||||||
|
let dir = tempfile_dir();
|
||||||
|
tokio::fs::write(dir.join("about.html"), "<p>about</p>")
|
||||||
|
.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"<p>about</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/alknet-http/src/server/healthz.rs
Normal file
62
crates/alknet-http/src/server/healthz.rs
Normal file
@@ -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<Body>) -> axum::response::Response {
|
||||||
|
let app = axum::Router::new().route("/healthz", axum::routing::get(healthz));
|
||||||
|
tower::ServiceExt::<Request<Body>>::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@
|
|||||||
//! `docs/architecture/crates/http/http-server.md`.
|
//! `docs/architecture/crates/http/http-server.md`.
|
||||||
|
|
||||||
pub mod adapter;
|
pub mod adapter;
|
||||||
|
pub mod decoy;
|
||||||
|
pub mod healthz;
|
||||||
|
|
||||||
pub use adapter::{DecoyConfig, HttpAdapter};
|
pub use adapter::{DecoyConfig, HttpAdapter};
|
||||||
|
pub use decoy::decoy_fallback;
|
||||||
|
pub use healthz::healthz;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: http/server/healthz-decoy
|
id: http/server/healthz-decoy
|
||||||
name: Implement /healthz raw route and stealth decoy fallback (DecoyConfig)
|
name: Implement /healthz raw route and stealth decoy fallback (DecoyConfig)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [http/server/http-adapter]
|
depends_on: [http/server/http-adapter]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -143,4 +143,8 @@ let router = Router::new()
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
> Implemented GET /healthz raw route (src/server/healthz.rs, 200 OK text/plain 'ok',
|
||||||
|
> no auth, no OperationContext) and stealth decoy fallback (src/server/decoy.rs:
|
||||||
|
> DecoyConfig NotFound=nginx 404 / StaticSite=serve files / Redirect). Wired real
|
||||||
|
> handlers into HttpAdapter router (adapter.rs) replacing placeholder 501s, using
|
||||||
|
> FromRef<RouterState> for DecoyConfig substate. 125 tests pass. Clippy clean.
|
||||||
Reference in New Issue
Block a user