use std::net::SocketAddr;
use std::time::Duration;
use axum::body::Body;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::Response;
use serde::Serialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub const LIMINAL_HEALTH_ROUTE: &str = "/frame/liminal/health";
pub const LIMINAL_READY_ROUTE: &str = "/frame/liminal/ready";
pub const LIMINAL_METRICS_ROUTE: &str = "/frame/liminal/metrics";
pub const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub const PROXY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
pub const PROXY_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
#[derive(Clone, Copy, Debug)]
pub struct ProxyDeadlines {
pub connect: Duration,
pub response: Duration,
}
pub const PRODUCTION_DEADLINES: ProxyDeadlines = ProxyDeadlines {
connect: PROXY_CONNECT_TIMEOUT,
response: PROXY_RESPONSE_TIMEOUT,
};
#[derive(Debug)]
pub enum ProxyFailure {
Unreachable {
detail: String,
},
BadResponse {
detail: String,
},
}
impl ProxyFailure {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::Unreachable { .. } => "LIMINAL_UNREACHABLE",
Self::BadResponse { .. } => "LIMINAL_BAD_RESPONSE",
}
}
#[must_use]
pub fn detail(&self) -> &str {
match self {
Self::Unreachable { detail } | Self::BadResponse { detail } => detail,
}
}
}
#[derive(Debug, Serialize)]
struct ProxyErrorBody<'a> {
error: &'static str,
upstream_path: &'a str,
detail: &'a str,
}
#[derive(Debug)]
pub struct UpstreamResponse {
pub status: u16,
pub content_type: Option<String>,
pub body: Vec<u8>,
}
pub async fn forward_health_request(
addr: SocketAddr,
path: &str,
deadlines: ProxyDeadlines,
) -> Result<UpstreamResponse, ProxyFailure> {
let mut stream = tokio::time::timeout(deadlines.connect, TcpStream::connect(addr))
.await
.map_err(|_| ProxyFailure::Unreachable {
detail: format!(
"connecting to the liminal health listener at {addr} timed out after {:?}",
deadlines.connect
),
})?
.map_err(|error| ProxyFailure::Unreachable {
detail: format!("connecting to the liminal health listener at {addr} failed: {error}"),
})?;
let exchange = async {
let request = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
stream
.write_all(request.as_bytes())
.await
.map_err(|error| ProxyFailure::BadResponse {
detail: format!("writing the upstream request failed: {error}"),
})?;
let mut raw = Vec::new();
let mut chunk = [0_u8; 8192];
loop {
let read =
stream
.read(&mut chunk)
.await
.map_err(|error| ProxyFailure::BadResponse {
detail: format!("reading the upstream response failed: {error}"),
})?;
if read == 0 {
break;
}
raw.extend_from_slice(&chunk[..read]);
if raw.len() > PROXY_MAX_RESPONSE_BYTES {
return Err(ProxyFailure::BadResponse {
detail: format!(
"upstream response exceeded {PROXY_MAX_RESPONSE_BYTES} bytes; refusing to buffer further"
),
});
}
}
Ok(raw)
};
let raw = tokio::time::timeout(deadlines.response, exchange)
.await
.map_err(|_| ProxyFailure::BadResponse {
detail: format!(
"the liminal health listener did not complete a response within {:?}",
deadlines.response
),
})??;
parse_http_response(&raw)
}
fn parse_http_response(raw: &[u8]) -> Result<UpstreamResponse, ProxyFailure> {
let header_end = find_header_end(raw).ok_or_else(|| ProxyFailure::BadResponse {
detail: "upstream response has no header terminator (connection closed early?)".to_owned(),
})?;
let head = std::str::from_utf8(&raw[..header_end]).map_err(|_| ProxyFailure::BadResponse {
detail: "upstream response headers are not valid UTF-8".to_owned(),
})?;
let body = raw[header_end + 4..].to_vec();
let mut lines = head.split("\r\n");
let status_line = lines.next().ok_or_else(|| ProxyFailure::BadResponse {
detail: "upstream response is empty".to_owned(),
})?;
let status = parse_status_line(status_line)?;
let mut content_type = None;
let mut content_length: Option<usize> = None;
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
if name == "content-type" {
content_type = Some(value.to_owned());
} else if name == "content-length" {
content_length = Some(value.parse().map_err(|_| ProxyFailure::BadResponse {
detail: format!("upstream Content-Length {value:?} is not a length"),
})?);
}
}
if let Some(declared) = content_length
&& declared != body.len()
{
return Err(ProxyFailure::BadResponse {
detail: format!(
"upstream declared Content-Length {declared} but sent {} body byte(s)",
body.len()
),
});
}
Ok(UpstreamResponse {
status,
content_type,
body,
})
}
fn parse_status_line(line: &str) -> Result<u16, ProxyFailure> {
let mut parts = line.split_whitespace();
let version = parts.next().unwrap_or_default();
if !version.starts_with("HTTP/1.") {
return Err(ProxyFailure::BadResponse {
detail: format!("upstream status line {line:?} is not HTTP/1.x"),
});
}
let code = parts.next().ok_or_else(|| ProxyFailure::BadResponse {
detail: format!("upstream status line {line:?} has no status code"),
})?;
code.parse().map_err(|_| ProxyFailure::BadResponse {
detail: format!("upstream status code {code:?} is not a number"),
})
}
fn find_header_end(raw: &[u8]) -> Option<usize> {
raw.windows(4).position(|window| window == b"\r\n\r\n")
}
#[must_use]
pub fn passthrough_response(upstream: UpstreamResponse) -> Response {
let status = StatusCode::from_u16(upstream.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut response = Response::new(Body::from(upstream.body));
*response.status_mut() = status;
if let Some(content_type) = upstream.content_type
&& let Ok(value) = HeaderValue::from_str(&content_type)
{
response.headers_mut().insert(header::CONTENT_TYPE, value);
}
response
}
#[must_use]
pub fn failure_response(path: &str, failure: &ProxyFailure) -> Response {
let body = ProxyErrorBody {
error: failure.code(),
upstream_path: path,
detail: failure.detail(),
};
let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| {
format!(
"{{\"error\":\"{}\",\"upstream_path\":\"{path}\",\"detail\":\"(detail serialization failed)\"}}",
failure.code()
)
.into_bytes()
});
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = StatusCode::BAD_GATEWAY;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
response
}
#[cfg(test)]
mod tests {
use super::{
ProxyDeadlines, ProxyFailure, forward_health_request, parse_http_response,
parse_status_line,
};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::time::Duration;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const TEST_DEADLINES: ProxyDeadlines = ProxyDeadlines {
connect: Duration::from_millis(500),
response: Duration::from_millis(500),
};
fn fake_upstream(
status: &str,
content_type: Option<&str>,
body: &str,
) -> Result<SocketAddr, Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let type_header = content_type
.map(|content_type| format!("Content-Type: {content_type}\r\n"))
.unwrap_or_default();
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{type_header}\r\n{body}",
body.len()
);
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request = [0_u8; 2048];
if stream.read(&mut request).is_ok() {
let _outcome = stream.write_all(response.as_bytes());
}
}
});
Ok(addr)
}
#[tokio::test]
async fn forwards_status_content_type_and_body_verbatim() -> TestResult {
let body = r#"{"status":"healthy","message":null}"#;
let addr = fake_upstream("200 OK", Some("application/json"), body)?;
let upstream = forward_health_request(addr, "/health", TEST_DEADLINES)
.await
.map_err(|failure| format!("forward failed: {failure:?}"))?;
assert_eq!(upstream.status, 200);
assert_eq!(upstream.content_type.as_deref(), Some("application/json"));
assert_eq!(upstream.body, body.as_bytes());
Ok(())
}
#[tokio::test]
async fn a_503_ready_response_passes_through_as_503() -> TestResult {
let body = r#"{"ready":false,"unmet_conditions":["listener_bound"]}"#;
let addr = fake_upstream("503 Service Unavailable", Some("application/json"), body)?;
let upstream = forward_health_request(addr, "/ready", TEST_DEADLINES)
.await
.map_err(|failure| format!("forward failed: {failure:?}"))?;
assert_eq!(upstream.status, 503);
assert_eq!(upstream.body, body.as_bytes());
Ok(())
}
#[tokio::test]
async fn liminal_down_is_a_typed_unreachable_error_not_a_hang() -> TestResult {
let released = TcpListener::bind("127.0.0.1:0")?;
let dead_addr = released.local_addr()?;
drop(released);
let outcome = tokio::time::timeout(
Duration::from_secs(5),
forward_health_request(dead_addr, "/health", TEST_DEADLINES),
)
.await
.map_err(|_| "the down path must be bounded, not a hang")?;
let failure = outcome.err().ok_or("a down listener must fail typed")?;
assert!(
matches!(failure, ProxyFailure::Unreachable { .. }),
"got {failure:?}"
);
assert_eq!(failure.code(), "LIMINAL_UNREACHABLE");
Ok(())
}
#[tokio::test]
async fn a_hung_upstream_is_a_typed_bad_response_within_the_deadline() -> TestResult {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let hold = std::thread::spawn(move || listener.accept().map(|(stream, _)| stream));
let outcome = tokio::time::timeout(
Duration::from_secs(5),
forward_health_request(addr, "/health", TEST_DEADLINES),
)
.await
.map_err(|_| "the hang path must be bounded, not a hang")?;
let failure = outcome.err().ok_or("a hung listener must fail typed")?;
assert!(
matches!(failure, ProxyFailure::BadResponse { .. }),
"got {failure:?}"
);
assert_eq!(failure.code(), "LIMINAL_BAD_RESPONSE");
drop(hold);
Ok(())
}
#[tokio::test]
async fn a_truncated_body_is_refused_not_passed_through() -> TestResult {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request = [0_u8; 2048];
if stream.read(&mut request).is_ok() {
let _outcome = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nbody",
);
}
}
});
let failure = forward_health_request(addr, "/metrics", TEST_DEADLINES)
.await
.err()
.ok_or("a truncated body must fail typed")?;
match failure {
ProxyFailure::BadResponse { ref detail } => {
assert!(detail.contains("Content-Length"), "detail: {detail}");
}
ProxyFailure::Unreachable { .. } => {
return Err(format!("expected BadResponse, got {failure:?}").into());
}
}
Ok(())
}
#[test]
fn status_line_parsing_is_strict() {
assert_eq!(parse_status_line("HTTP/1.1 200 OK").ok(), Some(200));
assert_eq!(
parse_status_line("HTTP/1.1 503 Service Unavailable").ok(),
Some(503)
);
assert!(parse_status_line("SPDY/9 200 OK").is_err());
assert!(parse_status_line("HTTP/1.1").is_err());
assert!(parse_status_line("HTTP/1.1 abc OK").is_err());
}
#[test]
fn response_parsing_requires_a_header_terminator() -> TestResult {
assert!(parse_http_response(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n").is_err());
let parsed = parse_http_response(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
)
.map_err(|failure| format!("well-formed response must parse: {failure:?}"))?;
assert_eq!(parsed.status, 200);
assert_eq!(parsed.body, b"ok");
Ok(())
}
}